コンテンツにスキップ

フレームの 6 フェーズ

エンジンの担当 この章は、エンジンが 1 フレームの中でやっていることの内側の話です。bot を書くだけなら、読まなくても大丈夫です。

試合は 3600 フレーム(60 秒)です。 その 1 フレーム、つまり 1/60 秒のあいだに、エンジンは決まった 6 つの仕事を この順番どおりにこなします。

順番が決まっていないと何が困るか、先に例を挙げます。

自分の弾と相手の弾が、同じフレームに同じ場所へ来た。 「相殺」が先か、「被弾」が先か?

相殺が先なら 2 発とも消えて、誰もダメージを受けません。 被弾が先なら、どちらかが 1 減ります。

どちらも自然に思えます。だから決めておかないと、実装ごとに勝敗が変わります。

1フレームの中で実行される6つのフェーズを、入力受領、旋回と移動、ミサイル生成と移動、弾相殺と被弾判定、環境更新、勝敗判定の順に上から下へつなげた図

順に見ていきます。

6 フレームに 1 回だけupdate が呼ばれます。 frame を 6 で割り切れるときです(フレーム 0, 6, 12, …)。

  • A の bot を動かす
  • B の bot を動かす
  • 返ってきた値を丸めて、入力として確定する

それ以外の 5 フレームでは、前の入力がそのまま使われます

返ってきた値は「丸められて」から使われます。 steer45.000001 と書いても 45.0000 と書いても、同じ整数になります。 小数の細かい差で結果が変わらないようにするためです。

A → B の順に、1 台ずつ処理します。

  1. 旋回 — いまの角度と目標角度の差を求め、1 フレームで回れる分だけ回す
  2. 速度を決めるdrive とバリア中かどうかで決まる
  3. X 方向に動かす → 壁にめりこんでいたら押し戻す
  4. Y 方向に動かす → 壁にめりこんでいたら押し戻す

2 台とも動かし終えたあと、タンクどうしの重なりを解きます。

タンクどうしが重なったときは、両方を同じだけ押し返します。 「どちらが先に止まっていたか」を見ません。 そういう履歴に頼る判定は、決定論のバグを生みやすいからです。

Phase 3 — 弾を撃つ・飛ぶ・はね返る

Section titled “Phase 3 — 弾を撃つ・飛ぶ・はね返る”
  1. 発射fireTrue で、リロードが終わっていて、バリアを張っていないタンクが撃つ。A → B の順に、弾に通し番号をつける
  2. 移動 — すでに飛んでいる弾を、通し番号の小さい順に 1 フレーム分進める
  3. 壁との判定 — 鉄ならはね返る、木箱なら耐久を 1 減らして消える
  4. 寿命切れ — 3 秒(180 フレーム)たった弾を消す

通し番号は、ここから先ずっと「迷ったときの順番」として使われます。

冒頭の問いの答えがここにあります。

相殺が先です。

  1. 弾どうしの相殺 — 通し番号の小さい順にペアを調べ、0.20 マス以内に近づいた 2 発を消す
  2. タンクへの命中 — 生き残った弾を、通し番号の小さい順に調べる
    • バリアを張っていて、弾が正面 45 度以内から来た → 無効化(弾だけ消える)
    • それ以外 → HP −1、弾は消える

相殺を先にしたのは、撃ち合いを成立させるためです。 被弾が先だと、正面から撃ち合ったとき「弾が消える前に当たってしまう」ことが起き、 相殺のルールがほとんど働かなくなります。

  1. こわれた木箱(耐久 0)を平地に変える
  2. サプライを落とす・拾わせる。効果の残り時間を減らす
  3. 縮小エリアを進める。鉄になるマスにタンクがいたら即死
  4. リロード・バリア持続・クールダウンのカウンタを減らす

縮小の即死が Phase 5 にあることに注目してください。 その前に動き終わっています。 つまり「縮小が来る前に逃げ切れるかどうか」は、 そのフレームの Phase 2 の動きで決まっています。

  1. 統計を更新する(与ダメージ・被弾数・移動距離・発射数・命中数)
  2. このフレームの状態を決まった形のバイト列にして、ハッシュに投入する
  3. リプレイの配列に、このフレームを積む
  4. どちらかの HP が 0、またはフレーム 3599 なら終了

2 番目がリプレイと検証のかなめです。 3600 フレームぶんを積み重ねた結果が、その試合の result_hash になります。 1 フレームでも違えば、ハッシュは似ても似つかない値になります。

このページでいちばん覚えておく価値があるのは、たぶんここです。

フレーム0から8までの時間軸。フレーム0と6でだけbotが考え、その入力が次の6フレームのあいだずっと効き続けることを示す図

つまり、あなたの bot が「止まれ」と言うまで、タンクは進み続けます

  • 0.1 秒のあいだ、判断は変えられない
  • そのあいだに戦車は 0.3 マス、弾は 0.8 マス進む
  • 「気づいてから避ける」では間に合わないことがある

だから強い bot は、先回りして考えます。 「いま危ないか」ではなく「0.1 秒後に危ないか」を見るのです。 incoming_missileseta(あと何秒で最接近するか)が入っているのは、そのためです。

0.1 秒後を見る
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 秒ほどで終わります。 そのあとで、できあがったリプレイを再生して画面に描いています。

試合はまず0.1〜0.3秒で最後まで計算され、リプレイの配列ができたあとで、それを60秒かけて再生して描くという3段階の図

この分けかたのおかげで、次のことが追加の実装なしで手に入ります。

  • 早送り・巻き戻し・コマ送り(配列の添字を動かすだけ)
  • 画面がカクついても試合結果が変わらない
  • タブを裏に回しても影響がない

逆に言えば、描画を待つ処理をシミュレーションの中に入れてはいけません。 入れた瞬間に、この性質はすべて失われます。