探索・アドベンチャーLESSON 43道具の持越し・有限分岐ダンジョン原作051

三部屋の地下道の作り方

次の部屋を見て、持っていく道具を選ぶ。
通路を通る・道具を取りかえる・次の部屋を選ぶ。3つの判断を別々の場面に分け、枝分かれする道を順に選んでいくゲームを作ります。

完成版の遊び方2つ先の部屋まで見て、1つだけ持てる道具を取りかえながら体力を残し、3つの部屋を通って帰ります。

三部屋の地下道の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の画面です。この講座では、仕組みがわかりやすいように小さくした「教材版」を、5つのステップで作ります。
PLAY / 試験公開

完成サンプルをブラウザーで遊ぶ

この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。

完成版の主な操作:上下通路/左右道具と部屋、Z決定、X戻す、R再開、P説明、1〜3構成

ボタンを押すと、ゲームの読み込みが始まります。

画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。

うまく表示されないとき

ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。

同じPyxelアプリをダウンロード(.pyxapp) / 別のタブで開く

場面ごとに、選べる行動を分ける。

いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。

1. 障害を表す文字列と、失敗を先に判定する状態の更新
2. 「通路」「道具」「次の部屋」という3つの場面
3. 「通路を選ぶ」と「次の部屋を選ぶ」を別々にする
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上、.venv とターミナル / 変数・if・for・関数・タプルと辞書 / dataclass・replace・入力の待ち方・履歴は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
上下:routeで通路選択 / 左右:gearで維持/交換、03以降のnextで左/右 / Z:現在のphaseを確定 / X:05だけ一決定Undo / R:同じBを初期化 / Esc:終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_passages.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。

教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。

STEP 01 / 05

通路の傷と三部屋の帰還を作る

障害を表す文字列と、失敗を先に判定する状態の更新。

まずは道具を持たない練習を作ります。元のゲームの状況 B から、部屋0 → 1 → 3 の左の道だけを取り出しました。どの部屋にも上と下の2つの通路があり、上下で選んで Z で通ります。P は穴、D は暗いところ、W は深い水です。通路の文字1つにつき HP(体力)が1減るので、PD も DD もダメージは2です。通りぬけると宝が増え、次の部屋へ自動で進みます。これは練習のためだけに省いたルールです。

Room は変わらない部屋のデータ、State は HP や今いる部屋など、変わっていく状態です。@dataclass(frozen=True) で直接の書きかえをできなくし、replace で必要なところだけ変えた新しい State を作ります。step は入力を受け取って新しい状態を返す関数です。描画や Pyxel を呼ばないので、ターミナルからでもテストできます。

宝を足したあとで、ゲームが終わったかを判定します。HP が0以下なら lost(失敗)、そうでなく深さ3の部屋にいれば won(成功)です。この順番を逆にすると、最後の通路で HP が0になったのに成功になってしまいます。宝4個・8個は追加の目安で、帰れたかどうかの条件には使いません。

選んでいるカーソルの位置は App が持つ情報で、HP や宝とは別のものです。Z を押すたびに1回だけ step に渡し、全部の操作キーを離すまで次の入力を受け付けません。時間がたってもダメージは受けないので、ゆっくり考えられます。

下のコードは 01_passages.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

01_passages.py / 注目する部分
    phase = "lost" if hp <= 0 else "won" if room.depth == 3 else "route"

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 01_passages.py
macOS / Linux
.venv/bin/python 01_passages.py
画面で見ること最初の状態で Z を押します。部屋1に進み、HP 4・宝4・PHASE ROUTE になります。通路 PD の2文字で、ダメージは2です。
  • P/D/W の文字数でダメージを数える
  • HP 0 の失敗を、最後の部屋の成功より先に判定する
  • 道具なし・左の道だけ・自動で次の部屋へ、は練習だけのルール
つまずきやすいところ最後の部屋かどうかを先に判定しないようにしましょう。HP 0以下の lost を先に判定し、失敗しても取った宝は残します。
少し変えてみよう

R → Z → Z → Z と押すと、HP 0・宝12で LOST です。続けて R → 下 → Z → 下 → Z → 下 → Z と押すと、HP 3・宝4で RETURNED(帰還)になります。

