3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Codeでトライ!③ - 1プロンプト指示でレトロな侵略者シューティングゲームを作ってもらった -

3
Last updated at Posted at 2026-07-24

はじめに

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が起動時に一度だけ実行され、ImageImageTexture を組み立ててキャッシュします。

ドット絵は、. を透明、それ以外の文字をパレットの色に対応させた「文字列の配列」として手書きしています。たとえばネズミはこんな感じです。

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_bmouse_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パイプライン)でも「動くかどうか」を機械的に確認できるのは地味に便利でした。

遊び方

  • 移動: (または A D)
  • 発射: Space または
  • リスタート: ゲームオーバー後に Enter

project.godot を Godot 4.7 で開いて実行するだけです。

まとめ

  • Godotは .tscn を書かずに、スクリプトからノードを組み立てるだけでもゲームが作れる
  • ドット絵は「文字列配列 + パレット辞書 + 左右ミラー」で外部画像ファイルなしに量産できる
  • 効果音も矩形波の周波数スイープをPCMに焼くだけでチップチューンになる
  • 敵編隊は「基準点+相対オフセット」で管理すると、レトロなシューティングゲームらしい緊張感のあるロジックが素直に書ける
  • Area2Dの当たり判定は「受け側だけが検知する」ルールにすると二重処理を避けられる
  • --headless --quit-after は実装中の壊れチェックに便利

「ネコ vs ネズミ」というお題からは想像しづらいですが、中身は割と骨太なレトロなシューティングゲームになりました。次はパワーアップアイテムやボス(大きいネズミ)あたりを追加してみたいです。

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?