0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

非エンジニアがPython+Pygameでローグライクを作った全記録

0
Last updated at Posted at 2026-02-08

非エンジニアがPython+Pygameでローグライクを作った全記録

2週間。15,000行。書いたコードは0行。全部AIが書いた。

自分は非エンジニアだ。コードは一行も書けない。でもゲームが好きだった。ディアブロ、Path of Exile、トルネコの大冒険。遊ぶ側からずっと「作る側に回りたい」と思っていた。

Claude Codeに「ディアブロみたいなゲームを作って」と言った。そしたらPythonのターミナルで動くローグライクが出来上がった。色のついた文字が画面を動き回る。壁は # で、プレイヤーは @ で、敵は赤い文字で表現されている。

意外と悪くなかった。むしろ結構よかった。

ゲーム作りたかった。でもGodotで挫折した

最初はGodotに手を出した。3Dの矢印が何を意味しているのかわからない。ノードツリー? シグナル? 概念が頭に入ってこない。非エンジニアにとって、ゲームエンジンの抽象概念は外国語みたいなものだ。

挫折した。でもゲームを作りたい気持ちは消えなかった。

Claude Codeを知った。AIにコードを書かせるツール。コードが書けなくても、「こういうゲームが欲しい」と日本語で言えば作ってくれるらしい。試してみた。

最初はウェブ版を作ろうとした。GitHub Pagesで公開できたら楽だろうと思った。だがUIの配置や操作に制限が多すぎて、チープなものしかできなかった。ゲームとして成立していなかった。

ターミナルUIは偶然の産物

「ディアブロみたいなハクスラ系のゲームを作りたい」とClaude Codeに伝えた。「リアルタイムは難しいから、その要素を取り入れたローグライクにしよう」という話になった。

そしたらClaude Codeがいつもより長く動いた。ウェブ版を作っていた時とは明らかに違う挙動。しばらく放置していたら、Pythonのターミナル上で動くデモが出来上がっていた。

ANSIエスケープシーケンスというものらしい。正直よくわからない。ただ、黒い画面に色付きの文字がびっしり並んで、ダンジョンの形をしていた。それが動く。敵がいる。戦える。

ここで気づいたことがある。丸と四角だけが動く画面は「ゲーム」に見えない。だが色のついた文字がきちんと動いている画面は「ゲーム」に見えた。非エンジニアの感覚として、これは大きな違いだった。ゲームとして認識できないものに対して、開発のモチベーションは湧かない。文字だけのターミナル画面が、自分にとっては最初にゲームとして「見えた」瞬間だった。

グラフィック化に何度も失敗した

「とりあえずこの状態で開発を続けて、後でグラフィックに置き換えればいいだろう」と思っていた。

Godotに移植しようとした。うまくいかなかった。Pygame版にして、画像生成AIにグラフィックを作らせてPNGを実装しようとした。これもうまくいかなかった。

本当はトルネコの大冒険や風来のシレンみたいな見た目にしたかった。だがグラフィックに挑戦すればするほど、全然魅力的なものにならない。画面をパッと見た時に「面白そうだ」と思える雰囲気が出ない。

問題の根っこは「デザインの言語化」だった。自分が「いいな」と思うものの具体的な特徴を、言葉でAIに伝えられない。「ドット絵で」「レトロな感じで」程度の指示では、思い描いているものとまるで違うものが出てくる。

ターミナル風UIでいく、と決めた

消去法だった。グラフィックは何度やってもダメ。でもターミナル版は見た目がよくて面白かった。

だったらこのままでいい。Pygameに移植してウィンドウアプリにすれば、ターミナルがなくても遊べる。ターミナルの見た目のまま。それで充分だろう。

もうひとつ理由がある。自分が面白いと思っているが、他の人がどう思うかはわからない。だったらとりあえず出す。反応を見る。グラフィックは後でいい。 完璧を待つより、まず世に出すことを優先した。

Diablo風カラーとBSPダンジョン

ターミナル風UIの肝は色だ。文字しかないのだから、色が全ての情報を担う。

装備のレアリティはDiablo風の色分けにした。白がNormal、青がMagic、黄色がRare、オレンジがUnique。ハクスラを遊んだことがある人なら、色を見た瞬間にレアリティがわかる。説明不要。

ダンジョンの生成にはBSPという手法を使った。Binary Space Partitioning。空間を再帰的に二分割して、その中に部屋を配置する。部屋が重ならないことが構造的に保証される。通路はL字で接続。迷路っぽさが自然に出る。

戦闘はターン制。ダメージに20%の揺れを持たせて、クリティカルヒットをDEX依存にした。装備は数式ベースでランダム生成。基礎値にアフィックスを足す方式で、レアリティが上がるほどアフィックスの数が増える。ディアブロのアイテムドロップと同じ快感を、文字だけの画面で再現したかった。

