フレームの 6 フェーズ
エンジンの担当 この章は、エンジンが 1 フレームの中でやっていることの内側の話です。bot を書くだけなら、読まなくても大丈夫です。
試合は 3600 フレーム(60 秒)です。 その 1 フレーム、つまり 1/60 秒のあいだに、エンジンは決まった 6 つの仕事を この順番どおりにこなします。
順番が決まっていないと何が困るか、先に例を挙げます。
自分の弾と相手の弾が、同じフレームに同じ場所へ来た。 「相殺」が先か、「被弾」が先か?
相殺が先なら 2 発とも消えて、誰もダメージを受けません。 被弾が先なら、どちらかが 1 減ります。
どちらも自然に思えます。だから決めておかないと、実装ごとに勝敗が変わります。
順に見ていきます。
Phase 1 — 入力を受け取る
Section titled “Phase 1 — 入力を受け取る”6 フレームに 1 回だけ、update が呼ばれます。
frame を 6 で割り切れるときです(フレーム 0, 6, 12, …)。
- A の bot を動かす
- B の bot を動かす
- 返ってきた値を丸めて、入力として確定する
それ以外の 5 フレームでは、前の入力がそのまま使われます。
返ってきた値は「丸められて」から使われます。
steer に 45.000001 と書いても 45.0000 と書いても、同じ整数になります。
小数の細かい差で結果が変わらないようにするためです。
Phase 2 — 向きを変える・動く
Section titled “Phase 2 — 向きを変える・動く”A → B の順に、1 台ずつ処理します。
- 旋回 — いまの角度と目標角度の差を求め、1 フレームで回れる分だけ回す
- 速度を決める —
driveとバリア中かどうかで決まる - X 方向に動かす → 壁にめりこんでいたら押し戻す
- Y 方向に動かす → 壁にめりこんでいたら押し戻す
2 台とも動かし終えたあと、タンクどうしの重なりを解きます。
タンクどうしが重なったときは、両方を同じだけ押し返します。 「どちらが先に止まっていたか」を見ません。 そういう履歴に頼る判定は、決定論のバグを生みやすいからです。
Phase 3 — 弾を撃つ・飛ぶ・はね返る
Section titled “Phase 3 — 弾を撃つ・飛ぶ・はね返る”- 発射 —
fireがTrueで、リロードが終わっていて、バリアを張っていないタンクが撃つ。A → B の順に、弾に通し番号をつける - 移動 — すでに飛んでいる弾を、通し番号の小さい順に 1 フレーム分進める
- 壁との判定 — 鉄ならはね返る、木箱なら耐久を 1 減らして消える
- 寿命切れ — 3 秒(180 フレーム)たった弾を消す
通し番号は、ここから先ずっと「迷ったときの順番」として使われます。
Phase 4 — 相殺してから、被弾
Section titled “Phase 4 — 相殺してから、被弾”冒頭の問いの答えがここにあります。
相殺が先です。
- 弾どうしの相殺 — 通し番号の小さい順にペアを調べ、0.20 マス以内に近づいた 2 発を消す
- タンクへの命中 — 生き残った弾を、通し番号の小さい順に調べる
- バリアを張っていて、弾が正面 45 度以内から来た → 無効化(弾だけ消える)
- それ以外 → HP −1、弾は消える
相殺を先にしたのは、撃ち合いを成立させるためです。 被弾が先だと、正面から撃ち合ったとき「弾が消える前に当たってしまう」ことが起き、 相殺のルールがほとんど働かなくなります。
Phase 5 — まわりが変わる
Section titled “Phase 5 — まわりが変わる”- こわれた木箱(耐久 0)を平地に変える
- サプライを落とす・拾わせる。効果の残り時間を減らす
- 縮小エリアを進める。鉄になるマスにタンクがいたら即死
- リロード・バリア持続・クールダウンのカウンタを減らす
縮小の即死が Phase 5 にあることに注目してください。 その前に動き終わっています。 つまり「縮小が来る前に逃げ切れるかどうか」は、 そのフレームの Phase 2 の動きで決まっています。
Phase 6 — 判定して、記録する
Section titled “Phase 6 — 判定して、記録する”- 統計を更新する(与ダメージ・被弾数・移動距離・発射数・命中数)
- このフレームの状態を決まった形のバイト列にして、ハッシュに投入する
- リプレイの配列に、このフレームを積む
- どちらかの HP が 0、またはフレーム 3599 なら終了
2 番目がリプレイと検証のかなめです。
3600 フレームぶんを積み重ねた結果が、その試合の result_hash になります。
1 フレームでも違えば、ハッシュは似ても似つかない値になります。
「思考は 10 Hz、世界は 60 Hz」
Section titled “「思考は 10 Hz、世界は 60 Hz」”このページでいちばん覚えておく価値があるのは、たぶんここです。
つまり、あなたの bot が「止まれ」と言うまで、タンクは進み続けます。
- 0.1 秒のあいだ、判断は変えられない
- そのあいだに戦車は 0.3 マス、弾は 0.8 マス進む
- 「気づいてから避ける」では間に合わないことがある
だから強い bot は、先回りして考えます。
「いま危ないか」ではなく「0.1 秒後に危ないか」を見るのです。
incoming_missiles に eta(あと何秒で最接近するか)が入っているのは、そのためです。
def update(state): if len(state.incoming_missiles) > 0: m = state.incoming_missiles[0] # 次に考えるのは 0.1 秒後。それまでに当たる弾は、いま対処する if m.eta < 0.15: toward = angle_to(state.me.x, state.me.y, m.x, m.y) return {"drive": "stop", "steer": toward, "fire": False, "barrier": True}
want = angle_to(state.me.x, state.me.y, state.enemy.x, state.enemy.y) return {"drive": "forward", "steer": want, "fire": True, "barrier": False}描くのは、ぜんぶ終わってから
Section titled “描くのは、ぜんぶ終わってから”もうひとつ、設計上の特徴があります。
試合は、画面に何も描かないまま最後まで走りきります。 60 秒の試合が、実時間で 0.1〜0.3 秒ほどで終わります。 そのあとで、できあがったリプレイを再生して画面に描いています。
この分けかたのおかげで、次のことが追加の実装なしで手に入ります。
- 早送り・巻き戻し・コマ送り(配列の添字を動かすだけ)
- 画面がカクついても試合結果が変わらない
- タブを裏に回しても影響がない
逆に言えば、描画を待つ処理をシミュレーションの中に入れてはいけません。 入れた瞬間に、この性質はすべて失われます。
- なぜここまで順番にこだわるのか → 決定論と固定小数点
- 弾の当たりかたを詳しく → たまとバリア
etaの読み方 → state リファレンス