QBall
概要
QBallサンプルは、トップダウンの3vs3スポーツアリーナ・ブローラーです。ボールをパスしたりアリーナから敵を倒したりして、高速な試合展開で対戦チームとスコアを競います。画面分割によって最大4人のローカルプレイヤーをサポートします。入力バッファリングと能力活性化の遅延により、高いpingでもスムーズなマルチプレイを実現します。
ダウンロード
バージョン | リリース日 | ダウンロード | ||
---|---|---|---|---|
2.1.1 | 2022年12月5日 | Quantum Ball 2.1.1 Build 36 |
技術情報
- Unity: 2021.3.13f1.
- プラットフォーム: PC (Windows / Mac)
ハイライト
テクニカル
- 複数のローカルプレイヤーがQuantumのデフォルト機能を活用
- 入力エンコーディング(バイトごとにVector2)
- ビュー内で高速に動くボールをカスタム補間
- 分割画面マルチプレイヤー(ローカルおよびオンライン)
ゲームプレイ
- 様々な能力のセット。
- 利用できる能力は、ボール保有率によって変化します。
- 複数のローカルプレイヤー。
- コヨーテタイム。
スクリーンショット
ローカルプレイヤー
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
は、各ローカルプレイヤーへの異なる入力デバイスの割り当てを自動的に行います。複数のローカルプレイヤーがいる場合、メインプレイヤーには常にマウスとキーボードが割り当てられ、追加プレイヤーにはそれぞれ異なるコントローラーが割り当てられます(コントローラーはゲームプレイを開始する前に接続する必要があります)。
能力
概要
各能力の状態データは、複数のタイマーとAbilityData
アセット参照を保持するAbility
構造体の内部に保管されています。
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
アセットに実装されます。このセットアップによってすべての能力ロジックが自己完結し、新しい能力の作成は定型的なコードは不要で、独自のロジックを書くのと同じくらい簡単になります。
入力バッファリング
能力の入力が検出されると、すぐに能力を起動するのではなくInputBufferTimerが開始されます。そして、AbilityData
は能力を起動するために、各フレームでタイマーが実行されているかどうかをチェックします。これによってさらにスムーズなプレイヤー体験と、状況に応じた高レイテンシーが軽減されます。例えば、プレイヤーがダッシュ中にボールを投げようとした場合、通常は何も起きずに入力が消費されます。入力バッファリングは、ダッシュの終了後すぐに起動するように投げる能力をキューに入れ、他のリモートプレイヤーにも少し早く送信されるので、時間内に到着して予測ミスを防ぐことができます。
起動遅延
能力が起動されるとまず遅延状態になり、入力が他のリモートプレイヤーに届くまでの時間が与えられ、予測ミスが防止されます。能力がローカルプレイヤーに反応するよう感じるために、能力のアニメーションは即時でトリガーされ遅延時間+実際の持続時間の間、継続します。
ボールを持っている場合の様々な能力
ボールを持っていない場合、プレイヤーは攻撃的なパンチと防御的なブロックの能力を使用できます。ボールを持っている場合、短い距離と長い距離の能力に置き換わります。使用できないアビリティは1ティックごとに更新されるので、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コンフィギュレーションが適用されます。1つ目はデフォルトの動作で、通常の動きが可能です。2つ目はボールを持っているときに適用され、プレイヤーの移動速度とジャンプの高さが減少します。3つ目は能力使用時やノックバックされている時に適用されます。入力に基づく移動と重力をすべて防ぎ、コードによる完全な制御を可能にします。コードによる移動時にプレイヤーが障害物の中に入るのを防ぐため、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);
}
コヨーテタイム
より良いゲームの雰囲気を実現し、プラットフォーム間をジャンプする際のプレイヤーのミスを最小限に抑えるため、「コヨーテタイム」という仕組みがあります。これにより、プレイヤーは空中に出た後すぐに通常どおりジャンプできるようになります。プレイヤーが接地している間、1ティックごとに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のInput Systemパッケージで処理されます。Qunatumコード側では、すべての方向入力はFPVector2を使用せずにシングルバイトにエンコードされ、帯域幅が節減されます。
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);
}
}
カメラ
カメラは CinemachineTargetGroup
によって Cinemachine
で制御されています。これは、ローカルプレーヤーには高いウェイトを、ボールには大きな半径を使用してすべてのアクターに焦点を合わせ、すべてのアクションを簡単にフレーミングできるようにするためです。