01_passages.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(211行)
01_passages.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""三部屋の地下道・教材1/5。Python3.11以上 / Pyxel2.9.9。"""
from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Room:
    id: str
    kind: str
    depth: int
    routes: tuple
    children: tuple

# 原作Bの手作りデータ。通路は (障害文字列, 宝, 拾う道具)。
ROOMS = {
    "0": Room("0", "M", 1, (("PD", 4, "-"), ("W", 2, "-")), ("1",)),
    "1": Room("1", "D", 2, (("DD", 4, "-"), ("P", 1, "-")), ("3",)),
    "3": Room("3", "W", 3, (("WW", 4, "-"), ("D", 1, "-")), ()),
}

@dataclass(frozen=True)
class State:
    room: str = "0"
    phase: str = "route"
    hp: int = 6
    treasure: int = 0
    tick: int = 0


def initial():
    return State()


def loss(route):
    return len(route[0])


def actions(state):
    return (("route", 0), ("route", 1)) if state.phase == "route" else ()


def step(state, action):
    if (not isinstance(action, (tuple, list)) or len(action) != 2
            or action[0] != "route" or type(action[1]) is not int
            or action[1] not in (0, 1) or state.phase != "route"):
        return state
    room = ROOMS[state.room]
    route = room.routes[action[1]]
    hp = state.hp - loss(route)
    phase = "lost" if hp <= 0 else "won" if room.depth == 3 else "route"
    # 練習限定: 道具を拾わず、左の次室へ自動で進む。
    next_room = room.children[0] if phase == "route" else state.room
    return replace(state, room=next_room, phase=phase, hp=hp,
                   treasure=state.treasure + route[1], tick=state.tick + 1)


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()
        self.selection = 0
        self.gated = False
        self.message = "ONE KEY, THEN RELEASE"

    
    def control(self, pressed, held):
        # 新しく押されたRは優先。押しっぱなしのRでは初期化しない。
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = "RELEASE ALL KEYS"
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        
        if self.state.phase in ("won", "lost"):
            return
        if self.state.phase == "route" and key in ("UP", "DOWN"):
            self.selection = 0 if key == "UP" else 1
            self.gated = True
            return
        
        if key != "Z":
            return
        before = self.state
        self.state = step(self.state, (self.state.phase, self.selection))
        self.message = "CONFIRMED" if self.state != before else "BLOCKED / NO CHANGE"
        if self.state != before:
            self.selection = 0
        self.gated = True

    def draw(self, p):
        # drawは表示のみ。状態・履歴・選択を一切変えない。
        state = self.state
        room = ROOMS[state.room]
        p.cls(1)
        p.text(12, 10, "THREE ROOMS / STAGE 1 / AUTHORED B", 10)
        p.text(12, 26, "ROOM "+room.id+" / DEPTH "+str(room.depth)+" OF 3", 7)
        p.text(12, 40, "HP "+str(state.hp)+"/6   TREASURE "+str(state.treasure)+"   PHASE "+state.phase.upper(), 7)
        for i in range(6):
            p.rect(340+i*22, 27, 16, 9, 8 if i < state.hp else 5)
        p.text(12, 56, "P/D/W: EACH LETTER COSTS 1 HP / NO EQUIPMENT", 6)
        p.text(12, 77, "CURRENT PASSAGES", 10)
        for i, route in enumerate(room.routes):
            y = 96 + i*73
            active = state.phase == "route" and self.selection == i
            p.rect(12, y, 268, 62, 2)
            p.rectb(12, y, 268, 62, 10 if active else 5)
            p.text(22, y+9, ("> " if active else "  ")+("UP" if i == 0 else "DOWN")+" : "+route[0], 10 if active else 7)
            p.text(22, y+24, "TREASURE +"+str(route[1])+"   HIT "+str(loss(route)), 7)
            p.text(22, y+42, "NO PICKUP IN THIS PRACTICE", 6)
        if state.phase == "route":
            damage = loss(room.routes[self.selection])
            p.text(12, 253, "SELECTED: HP "+str(state.hp)+" -> "+str(state.hp-damage), 11)
            p.text(12, 271, "Z: PASS, THEN AUTO LEFT", 7)
        else:
            p.text(12, 253, "RETURNED" if state.phase == "won" else "LOST / TREASURE RETAINED", 11 if state.phase == "won" else 8)
            p.text(12, 271, "R: TRY AGAIN", 7)
        p.text(306, 77, "PRACTICE: FIXED LEFT PATH", 10)
        p.text(306, 98, "ROOM 0 -> ROOM 1 -> ROOM 3", 7)
        p.text(306, 120, "NO GEAR / AUTOMATIC ROOM CHANGE", 6)
        p.text(306, 138, "P = PIT / D = DARK / W = WATER", 6)
        p.text(306, 161, "ONE OBSTACLE LETTER = ONE HIT", 7)
        p.text(306, 181, "HP 0 LOSES EVEN IN ROOM 3", 8)
        p.text(12, 320, self.message, 7)
        p.text(12, 339, "UP/DOWN + Z / R RESET / ESC QUIT", 6)
        p.text(12, 355, "TREASURE TARGETS ARE OPTIONAL. HP > 0 AT EXIT WINS.", 6)
        p.text(12, 371, "PRACTICE RULES: NO GEAR, LEFT ONLY. NOT THE FULL GAME.", 5)


CONTROL_KEYS = ("UP", "DOWN", "LEFT", "RIGHT", "Z", "X", "R")
DEMO_KEYS = ('Z',)


def main(headless=False, screenshot=None, demo=False, state_json=None):
    from pathlib import Path
    from dataclasses import asdict
    import json
    import pyxel as p  # ここだけPyxelが必要。モデルのテストは標準ライブラリのみ。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(600, 390, title="Three Rooms / Lesson 1", fps=30, display_scale=2, headless=headless)
    p.colors.from_list([0x090D16, 0x151D2B, 0x293441, 0x35654D, 0x674738, 0x778897, 0x85B9B5, 0xE8E1CE, 0xD96755, 0xC4884A, 0xF4C46B, 0xA8D68D, 0x4384A1, 0x8B969B, 0xD09B8C, 0xD6C5A6])
    keys = {name: getattr(p, "KEY_"+name) for name in CONTROL_KEYS}
    limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12
    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                p.screenshot(screenshot, scale=2)
            if state_json:
                path = Path(state_json)
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps(asdict(app.state), ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            pressed = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
    p.run(update, lambda: app.draw(p))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="三部屋の地下道・独立教材")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    parser.add_argument("--state-json")
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error("自動確認の出力には --headless-smoke が必要です")
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json)
STEP 02 / 05

一枠の道具を、通路の後で選ぶ

「通路」「道具」「次の部屋」という3つの場面。

R は縄、L は灯り、B は長ぐつ、- は道具なしです。BLOCK でどの道具がどの障害を防ぐかを表し、縄は P、灯りは D、長ぐつは W のダメージを全部防ぎます。たとえば DD を灯りを持って通れば、ダメージは0です。PD を縄で通ると P だけを防ぎ、D のダメージ1が残ります。1つの道具が防ぐのは1種類の文字だけですが、その文字が2つあれば両方とも防ぎます。

通路を選ぶ route、拾った道具を持つか取りかえるかを決める gear、次の部屋へ行く next の3つの場面に分けます。先に今の道具で通路のダメージを計算し、拾った道具は pending(保留)に入れておきます。gear の場面で右 → Z なら取りかえ、左 → Z なら今の道具のままです。取りかえは1つの枠の入れかえで、前の道具と効果を重ねることはありません。拾っても、減った HP は戻りません。

同じ Z でも、phase(場面)がちがえば別の意味になります。step の中でも場面を確かめ、gear の場面に next の入力が来ても無視します。画面の表示を切りかえるだけでは、ルールを守れません。受け付けなかった操作では、HP・宝・tick・履歴のどれも変えず、元の State を返します。

この段階でも、次の部屋は左だけです。next の場面では、Z だけで左へ進み、右への分かれ道はまだありません。通ったあとの通路カードのダメージは、その通路を通る前の道具で表示します。取りかえたあとの道具で計算すると、過去のダメージが小さく見えてしまうのを防ぐためです。

下のコードは 02_equipment.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

02_equipment.py / 注目する部分
    return sum(ch != BLOCK.get(gear, "") for ch in route[0])

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 02_equipment.py
macOS / Linux
.venv/bin/python 02_equipment.py
画面で見ること下 → Z で HP 5・宝2になり、L(灯り)を拾う GEAR の場面になります。右 → Z で灯りを持って NEXT へ。Z で部屋1に進み、Z で DD を通るとダメージ0で HP 5・宝6、R(縄)を拾う GEAR の場面です。
  • 道具に対応する障害の文字は全部防ぐ
  • まざった通路のほかの文字は、ダメージとして残る
  • 取りかえは1枠で、回復はなし。次の部屋はまだ左だけ
つまずきやすいところpending を先に道具の枠に入れると、今拾った道具で、すでに通った通路のダメージまで防いでしまいます。「通路の計算 → 道具を選ぶ」の順番を守ります。
少し変えてみよう

上の結果のあと、Z で灯りを持ったまま → Z で左の部屋3 → 下 → Z と押してみましょう。D を灯りで防ぎ、HP 5・宝7で RETURNED です。宝が8個なくても成功します。

02_equipment.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(246行)
02_equipment.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""三部屋の地下道・教材2/5。Python3.11以上 / Pyxel2.9.9。"""
from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Room:
    id: str
    kind: str
    depth: int
    routes: tuple
    children: tuple

# 原作Bの手作りデータ。通路は (障害文字列, 宝, 拾う道具)。
ROOMS = {
    "0": Room("0", "M", 1, (("PD", 4, "B"), ("W", 2, "L")), ("1",)),
    "1": Room("1", "D", 2, (("DD", 4, "R"), ("P", 1, "B")), ("3",)),
    "3": Room("3", "W", 3, (("WW", 4, "-"), ("D", 1, "-")), ()),
}

# 一枠だけ。R=縄、L=灯、B=長靴、-=なし。
BLOCK = {"R": "P", "L": "D", "B": "W"}

@dataclass(frozen=True)
class State:
    room: str = "0"
    phase: str = "route"
    hp: int = 6
    gear: str = "-"
    treasure: int = 0
    pending: str = "-"
    tick: int = 0
    trail: tuple = ()


def initial():
    return State()


def loss(route, gear):
    # 対応する文字を全て防ぐ。混成通路の別の文字は傷になる。
    return sum(ch != BLOCK.get(gear, "") for ch in route[0])


def actions(state):
    if state.phase == "next":
        return (("next", 0),)
    return tuple((state.phase, i) for i in (0, 1)) if state.phase in ("route", "gear", "next") else ()


