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