# 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)