QBall
概述
Q球範例是一個從上而下的3對3運動競技場亂鬥。傳球,將對手擊下競技場,並且在混亂快節奏的對戰中比敵方團隊獲得更多得分。它透過分割畫面來支援最多4名本機玩家。輸入緩衝及能力啟用延遲,在較高的Ping值下帶來一個順暢的多人玩家體驗。
下載
版本 | 發布日期 | 下載 | ||
---|---|---|---|---|
2.1.6 | 2023年5月30日 | Quantum 球 2.1.6 組建 237 |
技術資訊
- Unity:2021.3.13f1.
- 平台:PC (Windows / Mac)
聚焦點
技術
- 多人本機玩家利用預設Quantum功能。
- 輸入編碼(向量2為位元組)
- 針對檢視中的快速移動的球的自訂內插補點
- 分割畫面多人玩家(本機+線上)
遊戲遊玩
- 不同的能力集合。
- 取決於控球時間的可用的能力更改。
- 多人本機玩家。
- 土狼時間。
畫面截圖
本機玩家
UI及對戰配對
範例使用預設Quantum示範UI的一個已修改的版本。主要的額外功能是在連線畫面中的一個本機玩家計數下拉式選單。
在開始連線時使用一個SqlLobbyFilter
,以限制玩家的最大數量為6,其中也將本機玩家列入考慮。
C#
public const string LOCAL_PLAYERS_PROP_KEY = "LP";
public const string TOTAL_PLAYERS_PROP_KEY = "C0";
public static readonly TypedLobby SQL_LOBBY = new TypedLobby("customSqlLobby", LobbyType.SqlLobby);
C#
OpJoinRandomRoomParams joinRandomParams = new OpJoinRandomRoomParams()
{
TypedLobby = UIMain.SQL_LOBBY,
MatchingType = MatchmakingMode.FillRoom,
SqlLobbyFilter = $"{UIMain.TOTAL_PLAYERS_PROP_KEY} <= {Input.MAX_COUNT - UIConnect.LastSelectedLocalPlayersCount}",
};
在連線到一個房間之後,主客戶端會將自訂屬性與總玩家計數保持同步(包含針對所有已連線客戶端的額外本機玩家)。
C#
public static void UpdateRoomTotalPlayers()
{
if (UIMain.Client != null && UIMain.Client.InRoom && UIMain.Client.LocalPlayer.IsMasterClient)
{
int totalPlayers = 0;
foreach (var player in UIMain.Client.CurrentRoom.Players.Values)
{
totalPlayers += (int)player.CustomProperties[UIMain.LOCAL_PLAYERS_PROP_KEY];
}
UIMain.Client.CurrentRoom.SetCustomProperties(new Hashtable
{
{ UIMain.TOTAL_PLAYERS_PROP_KEY, totalPlayers }
});
}
}
本機玩家初始化
當遊戲遊玩開始一個不同的設置,則基於本機玩家的數量來具現化預製件。在設置預製件中,各個本機玩家有它們自己的Camera
、UI
及PlayerInput
。PlayerInput
自動地針對各個本機玩家來指派不同的輸入裝置。如果有多個本機玩家,主要玩家總是取得被指派的滑鼠及鍵盤,並且任何額外的玩家各自取得一個不同的控制器(在遊戲遊玩開始之前需要插上控制器)。
能力
概述
各個能力的狀態資料被儲存在一個Ability
架構之中,該架構持有一些計時器及一個AbilityData
資產參照。
C#
struct Ability
{
[ExcludeFromPrototype] AbilityType AbilityType;
[ExcludeFromPrototype] CountdownTimer InputBufferTimer;
[ExcludeFromPrototype] CountdownTimer DelayTimer;
[ExcludeFromPrototype] CountdownTimer DurationTimer;
[ExcludeFromPrototype] CountdownTimer CooldownTimer;
asset_ref<AbilityData> AbilityData;
}
Ability
架構被儲存在AbilityInventory
元件中的一個陣列之中。
C#
component AbilityInventory
{
[ExcludeFromPrototype] ActiveAbilityInfo ActiveAbilityInfo;
// Same order as AbilityType enum also used for activation priority
[Header("Ability Order: Block, Dash, Attack, ThrowShort, ThrowLong, Jump")]
array<Ability>[6] Abilities;
}
一個單一的AbilitySystem
以一個由資料驅動的方式來更新所有能力,方法是透過傳送所有相關狀態資料到它們相應的AbilityData
資產。
C#
public override void Update(Frame frame, ref Filter filter)
{
Input* input = frame.GetPlayerInput(filter.PlayerStatus->PlayerRef);
for (int i = 0; i < filter.AbilityInventory->Abilities.Length; i++)
{
AbilityType abilityType = (AbilityType)i;
ref Ability ability = ref filter.AbilityInventory->Abilities[i];
AbilityData abilityData = frame.FindAsset<AbilityData>(ability.AbilityData.Id);
abilityData.UpdateAbility(frame, filter.EntityRef, ref ability);
abilityData.UpdateInput(frame, ref ability, input->GetAbilityInputWasPressed(abilityType));
abilityData.TryActivateAbility(frame, filter.EntityRef, filter.PlayerStatus, ref ability);
}
}
基礎AbilityData
執行方式負責啟用能力邏輯和更新其狀態,同時使用多型在衍生的AbilityData
資產中執行所有不可變能力特定資料及邏輯。這個設定允許所有能力邏輯成為獨立式,並且建立新的能力就像撰寫它們的獨一無二的邏輯一樣簡單,而不需要任何重複使用的程式碼。
輸入緩衝
當偵測到能力輸入,但不是立即嘗試啟用該能力時,將啟動一個輸入緩衝計時器。然後AbilityData
檢查計時器是否在每幀運行,以啟用該能力。這允許更順暢的玩家體驗並且有助於在某些情況下緩解高延遲的問題。比如,如果玩家在一個衝刺中間嘗試擲球,他們的輸入通常會被耗用掉,而不會發生任何事情——輸入緩衝會在衝刺結束後立即啟動投擲能力,並且會提前一點發送給其他遠端玩家,以便它可以及時到達並且防止預測失誤。
啟用延遲
當啟用一個能力後,它首先進入延遲狀態,這提供輸入一些時間以到達其他遠端玩家,並且防止預測失誤。為了讓本機玩家感受到能力的回應,它們的動畫被立即觸發,並且持續整個延遲+實際持續時間。
持球時的不同能力
在沒有球的情況下,玩家可以取得攻擊打擊及防禦阻擋的能力。當持球時,它們被替換為短擲及遠擲的能力。不可用的能力仍然會在各個刷新時更新,這樣它們的InputBufferTimer
及CooldownTimer
可以被勾選。這與持球時降低的移動速度相結合,可以激勵傳球或依靠隊員的防護。
打擊
打擊能力使用一個複合命中偵測形狀,其附有多個大小增長的球體,以建立一個錐形的命中框。它應用擊退和擊暈狀態效果。可由多名玩家將擊退鏈接在一起,被擊退到空值將導致暫時暫停,然後重新生成。
阻擋能力
阻擋能力在持續時間時完全地防止所有攻擊。
投擲能力
因為所有能力都只針對一個單一目標方向,因此短傳及長傳可以帶來更好的控制。
衝刺能力
衝刺可以帶來由一個動畫曲線驅動的快速的移動。任何自訂移動都需要相對於目前的玩家位置來進行計算,以允許多個自訂移動疊加在一起,並進行KCC碰撞器穿透校正。
C#
if (abilityState.IsActive)
{
AbilityInventory* abilityInventory = frame.Unsafe.GetPointer<AbilityInventory>(entityRef);
Transform3D* transform = frame.Unsafe.GetPointer<Transform3D>(entityRef);
CharacterController3D* kcc = frame.Unsafe.GetPointer<CharacterController3D>(entityRef);
FP lastNormalizedPosition = DashMovementCurve.Evaluate(lastNormalizedTime);
FPVector3 lastRelativePosition = abilityInventory->ActiveAbilityInfo.CastDirection * DashDistance * lastNormalizedPosition;
FP newNormalizedTime = ability.DurationTimer.NormalizedTime;
FP newNormalizedPosition = DashMovementCurve.Evaluate(newNormalizedTime);
FPVector3 newRelativePosition = abilityInventory->ActiveAbilityInfo.CastDirection * DashDistance * newNormalizedPosition;
transform->Position += newRelativePosition - lastRelativePosition;
}
跳躍能力
跳躍也被執行為一個能力,這樣它可以受益於輸入緩衝及啟用延遲。輸入緩衝對於它特別有用,因為它允許在落地前不久將下一個跳躍排入佇列。針對跳躍的啟用延遲比其他能力低的多,否則它會感覺沒有反應。
角色控制器
KCC設置
有3個不同的KCC設置,其在應用時取決於玩家的狀態。第一個設置只是預設行為,其允許正常移動。第二個設置是持球時使用的,它減少玩家的移動速度及跳躍高度。第三個設置是在能力使用期間及被擊倒時應用。它防止所有基於輸入的移動及重力,並允許透過程式碼進行完全控制。仍然執行KCC->Move()
方法,以防止玩家在被程式碼移動時進到障礙物之中。
C#
public unsafe void UpdateKCCSettings(Frame frame, EntityRef playerEntityRef)
{
PlayerStatus* playerStatus = frame.Unsafe.GetPointer<PlayerStatus>(playerEntityRef);
AbilityInventory* abilityInventory = frame.Unsafe.GetPointer<AbilityInventory>(playerEntityRef);
CharacterController3D* kcc = frame.Unsafe.GetPointer<CharacterController3D>(playerEntityRef);
CharacterController3DConfig config;
if (playerStatus->IsKnockbacked || abilityInventory->HasActiveAbility)
{
config = frame.FindAsset<CharacterController3DConfig>(NoMovementKCCSettings.Id);
}
else if (playerStatus->IsHoldingBall)
{
config = frame.FindAsset<CharacterController3DConfig>(CarryingBallKCCSettings.Id);
}
else
{
config = frame.FindAsset<CharacterController3DConfig>(DefaultKCCSettings.Id);
}
kcc->SetConfig(frame, config);
}
土狼時間
為了玩家在平台間跳躍時達到更好的遊戲感受,並且最小化玩家錯誤,因而有一個「土狼時間」機制。它允許玩家在升空後不久就正常跳躍。當玩家著地時,每個刷新時都會啟動一個JumpCoyoteTimer
。當玩家嘗試跳躍時,並不是檢查是否著地,而是檢查JumpCoyoteTimer.IsRunning
。
球
檢視內插補點
當持球時,它的真實位置位於玩家的中心,它的物理被停用,而且它不會以任何方式被進一步操控。這允許檢視來暫時地控制球並透過動畫來移動其圖形。當球被玩家接住或放開,就會在真實空間及動畫空間之間快速內插補點它的轉換。
C#
public unsafe class BallEntityView : EntityView
{
private float _interpolationSpaceAlpha;
public void UpdateSpaceInterpolation()
{
// . . .
UpdateInterpolationSpaceAlpha(isBallHeldByPlayer);
if (_interpolationSpaceAlpha > 0f)
{
Vector3 interpolatedPosition = Vector3.Lerp(_lastBallRealPosition, _lastBallAnimationPosition, _interpolationSpaceAlpha);
Quaternion interpolatedRotation = Quaternion.Slerp(_lastBallRealRotation, _lastBallAnimationRotation, _interpolationSpaceAlpha);
transform.SetPositionAndRotation(interpolatedPosition, interpolatedRotation);
}
}
private void UpdateInterpolationSpaceAlpha(bool isBallHeldByPlayer)
{
float deltaChange = _spaceTransitionSpeed * Time.deltaTime;
if (isBallHeldByPlayer)
{
_interpolationSpaceAlpha += deltaChange;
}
else
{
_interpolationSpaceAlpha -= deltaChange;
}
_interpolationSpaceAlpha = Mathf.Clamp(_interpolationSpaceAlpha, 0f, 1f);
}
}
重力規模
當擲球時,為了在沒有拋物線及沒有急劇增加投擲力的情況下進行低傳球,球暫時不受重力影響。在投擲球之後,它的GravityScale
使用一個曲線來快速地從0內插補點到1,以將控制交還給物理系統,並獲得更逼真的結果。
C#
private void UpdateBallGravityScale(Frame frame, ref Filter filter, BallHandlingData ballHandlingData)
{
if (filter.BallStatus->GravityChangeTimer.IsRunning)
{
FP gravityScale = ballHandlingData.ThrowGravityChangeCurve.Evaluate(filter.BallStatus->GravityChangeTimer.NormalizedTime);
filter.PhysicsBody->GravityScale = gravityScale;
filter.BallStatus->GravityChangeTimer.Tick(frame.DeltaTime);
if (filter.BallStatus->GravityChangeTimer.IsDone)
{
ResetBallGravity(frame, filter.EntityRef);
}
}
}
自訂橫向摩擦力
當球在地面上彈跳/滾動時,會對其應用額外的橫向摩擦力,以在投擲時對其行進距離進行更精確的控制,並且防止它不斷地從邊緣滾動到空隙中。
C#
public void OnCollisionEnter3D(Frame frame, CollisionInfo3D info)
{
if (frame.Unsafe.TryGetPointer(info.Entity, out BallStatus* ballStatus))
{
ballStatus->HasCollisionEnter = true;
}
}
public void OnCollision3D(Frame frame, CollisionInfo3D info)
{
if (frame.Unsafe.TryGetPointer(info.Entity, out BallStatus* ballStatus))
{
ballStatus->HasCollision = true;
}
}
private void HandleBallCollisions(Frame frame, ref Filter filter, BallHandlingData ballHandlingData)
{
if (!filter.PhysicsBody->IsKinematic)
{
if (filter.BallStatus->HasCollisionEnter)
{
filter.PhysicsBody->Velocity.X *= ballHandlingData.LateralBounceFriction;
filter.PhysicsBody->Velocity.Z *= ballHandlingData.LateralBounceFriction;
frame.Events.OnBallBounced(filter.EntityRef);
}
if (filter.BallStatus->HasCollision)
{
filter.PhysicsBody->Velocity.X *= ballHandlingData.LateralGroundFriction;
filter.PhysicsBody->Velocity.Z *= ballHandlingData.LateralGroundFriction;
}
}
filter.BallStatus->HasCollisionEnter = false;
filter.BallStatus->HasCollision = false;
}
輸入
Unity的輸入系統套件處理輸入。在Quantum程式碼側,所有方向性輸入都被編碼為單一位元組,而不是使用FP向量2,來節省頻寬。
C#
// DSL Definition
input
{
Button Jump;
Button Dash;
Button PrimaryAction;
Button SecondaryAction;
Byte MovementEncoded;
Byte AimEncoded;
}
C#
// Extension of the input struct in CSharp
public unsafe partial struct Input
{
public FPVector2 Movement
{
get => DecodeDirection(MovementEncoded);
set => MovementEncoded = EncodeDirection(value);
}
public FPVector2 Aim
{
get => DecodeDirection(AimEncoded);
set => AimEncoded = EncodeDirection(value);
}
private byte EncodeDirection(FPVector2 direction)
{
if (direction == default)
{
return default;
}
FP angle = FPVector2.RadiansSigned(FPVector2.Up, direction) * FP.Rad2Deg;
angle = (((angle + 360) % 360) / 2) + 1;
return (byte)angle.AsInt;
}
private FPVector2 DecodeDirection(byte directionEncoded)
{
if (directionEncoded == default)
{
return default;
}
int angle = (directionEncoded - 1) * 2;
return FPVector2.Rotate(FPVector2.Up, angle * FP.Deg2Rad);
}
}
相機
相機由Cinemachine
透過CinemachineTargetGroup
來控制,以針對本機玩家使用更高的重量,針對球使用更大半徑,來聚焦在所有演出者,這樣可以輕易地取景所有動作。