def step(state, action):
    if (not isinstance(action, (tuple, list)) or len(action) != 2
            or action[0] != state.phase or type(action[1]) is not int
            or action[1] not in (0, 1)):
        return state
    kind, index = action
    room = ROOMS[state.room]
    if kind == "route":
        route = room.routes[index]
        damage = loss(route, state.gear)
        hp = state.hp - damage
        # 体力0の失敗を先に判定。宝は失敗時も記録する。
        phase = "lost" if hp <= 0 else "won" if room.depth == 3 else "gear"
        return replace(state, phase=phase, hp=hp, treasure=state.treasure + route[1],
                       pending=route[2] if phase == "gear" else "-", tick=state.tick + 1,
                       trail=state.trail + ((room.id, "route", index, state.gear, damage, route[1]),))
    if kind == "gear":
        gear = state.pending if index else state.gear
        return replace(state, phase="next", gear=gear, pending="-", tick=state.tick + 1,
                       trail=state.trail + ((room.id, "gear", index, gear),))
    if kind == "next":
        if index != 0:  # 練習限定: 左の子だけを選べる。
            return state
        return replace(state, room=room.children[index], phase="route", tick=state.tick + 1,
                       trail=state.trail + ((room.id, "next", index, room.children[index]),))
    return state


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()
        self.selection = 0
        self.gated = False
        self.message = "ONE KEY, THEN RELEASE"

    
    def control(self, pressed, held):
        # 新しく押されたRは優先。押しっぱなしのRでは初期化しない。
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = "RELEASE ALL KEYS"
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        
        if self.state.phase in ("won", "lost"):
            return
        if self.state.phase == "route" and key in ("UP", "DOWN"):
            self.selection = 0 if key == "UP" else 1
            self.gated = True
            return
        if self.state.phase == "gear" and key in ("LEFT", "RIGHT"):
            self.selection = 0 if key == "LEFT" else 1
            self.gated = True
            return
        
        if key != "Z":
            return
        before = self.state
        self.state = step(self.state, (self.state.phase, self.selection))
        self.message = "CONFIRMED" if self.state != before else "BLOCKED / NO CHANGE"
        if self.state != before:
            self.selection = 0
        self.gated = True

    def draw(self, p):
        # drawは表示のみ。状態・履歴・選択を一切変えない。
        state = self.state
        room = ROOMS[state.room]
        pass_gear = state.gear if state.phase == "route" else next((e[3] for e in reversed(state.trail) if e[0] == room.id and e[1] == "route"), state.gear)
        p.cls(1)
        p.text(12, 10, "THREE ROOMS / STAGE 2 / AUTHORED B", 10)
        p.text(12, 26, "ROOM "+room.id+" / DEPTH "+str(room.depth)+" OF 3", 7)
        p.text(12, 40, "HP "+str(state.hp)+"/6   TREASURE "+str(state.treasure)+"   PHASE "+state.phase.upper(), 7)
        for i in range(6):
            p.rect(340+i*22, 27, 16, 9, 8 if i < state.hp else 5)
        p.text(12, 56, "R BLOCKS P / L BLOCKS D / B BLOCKS W / ONE SLOT", 6)
        p.text(12, 77, "PASSAGES / GEAR BEFORE PICKUP", 10)
        for i, route in enumerate(room.routes):
            y = 96 + i*73
            active = state.phase == "route" and self.selection == i
            p.rect(12, y, 268, 62, 2)
            p.rectb(12, y, 268, 62, 10 if active else 5)
            p.text(22, y+9, ("> " if active else "  ")+("UP" if i == 0 else "DOWN")+" : "+route[0], 10 if active else 7)
            p.text(22, y+24, "TREASURE +"+str(route[1])+"   HIT "+str(loss(route, pass_gear)), 7)
            p.text(22, y+42, "PICK "+route[2]+" AFTER THE PASSAGE", 6)
        p.text(12, 245, "EQUIPPED "+state.gear+" / PICKED UP "+state.pending, 6)
        if state.phase == "route":
            damage = loss(room.routes[self.selection], state.gear)
            p.text(12, 261, "SELECTED: HP "+str(state.hp)+" -> "+str(state.hp-damage), 11 if state.hp > damage else 8)
            p.text(12, 278, "UP/DOWN: PASSAGE / Z: PASS", 7)
        elif state.phase == "gear":
            p.text(12, 261, ("> " if self.selection == 0 else "  ")+"KEEP "+state.gear, 11 if self.selection == 0 else 7)
            p.text(144, 261, ("> " if self.selection == 1 else "  ")+"SWAP "+state.pending, 11 if self.selection == 1 else 7)
            p.text(12, 278, "LEFT/RIGHT, Z / NO HEALING", 7)
        elif state.phase == "next":
            p.text(12, 261, "NEXT: "+("LEFT" if self.selection == 0 else "RIGHT"), 11)
            p.text(12, 278, "Z: FIXED LEFT ROOM", 7)
        else:
            p.text(12, 261, "RETURNED" if state.phase == "won" else "LOST / TREASURE RETAINED", 11 if state.phase == "won" else 8)
            p.text(12, 278, "R: TRY AGAIN", 7)
        p.text(306, 77, "PRACTICE: FIXED LEFT PATH", 10)
        p.text(306, 98, "ROOM 0 -> ROOM 1 -> ROOM 3", 7)
        p.text(306, 120, "GEAR DECISION, THEN Z FOR NEXT", 6)
        p.text(306, 138, "P = PIT / D = DARK / W = WATER", 6)
        p.text(306, 161, "ONLY UNBLOCKED LETTERS COST HP", 7)
        p.text(306, 181, "HP 0 LOSES EVEN IN ROOM 3", 8)
        p.text(12, 320, self.message, 7)
        p.text(12, 339, "UP/DOWN ROUTE / LEFT/RIGHT GEAR / Z CONFIRM / R RESET", 6)
        p.text(12, 355, "TREASURE TARGETS ARE OPTIONAL. HP > 0 AT EXIT WINS.", 6)
        p.text(12, 371, "PRACTICE RULES: LEFT CHILD ONLY. NO RIGHT BRANCH YET.", 5)


CONTROL_KEYS = ("UP", "DOWN", "LEFT", "RIGHT", "Z", "X", "R")
DEMO_KEYS = ('DOWN', 'Z', 'RIGHT', 'Z', 'Z', 'Z')


def main(headless=False, screenshot=None, demo=False, state_json=None):
    from pathlib import Path
    from dataclasses import asdict
    import json
    import pyxel as p  # ここだけPyxelが必要。モデルのテストは標準ライブラリのみ。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(600, 390, title="Three Rooms / Lesson 2", fps=30, display_scale=2, headless=headless)
    p.colors.from_list([0x090D16, 0x151D2B, 0x293441, 0x35654D, 0x674738, 0x778897, 0x85B9B5, 0xE8E1CE, 0xD96755, 0xC4884A, 0xF4C46B, 0xA8D68D, 0x4384A1, 0x8B969B, 0xD09B8C, 0xD6C5A6])
    keys = {name: getattr(p, "KEY_"+name) for name in CONTROL_KEYS}
    limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12
    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                p.screenshot(screenshot, scale=2)
            if state_json:
                path = Path(state_json)
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps(asdict(app.state), ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            pressed = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
    p.run(update, lambda: app.draw(p))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="三部屋の地下道・独立教材")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    parser.add_argument("--state-json")
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error("自動確認の出力には --headless-smoke が必要です")
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json)
STEP 03 / 05

左右の次室を選び、公開された木を読む

「通路を選ぶ」と「次の部屋を選ぶ」を別々にする。

ROOMS に元のゲームの状況 B の7つの部屋をすべて用意し、next の場面で左右を選べるようにします。部屋0の先は部屋1と2、部屋1の先は3と4、部屋2の先は5と6です。「上の通路を選んだから左の部屋へ行く」という結びつきはありません。通路・道具・次の部屋は、それぞれ別の判断です。

右のカードには、次に選べる2つの部屋の通路・宝・拾える道具と、その先の出口が表示されます。ここに、かくれたランダムな情報はありません。ROOMS から必要なデータを取り出して見せているだけです。元のゲームには A・B・C の3つの手作りの構成がありますが、教材は B だけです。ランダムな種(seed)から、終わりのない地下道を作る教材ではありません。

最初の部屋で下の W を通って灯りを持ち、左の部屋1へ行くと、上の DD の宝4をダメージなしで取れます。灯りを持ったまま右の部屋4へ進み、下の D を通れば、HP 5・宝8で帰れます。一方、最初に宝4と長ぐつを選ぶやり方では、HP を使いながら宝12をねらえます。宝の量だけでなく、残る HP と、次の部屋に合う道具を比べて考えます。

この段階から、元のゲームの状況 B のふつうのルールと、まったく同じ状態の変わり方になります。最初の状態からたどれる255の場面と、128通りの終わり方を、一緒に入れたデータ(fixture)で照らし合わせています。画面は教材用の文字(ASCII)で、元のゲームの表紙・一時停止・演出・記録の機能とは別のものです。

下のコードは 03_branches.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

03_branches.py / 注目する部分
        return replace(state, room=room.children[index], phase="route", tick=state.tick + 1,
                       trail=state.trail + ((room.id, "next", index, room.children[index]),))

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_branches.py
macOS / Linux
.venv/bin/python 03_branches.py
画面で見ること下 → Z → 右 → Z → Z → Z → Z → 右 → Z → 下 → Z と押します。灯りで DD と D を防ぎ、部屋4から HP 5・宝8で RETURNED です。
  • 上下の通路と、左右の次の部屋は別々に選ぶ
  • 次の2つの部屋とその出口は、見えている情報
  • 元のゲームの状況 B の、決まった7部屋だけを作る
