はじめに
claudeさんの実力を測るため、
Godot 4.7で、レトロな侵略者2Dシューティングをclaudeに作ってもらいました。
ただし、
- 自機はネコ
- 敵はネズミ
- 弾は肉球
- 見た目は8ビット風
というテーマ縛りです。
godotがインストールされている前提です。
下記記事を書いておりますので、もし良かったらご参考ください!
指示したプロンプトは下記のみになります
プロンプト:
godot2Dで、⚪︎⚪︎⚪︎⚪︎⚪︎⚪︎ゲームを作ってください。ただし敵はネズミ、自機はネコ、弾は肉球で。8ビットゲームのイメージで。
(⚪︎⚪︎⚪︎⚪︎⚪︎⚪︎の部分はご想像にお任せ致します)
待つこと10分・・・クオリティは置いておいて、出来上がりました!
そこそこゲームになっている・・・そして特に指示はしなかったのですが、敵の弾はチーズになってました
一人でプロジェクトの作成やコーディングするとなると、かなりの手間がかかりますが、たった10分でここまで作れるclaudeさんは先生・・・いや、師匠と呼ぶしかありませんね!
以下、claudeからの報告です
「実装を説明して」と尋ねればスラスラと書き出してくれます。
この記事で面白いのは中身のルールより作り方で、スプライト画像もSEの音声ファイルも1個も使っていません。ドット絵はGDScriptでピクセル単位に生成し、効果音も矩形波をその場で合成しています。project.godot と .gd ファイルだけで動く、完全に自己完結したプロジェクトです。
出来上がったプロジェクトはこんな構成です。
nezumi/
├── project.godot
├── scenes/
│ └── Main.tscn # 唯一の.tscn。ルートNode2D+スクリプトのみ
└── scripts/
├── autoload/
│ ├── pixel_art.gd # ドット絵をコードで生成するAutoload
│ ├── sfx.gd # 矩形波チップチューンSEを生成するAutoload
│ └── game.gd # スコア・残機・入力設定
├── player.gd # 自機(ネコ)
├── mouse_enemy.gd # 敵(ネズミ)
├── bullet.gd # 肉球/チーズ共用の弾
└── main.gd # 編隊制御・HUD・ゲーム進行
なぜ「.tscnをほぼ書かない」構成にしたのか
Godotのシーン(.tscn)はテキスト形式なので手書きも可能ですが、load_steps やノードのプロパティ順序など、エディタを介さずに手で書くと壊れやすい部分が多いです。
そこで今回は思い切って、実体を持つシーンファイルは Main.tscn の1つだけにしました。
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/main.gd" id="1"]
[node name="Main" type="Node2D"]
script = ExtResource("1")
ネコもネズミも弾も、すべて _ready() の中で Area2D.new() → Sprite2D.new() → CollisionShape2D.new() と組み立てて add_child() するコード生成方式です。
# mouse_enemy.gd 抜粋
func _ready() -> void:
collision_layer = 4
collision_mask = 0
sprite = Sprite2D.new()
sprite.texture = PixelArt.mouse_texture_a
sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
sprite.scale = Vector2(2, 2)
add_child(sprite)
var shape := CollisionShape2D.new()
var rect := RectangleShape2D.new()
rect.size = Vector2(26, 26)
shape.shape = rect
add_child(shape)
add_to_group("mice")
こうしておくと、キャラクター1体を増やすのも preload("res://scripts/mouse_enemy.gd").new() を1行呼ぶだけで済み、.tscnの構文ミスに悩まされることがありません。ゲームジャム的なスピード感で作るときに相性が良い手法だと感じました。
ドット絵を「文字列の配列」で描く
一番のポイントがここです。pixel_art.gd というAutoloadが起動時に一度だけ実行され、Image → ImageTexture を組み立ててキャッシュします。
ドット絵は、. を透明、それ以外の文字をパレットの色に対応させた「文字列の配列」として手書きしています。たとえばネズミはこんな感じです。
func _mouse_palette() -> Dictionary:
return {
"M": Color8(190, 190, 198), # body fur
"D": Color8(120, 120, 130), # shaded fur
"P": Color8(255, 170, 195), # ear / nose
"B": Color8(25, 22, 26), # eye
}
func _mouse_rows_a() -> Array:
return [
".M......",
".MM.....",
"MMM.....",
"MPPM....",
"MMMM....",
".MMMM...",
".MMMMM..",
".MBMMMM.",
".MMMMMMM",
".MMMMMMP",
".MMMMMMM",
"..MMMMMM",
"..MMMMMM",
"...MMMM.",
"...M..M.",
"...M..M.",
]
これは実は左半分だけです。すべてのキャラクターは左右対称なので、自前の _mirror() で文字列を反転結合し、8列→16列に広げています。
func _mirror(half_rows: Array) -> Array:
var out: Array = []
for row in half_rows:
out.append(String(row) + _reverse(String(row)))
return out
これで手書きする文字数が半分で済みます。左右非対称にしたい場合だけ _mirror() を通さずベタ書きすればいいので、汎用性も保てます。
最終的に Image に1ピクセルずつ焼き込んで ImageTexture 化します。
func _build(rows: Array, palette: Dictionary) -> ImageTexture:
var h := rows.size()
var w: int = String(rows[0]).length()
var img := Image.create_empty(w, h, false, Image.FORMAT_RGBA8)
img.fill(Color(0, 0, 0, 0))
for y in range(h):
var row: String = rows[y]
for x in range(w):
var c := row[x]
if c != "." and palette.has(c):
img.set_pixel(x, y, palette[c])
return ImageTexture.create_from_image(img)
ネズミは「脚を開いた/閉じた」の2フレームを持たせて、インベーダーらしい足踏みアニメを再現しました(mouse_rows_b は mouse_rows_a の下2行だけ差し替えたコピー)。
見た目を目視確認する(ヘッドレスGodot)
エディタを開かなくても、Godotは --headless --script で任意のGDScriptを実行できます。生成したテクスチャを Image.save_png() でPNGに書き出すデバッグ用スクリプトを一時的に用意し、実際に目視でチェックしてから本実装に進みました。
extends SceneTree
func _initialize() -> void:
var pa: Node = load("res://scripts/autoload/pixel_art.gd").new()
get_root().add_child(pa)
pa._ready()
var img := pa.mouse_texture_a.get_image()
img.resize(img.get_width() * 12, img.get_height() * 12, Image.INTERPOLATE_NEAREST)
img.save_png("res://tools/preview/mouse_a.png")
quit()
Godot.app/Contents/MacOS/Godot --headless --path . --script res://tools/export_sprites.gd
拡大したPNGを開くと、ちゃんと「大きな耳のネズミ」「オレンジのネコ顔」「ピンクの肉球」に見えることを確認できました。CIでスクリーンショット比較する仕組みにも応用できそうです(このデバッグ用スクリプトは確認後に削除しています)。
効果音も音声ファイルなしで合成する
8ビットゲームらしいピコピコ音も、AudioStreamWAV に自前でPCMサンプルを書き込んで作っています。周波数を時間で滑らせる(スイープさせる)だけの矩形波ですが、これだけで「撃った感」「やられた感」が出ます。
func _square_sweep(freq_start: float, freq_end: float, duration: float, volume: float) -> AudioStreamWAV:
var total_frames := int(SAMPLE_RATE * duration)
var data := PackedByteArray()
data.resize(total_frames * 2)
var phase := 0.0
for i in range(total_frames):
var t := float(i) / float(total_frames)
var freq: float = lerp(freq_start, freq_end, t)
phase += freq / SAMPLE_RATE
var sample_val := 1.0 if fmod(phase, 1.0) < 0.5 else -1.0
var env := 1.0 - t # だんだん減衰
var sample: float = clamp(sample_val * volume * env, -1.0, 1.0)
data.encode_s16(i * 2, int(sample * 32767.0))
var stream := AudioStreamWAV.new()
stream.data = data
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = SAMPLE_RATE
stream.stereo = false
return stream
- ショット音: 880Hz→1400Hz(上昇)
- 被弾音: 400Hz→50Hz(下降、長め)
- 撃破音: 520Hz→90Hz
- ウェーブクリア: 440Hz→900Hz
と、用途ごとにパラメータを変えるだけで一通り揃いました。AudioStreamPlayer を6個プールしておいて使い回すだけの簡易実装ですが、同時発音にも困りません。
敵編隊のアルゴリズム
インベーダーゲームらしさの核は「編隊が壁で跳ね返りながら降りてくる」動きです。今回は各ネズミに固有のgrid_offset(編隊内の相対座標)だけを持たせ、編隊全体の基準点 formation_origin を動かして、各ネズミの位置はformation_origin + grid_offsetで毎フレーム再計算する方式にしました。
func _update_formation(delta: float) -> void:
var mice_nodes := get_tree().get_nodes_in_group("mice")
if mice_nodes.is_empty():
_on_wave_clear()
return
formation_origin.x += formation_dir * formation_speed * delta
var min_local_x := INF
var max_local_x := -INF
for m in mice_nodes:
min_local_x = min(min_local_x, m.grid_offset.x)
max_local_x = max(max_local_x, m.grid_offset.x)
var left_edge: float = formation_origin.x + min_local_x
var right_edge: float = formation_origin.x + max_local_x + 32.0
var hit_edge := false
if left_edge < 10.0:
formation_origin.x += 10.0 - left_edge
hit_edge = true
elif right_edge > SCREEN_W - 10.0:
formation_origin.x -= right_edge - (SCREEN_W - 10.0)
hit_edge = true
if hit_edge:
formation_dir *= -1
formation_origin.y += 10.0 # 段下がり
for m in mice_nodes:
m.position = formation_origin + m.grid_offset
この設計のいいところは、両端の列が全滅すると自動的に編隊の可動域が広がることです。個々のネズミが自分の位置から画面端を判定するのではなく、生き残っている grid_offset の最小・最大値から編隊全体の端を毎回計算し直しているので、「サイドの列を先に倒すと編隊がより速く/広く動くようになる」という初代インベーダーの緊張感がそのまま再現されます。
倒した数に応じて速度も上げています。
func _on_mouse_died(points: int, global_pos: Vector2) -> void:
Game.add_score(points)
_spawn_explosion(global_pos)
var alive_ratio := float(alive_count) / float(max(initial_mouse_count, 1))
formation_speed = lerp(14.0 + (Game.wave - 1) * 3.0, 60.0, 1.0 - alive_ratio)
当たり判定はレイヤーで「一方通行」に設計する
Area2Dの area_entered は、双方が監視していると双方でイベントが発火します。これをそのまま実装すると「弾が消えた後にもう一度当たり判定処理が走る」といった二重処理のバグを踏みがちです。
そこで、衝突する2者のうち**「攻撃を受ける側」だけが検知する**というルールに統一しました。
| オブジェクト | layer | mask | 検知するもの |
|---|---|---|---|
| ネコ(自機) | 1 | 4 | 8 | ネズミ・敵弾(自分から検知) |
| 肉球(自機弾) | 2 | 4 | ネズミ(自分から検知) |
| ネズミ | 4 | 0 | 何も検知しない(受け身) |
| チーズ(敵弾) | 8 | 0 | 何も検知しない(受け身) |
ネズミとチーズ弾は mask = 0 にして完全に受け身にすることで、「誰が誰に当たったか」の判定ロジックを常に一箇所(攻撃を受ける側のスクリプト)にまとめられ、見通しが良くなりました。
# player.gd 抜粋
func _on_area_entered(area: Area2D) -> void:
if area.is_in_group("mice"):
# ネズミが自機に到達=侵略成功、問答無用でゲームオーバー
Game.lives = 0
alive = false
died.emit()
elif area.is_in_group("enemy_bullets"):
area.queue_free()
_hit()
ヘッドレス実行でリグレッションを潰す
GUIを開かなくても、--headless --quit-after N で「Nフレーム後に自動終了」させられるので、実装のたびに以下を回しています。
Godot.app/Contents/MacOS/Godot --headless --path . --quit-after 600 --verbose 2>&1 | grep -iE "error|warn|leak"
スクリプトの構文エラーや実行時エラーはもちろん、ノードのリークもここで検知できます。GUIがない環境(今回のようにエディタを持たずCLIだけで作業する場合や、CIパイプライン)でも「動くかどうか」を機械的に確認できるのは地味に便利でした。
遊び方
- 移動:
←→(またはAD) - 発射:
Spaceまたは↑ - リスタート: ゲームオーバー後に
Enter
project.godot を Godot 4.7 で開いて実行するだけです。
まとめ
- Godotは
.tscnを書かずに、スクリプトからノードを組み立てるだけでもゲームが作れる - ドット絵は「文字列配列 + パレット辞書 + 左右ミラー」で外部画像ファイルなしに量産できる
- 効果音も矩形波の周波数スイープをPCMに焼くだけでチップチューンになる
- 敵編隊は「基準点+相対オフセット」で管理すると、レトロなシューティングゲームらしい緊張感のあるロジックが素直に書ける
- Area2Dの当たり判定は「受け側だけが検知する」ルールにすると二重処理を避けられる
-
--headless --quit-afterは実装中の壊れチェックに便利
「ネコ vs ネズミ」というお題からは想像しづらいですが、中身は割と骨太なレトロなシューティングゲームになりました。次はパワーアップアイテムやボス(大きいネズミ)あたりを追加してみたいです。