Claude Codeのゲーム数学を読む|ベクトル・角度・状態機械・経路探索【AIでゲーム開発】
Claude Code が作った Godot 4 の見下ろし型ゲームでは、斜めに動く速さ・敵の視野・敵の3つの状態・壁を避ける道が、ベクトル・角度・状態機械・経路探索の4つの言葉の行で決まっていました。道を返す部品の点はマスの左上の角にあり、敵は見回りの途中で壁の角に当たって止まりました。点をマスの中心へずらす1行を足した写しでは、敵は壁を回り込んでプレイヤーに追いつきました。
このページの目次
Claude Code に頼んだ文
Claude Code に、Godot Engine 4 の小さな見下ろし型の2Dゲームを作ってもらいました。敵が決まった場所を見回り、近づいたプレイヤーを追いかけ、壁を回り込んで道を探すゲームです。次の文を1回だけ渡しました。敵の向きと視野を頼んだ1行は、この枠から外し、枠の下に書いています。
Claude Codeに見下ろし型の2Dゲームを作ってもらった文
作業フォルダは D:\Claude_Game_Site\drafts\W-143-A\read-game-math.play\ だけです。このフォルダの外は読まない・書かないでください。
Godot 4(4.7)で、小さな 2D の見下ろし型ゲームを作ってください。Godot のエディタは開けないので、project.godot・.tscn・.gd のファイルを直接書いてください。画像や音のファイルは使わず、形と色だけで描いてください(四角や三角でかまいません)。
作りたいもの:
- マス目の部屋に壁がいくつかある。壁は通れない。
- プレイヤーは矢印キーで上下左右と斜めに動く。斜めに動いても速さが変わらないようにしてほしい。
- 敵が1体いる。ふだんは決まった2〜3か所を順に回っている。
- プレイヤーが一定の距離まで近づくと、敵はプレイヤーを追いかけ始める。離れると元の見回りに戻る。
- さらに近くまで来たら、敵は立ち止まって「攻撃」の状態になる(何かが起きる必要はなく、色が変わるだけでよい)。
- 追いかけるときは壁を回り込んで道を探してほしい。マス目の上で道を探す仕組み(AStarGrid2D が使えるならそれで)にしてほしい。
- 敵の状態が変わったときに、今の状態の名前を print で出してほしい。
起動したらすぐ遊べるように、最初に開くシーンを project.godot に設定してください。
最後に、作ったファイルの一覧と、それぞれのファイルが何をしているかを短く教えてください。枠から外した1行では、敵が進む向きかプレイヤーの方へ三角の先を回すことと、正面の視野の範囲にいるプレイヤーにだけ気づくことを頼みました。
Claude Code は、フォルダの中のファイルを読み、書き換え、コマンドを動かすツールです。頼んでから返事が来るまでは298秒でした。返事には、プロジェクトの設定・3つのシーン・3つのスクリプトを作ったことと、画面なしの Godot で動かして例外が出なかったことが書かれていました。動いた結果は、このあと手元の Godot で数え直しています。
この試しで読んだ3つのスクリプト
スクリプト(GDScript のファイル)は3つあり、それぞれがシーンの一番上のノードに付いていました。
| ファイル | 付いているノード | 行数 | していること |
|---|---|---|---|
| Main.gd | Main.tscn の Main(Node2D) | 62 | 部屋の壁を置き、壁のマスの一覧を敵に渡す |
| Player.gd | Player.tscn の Player(CharacterBody2D) | 18 | 矢印キーで動く |
| Enemy.gd | Enemy.tscn の Enemy(CharacterBody2D) | 187 | 見回り・追いかけ・攻撃の3つの状態と、道探し |
部屋は横20マス・縦15マスで、1マスは32ピクセルです。プレイヤーは青い四角、敵は三角で、敵の色は状態によって緑・橙・赤に変わります。
手元の Godot 4.7.2 の画面なしの実行で、取り込みを済ませてから最初のシーンを1800回の更新だけ動かしました。どちらもエラーの行は出ませんでした。
Godot_v4.7.2-stable_win64_console.exe --headless --path <プロジェクトの写し> --import
Godot_v4.7.2-stable_win64_console.exe --headless --path <プロジェクトの写し> --quit-after 1800
この1800回の間に、状態が変わったときの行は1行も出ませんでした。理由は最後の節に書きます。キーの押下は、台本からキーを押した状態にして数えています。窓を開いて人の手で矢印キーを押して遊ぶことは、この試しでは確かめきれませんでした。
このページでは、この3つのファイルに出てきた行だけを例にして、ベクトル・角度・状態機械・経路探索の4つを順に読みます。計算の仕方は扱いません。変数・条件・関数・シグナルの読み方は、Claude CodeのGDScriptを読むにあります。
ベクトル:向きと長さを1つに持つ値
Godot の2Dでは、位置も速さも、x と y の2つの数を1つにまとめた値で持ちます。この値をベクトルと呼び、向きと長さの両方を表します。Player.gd の動きは、次の2行だけです。
var dir := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
velocity = dir * speed
1行目は、左・右・上・下の4つのキーの押し具合から、ベクトルを1つ作ります。このベクトルは、長さが1までに抑えられます。2行目で、そのベクトルに速さの140を掛けたものを、1秒あたりに進むピクセルとして使います。キーを押した状態にして数えた結果は次のとおりです。
| 押したキー | 1行目で作られたベクトル | 速さ |
|---|---|---|
| 右だけ | (1.0, 0.0) | 140.0 |
| 右と下 | (0.707107, 0.707107) | 140.0 |
右と下を押したときの (1, 1) を、そのまま140倍すると、長さは約198になります。右と下を押したままにして30回の物理の更新(初めの設定で0.5秒分)を進めると、斜めに進んだ長さは約70でした。
Enemy.gd で敵を進める行には、次の形が出てきます。
velocity = to_target.normalized() * speed
to_target
は、敵の位置から次の道の点までのベクトルです。
normalized()
は、向きを変えずに長さを1にしたベクトルを返します。長さが0のベクトルでは (0, 0) を返します。手元の Godot で出した値は次のとおりです。
Vector2(3, 4).length() # 5.0
Vector2(3, 4).normalized() # (0.6, 0.8)
Vector2(1, 1).normalized() # (0.707107, 0.707107)
Vector2(0, 0).normalized() # (0.0, 0.0)
そのため、この行の敵は、次の点がどれだけ離れていても、速さ90で進みます。
2つの位置のあいだの距離は、次の行で測っています。
var dist := global_position.distance_to(player.global_position)
Vector2(0, 0).distance_to(Vector2(3, 4)) # 5.0
この距離が、次の「状態機械」の節で、160・40・220 の3つの数と比べられます。
角度:2つの向きの差をラジアンで返す
敵がプレイヤーに気づく条件の1つは、Enemy.gd の次の行にあります。
var ang := rad_to_deg(abs(facing_dir.angle_to(to_player)))
if ang > vision_half_angle_deg:
return false
1行目は、内側から順に読みます。いちばん内側で、敵の正面の向きからプレイヤーへの向きまでの角度を求めます。角度はラジアンという単位で、-π から π の範囲で返り、回る向きで符号が変わります。その外側で符号を外し、いちばん外側でラジアンを度に直します。
2行目で、その角度を55と比べています。55度より大きければ、気づかずに終わります。敵の視野は、正面の向きから左右に55度ずつです。
Vector2.RIGHT.angle_to(Vector2.DOWN) # 1.5707964(度に直すと約90)
Vector2.RIGHT.angle_to(Vector2.UP) # -1.5707964(度に直すと約-90)
Godot の2Dでは、y は下へ向かって増えます。右向きから下向きへの角度が正の数になっているのは、そのためです。
敵を止めて右を向かせたまま、プレイヤーを距離100の3つの場所に置いて、敵の状態を見ました。
| プレイヤーを置いた場所(距離100) | 向きの差 | 敵の状態 |
|---|---|---|
| 右から下へ50度回した方向 | 50度 | 見回りから追いかけに変わった |
| 右から下へ60度回した方向 | 60度 | 見回りのまま |
| 真後ろ(左) | 180度 | 見回りのまま |
敵の三角の向きは、次の行で決まります。
rotation = facing_dir.angle()
angle()
は、右向き (1, 0) から測った角度をラジアンで返します。ノードの回転の角度もラジアンです。Enemy.gd の三角は、先が右を向く形で描かれているので、この角度だけ回すと、三角の先が敵の正面の向きを指します。下向きのベクトルでは、約90度が返りました。
Vector2(0, 1).angle() # 度に直すと約90
その1行前には、向きを一度に変えずに少しずつ回す行があります。
facing_dir = facing_dir.slerp(desired, clamp(delta * 10.0, 0.0, 1.0))
slerp()
は、2つのベクトルのあいだを、2番目の数の割合(0.0 から 1.0)だけ進めたベクトルを返します。
状態機械:敵のいまの状態を1つの変数に持つ
Enemy.gd の敵は、見回り・追いかけ・攻撃の3つの状態のどれか1つにいます。状態の名前は、次の2行で決めています。
enum State { PATROL, CHASE, ATTACK }
var state: State = State.PATROL
1行目は、3つの名前に 0・1・2 の数を順に割り当てます。2行目の変数に、いまの状態が1つだけ入ります。始めは見回り(PATROL)です。
状態を変える条件は、1つの関数の中にまとまっています。
match state:
State.PATROL:
if dist <= chase_range and _can_see_player():
new_state = State.CHASE
State.CHASE:
if dist <= attack_range:
new_state = State.ATTACK
elif dist > leash_range:
new_state = State.PATROL
State.ATTACK:
if dist > attack_range and dist <= chase_range:
new_state = State.CHASE
elif dist > leash_range:
new_state = State.PATROL
いまの状態ごとに、見る条件が分かれています。3つの距離は、ファイルの上のほうで決めています。
| いまの状態 | 条件 | 次の状態 |
|---|---|---|
| 見回り(PATROL) | 距離が160以下で、視野に入っていて、あいだに壁が無い | 追いかけ(CHASE) |
| 追いかけ(CHASE) | 距離が40以下 | 攻撃(ATTACK) |
| 追いかけ(CHASE) | 距離が220より大きい | 見回り(PATROL) |
| 攻撃(ATTACK) | 距離が40より大きく160以下 | 追いかけ(CHASE) |
| 攻撃(ATTACK) | 距離が220より大きい | 見回り(PATROL) |
状態が変わったときだけ、次の行で名前を出します。
print("enemy state -> ", State.keys()[state])
State.keys()
は、3つの名前を並べた一覧を返し、いまの状態の数で、その中の1つを取り出しています。敵を止めて右を向かせ、プレイヤーを右へ100・30・60、最後に左へ250の距離に順に置くと、次の4行が1行ずつ出ました。
enemy state -> CHASE
enemy state -> ATTACK
enemy state -> CHASE
enemy state -> PATROL
「あいだに壁が無い」は、敵からプレイヤーへ線を1本伸ばして、壁に当たるかで見ています。
query.collision_mask = 1 # 壁のレイヤーだけを見る
var hit := space_state.intersect_ray(query)
if hit:
return false
線が何にも当たらなければ、結果は空になります。空でなければ、気づかずに終わります。敵を壁の左、プレイヤーを壁の右に置くと、距離128で真正面でも、敵は見回りのままでした。
経路探索:マス目の上で壁を避けた道を返す
敵が壁を回り込む道は、Godot の AStarGrid2D という部品で探しています。Main.gd が壁のマスの一覧を渡すと、Enemy.gd の次の行で道探しの準備をします。
astar.region = Rect2i(0, 0, cols, rows)
astar.cell_size = Vector2(CELL_SIZE, CELL_SIZE)
astar.diagonal_mode = AStarGrid2D.DIAGONAL_MODE_ONLY_IF_NO_OBSTACLES
astar.update()
for c in wall_cells:
astar.set_point_solid(c, true)
1行目は、道を探してよいマスの範囲で、ここでは左上の (0, 0) から横20・縦15マスです。2行目は1マスの大きさで、返す道の点の座標を計算するのに使います。3行目は斜めに進む条件で、この設定では、進もうとする斜めのまわりに壁があるときは斜めを使いません。
4行目は、ここまでの設定を反映します。この行は、通れないマスの印も消します。そのため、最後の2行で壁のマスを1つずつ通れないマスにする処理は、この行のあとに置かれています。
道は、次の行で求めています。
current_path = astar.get_point_path(start_cell, end_cell)
始まりのマスから終わりのマスまでの道の点を、始まりから順に並べて返します。縦に4マス続く壁 (8, 3)〜(8, 6) をはさんで、マス (6, 5) から (10, 5) までの道を出すと、次の7マスになりました。まっすぐ横に進めば5マスのところを、壁の下を回っています。道の中に壁のマスは入っていませんでした。
astar.get_id_path(Vector2i(6, 5), Vector2i(10, 5))
# [(6, 5), (7, 6), (7, 7), (8, 7), (9, 7), (10, 6), (10, 5)]
astar.get_point_path(Vector2i(6, 5), Vector2i(10, 5))
# [(192, 160), (224, 192), (224, 224), (256, 224), (288, 224), (320, 192), (320, 160)]
1つ目はマスの番号の並び、2つ目は同じ道をピクセルの座標にしたものです。Enemy.gd の敵が進むのは、2つ目の座標です。
道の点がマスの角にあり、敵が壁の角で止まった
道の点の座標は、マスの番号に1マスの大きさを掛け、ずらす量を足して求められます。ずらす量の初めの値は (0, 0) で、Enemy.gd にはずらす量を決める行がありませんでした。そのため、道の点はマスの番号の32倍になり、マスの左上の角に来ます。上の例でも、マス (6, 5) の中心は (208, 176) ですが、1つ目の点は (192, 160) でした。
Main.gd は、壁の四角をマスの中心に置いています。壁の辺はどれも32の倍数の線の上にあり、道の点を結ぶ線は、壁の辺の上を通ることがあります。敵の当たり判定は半径12の円なので、中心が壁の辺の上に来ると、円の半分が壁に重なる位置になります。
キーを押さずに、1800回の物理の更新(初めの設定で30秒分)を進めて、敵の位置を数えました。敵は1つ目と2つ目の見回りの点を回ったあと、3つ目の点へ向かう途中で、横長の壁の右端の上 (512, 276) で止まりました。480回目から1800回目まで、位置は変わりませんでした。このとき敵が向かっていた点は (512, 288) で、壁の右上の角です。始めの節で状態の行が1行も出なかったのは、敵がこの位置で止まり、プレイヤーを視野に入れなかったためです。
壁の向こうのプレイヤーを追いかけさせる試しでも、敵は (227〜239, 224) の間を行き来し、600回の物理の更新の間に攻撃の状態になりませんでした。どちらの試しでも、エラーの行は出ませんでした。
別の写しの Enemy.gd で、1マスの大きさを決める行の次に、道の点をマスの半分だけずらす1行を足しました。ほかの行は変えていません。
astar.offset = Vector2(CELL_SIZE / 2.0, CELL_SIZE / 2.0)
この写しでは、道の点がマスの中心に移りました。
astar.get_point_path(Vector2i(6, 5), Vector2i(10, 5))
# [(208, 176), (240, 208), (240, 240), (272, 240), (304, 240), (336, 208), (336, 176)]
マスの番号の並びは、1行を足す前と同じ7マスでした。見回りでは、敵は3つ目の点まで着き、1つ目の点へ戻る途中でプレイヤーに気づいて、追いかけから攻撃に変わりました。壁の向こうのプレイヤーを追いかけさせる試しでは、134回目の物理の更新で攻撃の状態になり、その間に敵の中心が壁のマスに入った回は0でした。視野と距離の試しの結果は、1行を足す前と同じでした。
情報源
- Vector2(Godot Engine の公式ドキュメント)https://docs.godotengine.org/en/stable/classes/class_vector2.html確認日 2026-09-24
- Vector math(Godot Engine の公式ドキュメント)https://docs.godotengine.org/en/stable/tutorials/math/vector_math.html確認日 2026-09-24
- Input(Godot Engine の公式ドキュメント)https://docs.godotengine.org/en/stable/classes/class_input.html確認日 2026-09-24
- CharacterBody2D(Godot Engine の公式ドキュメント)https://docs.godotengine.org/en/stable/classes/class_characterbody2d.html確認日 2026-09-24
- Node2D(Godot Engine の公式ドキュメント)https://docs.godotengine.org/en/stable/classes/class_node2d.html確認日 2026-09-24
- @GlobalScope(Godot Engine の公式ドキュメント)https://docs.godotengine.org/en/stable/classes/class_%40globalscope.html確認日 2026-09-24
- GDScript reference(Godot Engine の公式ドキュメント)https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_basics.html確認日 2026-09-24
- Ray-casting(Godot Engine の公式ドキュメント)https://docs.godotengine.org/en/stable/tutorials/physics/ray-casting.html確認日 2026-09-24
- AStarGrid2D(Godot Engine の公式ドキュメント)https://docs.godotengine.org/en/stable/classes/class_astargrid2d.html確認日 2026-09-24
- Idle and Physics Processing(Godot Engine の公式ドキュメント)https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html確認日 2026-09-24
- Command line tutorial(Godot Engine の公式ドキュメント)https://docs.godotengine.org/en/stable/tutorials/editor/command_line_tutorial.html確認日 2026-09-24
- Claude Code overview(Claude Code の公式ドキュメント)https://code.claude.com/docs/en/overview確認日 2026-09-24