つまずきやすいところ通路の番号と、次の部屋の番号を同じ選択として扱わないようにしましょう。route は通路、next は room.children(その部屋の先の部屋)を見ます。
少し変えてみよう

R → Z → 右 → Z → Z → Z → Z → Z → Z と押してみましょう。最初の部屋で長ぐつを拾い、左・左と進んで上の通路を選びます。HP 2・宝12で RETURNED です。上の結果と HP・宝を比べてみてください。

03_branches.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(254行)
03_branches.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""三部屋の地下道・教材3/5。Python3.11以上 / Pyxel2.9.9。"""
from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Room:
    id: str
    kind: str
    depth: int
    routes: tuple
    children: tuple

# 原作Bの手作りデータ。通路は (障害文字列, 宝, 拾う道具)。
ROOMS = {
    "0": Room("0", "M", 1, (("PD", 4, "B"), ("W", 2, "L")), ("1", "2")),
    "1": Room("1", "D", 2, (("DD", 4, "R"), ("P", 1, "B")), ("3", "4")),
    "2": Room("2", "N", 2, (("PW", 4, "R"), ("D", 2, "B")), ("5", "6")),
    "3": Room("3", "W", 3, (("WW", 4, "-"), ("D", 1, "-")), ()),
    "4": Room("4", "N", 3, (("PW", 4, "-"), ("D", 2, "-")), ()),
    "5": Room("5", "D", 3, (("DD", 4, "-"), ("P", 1, "-")), ()),
    "6": Room("6", "R", 3, (("PP", 3, "-"), ("W", 1, "-")), ()),
}

# 一枠だけ。R=縄、L=灯、B=長靴、-=なし。
BLOCK = {"R": "P", "L": "D", "B": "W"}

@dataclass(frozen=True)
class State:
    room: str = "0"
    phase: str = "route"
    hp: int = 6
    gear: str = "-"
    treasure: int = 0
    pending: str = "-"
    tick: int = 0
    trail: tuple = ()


def initial():
    return State()


def loss(route, gear):
    # 対応する文字を全て防ぐ。混成通路の別の文字は傷になる。
    return sum(ch != BLOCK.get(gear, "") for ch in route[0])


def actions(state):
    return tuple((state.phase, i) for i in (0, 1)) if state.phase in ("route", "gear", "next") else ()


def step(state, action):
    if (not isinstance(action, (tuple, list)) or len(action) != 2
            or action[0] != state.phase or type(action[1]) is not int
            or action[1] not in (0, 1)):
        return state
    kind, index = action
    room = ROOMS[state.room]
    if kind == "route":
        route = room.routes[index]
        damage = loss(route, state.gear)
        hp = state.hp - damage
        # 体力0の失敗を先に判定。宝は失敗時も記録する。
        phase = "lost" if hp <= 0 else "won" if room.depth == 3 else "gear"
        return replace(state, phase=phase, hp=hp, treasure=state.treasure + route[1],
                       pending=route[2] if phase == "gear" else "-", tick=state.tick + 1,
                       trail=state.trail + ((room.id, "route", index, state.gear, damage, route[1]),))
    if kind == "gear":
        gear = state.pending if index else state.gear
        return replace(state, phase="next", gear=gear, pending="-", tick=state.tick + 1,
                       trail=state.trail + ((room.id, "gear", index, gear),))
    if kind == "next":
        return replace(state, room=room.children[index], phase="route", tick=state.tick + 1,
                       trail=state.trail + ((room.id, "next", index, room.children[index]),))
    return state


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()
        self.selection = 0
        self.gated = False
        self.message = "ONE KEY, THEN RELEASE"

    
    def control(self, pressed, held):
        # 新しく押されたRは優先。押しっぱなしのRでは初期化しない。
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = "RELEASE ALL KEYS"
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        
        if self.state.phase in ("won", "lost"):
            return
        if self.state.phase == "route" and key in ("UP", "DOWN"):
            self.selection = 0 if key == "UP" else 1
            self.gated = True
            return
        if self.state.phase in ("gear", "next") and key in ("LEFT", "RIGHT"):
            self.selection = 0 if key == "LEFT" else 1
            self.gated = True
            return
        
        if key != "Z":
            return
        before = self.state
        self.state = step(self.state, (self.state.phase, self.selection))
        self.message = "CONFIRMED" if self.state != before else "BLOCKED / NO CHANGE"
        if self.state != before:
            self.selection = 0
        self.gated = True

    def draw(self, p):
        # drawは表示のみ。状態・履歴・選択を一切変えない。
        state = self.state
        room = ROOMS[state.room]
        pass_gear = state.gear if state.phase == "route" else next((e[3] for e in reversed(state.trail) if e[0] == room.id and e[1] == "route"), state.gear)
        p.cls(1)
        p.text(12, 10, "THREE ROOMS / STAGE 3 / AUTHORED B", 10)
        p.text(12, 26, "ROOM "+room.id+" / DEPTH "+str(room.depth)+" OF 3", 7)
        p.text(12, 40, "HP "+str(state.hp)+"/6   TREASURE "+str(state.treasure)+"   PHASE "+state.phase.upper(), 7)
        for i in range(6):
            p.rect(340+i*22, 27, 16, 9, 8 if i < state.hp else 5)
        p.text(12, 56, "R BLOCKS P / L BLOCKS D / B BLOCKS W / ONE SLOT", 6)
        p.text(12, 77, "PASSAGES / GEAR BEFORE PICKUP", 10)
        for i, route in enumerate(room.routes):
            y = 96 + i*73
            active = state.phase == "route" and self.selection == i
            p.rect(12, y, 268, 62, 2)
            p.rectb(12, y, 268, 62, 10 if active else 5)
            p.text(22, y+9, ("> " if active else "  ")+("UP" if i == 0 else "DOWN")+" : "+route[0], 10 if active else 7)
            p.text(22, y+24, "TREASURE +"+str(route[1])+"   HIT "+str(loss(route, pass_gear)), 7)
            p.text(22, y+42, "PICK "+route[2]+" AFTER THE PASSAGE", 6)
        p.text(12, 245, "EQUIPPED "+state.gear+" / PICKED UP "+state.pending, 6)
        if state.phase == "route":
            damage = loss(room.routes[self.selection], state.gear)
            p.text(12, 261, "SELECTED: HP "+str(state.hp)+" -> "+str(state.hp-damage), 11 if state.hp > damage else 8)
            p.text(12, 278, "UP/DOWN: PASSAGE / Z: PASS", 7)
        elif state.phase == "gear":
            p.text(12, 261, ("> " if self.selection == 0 else "  ")+"KEEP "+state.gear, 11 if self.selection == 0 else 7)
            p.text(144, 261, ("> " if self.selection == 1 else "  ")+"SWAP "+state.pending, 11 if self.selection == 1 else 7)
            p.text(12, 278, "LEFT/RIGHT, Z / NO HEALING", 7)
        elif state.phase == "next":
            p.text(12, 261, "NEXT: "+("LEFT" if self.selection == 0 else "RIGHT"), 11)
            p.text(12, 278, "LEFT/RIGHT, Z: NEXT ROOM", 7)
        else:
            p.text(12, 261, "RETURNED" if state.phase == "won" else "LOST / TREASURE RETAINED", 11 if state.phase == "won" else 8)
            p.text(12, 278, "R: TRY AGAIN", 7)
        p.text(306, 77, "PUBLIC NEXT ROOMS + THEIR EXITS", 10)
        if not room.children:
            p.text(306, 99, "AFTER THIS PASSAGE: THE SURFACE", 11)
        for side, child_id in enumerate(room.children):
            child = ROOMS[child_id]
            y = 96 + side*99
            active = state.phase == "next" and self.selection == side
            p.rectb(306, y, 280, 92, 10 if active else 5)
            p.text(316, y+8, ("LEFT" if side == 0 else "RIGHT")+" / ROOM "+child.id+" / "+child.kind, 10)
            for index, route in enumerate(child.routes):
                p.text(316, y+24+index*13, ("UP " if index == 0 else "DOWN ")+route[0]+"  T+"+str(route[1])+"  PICK "+route[2], 7)
            for index, exit_id in enumerate(child.children):
                exits = ROOMS[exit_id].routes
                p.text(316, y+55+index*13, "EXIT "+exit_id+": "+" / ".join(q[0]+" T+"+str(q[1]) for q in exits), 6)
        p.text(12, 320, self.message, 7)
        p.text(12, 339, "UP/DOWN ROUTE / LEFT/RIGHT GEAR, NEXT / Z CONFIRM / R RESET", 6)
        p.text(12, 355, "TREASURE TARGETS ARE OPTIONAL. HP > 0 AT EXIT WINS.", 6)
        p.text(12, 371, "FINITE AUTHORED B TREE. NO RANDOM GENERATION. SILENT.", 5)


CONTROL_KEYS = ("UP", "DOWN", "LEFT", "RIGHT", "Z", "X", "R")
DEMO_KEYS = ('DOWN', 'Z', 'RIGHT', 'Z', 'Z', 'Z', 'Z', 'RIGHT', 'Z', 'DOWN', 'Z')


def main(headless=False, screenshot=None, demo=False, state_json=None):
    from pathlib import Path
    from dataclasses import asdict
    import json
    import pyxel as p  # ここだけPyxelが必要。モデルのテストは標準ライブラリのみ。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(600, 390, title="Three Rooms / Lesson 3", fps=30, display_scale=2, headless=headless)
    p.colors.from_list([0x090D16, 0x151D2B, 0x293441, 0x35654D, 0x674738, 0x778897, 0x85B9B5, 0xE8E1CE, 0xD96755, 0xC4884A, 0xF4C46B, 0xA8D68D, 0x4384A1, 0x8B969B, 0xD09B8C, 0xD6C5A6])
    keys = {name: getattr(p, "KEY_"+name) for name in CONTROL_KEYS}
    limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12
    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                p.screenshot(screenshot, scale=2)
            if state_json:
                path = Path(state_json)
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps(asdict(app.state), ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            pressed = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
    p.run(update, lambda: app.draw(p))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="三部屋の地下道・独立教材")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    parser.add_argument("--state-json")
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error("自動確認の出力には --headless-smoke が必要です")
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json)
STEP 04 / 05

公開情報から、交換後の傷を予測する

確定した状態と、「もし〜なら」の計算を分ける。

次の部屋のカードに、HIT(ダメージ)と「HP→」(予想 HP)を加えます。gear の場面で左右を選ぶだけで、「今の道具のまま」と「取りかえた道具」それぞれでの予想に切りかわります。Z を押すまでは、道具も HP も変わりません。表示のための計算を step にまぜず、forecast_context と public_future という、読むだけの関数に分けます。

最初に下の W を通ると、HP 5で灯りを拾います。gear の場面で左(道具なしのまま)を選ぶと、次の左の部屋の DD はダメージ2・予想 HP 3 です。右で灯りに取りかえる案を見ると、ダメージ0・予想 HP 5 になります。右の部屋の PW は灯りでは防げずダメージ2のまま、D はダメージ0です。いいところだけでなく、防げない通路も一緒に表示します。

route の場面では、今選んでいる通路を通った直後の HP をもとに、今の道具のままで次の部屋を計算します。gear の場面では、通路を通ったあとの HP と、選んでいる道具を使います。next の場面では、確定した道具を使います。今の通路で HP が0以下になる案なら、次の部屋の予想は出しません。さらにその先の部屋は、途中で選ぶ道具がまだ決まっていないので、元と同じ障害と宝だけを表示します。

この予想は、正解を教えてくれる探索の機能ではありません。見えているデータを、同じダメージの計算で出しているだけの補助です。通路や道具を自動で選ぶこともしません。予想の表示を何回切りかえても、カーソルを動かしても、状態・tick・trail(通った記録)が変わらないことをテストしています。

下のコードは 04_preview.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

04_preview.py / 注目する部分
    gear = state.pending if state.phase == "gear" and selection else state.gear

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 04_preview.py
macOS / Linux
.venv/bin/python 04_preview.py
画面で見ること下 → Z → 右 と押します。まだ GEAR の場面で、道具なし・HP 5・宝2 のままです。右の予想は「WITH GEAR L」になり、左の部屋の DD は HIT 0・HP→5、右の部屋の PW は HIT 2・HP→3 です。
  • 予想には、見えているデータだけを使う
  • 通路の場面では、選んだ通路を通ったあとの HP をもとにする
  • 予想を何回切りかえても、状態は変わらない
つまずきやすいところ予想を出すたびに step を呼んではいけません。選んでいる道具の候補と、仮の HP だけを別の変数に置き、State はそのままにして計算します。
少し変えてみよう

上の結果から左を押すと、DD の予想 HP は3、右を押すと5に戻ります。まだ Z で確定していないので、実際の道具は「-」、HP 5、tick 1 のままです。

04_preview.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(283行)
04_preview.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""三部屋の地下道・教材4/5。Python3.11以上 / Pyxel2.9.9。"""
from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Room:
    id: str
    kind: str
    depth: int
    routes: tuple
    children: tuple

