[DevLog #006] 「回避したのに当たった」を永遠になくすために、ダメージの判定をクライアントに委ねた話

DevLog

はじめに

アクションゲームで一番がっかりする瞬間のひとつが、「回避したのに当たった」です。

ちゃんとボタンを押した。キャラクターも転がった。なのに、ダメージを受けた。

シングルプレイならまずない体験ですが、オンラインマルチプレイでは珍しくありません。今回はこの問題をどうやって解決したかを書きます。


なぜ「回避したのに当たる」のか

まず、なぜこれが起きるかを説明します。

マルチプレイゲームでは、ダメージ判定はサーバーが行います。流れはこうです。

プレイヤーが回避ボタンを押す
  → サーバーへ「回避開始」を通知(Server RPC)
  → サーバーが受け取る(Ping分だけ後)
  → ここからサーバーが「回避中」と認識

問題は、「ボタンを押した瞬間」から「サーバーが認識する瞬間」の間に、タイムラグがあることです。

今回使っているアセット(FCS)には ClientLagFreeRoll という機能があり、ボタンを押した瞬間に自分の画面ではすぐ回避アニメーションが始まります。操作感のためにあえてこうなっています。

でも、サーバーはまだ「このプレイヤーは回避していない」と思っています。

その間に敵AIがサーバー側で攻撃判定を出すと、「クライアントでは回避中。でもサーバーでは回避していない」という矛盾が生まれ、ダメージが通ってしまいます。


最初に思いついた対処法とその問題点

「回避中はダメージを受けないフラグを立てればいい」と思いますよね。

実際、CanTakeDamage という関数の中でそれをやっていました。でもこの判定がサーバー側で動いているため、サーバーが「回避していない」と思っている期間中は意味がありません。

クライアント側で判定すればいい……でも、それだと「クライアントが虚偽申告する」チートのリスクがあります。通常のオンラインゲームでは、クライアントを信用しない設計が基本です。


解決策:「Receiver Authority(受信者権限)」という考え方

今回採用したのが Receiver Authority という設計パターンです。

簡単に言うと「ダメージを受ける側(クライアント)が、自分に当たったかどうかを判定する」という考え方です。

処理の流れはこうなります。

サーバー:「この敵の攻撃が当たった」
  ↓
Client_VerifyHit(クライアントRPC)を送信する
  ↓
クライアント:「今、回避中か?」を自分の状態で確認
  ├─ 回避していない → Server_AcceptDamage(「当たりました」とサーバーへ返す)
  └─ 回避中 → 何もしない(「当たりませんでした」= 無視)
  ↓
サーバー:Server_AcceptDamage を受け取ったときだけ正式なダメージを処理

「サーバーが当たったかどうかを決める」のではなく、「クライアントが受け取るかどうかを決める」に変えたのです。


なぜこれがPvEゲームでは成立するか

「クライアントに権限を渡すのは危険では?」と思う方もいるかもしれません。

確かに、PvPゲームでこれをやると「いつでも無敵です」と申告できるチートになってしまいます。でも、このゲームは**協力プレイ(PvE)**です。チートをしても自分が楽になるだけで、他のプレイヤーへの悪影響はほぼありません。

また、クライアントが「回避中ではない」のに「回避中だ」と虚偽申告してもメリットがなく、逆に「ダメージを受けなければならない状況でも受けない」チートをされた場合のデメリットも、PvEでは大きくありません。

こうした判断から、PvEの協力ゲームでは Receiver Authority は十分に成立する設計です。


実装上で気づいた「落とし穴」

ひとつハマった点があります。

既存の CanTakeDamage 関数は、回避中かどうかを「アニメーションが再生中かどうか(IsPlayingMontage)」で判断していました。

これだと「アニメーションが始まった瞬間」からしか無敵が機能しません。でも今回やりたいのは「ボタンを押した瞬間に無敵」です。

そこで、移動状態を管理する MovementStatusComponent の bIsRolling フラグを使うように変更しました。このフラグはボタンを押した瞬間に立ちます。0フレーム無敵の実現です。

変更前:IsPlayingMontage(アニメーション依存)
変更後:bIsRolling(フラグ依存 = ボタン押下と同時)

さらに「幽霊被弾」も防ぐ

上記だけだと、もうひとつ問題が残ります。

サーバーは「この攻撃は当たらなかった」と知っていても、すでに「ダメージエフェクトを再生しろ」という指令を全クライアントへ送ってしまっていることがあります。このとき、回避したプレイヤーの画面でもヒットエフェクトやダメージ数値が出てしまいます(HP は減らないが見た目だけ食らった演出が出る「幽霊被弾」)。

これを防ぐため、エフェクト・ダメージ表示・ヒットリアクションアニメーションそれぞれの直前にも CanTakeDamage のガードチェックを入れました。

エフェクト再生の直前
  → CanTakeDamage が false(回避中)ならスキップ
Code language: JavaScript (javascript)

地味ですが、これで「数値は減らないけど見た目だけやられた」という違和感がなくなります。


次の課題:ガード(Block)システム

回避の問題を解決したら、次はガードです。

今回の調査で、FCSにはDefensiveComponentというコンポーネントとして、ガードとパリィの処理がすでに実装されていることがわかりました。

機能内容
ガード成功ダメージ分のスタミナを消費(盾装備で半減)
スタミナ枯渇ガードブレイク → よろけ状態
パリィジャストガード成功 → 敵に長時間スタン
突進・ガード崩しガード不可(即ガードブレイク)

機能的にはかなり充実しています。ただ、回避と同じ問題——「アニメーションがサーバーの返答を待って遅れる」「Ping 窓の間に食らう」——が残っています。

ここで気をつけないといけないのが、ガードは回避と同じ方法で直せない点です。

回避は「攻撃が完全にすり抜けた」扱い。ガードは「攻撃が当たったうえで受け止めた」扱いです。もし回避と同じように CanTakeDamage = false にしてしまうと、スタミナ消費もパリィ判定もガードブレイク処理も全部スキップされてしまいます。

そのため、ガードには「ガード状態だったことをサーバーに伝えるフラグbWasBlockingOnClient)」を別途追加して、サーバー側でガード処理に分岐させる設計が必要になります。今回の調査でこれが確定したので、次回の実装で対応します。


今日学んだこと

  • Receiver Authority:ダメージを受ける側に判定権を渡すことで、ラグ窓の問題を解決できる
  • 0フレーム無敵:アニメーション依存ではなくフラグ依存にすることで、ボタンを押した瞬間から無敵にできる
  • ガードと回避は別物:回避は「すり抜け」、ガードは「受け止め」。同じ方法では直せない

現在の状況

回避中に攻撃が当たる問題が解決しました。これで「ちゃんと回避すれば絶対に当たらない」状態になりました。

次回はガードのラグ対策を実装します。スタミナ・パリィ・ガードブレイクという奥深いシステムを、ラグに負けないように整えていきます。


次回:ガード処理をラグに負けない設計にした話

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