第05回 当たり判定・被ダメージと状態遷移
接触から表示とゲーム進行へつなぐ
敵や弾が触れたときには、距離の判定、HPの変更、アニメーション、効果音、勝敗判定が関わります。それぞれの処理を分けて読むと、被弾のルールや見せ方を変更できます。
第02回では、動作と画像の対応をPlayerClipsで設定しました。この回では、どの条件でHurtやDefeatedが選ばれるかをPlayerの処理と結び付けます。
手元の画像やルールを使い、作品に合う接触距離、被弾後の時間、勝敗条件を考えてください。
変更したい内容と編集場所
| 変えたい内容 | ファイル・処理 |
|---|---|
| 主人公の判定半径・無敵時間・初期HP | Source/AutoShooter/Tuning/GameTuning.hのplayer名前空間 |
| 敵の判定半径・接触ダメージ | Source/AutoShooter/Objects/Enemy.cppのRows |
| 弾の判定半径 | GameTuning.hのattack::ShotRadius |
| 弾の移動履歴・貫通・命中履歴 | Source/AutoShooter/Objects/Attack.cpp |
| 主人公のHPと表示状態 | Source/AutoShooter/Objects/Player.cpp |
| 被弾・やられの画像と再生時間 | Source/AutoShooter/Resources/CharacterAnimation.h |
| 判定の呼び出し、撃破後の処理、勝敗 | Source/AutoShooter/GameModel.cpp |
| 点と線分の距離計算 | Source/DxGame/Math.hのSegmentDistanceSquared |
共通の数学処理は距離を計算します。どの物体を比べ、命中後に何を起こすかはゲーム側が決めます。距離計算の関数にHPや画像を持たせる必要はありません。
判定とダメージを適用する順序
GameModel::UpdatePlayingは、主人公、敵、攻撃を更新してから当たり判定を呼びます。ResolveCollisionsでは、弾と敵、衝撃波と敵、主人公と敵の順に調べます。
for (auto& shot : attacks.GetMutableShots())
{
if (!shot.IsActive())
continue;
for (auto& enemy : enemies.GetAll())
{
if (shot.TryHit(enemy))
DamageEnemy(enemy, shot.GetDamage());
if (!shot.IsActive())
break;
}
}TryHitは、今回その攻撃から命中が成立したかを返します。HPを減らす処理はGameModel::DamageEnemyへ分けています。
貫通しない弾は、敵へ命中すると非活動になります。そこで内側のループをbreakし、ほかの敵を調べるのを終えます。
この処理は敵配列の順で調べています。弾の線分上で手前にいる敵から並べ替えているわけではありません。手前の敵へ先に当てるルールを作るなら、命中候補と線分上の位置を求め、その順序で適用する処理が接続箇所になります。
主人公と敵は円で判定する
GameModel::ResolveCollisionsの接触部分です。
for (const auto& enemy : enemies.GetItems())
{
if (!enemy.IsActive())
continue;
float radius = enemy.GetRadius() + tuning::player::Radius;
if (LengthSquared(enemy.GetPosition() - player.GetPosition()) < radius * radius &&
player.TryTakeDamage(enemy.GetDamage()))
{
events.damage = true;
effects.Spawn(player.GetPosition(), 0xFF6688, 70);
}
}中心間の二乗距離が、半径の和の二乗より小さいときに接触とします。配布版の主人公半径は18pxです。画像の外枠や透過部分は、この計算に使いません。
&&は両方の条件が成立するかを調べます。左側が成立した場合だけTryTakeDamageを呼び、実際にダメージを受けたときに効果を出します。
円の中心はゲーム内の座標です。画像の見た目の中央とは限らず、第02回で設定した描画基準点とも関係します。絵の大きさや基準点を変えた作品では、どこで接触するかを実際に確かめられます。
例えば主人公の判定を半径12pxにするなら、GameTuning.hのplayer::Radiusの1行を置き換えます。
constexpr float Radius = 12;これは表示サイズを変えず、接触距離だけを変える例です。敵の判定半径はEnemy.cppの基本定義で指定します。ウェーブで上書きするHP・速度とは別の項目です。
弾は前の位置から現在位置まで調べる
現在位置だけでは通過を見落とす
弾が1回の更新で敵を通り越すと、移動前も移動後も敵の外側になる場合があります。現在位置だけの判定では、その間に通ったことが分かりません。
Shot::Updateでは、移動前の位置をpreviousへ保存します。
void Shot::Update(float seconds)
{
if (!active)
return;
previous = position;
position = position + velocity * seconds;
life -= seconds;
if (life < 0)
active = false;
}TryHitでは、previousからpositionまでの線分と敵の中心との最短距離を使います。弾の半径を敵の半径へ足すことで、弾の太さも含めて調べます。
bool Shot::TryHit(const Enemy& enemy)
{
if (!active || !enemy.IsActive())
return false;
float radius = enemy.GetRadius() + tuning::attack::ShotRadius;
if (SegmentDistanceSquared(enemy.GetPosition(), previous, position) > radius * radius)
return false;
auto end = hitIds.begin() + hitCount;
if (std::find(hitIds.begin(), end, enemy.GetId()) != end)
return false;
hitIds[hitCount++] = enemy.GetId();
if (--hitsLeft == 0)
active = false;
return true;
}配布版は敵の更新後の中心に対し、弾の移動線分を比較します。弾の通過を調べる仕組みであり、敵と弾の両方の移動を連続時間で厳密に追跡する処理ではありません。
また、寿命が尽きて非活動になった弾は判定しません。移動・寿命更新・判定の順序も、命中結果に関わります。
線分上の最も近い点
Math.hのSegmentDistanceSquaredは、点から線分までの二乗距離を返します。
inline float SegmentDistanceSquared(Vector2 point, Vector2 start, Vector2 end)
{
auto segment = end - start;
float length = LengthSquared(segment);
if (length < 0.001f)
return LengthSquared(point - start);
auto relative = point - start;
float t = std::clamp(
(relative.x * segment.x + relative.y * segment.y) / length,
0.0f, 1.0f);
return LengthSquared(point - (start + segment * t));
}tは線分上の位置を表す比率で、0が始点、1が終点です。内積から求めた比率をstd::clampで0〜1へ制限し、線分の延長上ではなく線分上の点を求めます。
弾の半径を変える場合は、GameTuning.hのattack::ShotRadiusを編集します。例えば次の1行へ置き換えると、画像は同じまま半径8pxで判定します。
constexpr float ShotRadius = 8;貫通数と命中履歴
Shot::Spawnでは、命中できる残り体数を設定します。
hitsLeft = 1 + stats.penetration;
hitCount = 0;penetrationは追加で貫通できる敵の数です。0なら合計1体、2なら合計3体に命中できます。貫通数を増やしても、同じ敵へ繰り返し当たる設定にはなりません。
TryHitのstd::findは、すでに記録したIDの範囲を検索します。見つかったらfalse、初めてならIDを追加して残り体数を減らします。
命中履歴の容量はattack::HitCapacityです。Spawnと性能表の検査では、この容量に収まる貫通数かを確認します。容量と貫通数は、同じ上限を参照する形で接続されています。
衝撃波も命中履歴を持ちますが、敵のプール枠ごとにIDを保存する別の方法です。詳しくは第06回で扱います。
命中から敵の撃破へ
GameModel::DamageEnemyは、敵のHP変更と撃破後の処理をつなぎます。
void GameModel::DamageEnemy(Enemy& enemy, int damage)
{
bool defeated = enemy.TakeDamage(damage);
events.hit = true;
effects.Spawn(enemy.GetPosition(), Enemy::GetDefinition(enemy.GetType()).effectColor, 30);
if (!defeated)
return;
++kills;
effects.Spawn(enemy.GetPosition(), 0xFFB098, 65);
pickups.Spawn(enemy.GetPosition(), enemy.GetExperience());
}Enemy&は変更可能な参照です。コピーではなく、その敵自身へダメージを適用します。
TakeDamageは、今回のダメージで倒れたときにtrueを返します。命中通知は通常の命中でも出しますが、撃破数の加算と経験値生成は撃破時だけです。
攻撃ごとに撃破処理を重複して書かず、この関数へダメージを渡すことで共通化しています。別の攻撃方式からも同じ処理を利用できます。
被弾、無敵時間、アニメーション
Player.cppのTryTakeDamageを確認します。
bool Player::TryTakeDamage(int damage)
{
if (invincible > 0 || hp == 0)
return false;
hp = std::max(0, hp - damage);
invincible = tuning::player::InvincibleSeconds;
const auto& hurtClip = PlayerClip(Player::Motion::Hurt);
hurtSeconds = hurtClip.frameCount * hurtClip.frameSeconds;
ChangeMotion(hp == 0 ? Player::Motion::Defeated : Player::Motion::Hurt);
motionSeconds = 0;
return true;
}無敵時間中、またはHP0なら被弾を受け付けません。受け付けた場合はHPを減らし、無敵時間を設定します。残りHPが0ならDefeated、生きていればHurtへ切り替えます。
hurtSecondsは、第02回で編集したHurtクリップのコマ数×1コマ秒数です。この実装では、被弾表示を優先する時間を再生定義から求めています。Hurtのコマ数や秒数を変えると、その優先時間も変わります。
無敵時間は別のinvincibleで管理しています。被弾アニメーションが終わっても無敵が続く設定や、被弾表示中に無敵が終わる設定が可能です。描画側の点滅は無敵時間を参照します。
例えば無敵時間を1.2秒にするなら、GameTuning.hのplayer::InvincibleSecondsを置き換えます。
constexpr float InvincibleSeconds = 1.2f;接触が成立しても、この残り時間が正なら追加ダメージを受けません。ただし、敵との物理的な押し戻しや接触禁止を設定する値ではありません。
無敵時間を0にすると、同じ更新内で調べる別の敵からもダメージを受け得ます。どの単位で被弾を制限するかは、ゲームのルールとして変更できます。
待機・走行・被弾・やられの切り替え
Player::Updateでは、移動後に無敵時間と被弾表示時間を減らします。
invincible = std::max(0.0f, invincible - seconds);
hurtSeconds = std::max(0.0f, hurtSeconds - seconds);その後、実際の移動量と被弾表示時間から動作を選びます。
bool moved = LengthSquared(position - previous) > 0;
ChangeMotion(hurtSeconds > 0 ? Player::Motion::Hurt : moved ? Player::Motion::Run : Player::Motion::Idle);
UpdateAnimation(seconds);被弾表示を優先し、その時間が終わったら、動いていればRun、動いていなければIdleです。入力そのものではなく座標の変化を見ているため、画面端で入力しても座標が変わらなければIdleになります。
ChangeMotionは、動作が変わった場合だけ経過時間を0へ戻します。
void Player::ChangeMotion(Player::Motion next)
{
if (motion == next)
return;
motion = next;
motionSeconds = 0;
}同じRunが続くたびに0へ戻すと、先頭コマばかり表示されます。同じ動作なら再生時間を継続し、切り替わったときに先頭から再生する設計です。
TryTakeDamageは新たな被弾の際にmotionSecondsを明示的に0へ戻します。同じHurt中の再被弾でも、被弾アニメーションを先頭から始められます。
HP0のときはDefeatedを維持します。GameModelはGameOver中もUpdateAnimationを呼ぶため、ゲーム進行を止めながら、やられの表示だけを進められます。
ゲーム全体の状態と勝敗
Player::Motionは主人公の表示状態です。ゲーム全体の進行状態は、別のGameModel::Stateで管理します。
| 状態 | 更新の扱い |
|---|---|
| Playing | 移動、攻撃、判定、ゲーム内時間を更新 |
| Upgrade | 強化選択を受け付け、戦闘更新へ進まない |
| Paused | 戦闘更新へ進まない |
| GameOver | 主人公の表示時間を進め、再開入力を受け付ける |
| Clear | 再開入力を受け付ける |
| Title | 開始入力を受け付ける。配布版は通常起動でPlayingから始まる |
UpdatePlayingの最後で勝敗を判定します。
if (player.GetHp() <= 0)
ChangeState(State::GameOver);
else
if (!preview && playTime >= tuning::PlaySeconds)
ChangeState(State::Clear);同じ更新でHP0と制限時間到達が重なった場合は、GameOverを優先します。previewは見本用の動作設定を識別する値で、通常プレイではfalseです。
配布版の制限時間は180秒です。クリア時間を変える場合はGameTuning.hのPlaySecondsと、EnemyWaves.cppのウェーブ表を対応させます。ウェーブ表は、各開始時刻がクリア時刻より前になることなどを検査しています。
リセットが初期化するもの
RキーのリセットはGameModel::Startを呼びます。主人公だけでなく、敵、弾、衝撃波、経験値、効果、レベル、時間などを初期化します。
player.Reset();
enemies.Reset();
attacks.Reset();
pickups.Reset();
effects.Reset();
kills = 0;
playerLevel = 1;
experience = 0;
choiceCount = 0;
playTime = 0;
events = {};武装も一度レベル0へ戻し、配布版ではBoltをレベル1に設定します。作品の開始武装を変える場合は、Startの武装初期化部分が編集箇所です。
リセットは現在のコードで定義した開始状態へ戻る操作です。編集したコードや画像はそのまま残ります。
自分の被弾ルールを確かめる
接触距離、HPの減少、被弾表示、無敵中の点滅、敗北後の表示をつなげて確認します。例えば被弾画像を長くした作品では、移動しながらHurtが続くことや、無敵時間との長さの違いも見えます。
判定半径を変えたときは、画像のどこで触れるかを見ます。弾速を変えたときは、移動線分による命中と寿命の関係を確認できます。
無敵時間を長くした結果、どれだけ離脱しやすくなるか、どんな緊張感になるかは、敵や移動ルールによって変わります。実際に遊び、作品に合う設定へ調整してください。変更した判定と表示は、そのまま次の制作へ引き継ぎます。