# 原作Bの手作りデータ。通路は (障害文字列, 宝, 拾う道具)。
ROOMS = {
    "0": Room("0", "M", 1, (("PD", 4, "B"), ("W", 2, "L")), ("1", "2")),
    "1": Room("1", "D", 2, (("DD", 4, "R"), ("P", 1, "B")), ("3", "4")),
    "2": Room("2", "N", 2, (("PW", 4, "R"), ("D", 2, "B")), ("5", "6")),
    "3": Room("3", "W", 3, (("WW", 4, "-"), ("D", 1, "-")), ()),
    "4": Room("4", "N", 3, (("PW", 4, "-"), ("D", 2, "-")), ()),
    "5": Room("5", "D", 3, (("DD", 4, "-"), ("P", 1, "-")), ()),
    "6": Room("6", "R", 3, (("PP", 3, "-"), ("W", 1, "-")), ()),
}

# 一枠だけ。R=縄、L=灯、B=長靴、-=なし。
BLOCK = {"R": "P", "L": "D", "B": "W"}

@dataclass(frozen=True)
class State:
    room: str = "0"
    phase: str = "route"
    hp: int = 6
    gear: str = "-"
    treasure: int = 0
    pending: str = "-"
    tick: int = 0
    trail: tuple = ()


def initial():
    return State()


def loss(route, gear):
    # 対応する文字を全て防ぐ。混成通路の別の文字は傷になる。
    return sum(ch != BLOCK.get(gear, "") for ch in route[0])


def actions(state):
    return tuple((state.phase, i) for i in (0, 1)) if state.phase in ("route", "gear", "next") else ()


def step(state, action):
    if (not isinstance(action, (tuple, list)) or len(action) != 2
            or action[0] != state.phase or type(action[1]) is not int
            or action[1] not in (0, 1)):
        return state
    kind, index = action
    room = ROOMS[state.room]
    if kind == "route":
        route = room.routes[index]
        damage = loss(route, state.gear)
        hp = state.hp - damage
        # 体力0の失敗を先に判定。宝は失敗時も記録する。
        phase = "lost" if hp <= 0 else "won" if room.depth == 3 else "gear"
        return replace(state, phase=phase, hp=hp, treasure=state.treasure + route[1],
                       pending=route[2] if phase == "gear" else "-", tick=state.tick + 1,
                       trail=state.trail + ((room.id, "route", index, state.gear, damage, route[1]),))
    if kind == "gear":
        gear = state.pending if index else state.gear
        return replace(state, phase="next", gear=gear, pending="-", tick=state.tick + 1,
                       trail=state.trail + ((room.id, "gear", index, gear),))
    if kind == "next":
        return replace(state, room=room.children[index], phase="route", tick=state.tick + 1,
                       trail=state.trail + ((room.id, "next", index, room.children[index]),))
    return state


def forecast_context(state, selection):
    """公開情報だけ。通路選択中は、その通路を通った直後を仮定する。"""
    gear = state.pending if state.phase == "gear" and selection else state.gear
    hp = state.hp
    if state.phase == "route":
        hp -= loss(ROOMS[state.room].routes[selection], state.gear)
    return hp, gear


