[DevLog #008] パリィだけが「当たらない」問題に、3つの設計で挑んだ話

DevLog

「ガードは直った。でもパリィだけが効かない」

前回の DevLog では、ガード(Block)に Receiver Authority を実装して「ガードしたのに当たった」問題を解決しました。

しかし、実際にテストしてみると新たな問題が発覚しました。

サーバー(ホスト)のキャラクターはパリィできる。クライアントのキャラクターはできない。

同じ攻撃、同じタイミングで押しているのに、プレイヤーによって結果が変わります。マルチプレイゲームとして致命的な差異でした。


なぜパリィだけ効かないのか

ガードの Receiver Authority では、BlockUp 変数をクライアントの値でサーバー側に上書きすることで問題を解決しました。では、同じことをパリィでもやればいい……はずでした。

FCS(Fighting Character System)のパリィ判定は ParryUp 変数で行われます。ガードボタンを押すと、サーバー側で BeginParry という関数が実行され、ParryUptrue になるパリィ窓が開きます。

問題はここにありました。

クライアントがボタン押下
  → Server RPC 送信(Ping/2 遅延)
  → サーバーで BeginParry 実行(ParryUp = true)
  → ParryUp がレプリケーション(さらに Ping/2 遅延)
  → クライアントに届く頃には ParryUp が false に戻っている

ParryUp はサーバーからレプリケートされた値なので、クライアントが読んでも常に Ping 分遅れています。 ガードのように「サーバーに書き込む」だけでは解決できませんでした。


試行錯誤:3つの設計

設計①:タイムスタンプ方式

最初に考えたのは、「ガードを開始したサーバー時刻」を記録しておき、ダメージ処理時に「パリィ窓内か?」を判定する方法でした。

SR_StartGuard 実行時:
  ParryWindowExpireServerTime = GetGameTimeSinceCreation + 0.5秒

Server_AcceptDamage で:
  GetGameTimeSinceCreation <= ParryWindowExpireServerTime → パリィ成功

一見きれいな設計ですが、実装してみると問題が続出しました。

  • GetGameTimeSinceCreation は Actor の関数なので、Component BP では Target に CharacterRef の接続が必須
  • 接続漏れによるコンパイルエラーが2箇所発生
  • タイムスタンプのズレにより、結局クライアントのパリィが通らない

→ 廃棄。複雑さに対してリターンが得られませんでした。


設計②:bWasParryingOnClient = ParryUp

次に「Client_VerifyHit でクライアントの ParryUp を読んでサーバーに渡す」シンプルな方式を試しました。ガードの BlockUp で実績のある方法の応用です。

Client_VerifyHit:
  bWasParryingOnClient = DefensiveComponent.ParryUp

Server_AcceptDamage:
  if bWasParryingOnClient → Set ParryUp = true

しかし、これも効きませんでした。ParryUp 自体がサーバーからレプリケートされる変数なので、クライアントで読んでも遅延があります。BeginParrySendBlock でローカル呼び出しする修正も試しましたが、根本的な解決にはなりませんでした。

→ 廃棄。「レプリケートされた変数を読む」アプローチには限界がありました。


設計③:bLocalParryActive(確定版)

試行錯誤の末にたどり着いたのは、「サーバーの状態を一切参照しない」という発想でした。

bLocalParryActive という非レプリケートのローカル変数を DefensiveComponent に追加します。

ボタン押下(SendBlock):
  bLocalParryActive = true
  タイマー開始(ParryOpening 秒後に false)
  SR_StartGuard(サーバーへ RPC)

ボタン離す(ReleaseBlock):
  bLocalParryActive = false
  タイマークリア

Client_VerifyHit:
  bWasParryingOnClient = bLocalParryActive  ← レプリケーション遅延ゼロ

ポイントは 「ボタンを押した瞬間をクライアントが自力で記録する」 ことです。サーバーとのやりとりなく、クライアントの手元で「今パリィ窓か?」を判断できます。


PvE だから「クライアントを信頼する」設計が成立する

この設計の前提として重要なのが、このゲームは PvE(プレイヤー vs AI)専用という点です。

マルチプレイゲームでは一般的に「クライアントを信頼するな」が鉄則です。クライアントが「パリィした」と言っても、改造クライアントで無敵化できてしまうからです。

ところが PvE なら事情が変わります。

PvP PvE(今回)
クライアント信頼のリスク 改造で無敵化・チート可 AI は不正できない
プレイヤー体験の優先度 公平性が重要 気持ちよさが最優先

AI が「ズルをする」ことはありません。「クライアントが押したと言ったタイミング」でパリィが成功するのは、体験として正しい設計です。


おまけ:敵のひるみアニメーションも即時化できた

パリィ成功時の敵のひるみ(スタッガー)も、サーバー経由で来るため Ping 分遅れて見えていました。

こちらは別の発見がありました。FCS の EventBlockStagger Calculation が、ディフェンダー自身の TakingDamage コンポーネント(self)に対して呼ばれていることが T3D 解析で判明しました。

Client_VerifyHitBPC_TakingDamage の中にあるため、self で直接 Stagger Calculation を呼べます

Client_VerifyHit(クライアント上で実行):
  bWasParryingOnClient == true
    → bLocalParryStaggerCalled = true
    → Stagger Calculation(self, AnimLength, 1.0, 0.0) ← 即時実行

後からサーバーの Stagger Calculation が呼ばれても:
  → bLocalParryStaggerCalled == true → スキップ(重複防止)

IsLocallyControlled && bLocalParryStaggerCalled のチェックをサーバー側の Stagger Calculation 先頭に挿入するだけで、二重再生なしに即時スタッガーが実現できます。


まとめ

今回のパリィ対応でわかったことをまとめます。

教訓 内容
レプリケート変数はクライアントで読んでも遅延がある ParryUp はサーバーから届く値。クライアントで読んでも Ping 分ズレている
PvE ではクライアントを信頼できる AI は不正しない。体験を優先する設計が正しい
ローカル変数でタイミングを記録する サーバーへの問い合わせなしに「今パリィ窓か?」を管理できる
コンポーネントの構造を知ると近道がある Client_VerifyHit が同一コンポーネント内にあるため、Stagger Calculation(self) が使える

3つの設計を試して廃棄しながら確定版にたどり着いたプロセスは、遠回りに見えて、FCS のレプリケーション構造への理解が深まった過程でもありました。


次回:Phase 6 の実際の Blueprint 実装とテスト結果をお届けする予定です。

タイトルとURLをコピーしました