[DevLog #007] 「ガードしたのに当たった」を解決するために、回避とは違うアーキテクチャが必要だった話

DevLog

「回避で使った方法がそのまま使えなかった」

前回の記事で、回避(Roll)のラグ対策として「Receiver Authority」を実装しました。
ざっくり言うと「自分の画面でギリギリ回避したならノーダメージ」を、サーバーが最終判定しても保証するしくみです。

ガード(Block)にも同じことをしたい。
ボタンを押したのにガードが間に合わなかった、という体験をなくしたい。

でも実装しようとしたら、回避とは根本的に別の方法が必要だとわかりました。


回避とガードで何が違うのか

回避の場合、ダメージを受けるかどうかは「避けたか/避けなかったか」の2択です。
避けていたなら、その攻撃は完全に空振り扱いにすればいい。

実装上は CanTakeDamage = false にするだけで、攻撃の当たり判定を完全に無視できます。

ガードの場合、それが使えません。

ガードは「攻撃が当たった上で受け止める」という動作です。
CanTakeDamage = false にしてしまうと、FCS 内部の EventBlock が呼ばれなくなる。
EventBlock の中にはスタミナ消費・パリィ判定・ガードブレイクがまとめて入っています。
つまりガードの「中身」が全部スキップされてしまいます。

回避(Roll) ガード(Block)
攻撃の扱い 完全ミス(すり抜け) 当たった上で受け止める
CanTakeDamage = false 使える 使えない(EventBlock が呼ばれなくなる)
スタミナ消費 なし あり(ガードブレイクに直結)
パリィ判定 なし あり

では何をどう変えるか

問題の本質

FCS の標準実装では、ガード判定の核心部分である CanBlockAttackサーバー側の BlockUp 変数を参照しています。

CanBlockAttack → サーバーの BlockUp == true?
  → true:EventBlock(ガード成功処理)
  → false:TakeDamage(通常ダメージ)

Ping が 100ms あると、ボタンを押してからサーバーに届くまでに 100ms かかります。
その間に攻撃が来ると、サーバーの BlockUp はまだ false のまま。
自分の画面ではガードしているのに、サーバーはガードしていないと判断してしまいます。

解決策:クライアントの BlockUp をサーバーに「押し付ける」

考え方はシンプルです。

  1. クライアントでガードボタンを押した瞬間、即座に BlockUp = true をセット(これが「自分がガードしていた」という正確な情報源)
  2. 攻撃が飛んできたとき、クライアントでその BlockUp の値をサーバーへ送る
  3. サーバーはその値で自分の BlockUp強制上書きしてから、既存の判定フローに流す
Server_AcceptDamage の冒頭:
  DefensiveComponent → Set BlockUp(クライアントから受け取った値で上書き)
  ↓
CanBlockAttack(上書き後の BlockUp を評価)
  → true:EventBlock(スタミナ消費・パリィ・ガードブレイク)
  → false:TakeDamage(通常ダメージ)

CanBlockAttack をスキップしないのが重要です。
この関数には「凍結中はガード不可」「ガード不能攻撃を通す」といった属性チェックが含まれているからです。
スキップするとゲームのバランスが崩れます。


もう一つの改修:アニメーションの即時化

ダメージ遮断の話とは別に、ガードのアニメーション自体も遅延しています。

FCS の標準実装では、ボタンを押すと:
1. SendBlock(クライアント)
2. SR_BeginBlock(Server RPC)
3. MC_BeginBlock(Multicast)← ここでアニメーション再生

Multicast を経由するので、アニメーション開始まで Ping 分待たされます。
「ガードを構えた感触」がなく、操作が重く感じます。

こちらは攻撃システムで使っている「3段構えアーキテクチャ」をそのまま流用します。

【ローカル即時】ボタン押下
  → Set BlockUp(true) + PlayAnimMontage + MovementSpeed(Block)
  → SR_StartGuard(自作 Server RPC)

【サーバー】SR_StartGuard
  → 状態更新(BlockUp, MovementSpeed)
  → MC_StartGuard(自作 Multicast)

【他クライアント】MC_StartGuard
  → IsLocallyControlled?
      自分 → スキップ(ローカルで再生済み)
      他人 → アニメ + 状態更新

既存の SR_BeginBlock は Multicast を内包しているため置き換えが必要です。
SR_BeginBlock を呼ぶと二重再生になります。


パリィも同じ問題がある

設計を詰めていて気づいたのですが、パリィも同じ問題を抱えています。

FCS ではパリィは ParryUp という、BlockUp とは別の変数で管理されています。
パリィ窓(ジャストガードの受付時間)中に攻撃が来たとき、サーバーの ParryUp がまだ届いていなければ:

  • BlockUp は force-overwrite されるのでガードは成立する
  • でも ParryUpfalse のまま
  • ガード成功・パリィ不発

なので bWasParryingOnClient も同様にクライアントから渡す必要があります。

Server_AcceptDamage の冒頭:
  Set BlockUp(bWasBlockingOnClient で上書き)
  Set ParryUp(bWasParryingOnClient で上書き)  ← これも必須

Client_VerifyHit 内でクライアントの BlockUpParryUp を両方取得して渡すだけです。
構造はまったく同じなので、実装コストはほぼゼロです。

「でも敵のスキ(よろけ)は即時にならないの?」

パリィが成功すると、敵は一定時間よろけて無防備になります(スキができる)。
この「よろけアニメーション」は即時化されるのか、気になったので整理します。

パリィ成功後のフローはこうなっています。

ParryUp force-overwrite → EventBlock → パリィ成功
  → AttackParried(攻撃者への処理)
  → StaggerCalculation(よろけ時間計算)
  → MC_AttackBlockedAnim(Multicast)← 全クライアントへよろけアニメを配信

最後の Multicast はサーバーから全員に送られるので、よろけアニメーションは Ping 分だけ遅れて見えます。
つまり「パリィが成功と認定される」は即時化されますが、「敵がよろける見た目」は即時化されません。

即時化 理由
パリィの成功判定 Receiver Authority で force-overwrite 済み
敵のよろけアニメーション Multicast 経由(サーバー → 全クライアント)

「でもパリィ中に敵がまだ剣を振ってくるんじゃないの?」と気になったので、ここで整理します。

50〜100ms の間、自分のパリィアニメが再生されているのに、敵はまだ攻撃モーションのままに見えます。
よろけアニメの Multicast が届いてはじめて、敵がよろけ始めます。

つまり見た目のズレは確かに起きます。

ただし、ダメージは来ません。
サーバーではパリィ処理が完了した瞬間に AI はスタガー状態になっています。
その後の AI の攻撃はサーバー側でブロックされるため、画面上で剣が振られて見えても当たり判定はありません。

状態
見た目 50〜100ms、敵がまだ剣を振っているように見える
ダメージ 来ない(サーバーでスタガー済み)

「当たり判定はズレない」さえ保証されていれば、PvE として十分と判断しています。
敵アニメーションの先行予測まで実装すると複雑さが跳ね上がり、今のスコープには合いません。


設計として面白かった点

今回の設計で一番「なるほど」と思ったのは、サーバーに嘘をつかせるという発想でした。

サーバーは「ガードしていない」と思っている。
でもクライアントは「確かにガードしていた」と知っている。
だからクライアントが「自分はガードしていたよ」とサーバーに伝えて、サーバーの認識を書き換える。

PvE ゲームだからこそ成立する方式です。
PvP だとクライアントの情報は信頼できないので、この設計は使えません。


現在の状況

設計は確定しました。実装は進行中です。
完成したら改めてデバッグ方法と一緒に続きを書きます。

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