「ガードは直った。でもパリィだけが効かない」
前回の DevLog では、ガード(Block)に Receiver Authority を実装して「ガードしたのに当たった」問題を解決しました。
しかし、実際にテストしてみると新たな問題が発覚しました。
サーバー(ホスト)のキャラクターはパリィできる。クライアントのキャラクターはできない。
同じ攻撃、同じタイミングで押しているのに、プレイヤーによって結果が変わります。マルチプレイゲームとして致命的な差異でした。
なぜパリィだけ効かないのか
ガードの Receiver Authority では、BlockUp 変数をクライアントの値でサーバー側に上書きすることで問題を解決しました。では、同じことをパリィでもやればいい……はずでした。
FCS(Fighting Character System)のパリィ判定は ParryUp 変数で行われます。ガードボタンを押すと、サーバー側で BeginParry という関数が実行され、ParryUp が true になるパリィ窓が開きます。
問題はここにありました。
クライアントがボタン押下
→ 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 自体がサーバーからレプリケートされる変数なので、クライアントで読んでも遅延があります。BeginParry を SendBlock でローカル呼び出しする修正も試しましたが、根本的な解決にはなりませんでした。
→ 廃棄。「レプリケートされた変数を読む」アプローチには限界がありました。
設計③: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 の EventBlock → Stagger Calculation が、ディフェンダー自身の TakingDamage コンポーネント(self)に対して呼ばれていることが T3D 解析で判明しました。
Client_VerifyHit は BPC_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 実装とテスト結果をお届けする予定です。