これらの実装は全部Claude Codeがやった。自分は「ディアブロみたいな装備システムにして」「ダンジョンは毎回ランダムに生成して」と言っただけだ。

AI支援で15,000行を管理する

15,000行が1ファイルに入っている。普通に考えたらおかしい。だがAI支援開発では意外と機能する。

AIはファイル全体を読み込んで文脈を理解する。ファイルを分割するとファイル間の参照関係をAIが追いかける手間が増える。1ファイルなら「この関数はあの関数を呼んでいる」という関係を、AIが一発で把握できる。

開発の進め方は小さなイテレーションの繰り返しだった。「敵を倒したら経験値が入るようにして」と言う。動く。「レベルアップ時にHP全回復して」と言う。動く。「レベルアップ演出をASCIIアートで追加して」と言う。動く。一度に完璧を求めず、一つずつ足していく。非エンジニアの自分でも、この方法なら迷子にならなかった。

バランス調整には仕様書を使った。CLAUDE.mdという設定ファイルに「10F到達率70-80%」「15Fクリア率50-60%」と書いておく。AIはこの数値を基準にして敵の強さやアイテムの出現率を調整する。「いい感じにして」では通らない。具体的な数字が必要だ。

正直な話

等身大で言う。

技術的にすごいことをやったという実感はない。自分はコードを一行も書いていない。Claude Codeに「作って」と言い続けただけだ。

ターミナル風UIは最初から狙ったものではなく、偶然の産物だった。グラフィック化に失敗し続けた結果の消去法。理想を言えば、もっと見た目が華やかなゲームにしたかった。

だが、出来上がったものは自分が遊んで面白いと思えるものだった。ハクスラの快感——敵を倒す、レアアイテムが落ちる、キャラが強くなる——は文字だけの画面でもちゃんと成立する。色付きテキストには、グラフィカルな表現とは違うベクトルの説得力がある。

コードが書けなくてもゲームは作れた。ただし「言葉で伝える力」は必要だった。AIに何を作ってほしいか、具体的に伝えられるかどうかが全てだ。デザインの言語化は今でも課題で、これが解決したらもっと先に行ける気がしている。

ゲームはitch.ioで公開中だ: https://yurukusa.itch.io/azure-flame-dungeon

この自律開発を支えた運用キットをCC-Codex Ops Kitで公開中。一晩88タスク自動実行できた環境が手に入ります。


裏側:コードの中身

ここからは実際のコード。Claude Codeが書いたものを、後から構造を聞いて整理した。

ターミナル風レンダラー

Pygameの描画面を80x40のグリッドに区切り、1マスに1文字を配置する。等幅フォントだからマス目がきれいに揃う。これがターミナル風UIの正体だ。

import pygame

# 等幅フォントを使う理由: グリッド配置で位置計算が単純になる
# プロポーショナルフォントだとダンジョンの地図表示が崩れる
FONT_SIZE = 16
CELL_WIDTH, CELL_HEIGHT = 10, 18
COLS, ROWS = 80, 40  # 伝統的なターミナルサイズ

class TerminalRenderer:
    """Pygameの描画面をターミナルのように使うレンダラー"""

    def __init__(self):
        pygame.init()
        self.screen = pygame.display.set_mode(
            (COLS * CELL_WIDTH, ROWS * CELL_HEIGHT))
        self.font = pygame.font.SysFont("consolas", FONT_SIZE)
        # 画面バッファ: (文字, 前景色, 背景色) のグリッド
        self.buffer = [
            [(' ', (200,200,200), (0,0,0)) for _ in range(COLS)]
            for _ in range(ROWS)]

    def put_string(self, x, y, text, fg=(200,200,200), bg=(0,0,0)):
        for i, char in enumerate(text):
            if 0 <= x+i < COLS and 0 <= y < ROWS:
                self.buffer[y][x+i] = (char, fg, bg)

    def render(self):
        self.screen.fill((0, 0, 0))
        for row in range(ROWS):
            for col in range(COLS):
                char, fg, bg = self.buffer[row][col]
                if bg != (0,0,0):
                    rect = pygame.Rect(col*CELL_WIDTH, row*CELL_HEIGHT,
                                       CELL_WIDTH, CELL_HEIGHT)
                    pygame.draw.rect(self.screen, bg, rect)
                if char != ' ':
                    surface = self.font.render(char, True, fg)
                    self.screen.blit(surface,
                        (col*CELL_WIDTH, row*CELL_HEIGHT))
        pygame.display.flip()

Diablo風カラーテーブル

色がレアリティの全てを語る。

RARITY_COLORS = {
    "Normal": (200,200,200), "Magic":  (100,100,255),
    "Rare":   (255,255,100), "Unique": (255,165,0),
    "Legend": (255,50,50),   "Set":    (0,255,100),
}

BSPダンジョン生成

空間を再帰的に二分割し、各領域に部屋を配置。隣接する部屋をL字通路で接続する。

import random