def public_future(state, selection):
    hp, gear = forecast_context(state, selection)
    if hp <= 0 or state.phase in ("won", "lost"):
        return ()
    result = []
    for child_id in ROOMS[state.room].children:
        child = ROOMS[child_id]
        routes = tuple({"obstacles": route[0], "treasure": route[1], "pickup": route[2],
                        "damage": loss(route, gear), "hp_after": hp - loss(route, gear)}
                       for route in child.routes)
        result.append({"room": child.id, "kind": child.kind, "routes": routes})
    return tuple(result)


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()
        self.selection = 0
        self.gated = False
        self.message = "ONE KEY, THEN RELEASE"

    
    def control(self, pressed, held):
        # 新しく押されたRは優先。押しっぱなしのRでは初期化しない。
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = "RELEASE ALL KEYS"
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        
        if self.state.phase in ("won", "lost"):
            return
        if self.state.phase == "route" and key in ("UP", "DOWN"):
            self.selection = 0 if key == "UP" else 1
            self.gated = True
            return
        if self.state.phase in ("gear", "next") and key in ("LEFT", "RIGHT"):
            self.selection = 0 if key == "LEFT" else 1
            self.gated = True
            return
        
        if key != "Z":
            return
        before = self.state
        self.state = step(self.state, (self.state.phase, self.selection))
        self.message = "CONFIRMED" if self.state != before else "BLOCKED / NO CHANGE"
        if self.state != before:
            self.selection = 0
        self.gated = True

    def draw(self, p):
        # drawは表示のみ。状態・履歴・選択を一切変えない。
        state = self.state
        room = ROOMS[state.room]
        pass_gear = state.gear if state.phase == "route" else next((e[3] for e in reversed(state.trail) if e[0] == room.id and e[1] == "route"), state.gear)
        p.cls(1)
        p.text(12, 10, "THREE ROOMS / STAGE 4 / AUTHORED B", 10)
        p.text(12, 26, "ROOM "+room.id+" / DEPTH "+str(room.depth)+" OF 3", 7)
        p.text(12, 40, "HP "+str(state.hp)+"/6   TREASURE "+str(state.treasure)+"   PHASE "+state.phase.upper(), 7)
        for i in range(6):
            p.rect(340+i*22, 27, 16, 9, 8 if i < state.hp else 5)
        p.text(12, 56, "R BLOCKS P / L BLOCKS D / B BLOCKS W / ONE SLOT", 6)
        p.text(12, 77, "PASSAGES / GEAR BEFORE PICKUP", 10)
        for i, route in enumerate(room.routes):
            y = 96 + i*73
            active = state.phase == "route" and self.selection == i
            p.rect(12, y, 268, 62, 2)
            p.rectb(12, y, 268, 62, 10 if active else 5)
            p.text(22, y+9, ("> " if active else "  ")+("UP" if i == 0 else "DOWN")+" : "+route[0], 10 if active else 7)
            p.text(22, y+24, "TREASURE +"+str(route[1])+"   HIT "+str(loss(route, pass_gear)), 7)
            p.text(22, y+42, "PICK "+route[2]+" AFTER THE PASSAGE", 6)
        p.text(12, 245, "EQUIPPED "+state.gear+" / PICKED UP "+state.pending, 6)
        if state.phase == "route":
            damage = loss(room.routes[self.selection], state.gear)
            p.text(12, 261, "SELECTED: HP "+str(state.hp)+" -> "+str(state.hp-damage), 11 if state.hp > damage else 8)
            p.text(12, 278, "UP/DOWN: PASSAGE / Z: PASS", 7)
        elif state.phase == "gear":
            p.text(12, 261, ("> " if self.selection == 0 else "  ")+"KEEP "+state.gear, 11 if self.selection == 0 else 7)
            p.text(144, 261, ("> " if self.selection == 1 else "  ")+"SWAP "+state.pending, 11 if self.selection == 1 else 7)
            p.text(12, 278, "LEFT/RIGHT, Z / NO HEALING", 7)
        elif state.phase == "next":
            p.text(12, 261, "NEXT: "+("LEFT" if self.selection == 0 else "RIGHT"), 11)
            p.text(12, 278, "LEFT/RIGHT, Z: NEXT ROOM", 7)
        else:
            p.text(12, 261, "RETURNED" if state.phase == "won" else "LOST / TREASURE RETAINED", 11 if state.phase == "won" else 8)
            p.text(12, 278, "R: TRY AGAIN", 7)
        hp, gear = forecast_context(state, self.selection)
        p.text(306, 77, ("NEXT: FROM HP " if room.children else "EXIT: HP ")+str(hp)+" WITH GEAR "+gear, 10)
        forecasts = public_future(state, self.selection)
        if not room.children:
            p.text(306, 99, "AFTER THIS PASSAGE: THE SURFACE", 11)
        elif not forecasts:
            p.text(306, 99, "THIS ROUTE ENDS THE RUN", 8)
        for side, info in enumerate(forecasts):
            child = ROOMS[info["room"]]
            y = 96 + side*99
            active = state.phase == "next" and self.selection == side
            p.rectb(306, y, 280, 92, 10 if active else 5)
            p.text(316, y+8, ("LEFT" if side == 0 else "RIGHT")+" / ROOM "+child.id+" / "+child.kind, 10)
            for index, route in enumerate(info["routes"]):
                line = ("UP " if index == 0 else "DOWN ")+route["obstacles"]+" T+"+str(route["treasure"])+" HIT "+str(route["damage"])+" HP->"+str(route["hp_after"])+" PICK "+route["pickup"]
                p.text(316, y+24+index*13, line, 11 if route["damage"] == 0 else 8 if route["hp_after"] <= 0 else 7)
            for index, exit_id in enumerate(child.children):
                exits = ROOMS[exit_id].routes
                p.text(316, y+55+index*13, "EXIT "+exit_id+": "+" / ".join(q[0]+" T+"+str(q[1]) for q in exits), 6)
        p.text(306, 300, "PREVIEW ONLY / NO STATE CHANGE", 6)
        p.text(12, 320, self.message, 7)
        p.text(12, 339, "UP/DOWN ROUTE / LEFT/RIGHT GEAR, NEXT / Z CONFIRM / R RESET", 6)
        p.text(12, 355, "TREASURE TARGETS ARE OPTIONAL. HP > 0 AT EXIT WINS.", 6)
        p.text(12, 371, "FINITE AUTHORED B TREE. NO RANDOM GENERATION. SILENT.", 5)


CONTROL_KEYS = ("UP", "DOWN", "LEFT", "RIGHT", "Z", "X", "R")
DEMO_KEYS = ('DOWN', 'Z', 'RIGHT')


def main(headless=False, screenshot=None, demo=False, state_json=None):
    from pathlib import Path
    from dataclasses import asdict
    import json
    import pyxel as p  # ここだけPyxelが必要。モデルのテストは標準ライブラリのみ。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(600, 390, title="Three Rooms / Lesson 4", fps=30, display_scale=2, headless=headless)
    p.colors.from_list([0x090D16, 0x151D2B, 0x293441, 0x35654D, 0x674738, 0x778897, 0x85B9B5, 0xE8E1CE, 0xD96755, 0xC4884A, 0xF4C46B, 0xA8D68D, 0x4384A1, 0x8B969B, 0xD09B8C, 0xD6C5A6])
    keys = {name: getattr(p, "KEY_"+name) for name in CONTROL_KEYS}
    limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12
    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                p.screenshot(screenshot, scale=2)
            if state_json:
                path = Path(state_json)
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps(asdict(app.state), ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            pressed = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
    p.run(update, lambda: app.draw(p))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="三部屋の地下道・独立教材")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    parser.add_argument("--state-json")
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error("自動確認の出力には --headless-smoke が必要です")
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json)
STEP 05 / 05

通路・道具・次室を一決定ずつ戻す

書きかえない State 全体と、入力の意味を履歴に残す。

Timeline に、今の State、過去の State を並べたタプル past、実際に受け付けた入力を並べたタプル inputs を入れます。apply はまず step を呼び、状態が変わったときだけ、直前の State と入力を積みます。受け付けなかった操作では、履歴は増えません。カーソルの移動や予想の切りかえも、ゲームの判断ではないので、履歴には入りません。

X は、最後に保存した状態を取り出し、past と inputs の最後を1つずつ取り除きます。HP だけを戻すやり方では、宝・道具・拾う道具・今の部屋・phase・tick・trail がずれてしまうので、State 全体を保存します。State の中身もタプルなので、あとの操作で過去の内容が書きかわることはありません。

