ふくふくHukuhuku Inc.
EP.10LLM Gamedev 12分公開: 2026-05-26

プロシージャル生成 + LLM ── マップ・ダンジョン・クエストを動的に

プロシージャル生成 (Wave Function Collapse / Cellular Automata / BSP) と LLM を組合せた動的マップ / ダンジョン / クエスト生成。古典アルゴリズムが「構造」 を、LLM が「意味」 を担う棲み分け。

#プロシージャル生成#WFC#ダンジョン#クエスト#LLM
執筆 / 監修
松尾 亮合同会社ふくふく 代表社員

データ基盤・データパイプライン構築 / BI / 生成 AI 活用支援を専門とするエンジニア (28 年)。 本記事は AI 利用ポリシーに基づき、生成 AI の補助で執筆 → 人間が監修・編集して公開しています。

プロフィール詳細
シェア

EP.10 は + 。古典アルゴリズム ( / Cellular Automata / BSP) が 「構造」 を、 が 「意味」 を担う棲み分けで、無限に遊べて毎回違う体験のあるゲームを作る話。Roguelike / Survival / Sandbox との相性が抜群です。

1. 役割分担: アルゴリズム vs LLM

要素アルゴリズムが得意LLM が得意
マップ構造✅ WFC / CA / BSP❌ 整合性が崩れる
部屋の名称・歴史❌ 機械的✅ 雰囲気のあるストーリー
敵配置 (バランス)✅ 階層難度の数式調整△ バランス感覚は弱い
敵の説明文・台詞❌ テンプレート✅ キャラ立て可能
クエスト構造✅ Goal / Step / Reward の骨格△ 骨格は弱め
クエスト本文・動機❌ 型にハマる✅ 多様性

2. Cellular Automata で洞窟生成 (30 行)

cellular_cave.py
Python
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 で部屋に「意味」 を付ける

洞窟マップから物語を生成
Python
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`) を使えば数行で動く。 の代表的な手法。

WFC 簡易版 (1 次元)
Python
# 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 C

5. クエスト生成 (構造 + 物語)

クエスト骨格をアルゴリズムで、本文を LLM で
Python
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 活用余地
RoguelikeBSP / CA で部屋・洞窟敵フレーバー / アイテム名 / 部屋描写
サンドボックス (Minecraft 系)Perlin Noise / バイオーム村人の役職・物語
メトロイドヴァニア (一部)BSP + 強化版ボス前演出 / マップ名
SurvivalWFC / Voxelイベントランダムテキスト
TRPG / ナラティブストーリーグラフシーン記述・NPC 反応

8. アルゴリズムと LLM の境界線設計

「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 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。

シリーズの外も探す:

まずは、現状を聞かせてください。

要件が固まっていなくて大丈夫です。現状診断と方針提案までを無料でお手伝いします。

無料相談フォームへ hello [at] hukuhuku [dot] co [dot] jp