This document is about: QUANTUM 3
SWITCH TO

QBall

Level 4

概要

QBallサンプルは、トップダウンの3v3スポーツアリーナバトラーです。ボールをパスし、対戦相手をアリーナから押し出し、混沌とした超高速の試合で敵チームに得点しましょう。最大4人のローカルプレイヤーに分割画面で対応しています。入力バッファリングとアビリティの発動遅延により、高いpingでもスムーズなマルチプレイヤー体験を提供します。

このサンプルは、MicroverseLabsスタジオによって開発されました。

ダウンロード

Version Release Date Download

技術情報

  • Unity: 2021.3.18f1
  • プラットフォーム: PC (Windows / Mac)

ハイライト

技術

  • デフォルトのQuantum機能を活用した複数のローカルプレイヤー。
  • 入力エンコーディング(Vector2をバイトとして)
  • 視界内の高速移動ボールのためのカスタム補間
  • スプリットスクリーンマルチプレイヤー(ローカル + オンライン)

ゲームプレイ

  • 異なるアビリティセット。
  • ボールの所持状況に応じて利用できるアビリティが変化。
  • 複数のローカルプレイヤー。
  • コヨーテタイム。

スクリーンショット

Abilities

概要

各アビリティの状態データは、いくつかのタイマーと 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 は、各フレームでタイマーが動いているかどうかをチェックし、アビリティをアクティブにします。これにより、プレイヤーの体験がスムーズになり、いくつかの状況での高いレイテンシを軽減するのに役立ちます。例えば、プレイヤーがダッシュの最中にボールを投げようとする場合、通常は何も起こらずに入力が消費されてしまいますが、入力バッファリングにより、ダッシュが終了次第投げるアビリティがアクティブにされ、他のリモートプレイヤーに少し早めに送信されることで、時間内に到着し、予測の誤りを防ぎます。

アクティベーション遅延

アビリティがアクティブになると、まず遅延状態に入り、入力が他のリモートプレイヤーに届く時間を確保し、予測の誤りを防ぎます。ローカルプレイヤーに応答性を感じさせるために、アニメーションは即座にトリガーされ、遅延時間と実際の持続時間の合計分だけ持続します。

ボール保持時の異なるアビリティ

ボールを持っていない場合、プレイヤーは攻撃的なパンチと防御的なブロックのアビリティにアクセスできます。ボールを持つ場合、これらは短距離および長距離の投げアビリティに置き換えられます。利用できないアビリティは各ティックごとに更新されるため、InputBufferTimerCooldownTimer のカウントダウンが行われます。ボールを保持しているときの移動速度が減少することと相まって、パスを促進したり、チームメイトに保護を任せたりするインセンティブとなります。

パンチ

パンチアビリティは、複数のサイズが増大する球体を使用した複合ヒット検出シェイプを利用し、円錐型のヒットボックスを作成します。これにより、ノックバックとスタンのステータス効果が適用されます。ノックバックは複数のプレイヤーによって連鎖させることができ、虚無にノックバックされると短いタイムアウトの後に再出現します。

ブロックアビリティ

ブロックアビリティは、持続している間、すべての攻撃を完全に防ぎます。

投げアビリティ

すべてのアビリティが単一の照準方向によってターゲットされるため、短距離と長距離のパスはより良い制御を可能にします。

ダッシュアビリティ

ダッシュはアニメーションカーブによって駆動される急速な移動を可能にします。カスタム移動を行う場合、現在のプレイヤー位置に対して相対的に計算する必要があり、複数のカスタム移動が重なることを可能にし、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コード側では、すべての方向入力が帯域幅を節約するために単一のバイトにエンコードされています。

C#

// DSL定義
input
{
    Button Jump;
    Button Dash;
    Button PrimaryAction;
    Button SecondaryAction;
    Byte MovementEncoded;
    Byte AimEncoded;
}

C#

// 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を使用してすべてのアクターに焦点を合わせ、ローカルプレイヤーには高い重みを、ボールには大きな半径を与えることで、すべてのアクションを容易にフレーミングできるようにします。

第三者アセット

このサンプルには、無料の第三者アセットが含まれています。完全なパッケージは、各サイトでご自身のプロジェクト用に取得できます:

Back to top