結果の画面でも X を受け付けるように、「ゲームが終わったら入力を止める」処理より前に Undo を置きます。HP が0になる通路で取った宝もいったん記録され、Undo するとその通路を通る前の宝の数に戻ります。過去の失敗を書きかえて都合のいい記録を残す機能ではなく、最後の判断そのものを取り消す練習用の機能です。R は状態と履歴を最初に戻します。

教材では、Undo したあとの選択を上か左に戻します。選んでいる位置はゲームの State ではなく画面側の情報なので、元のカーソルの位置まで戻す必要はありません。128通りの終わり方すべてを最後まで進めてから、1手ずつ最初の状態まで戻すテストで、通路・取りかえ・次の部屋への移動・成功・失敗からの Undo を確かめています。

下のコードは 05_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

05_undo.py / 注目する部分
    if state == timeline.state:
        return timeline
    return Timeline(state, timeline.past + (timeline.state,), timeline.inputs + (tuple(action),))

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること03 の安全な手順で HP 5・宝8の RETURNED にしてから X を押します。部屋4の ROUTE の場面、灯りを持った HP 5・宝6・tick 6 に戻ります。下 → Z で、同じ成功をもう一度できます。
  • 実際に決まった判断だけを履歴に入れる
  • State 全体と入力の意味を、一緒に戻す
  • 成功・失敗のあとも X が使え、R は履歴も最初に戻す
つまずきやすいところ状態を保存せずに、HP にダメージを足し戻すだけでは、宝や phase を元に戻せません。また、受け付けなかった操作まで past に積むと、X を押しても何も変わらない履歴が増えてしまいます。
少し変えてみよう

