QBall

概述
QBall範例是一個自上而下的3v3競技場搏擊者。傳球,將對手趕出競技場,在混亂、閃電般快速的比賽中與敵方球隊得分。它透過分屏最多支持4名本機玩家。輸入緩衝和能力啟動延遲可以在更高的ping值下實現流暢的多人遊戲體驗。
此示例由MicroverseLabs工作室開發。
下載
版本 | 發佈日期 | 下載 | |
---|---|---|---|
技術資訊
- Unity:2021.3.18f1。
- 平台:PC (Windows / Mac)
焦點
技術
- 多個本機玩家利用預設的Quantum功能。
- 輸入編碼(Vector2為位元組)。
- 視圖中快速移動的球的自定義插值。
- 分屏多人遊戲(本機+線上)。
遊戲遊玩
- 不同的能力組。
- 可用能力會根據控球情况而變化。
- 多個本機玩家。
- 土狼時間。
畫面截圖




本機玩家
UI和配對
該示例使用預設Quantum Demo Manu的修改版本。主要新增功能是連接畫面上的本機玩家計數下拉式功能表。

啟動連接時使用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#
protected override void OnConnect(QuantumMenuConnectArgs connectArgs, ref MatchmakingArguments args)
{
args.RandomMatchingType = MatchmakingMode.FillRoom;
args.Lobby = LocalPlayerCountManager.SQL_LOBBY;
args.CustomLobbyProperties = new string[] { LocalPlayerCountManager.TOTAL_PLAYERS_PROP_KEY };
args.SqlLobbyFilter = $"{LocalPlayerCountManager.TOTAL_PLAYERS_PROP_KEY} <= {Input.MAX_COUNT - _localPlayersCountSelector.GetLastSelectedLocalPlayersCount()}";
}
連接到房間後,主用戶端會將自訂屬性與玩家總數保持同步(包括所有連接用戶端的其他本機玩家)。
C#
private void UpdateRoomTotalPlayers()
{
if (_connection != null && _connection.Client.InRoom && _connection.Client.LocalPlayer.IsMasterClient)
{
int totalPlayers = 0;
foreach (var player in _connection.Client.CurrentRoom.Players.Values)
{
if (player.CustomProperties.TryGetValue(LOCAL_PLAYERS_PROP_KEY, out var localPlayersCount))
{
totalPlayers += (int)localPlayersCount;
}
}
_connection.Client.CurrentRoom.SetCustomProperties(new PhotonHashtable
{
{ 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)
{
QuantumDemoInputTopDown 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
資產中實現的。這種設定允許所有能力邏輯都是自包含的,創建新能力就像編寫其獨特的邏輯一樣簡單,而不需要任何模範程式碼。
輸入緩衝
當檢測到能力輸入而不是立即嘗試啟動能力時,會啟動InputBufferTimer。然後,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 : QuantumEntityView
{
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碼方面,以QuantumDemoInputTopDown
為基礎。
C#
// DSL Definition
[ExcludeFromPrototype]
struct QuantumDemoInputTopDown {
FPVector2 MoveDirection;
FPVector2 AimDirection;
button Left;
button Right;
button Up;
button Down;
button Jump;
button Dash;
button Fire;
button AltFire;
button Use;
}
C#
// Example use case
public override void Update(Frame frame, ref Filter filter)
{
QuantumDemoInputTopDown input = *frame.GetPlayerInput(filter.PlayerStatus->PlayerRef);
// . . .
}
相機
相機由Cinemachine
透過CinemachineTargetGroup
控制,以便使用更高的本機玩家權重和更大的球半徑來聚焦所有演員,從而可以輕鬆地拍攝所有動作。
第三方資產
此示例包括第三方免費資產。您可以在各自的網站上為自己的項目獲取完整的套裝軟體:
Back to top