EP.10 は + 。古典アルゴリズム ( / Cellular Automata / BSP) が 「構造」 を、 が 「意味」 を担う棲み分けで、無限に遊べて毎回違う体験のあるゲームを作る話。Roguelike / Survival / Sandbox との相性が抜群です。
1. 役割分担: アルゴリズム vs LLM
| 要素 | アルゴリズムが得意 | LLM が得意 |
|---|---|---|
| マップ構造 | ✅ WFC / CA / BSP | ❌ 整合性が崩れる |
| 部屋の名称・歴史 | ❌ 機械的 | ✅ 雰囲気のあるストーリー |
| 敵配置 (バランス) | ✅ 階層難度の数式調整 | △ バランス感覚は弱い |
| 敵の説明文・台詞 | ❌ テンプレート | ✅ キャラ立て可能 |
| クエスト構造 | ✅ Goal / Step / Reward の骨格 | △ 骨格は弱め |
| クエスト本文・動機 | ❌ 型にハマる | ✅ 多様性 |
2. Cellular Automata で洞窟生成 (30 行)
import random
def generate_cave(width=40, height=20, fill_prob=0.45, steps=5): """Cellular Automata で洞窟マップを生成""" # 初期化: 各セルをランダムに壁 (#) or 床 (.) で埋める grid = [[('#' if random.random() < fill_prob else '.') for _ in range(width)] for _ in range(height)]
# 反復: 周辺 8 マスの壁の数で次世代の状態を決定 for _ in range(steps): new = [row[:] for row in grid] for y in range(1, height - 1): for x in range(1, width - 1): walls = sum( 1 for dy in (-1, 0, 1) for dx in (-1, 0, 1) if (dy, dx) != (0, 0) and grid[y + dy][x + dx] == '#' ) if walls >= 5: new[y][x] = '#' elif walls <= 3: new[y][x] = '.' grid = new return grid
# 実行random.seed(42)cave = generate_cave()for row in cave: print(''.join(row))
# 出力例 (洞窟の壁と空間が滑らかに):# ############################...# ###..........##....###....##...# ###..####.....##......##...##..# ###..######....##....######....# (中略)3. LLM で部屋に「意味」 を付ける
from anthropic import Anthropicimport os
client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
def find_rooms(grid): """連結成分で「部屋」 を識別 (簡略版)""" height, width = len(grid), len(grid[0]) visited = [[False] * width for _ in range(height)] rooms = []
def flood(y, x): stack = [(y, x)] cells = [] while stack: y, x = stack.pop() if (0 <= y < height and 0 <= x < width and not visited[y][x] and grid[y][x] == '.'): visited[y][x] = True cells.append((y, x)) stack.extend([(y+1, x), (y-1, x), (y, x+1), (y, x-1)]) return cells
for y in range(height): for x in range(width): if grid[y][x] == '.' and not visited[y][x]: cells = flood(y, x) if len(cells) >= 8: # 8 マス以上を部屋とみなす rooms.append(cells) return rooms
def describe_room(room_cells, depth: int = 1) -> str: """部屋を LLM に描写させる""" size = len(room_cells) shape_desc = "小さい" if size < 20 else "広い" if size < 60 else "巨大な"
prompt = f"""あなたは TRPG のゲームマスター。洞窟の地下 {depth} 階に、{shape_desc} 部屋 (約 {size} マス) があります。2-3 文で、この部屋の雰囲気・痕跡・気配を描写してください。プレイヤーの想像をかきたてる、具体的だが余韻のある描写を。"""
response = client.messages.create( model="claude-haiku-4-5-20251001", max_tokens=200, messages=[{"role": "user", "content": prompt}], ) return response.content[0].text
# 使い方rooms = find_rooms(cave)for i, room in enumerate(rooms): print(f"\n[部屋 {i+1}] {len(room)} マス") print(describe_room(room, depth=3))4. Wave Function Collapse (WFC) の例
はタイル隣接ルールから矛盾のないマップを生成。複雑だが、ライブラリ (`mxgmn/WaveFunctionCollapse` / Python: `pylab-wfc`) を使えば数行で動く。 の代表的な手法。
# 1 次元 WFC の最小実装 (概念理解用)import random
# タイル定義 + 隣接ルールTILES = ['S', 'C', 'L', 'M'] # Sea / Coast / Land / MountainRULES = { 'S': ['S', 'C'], # 海の隣は海 or 海岸 'C': ['S', 'C', 'L'], # 海岸の隣は海・海岸・陸 'L': ['C', 'L', 'M'], # 陸の隣は海岸・陸・山 'M': ['L', 'M'], # 山の隣は陸 or 山}
def wfc_1d(length=20, seed=0): random.seed(seed) # 各セルの可能性 (最初は全タイル可能) cells = [set(TILES) for _ in range(length)]
while any(len(c) > 1 for c in cells): # エントロピー最小 (= 選択肢が少ない) セルを選ぶ i = min((idx for idx, c in enumerate(cells) if len(c) > 1), key=lambda idx: len(cells[idx])) # 1 つ確定 cells[i] = {random.choice(list(cells[i]))} # 隣接セルに制約伝播 for j in (i-1, i+1): if 0 <= j < length and len(cells[j]) > 1: tile = next(iter(cells[i])) cells[j] = cells[j] & set(RULES[tile]) if not cells[j]: cells[j] = {random.choice(TILES)} # 矛盾時は再選択
return [next(iter(c)) for c in cells]
print(' '.join(wfc_1d()))# 例: S S C L L L M M L L C S S C L M M L L C5. クエスト生成 (構造 + 物語)
import random
QUEST_TEMPLATES = [ {"type": "fetch", "structure": "<NPC> から依頼 → <ITEM> を <LOCATION> で集める → 報告"}, {"type": "kill", "structure": "<NPC> から依頼 → <ENEMY> を <COUNT> 体倒す → 報告"}, {"type": "escort","structure": "<NPC> を <DESTINATION> まで護衛 → 道中の戦闘 → 到着"}, {"type": "deliver", "structure": "<ITEM> を <NPC_A> から <NPC_B> へ届ける"},]
NPCS = ["老マスター ヘリアス", "鍛冶屋 ボルガ", "錬金術師 リリス"]ITEMS = ["銀の杯", "古文書", "魔石"]LOCATIONS = ["北の塔", "古代遺跡", "霧の森"]ENEMIES = ["ゴブリン", "スケルトン", "影狼"]
def generate_quest_skeleton(): tmpl = random.choice(QUEST_TEMPLATES) params = { "NPC": random.choice(NPCS), "ITEM": random.choice(ITEMS), "LOCATION": random.choice(LOCATIONS), "ENEMY": random.choice(ENEMIES), "COUNT": random.choice([3, 5, 7, 10]), "DESTINATION": random.choice(LOCATIONS), "NPC_A": random.choice(NPCS), "NPC_B": random.choice(NPCS), } desc = tmpl["structure"] for k, v in params.items(): desc = desc.replace(f"<{k}>", str(v)) return {"type": tmpl["type"], "params": params, "structure": desc}
def llm_flesh_out_quest(skeleton: dict) -> dict: """LLM に骨格を渡して、物語・動機・報酬を生成させる""" prompt = f"""以下のクエスト骨格に、依頼動機・物語背景・報酬を肉付けしてください。中世ファンタジー世界、3-5 文で。
骨格: {skeleton['structure']}タイプ: {skeleton['type']}"""
# client.messages.create() で生成 (省略) # response.content[0].text を返す return {**skeleton, "story": "(LLM 生成テキスト)", "reward": "ゴールド + アイテム"}
# 使い方for _ in range(3): sk = generate_quest_skeleton() print(sk["structure"]) print(llm_flesh_out_quest(sk)["story"]) print()6. プロシージャル + LLM のコスト管理
- 毎フレーム LLM 呼出はしない: 部屋に入った時だけ、ダンジョン生成時だけ
- キャッシュ: 同じシードの dungeon は同じ description を返す
- 前計算 + メタデータ保存: 開発時に 1000 種類生成しておき、ランダム選択
- ローカル LLM で都度生成: ネット不要、コスト 0、レイテンシ 1-2 秒
7. プロシージャル生成のジャンル別実例
| ジャンル | アルゴリズム | LLM 活用余地 |
|---|---|---|
| Roguelike | BSP / CA で部屋・洞窟 | 敵フレーバー / アイテム名 / 部屋描写 |
| サンドボックス (Minecraft 系) | Perlin Noise / バイオーム | 村人の役職・物語 |
| メトロイドヴァニア (一部) | BSP + 強化版 | ボス前演出 / マップ名 |
| Survival | WFC / Voxel | イベントランダムテキスト |
| TRPG / ナラティブ | ストーリーグラフ | シーン記述・NPC 反応 |
8. アルゴリズムと LLM の境界線設計
LLM の出力が変わってもゲームバランスが壊れない範囲で LLM を使う。例: 「部屋の描写」 はテキストのみで戦闘パラメータには影響しない → LLM 任せて OK。「敵の HP / 攻撃力」 は LLM 直接出力ではなく、システム側のテーブルから引く。LLM = 表層、ロジック = 内部 の分離を徹底。
9. 関連 EP
- EP.08: NPC 実行時 LLM ── 部屋描写と組合せて、入った時に NPC が反応
- EP.11: バランス調整 ── 生成したダンジョンの難度を LLM プレイヤーで自動テスト
- 現代アルゴリズム図鑑シリーズ EP.11 (Reservoir Sampling) など参考
10. 次の話
EP.11 は ゲームバランス調整 ── LLM プレイヤーで自動テスト ── LLM にプレイヤー役をやらせて、ステージの難度・敵 AI のバランスを統計的に検証する話。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。