R から Z を7回押すと、道具なしで左・左と進み、HP 0・宝12で LOST です。X を押すと、HP 2・宝8の ROUTE の場面に戻ります。R → X では、最初の状態のままです。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(312行)
05_undo.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""三部屋の地下道・教材5/5。Python3.11以上 / Pyxel2.9.9。"""
from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Room:
    id: str
    kind: str
    depth: int
    routes: tuple
    children: tuple

# 原作Bの手作りデータ。通路は (障害文字列, 宝, 拾う道具)。
ROOMS = {
    "0": Room("0", "M", 1, (("PD", 4, "B"), ("W", 2, "L")), ("1", "2")),
    "1": Room("1", "D", 2, (("DD", 4, "R"), ("P", 1, "B")), ("3", "4")),
    "2": Room("2", "N", 2, (("PW", 4, "R"), ("D", 2, "B")), ("5", "6")),
    "3": Room("3", "W", 3, (("WW", 4, "-"), ("D", 1, "-")), ()),
    "4": Room("4", "N", 3, (("PW", 4, "-"), ("D", 2, "-")), ()),
    "5": Room("5", "D", 3, (("DD", 4, "-"), ("P", 1, "-")), ()),
    "6": Room("6", "R", 3, (("PP", 3, "-"), ("W", 1, "-")), ()),
}

# 一枠だけ。R=縄、L=灯、B=長靴、-=なし。
BLOCK = {"R": "P", "L": "D", "B": "W"}

@dataclass(frozen=True)
class State:
    room: str = "0"
    phase: str = "route"
    hp: int = 6
    gear: str = "-"
    treasure: int = 0
    pending: str = "-"
    tick: int = 0
    trail: tuple = ()


def initial():
    return State()


def loss(route, gear):
    # 対応する文字を全て防ぐ。混成通路の別の文字は傷になる。
    return sum(ch != BLOCK.get(gear, "") for ch in route[0])


def actions(state):
    return tuple((state.phase, i) for i in (0, 1)) if state.phase in ("route", "gear", "next") else ()


def step(state, action):
    if (not isinstance(action, (tuple, list)) or len(action) != 2
            or action[0] != state.phase or type(action[1]) is not int
            or action[1] not in (0, 1)):
        return state
    kind, index = action
    room = ROOMS[state.room]
    if kind == "route":
        route = room.routes[index]
        damage = loss(route, state.gear)
        hp = state.hp - damage
        # 体力0の失敗を先に判定。宝は失敗時も記録する。
        phase = "lost" if hp <= 0 else "won" if room.depth == 3 else "gear"
        return replace(state, phase=phase, hp=hp, treasure=state.treasure + route[1],
                       pending=route[2] if phase == "gear" else "-", tick=state.tick + 1,
                       trail=state.trail + ((room.id, "route", index, state.gear, damage, route[1]),))
    if kind == "gear":
        gear = state.pending if index else state.gear
        return replace(state, phase="next", gear=gear, pending="-", tick=state.tick + 1,
                       trail=state.trail + ((room.id, "gear", index, gear),))
    if kind == "next":
        return replace(state, room=room.children[index], phase="route", tick=state.tick + 1,
                       trail=state.trail + ((room.id, "next", index, room.children[index]),))
    return state


def forecast_context(state, selection):
    """公開情報だけ。通路選択中は、その通路を通った直後を仮定する。"""
    gear = state.pending if state.phase == "gear" and selection else state.gear
    hp = state.hp
    if state.phase == "route":
        hp -= loss(ROOMS[state.room].routes[selection], state.gear)
    return hp, gear


def public_future(state, selection):
    hp, gear = forecast_context(state, selection)
    if hp <= 0 or state.phase in ("won", "lost"):
        return ()
    result = []
    for child_id in ROOMS[state.room].children:
        child = ROOMS[child_id]
        routes = tuple({"obstacles": route[0], "treasure": route[1], "pickup": route[2],
                        "damage": loss(route, gear), "hp_after": hp - loss(route, gear)}
                       for route in child.routes)
        result.append({"room": child.id, "kind": child.kind, "routes": routes})
    return tuple(result)


@dataclass(frozen=True)
class Timeline:
    state: State = State()
    past: tuple = ()
    inputs: tuple = ()


def apply(timeline, action):
    state = step(timeline.state, action)
    if state == timeline.state:
        return timeline
    return Timeline(state, timeline.past + (timeline.state,), timeline.inputs + (tuple(action),))


def undo(timeline):
    return Timeline(timeline.past[-1], timeline.past[:-1], timeline.inputs[:-1]) if timeline.past else timeline


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.timeline = Timeline()
        self.selection = 0
        self.gated = False
        self.message = "ONE KEY, THEN RELEASE"

    @property
    def state(self):
        return self.timeline.state

    
    def control(self, pressed, held):
        # 新しく押されたRは優先。押しっぱなしのRでは初期化しない。
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = "RELEASE ALL KEYS"
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key == "X":
            old = self.timeline
            self.timeline = undo(self.timeline)
            self.selection = 0
            self.message = "UNDO ONE DECISION" if self.timeline != old else "NOTHING TO UNDO"
            self.gated = True
            return
        
        if self.state.phase in ("won", "lost"):
            return
        if self.state.phase == "route" and key in ("UP", "DOWN"):
            self.selection = 0 if key == "UP" else 1
            self.gated = True
            return
        if self.state.phase in ("gear", "next") and key in ("LEFT", "RIGHT"):
            self.selection = 0 if key == "LEFT" else 1
            self.gated = True
            return
        
        if key != "Z":
            return
        before = self.state
        self.timeline = apply(self.timeline, (self.state.phase, self.selection))
        self.message = "CONFIRMED" if self.state != before else "BLOCKED / NO CHANGE"
        if self.state != before:
            self.selection = 0
        self.gated = True

    def draw(self, p):
        # drawは表示のみ。状態・履歴・選択を一切変えない。
        state = self.state
        room = ROOMS[state.room]
        pass_gear = state.gear if state.phase == "route" else next((e[3] for e in reversed(state.trail) if e[0] == room.id and e[1] == "route"), state.gear)
        p.cls(1)
        p.text(12, 10, "THREE ROOMS / STAGE 5 / AUTHORED B", 10)
        p.text(12, 26, "ROOM "+room.id+" / DEPTH "+str(room.depth)+" OF 3", 7)
        p.text(12, 40, "HP "+str(state.hp)+"/6   TREASURE "+str(state.treasure)+"   PHASE "+state.phase.upper(), 7)
        for i in range(6):
            p.rect(340+i*22, 27, 16, 9, 8 if i < state.hp else 5)
        p.text(12, 56, "R BLOCKS P / L BLOCKS D / B BLOCKS W / ONE SLOT", 6)
        p.text(12, 77, "PASSAGES / GEAR BEFORE PICKUP", 10)
        for i, route in enumerate(room.routes):
            y = 96 + i*73
            active = state.phase == "route" and self.selection == i
            p.rect(12, y, 268, 62, 2)
            p.rectb(12, y, 268, 62, 10 if active else 5)
            p.text(22, y+9, ("> " if active else "  ")+("UP" if i == 0 else "DOWN")+" : "+route[0], 10 if active else 7)
            p.text(22, y+24, "TREASURE +"+str(route[1])+"   HIT "+str(loss(route, pass_gear)), 7)
            p.text(22, y+42, "PICK "+route[2]+" AFTER THE PASSAGE", 6)
        p.text(12, 245, "EQUIPPED "+state.gear+" / PICKED UP "+state.pending, 6)
        if state.phase == "route":
            damage = loss(room.routes[self.selection], state.gear)
            p.text(12, 261, "SELECTED: HP "+str(state.hp)+" -> "+str(state.hp-damage), 11 if state.hp > damage else 8)
            p.text(12, 278, "UP/DOWN: PASSAGE / Z: PASS", 7)
        elif state.phase == "gear":
            p.text(12, 261, ("> " if self.selection == 0 else "  ")+"KEEP "+state.gear, 11 if self.selection == 0 else 7)
            p.text(144, 261, ("> " if self.selection == 1 else "  ")+"SWAP "+state.pending, 11 if self.selection == 1 else 7)
            p.text(12, 278, "LEFT/RIGHT, Z / NO HEALING", 7)
        elif state.phase == "next":
            p.text(12, 261, "NEXT: "+("LEFT" if self.selection == 0 else "RIGHT"), 11)
            p.text(12, 278, "LEFT/RIGHT, Z: NEXT ROOM", 7)
        else:
            p.text(12, 261, "RETURNED" if state.phase == "won" else "LOST / TREASURE RETAINED", 11 if state.phase == "won" else 8)
            p.text(12, 278, "X: UNDO / R: TRY AGAIN", 7)
        hp, gear = forecast_context(state, self.selection)
        p.text(306, 77, ("NEXT: FROM HP " if room.children else "EXIT: HP ")+str(hp)+" WITH GEAR "+gear, 10)
        forecasts = public_future(state, self.selection)
        if not room.children:
            p.text(306, 99, "AFTER THIS PASSAGE: THE SURFACE", 11)
        elif not forecasts:
            p.text(306, 99, "THIS ROUTE ENDS THE RUN", 8)
        for side, info in enumerate(forecasts):
            child = ROOMS[info["room"]]
            y = 96 + side*99
            active = state.phase == "next" and self.selection == side
            p.rectb(306, y, 280, 92, 10 if active else 5)
            p.text(316, y+8, ("LEFT" if side == 0 else "RIGHT")+" / ROOM "+child.id+" / "+child.kind, 10)
            for index, route in enumerate(info["routes"]):
                line = ("UP " if index == 0 else "DOWN ")+route["obstacles"]+" T+"+str(route["treasure"])+" HIT "+str(route["damage"])+" HP->"+str(route["hp_after"])+" PICK "+route["pickup"]
                p.text(316, y+24+index*13, line, 11 if route["damage"] == 0 else 8 if route["hp_after"] <= 0 else 7)
            for index, exit_id in enumerate(child.children):
                exits = ROOMS[exit_id].routes
                p.text(316, y+55+index*13, "EXIT "+exit_id+": "+" / ".join(q[0]+" T+"+str(q[1]) for q in exits), 6)
        p.text(306, 300, "PREVIEW ONLY / NO STATE CHANGE", 6)
        p.text(12, 320, self.message, 7)
        p.text(12, 339, "UP/DOWN ROUTE / LEFT/RIGHT GEAR, NEXT / Z CONFIRM / X UNDO / R RESET", 6)
        p.text(12, 355, "TREASURE TARGETS ARE OPTIONAL. HP > 0 AT EXIT WINS.", 6)
        p.text(12, 371, "FINITE AUTHORED B TREE. NO RANDOM GENERATION. SILENT.", 5)


CONTROL_KEYS = ("UP", "DOWN", "LEFT", "RIGHT", "Z", "X", "R")
DEMO_KEYS = ('DOWN', 'Z', 'RIGHT', 'Z', 'Z', 'Z', 'Z', 'RIGHT', 'Z', 'DOWN', 'Z', 'X')


def main(headless=False, screenshot=None, demo=False, state_json=None):
    from pathlib import Path
    from dataclasses import asdict
    import json
    import pyxel as p  # ここだけPyxelが必要。モデルのテストは標準ライブラリのみ。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(600, 390, title="Three Rooms / Lesson 5", fps=30, display_scale=2, headless=headless)
    p.colors.from_list([0x090D16, 0x151D2B, 0x293441, 0x35654D, 0x674738, 0x778897, 0x85B9B5, 0xE8E1CE, 0xD96755, 0xC4884A, 0xF4C46B, 0xA8D68D, 0x4384A1, 0x8B969B, 0xD09B8C, 0xD6C5A6])
    keys = {name: getattr(p, "KEY_"+name) for name in CONTROL_KEYS}
    limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12
    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                p.screenshot(screenshot, scale=2)
            if state_json:
                path = Path(state_json)
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps(asdict(app.state), ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            pressed = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
    p.run(update, lambda: app.draw(p))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="三部屋の地下道・独立教材")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    parser.add_argument("--state-json")
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error("自動確認の出力には --headless-smoke が必要です")
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json)
AFTER STEP 05

仕組みがわかったら、元の完成版を読んでみよう。

元のゲームの状況 B(B-01@051-bank-1)です。手作りの7つの部屋が二またに分かれていて、そこから3部屋を通ります。最初の HP は6です。通路では、1つだけ持てる今の道具が、対応する障害を全部防ぎ、ダメージと宝を計算します。生き残っていれば道具を選び、次の部屋へ進みます。最後の部屋での成功より HP 0以下の失敗を先に判定し、失敗しても宝は残ります。宝4個・8個は自由な目標です。

ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。

比べる点5ステップの教材元の完成版
段階とデータ01 は道具なし・左の道だけ・自動で次の部屋へ、02 は道具の選択と左の道だけ、03 以降は状況 B のふつうのルールすべてです。A・B・C の3つの手作りの構成です。ランダムに無限に作るものではありません。
予測03 で次の部屋とその出口のデータを表示し、04 と 05 で選んでいる道具と仮の HP から次の部屋を予想します。その先の部屋は、障害と宝だけを表示します。最初から、見えている次の部屋・出口と、選んでいる道具でのダメージの予想を表示します。
入力と表示600×390 の画面に文字(ASCII)と図形で表示し、すぐに始まります。どの操作のあとも全部のキーを離すまで待ちます。R を押した瞬間を優先し、同時押しは受け付けません。場面はすぐ切りかわります。日本語の文字、表紙・一時停止・面の選択、通るときの演出と、その間の入力停止があります。
記録・比較道具ありの状況 B だけです。05 の中の Timeline までを作ります。テスト用に画像(PNG)や状態(JSON)を出力しますが、ふつうの記録の代わりではありません。道具なしの比較用の条件、リプレイの保存と読みこみ、スクリーンショット・GIF、複数の面があります。
必要環境と権利Python 3.11以上(テストは 3.12.14)、Pyxel 2.9.9 です。標準の文字(ASCII)だけを使い、Noto の文字は使っていません。元の MIT ライセンスと、参照している OFL の文書は残しています。古いリプレイの環境の欄にある「Python 3.10+」は、昔の情報です。Noto をもとにした文字を使っています。

完成版のコードで、次に読むところ

  1. 原作model.py: makeとLEVELSでA/B/Cのデータ構造を読む。
  2. 原作model.py: record/replayで意味入力・盤面hash・最終状態を検査する。
  3. 原作app.py: futureで次室と出口の表示、actで通過演出と入力停止を読む。
  4. 原作solver_results.jsonでHPと宝の非優越な結果を比べる。数値最適化を人の面白さの証拠にはしない。
自分のゲームに変えるなら

最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。

教材をダウンロードする

無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について

検証について

どこまで動作を確認したかを見る

2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。

この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る