はじめに
FPSゲームにおいて,リコイル(射撃時の反動のこと)というものはプレイヤーにとって正確な射撃を阻害する要素のひとつであり,ありがたくないものです.しかし,リコイルは射撃に適度な難易度と奥深さを与える要素のひとつでもあり,FPSゲームにおいて必須の要素と言えるでしょう.
そんなリコイルですが,世の中には連射時にクロスヘア(画面の中心に表示される十字のマーク)に対して弾がまっすぐにとばず,さらに静止状態でないと正確な射撃が行えないゲームがあります.これらの一見不便そうな特性は,対戦型のゲームにおいてプレースタイルを開発者の意図する方向性に導きゲームを面白いものにする効果を持っていますが,対戦型のゲームでなくてもこれらの特性はゲームをより面白くする可能性を秘めていると考えられます.
たとえば,何かから逃げ回りながら戦闘を行わなければならないようなゲームにおいては,「逃げる」という選択と,正確な射撃のために「止まる」という対立する選択をプレイヤーに迫ることとなり,ゲームをより奥深いものにするでしょう.さらに,連射時には射撃の正確さが損なわれていくとなれば,プレイヤーは立ち止まって少し撃ち,また逃げるという行為を繰り返さざるを得なくなり,このプレイスタイルを習得するためにプレイヤーはそのゲームを何度もプレイしなければならなくなります.
また開発者としても,理想的なリコイルを追及することは面白いことだと思います.今回は,そんなリコイルを実現するにはどうすればいいか,ということを自分なりに工夫した結果得られた方法をご紹介します.
【図1】完成したシステムによる射撃の様子.連射時間が長くなるごとに,拡散が大きくなっていることがわかる.また,ある程度弾を発射すると拡散が主に水平方向に制限され,左右に周期的に拡散の方向が入れ替わっていることがわかる.なお,射撃後ある程度時間が経過したあとに再び連射しても,同じリコイル,スプレッドパターンが得られる.目標のスプレッドパターンはある有名なFPSゲームに存在するスプレッドパターンとした.
「スプレッド(拡散)」と「リコイル」
概要では,射撃により生じるあらゆる形態の反動や射撃のぶれをひとくくりに「リコイル」と称してきましたが,以降は射撃に伴う視点のぶれを「リコイル」,クロスヘアからの弾のずれを「スプレッド」,あるいは「拡散」と表現します.
理想的な拡散とリコイル
説明に入る前に,まずはどのようなスプレッド,リコイルを目指すかを簡単に整理しておきます.
リコイル
- 射撃時に垂直方向,水平方向に視点が急速にぶれ,その後徐々にもとの位置に戻る.
- 連射中とくにリコイルへの対処を行わない場合,視点が上に移動する.
- ある程度連射時間が経過すると,上方向の視点の移動がなくなる.
スプレッド
- 静止状態で,前回の射撃から十分に時間が経過している場合は,拡散がほぼない(あるいは,拡散がない).
- 静止状態で,前回の射撃から十分に時間が経過していない場合は,拡散が生じる.
- 前回の射撃からの時間に応じた拡散の大きさは,時間の経過とともに小さくなる.
- 静止していないとき,移動速度の大きさに応じて拡散が大きくなる.
- 連射時は撃った弾の数に応じて,拡散が徐々に大きくなる.
要素の分割
ここで,「リコイル」と「スプレッド」を,さらに細かい要素に分割していきます.
リコイル
- ローリングリコイル:視点方向を軸とした視点のぶれ.
- ノッキングリコイル:垂直方向と水平方向の視点のぶれ.
- パンチングリコイル:FOVのぶれ.
スプレッド
- リフティングスプレッド:ポテンシャルの大きさに伴い増加する上方向への拡散.
- ランダムスプレッド:ポテンシャルの大きさに伴い増加する楕円状の拡散.
- ランニングスプレッド:移動速度の大きさに伴い増加する楕円状の拡散.
※ ローリングリコイルとスライディングリコイルは視覚的な効果しか持たず,プレイヤーの正確な射撃を阻害する効果を持ちません.
※ ポテンシャル:射撃のたびに増加し,時間経過とともに減少していく量(詳細は後述).
※ ポテンシャル,ノッキングリコイルなどの用語は独自のものです.
各要素を図で表すと図2ようになります.
【図2】リコイルとスプレッドの要素.リコイルは,主にカメラをカメラの中心を通る軸まわりに細かく回転させることで実現する.スプレッドは,まずリフティングスプレッドを計算したあと,そこを中心とした楕円内に存在するベクトルをランダムスプレッドとして,リフティングスプレッドに加算します.さらに,ランダムスプレッドを加算して得られたベクトルに対してランニングスプレッドを加算します.
スプレッドに関係する事柄
ポテンシャル
ランダムスプレッドおよびリフティングスプレッドの大きさを決めるために,ポテンシャル($P$)を定義します.このポテンシャルは射撃のたびに増大し,時間経過とともに,経過した時間だけ減少します.射撃毎にポテンシャルを下記のように更新していきます.
p_{\rm next} = P_{\rm current} + f + c_{\rm p} P_{\rm max} \\
P_{\rm next} = \left\{
\begin{array}{l}
p_{\rm next} & (p_{\rm next} \leq P_{\rm max} + f) \\
P_{\rm max} + f & (p_{\rm next} > P_{\rm max} + f)
\end{array}
\right. \\
ここで,$f$ は射撃間隔,$P_{\rm max}$ はポテンシャルの最大値,$c_p$ は射撃ごとにポテンシャルの最大値の何割だけポテンシャルを増加させるかの係数で,0以上1以下の値をもつ係数です.$P_{\rm next}$ は射撃後のポテンシャル,$P_{\rm current}$ は射撃前のポテンシャルです.ポテンシャルの最大値は,何秒経過するとポテンシャルが0になるかを表します.
また,現在のポテンシャルをポテンシャルの最大値で割った値 $P'$ をポテンシャル率と呼ぶことにします.
座標系
スプレッドを計算する際には,視点方向をp,視点の左右方向をq,視点の上下方向をrとした,pqr座標系を定義します.まずはじめに $(p, q, r) = (10, q', 0)$ のベクトル(pqrベクトル)を用意し,このベクトルの $q$ 成分と $r$ 成分にそれぞれのスプレッド要素がランダムな値を加算していくことで,新たなベクトル(PQRベクトル)を得ます.そして,PQRベクトルをxyz座標系に直すことで,射撃方向を決定します.
ここで,$q'$ は前回の射撃の際に生成されたPQRベクトルの $q$ 成分($q_{\rm prev}$)から導かれる値で,下記のような方法で計算するのが良いでしょう.
q' = q_{\rm prev} P'^{\alpha}
なお,$P'$ はポテンシャル率,$\alpha$ は1以上の任意の定数です.
※ ここではpqrベクトルの $p$ 成分の初期値を10としたが,この値は変えても問題ない.
【図3】座標系の定義と,pqrベクトルおよびPQRベクトル.原点はワールドの原点ではなく,プレイヤーのカメラの中心に一致する点に注意が必要である.
シード値の管理
スプレッドおよびリコイルのパターンを一定にしたい場合,スプレッドおよびリコイルを計算するときのシード値は別にしたほうが良いでしょう.また,シード値はベースのシード値($S_{\rm base}$)とそれに加算する値($S_{\rm add}$)に分けておき,射撃のたびにたとえば $S_{\rm add}$ に1を加え,$S_{\rm base} + S_{\rm add}$で乱数を初期化します.$S_{\rm add}$ はポテンシャルが0になったときに0とすると,前回の射撃から十分に時間が経過したあとの連射時のスプレッドおよびリコイルのパターンが一定となり,経過時間が十分でないときに連射を始めた場合には疑似的にランダムなパターンが得られます.
各要素の実装方法
ローリングリコイル
ローリングリコイルは,はじめに勢いよく視点が傾き,徐々に元にもどるような挙動が望ましいと考えられます.また視点の傾きが元に戻る際に,元の視点の傾きを超過することは大きな問題にならないでしょう.そのため,視点方向を軸とした回転の大きさの決定方法としては,臨界減衰,不足減衰という2種類の減衰振動モデルが採用可能です.
(1)臨界減衰モデル
臨界減衰モデルを採用するとき,開発者は下記の2つの値を決めておくとよいでしょう.
- $t_{\rm 99}$:振動が99 %減衰するときの時間.具体的には,下記の $\theta_r$ に関する式の $t e^{-\gamma t}$ の値が0.01となるときの $t$ の値.
- $A_1$:最初のピークにおける極大値(極小値).ランダムで正の値,負の値にする.
また,射撃から $t$ 秒後の視点の角速度 $\omega_r$ と傾き $\theta_r$ は下記の式で表されます.
\left\{
\begin{array}{l}
\omega_r = \omega_{\rm 0} (1 - \gamma t) e^{-\gamma t} \\
\theta_r = \omega_{\rm 0} t e^{-\gamma t}
\end{array}
\right. \\
\\
\begin{array}{l}
\gamma = ln(100 t_{99}) / t_{99} \\
\omega_0 = e A_1 \gamma
\end{array}
【図4】臨界減衰.一度ピークを迎えると,あとは次第に0に収束していく.なお,図は初期値を0,微分の初期値を0より大きな値と仮定したときの臨界減衰の挙動を表す.
(2)不足減衰モデル
不足減衰モデルを採用するとき,開発者は下記の3つの値を決めておくとよいでしょう.
- $t_1$:最初のピークを迎えるまでの時間.
- $t_{\rm 99}$:振動が99 %減衰するときの時間.具体的には,下記の $\theta_r$ に関する式の $e^{-\gamma t}$ の値が0.01となるときの $t$ の値.
- $A_1$:最初のピークにおける極大値(極小値).ランダムで正の値,負の値にする.
また,射撃から $t$ 秒後の視点の角速度 $\omega_r$ と傾き $\theta_r$ は下記の式で表されます.
\left\{
\begin{array}{l}
\omega_r = \omega_0 \{\omega cos(\omega t) - \gamma sin(\omega t)\} e^{-\gamma t} \\
\theta_r = \omega_0 e^{-\omega t} sin(\omega t)
\end{array}
\right. \\
\\
\begin{array}{l}
\omega = \pi / (2 t_1) \\
\gamma = ln(100) / t_{99} \\
\omega_0 = A_1 / e^{-\gamma t_1} \\
\end{array}
【図5】不足減衰.周期的にピークを迎えながらも,徐々に振幅が小さくなっていくことが特徴的である.なお,図は初期値を0,微分の初期値を0より大きな値とした場合の不足減衰の挙動を表す.
ノッキングリコイル
ノッキングリコイルも,勢いよく垂直方向と水平方向に視点がぶれ,徐々に元にもどるような挙動が望ましいです.しかし,ローリングリコイルと違って,視点が元の位置に戻る際に元の視点の位置を超過するような挙動は望ましくありません(射撃間隔によって,視点が下にさがってしまうため).よって,ノッキングリコイルに対しては臨界減衰モデルのみが妥当と言えるでしょう.
また,ノッキングリコイルにおいても,開発者はローリングリコイルで定めたような2つの値を決める必要がありますが,ノッキングリコイルにおいては,$t_{99}$の値を銃の射撃間隔 $f$ よりも大きくする必要があります.この条件を満たさない場合,ノッキングリコイルによって視点がぐらつくだけで,プレイヤーはとくにコントロールしなくてもクロスヘアがつねに最初に合わせた位置に留まってしまうこととなります.
ポテンシャルが最大となった場合は垂直方向のノッキングリコイルを0にします.際限なく上方向に視点が移動することを防ぐためです.
パンチングリコイル
パンチングリコイルも臨界減衰モデルと不足減衰モデルの両方が採用可能ですが,臨界減衰モデルのほうがおすすめです.
リフティングスプレッド
リフティングスプレッドは$r$ 成分(垂直成分)のみに関係するスプレッドの要素となります.$r$ 成分に加算する値は下記のように算出します.
\Delta r_{\rm lift} = M_{\rm lift} P'^a
ここで,$M_{\rm lift}$ はリフティングスプレッドの大きさ,$P'$ はポテンシャル率,$a$ は1以上の任意の定数です.
※ リフティングスプレッドを算出するときに,乱数は使用しないほうが良いと考えています.垂直成分のぶれは,ランダムスプレッドのみに依存かたちにするとスプレッドパターンがきれいになります.
ランダムスプレッド
ランダムスプレッドは,水平成分($q$ 成分)および垂直成分($r$ 成分)に関係するスプレッドの要素となります.ここで,乱数を使って水平成分および垂直成分の値を別々に決めると,スプレッドパターンが長方形となってしまいます.できれば,スプレッドパターンは横長の楕円にしたいところです.そこで,極座標系に直して半径と角度を乱数によって計算する方法が考えられますが,この方法だと水平方向へのスプレッドが小さくなる確率が大きくなるためおすすめできません.おすすめの方法は,水平成分の値をまず乱数によって決定し,垂直成分は,その水平成分の位置において垂直成分が取りうる値の範囲内で決める方法です.
また,水平成分に加える値の符号を決める際には少し工夫が必要です.たとえば,水平成分の符号を毎回ランダムに決定してしまうと,プレイヤーは水平方向のスプレッドを制御することが不可能になってしまいます.そのため,たとえば $S_{\rm add}$ の値を使用して,「$S_{\rm add}$ が0から1のときはプラス,2から5のときはマイナス...26以降は8発ごとに正負が入れ替わる」といったような決め方にします.正負の区切り方は乱数に任せず,開発者が任意に定めるほうがいいと思います.
数式で表すと下記のようになります.ここで,$M_{\rm rand,h}$ は水平方向のランダムスプレッドの最大値,$M_{\rm rand,v}$ は垂直方向のランダムスプレッドの最大値,$b$ は1以上の任意の定数です.$Range()$ は,引数を最大値としてランダムな値を返す関数,$\epsilon()$ は引数に応じて-1 か 1 を返す関数です.なお,$Range()$ 関数が返す値の最小値は0にする必要はありません.たとえば,与えられた最大値の0.5倍の値を最小値として値を返すように関数を設計すると,ランダムスプレッドによって常にスプレッドが増大していくことが期待できます.
\left\{
\begin{array}{l}
\Delta q_{\rm rand} = \epsilon(S_b) Range(M_{\rm rand, h} P'^b) \\
\Delta r_{\rm rand} = Range(M_{\rm rand, v} \sqrt{1 - (\Delta q_{\rm rand} / M_{\rm rand, h})^2})
\end{array}
\right. \\
【図6】ランダムスプレッドの計算方法.はじめに水平方向の値を決め,次に垂直方向の値を決める.
【図7】水平成分の符号をランダムに決定した場合のスプレッドのようす.図1と比較して,水平方向のスプレッドに周期性がなく,プレイヤーがスプレッドを制御することが非常に困難になることが予想される.
ランニングスプレッド
ランニングスプレッドも,楕円形のスプレッドパターンが望ましいと言えます.しかし,こちらは極端に横長な楕円のスプレッドパターンにする必要はないため,半径と角度を乱数によって定め,$q$ 成分と $r$ 成分に加算する値を決定する方法も採用可能だと思います.
また,ランニングスプレッドを計算するときのシード値として $S_{\rm base}$ と $S_{\rm add}$ の和を使用すると,「走り撃ちもじつはパターンが存在する」ということになってしまいます.そのため,ランニングスプレッドを計算するときの乱数の初期化は,たとえば現在の時間を使うなどの方法で行う必要があります.
ランニングスプレッドのスプレッドパターンを完全な円とした場合,角度を $-\pi$ から $\pi$ の間でランダムに決め,半径 $l$ を下記のように定めます.ここで,$v$ はプレイヤーの現在の速度の大きさ,$V$ はプレイヤーの速度の大きさの最大値です.$c$ は1以上の任意の定数です.
l = Range(M_{\rm run} (v / V)^c) \\
ランニングスプレッドにおいては,$Range()$ 関数が返す値の最小値は 0 とするのが望ましいと思います.走りながらでもクロスヘアに対してまっすぐ弾がとぶラッキーがあってもいいのではないかと思うためです.
【図8】ランニングスプレッドによる拡散のようす.クロスヘアで狙った位置から大きく外れていることがわかる.
最後に
完全ガイドなどとタイトルに入れましたが,ここで紹介した方法はあくまで個人的に工夫して得られた方法に過ぎず,あらゆるゲームで採用されている一般的な方法でもなければ,既存の方法の欠点を克服する画期的な方法でもありません.むしろまだまだ欠点が残されていると感じています(とくに$S_{\rm add}$を使用したランダムスプレッドの水平成分の符号の決定方法について.途中でフルオート状態を解除し,再びフルオートで撃ちなおしたときに水平方向のスプレッドが大きくなってしまう問題がある).しかし,これを読んで面白そう,やってみたいと感じたらぜひご自身で理想のリコイル・スプレッドを追及してみてください.
参考
- 不足減衰と臨界減衰