class DungeonGenerator:
    def __init__(self, width=80, height=40):
        self.width, self.height = width, height
        self.tiles = [[0]*width for _ in range(height)]
        self.rooms = []

    def generate(self, depth=5):
        root = {"x":1, "y":1, "w":self.width-2, "h":self.height-2}
        leaves = self._split(root, depth)
        for leaf in leaves:
            room = self._create_room(leaf)
            self.rooms.append(room)
            self._carve_room(room)
        for i in range(len(self.rooms)-1):
            self._connect_rooms(self.rooms[i], self.rooms[i+1])
        return self.tiles

    def _split(self, node, depth):
        if depth <= 0 or node["w"] < 10 or node["h"] < 10:
            return [node]
        horizontal = node["h"] > node["w"] or (
            node["h"] == node["w"] and random.random() < 0.5)
        if horizontal:
            s = random.randint(node["y"]+4, node["y"]+node["h"]-4)
            c1 = {**node, "h": s-node["y"]}
            c2 = {"x":node["x"], "y":s,
                  "w":node["w"], "h":node["h"]-(s-node["y"])}
        else:
            s = random.randint(node["x"]+4, node["x"]+node["w"]-4)
            c1 = {**node, "w": s-node["x"]}
            c2 = {"x":s, "y":node["y"],
                  "w":node["w"]-(s-node["x"]), "h":node["h"]}
        return self._split(c1, depth-1) + self._split(c2, depth-1)

    def _create_room(self, leaf):
        w = random.randint(4, max(4, leaf["w"]-2))
        h = random.randint(4, max(4, leaf["h"]-2))
        x = random.randint(leaf["x"], leaf["x"]+leaf["w"]-w)
        y = random.randint(leaf["y"], leaf["y"]+leaf["h"]-h)
        return {"x":x, "y":y, "w":w, "h":h}

    def _carve_room(self, room):
        for r in range(room["y"], room["y"]+room["h"]):
            for c in range(room["x"], room["x"]+room["w"]):
                if 0 <= r < self.height and 0 <= c < self.width:
                    self.tiles[r][c] = 1

    def _connect_rooms(self, a, b):
        ax, ay = a["x"]+a["w"]//2, a["y"]+a["h"]//2
        bx, by = b["x"]+b["w"]//2, b["y"]+b["h"]//2
        for x in range(min(ax,bx), max(ax,bx)+1):
            self.tiles[ay][x] = 1
        for y in range(min(ay,by), max(ay,by)+1):
            self.tiles[y][bx] = 1

戦闘システム

ダメージに20%の揺れ。クリティカルはDEX依存で上限30%。

class BattleSystem:
    def calculate_damage(self, attacker, defender):
        base = max(1, attacker.attack - defender.defense // 2)
        variance = random.uniform(0.8, 1.2)
        damage = int(base * variance)
        if random.random() * 100 < min(attacker.dex * 0.5, 30):
            return int(damage * 1.5), True
        return damage, False

装備生成

基礎値 + ランダムアフィックス。レアリティが上がるほどアフィックスが増える。

from dataclasses import dataclass, field

@dataclass
class Equipment:
    name: str
    slot: str
    rarity: str
    base_attack: int = 0
    base_defense: int = 0
    affixes: list = field(default_factory=list)

    def total_attack(self):
        return self.base_attack + sum(
            a["value"] for a in self.affixes if a["type"] == "attack")

def generate_equipment(floor_level, rarity="Normal"):
    slot = random.choice(["weapon","head","body","ring","belt"])
    base = floor_level * 2 + random.randint(1, 5)
    equip = Equipment(
        name=f"Tier{floor_level//10+1} {slot.title()}",
        slot=slot, rarity=rarity,
        base_attack=base if slot=="weapon" else 0,
        base_defense=base if slot!="weapon" else 0)
    count = {"Normal":0,"Magic":2,"Rare":4,"Legend":6}.get(rarity,0)
    pool = [{"type":"attack","value":random.randint(1,floor_level)},
            {"type":"defense","value":random.randint(1,floor_level)},
            {"type":"hp","value":random.randint(5,floor_level*3)}]
    for _ in range(count):
        equip.affixes.append(random.choice(pool).copy())
    return equip

差分レンダリング

80x40=3,200文字を毎フレーム全描画すると重い。変更があったセルだけ再描画する。

def render_diff(self, screen, font):
    for (x,y), (char,fg,bg) in self.current.items():
        if self.previous.get((x,y)) == (char,fg,bg):
            continue
        rect = pygame.Rect(x*CELL_WIDTH, y*CELL_HEIGHT,
                           CELL_WIDTH, CELL_HEIGHT)
        pygame.draw.rect(screen, bg, rect)
        if char != ' ':
            screen.blit(font.render(char, True, fg),
                        (x*CELL_WIDTH, y*CELL_HEIGHT))
    self.previous = self.current.copy()

筆者: ゆるくさ

他の記事も読む:

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?