# MIT License # # Copyright (c) 2026 # # 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. # # Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed # under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt. """小さな渡し船・教材 1/5。Python 3.11+ / Pyxel 2.9.9。""" from dataclasses import dataclass, replace CAPACITY = 3 PORTS = ("H", "A", "B") PEOPLE = ("p", "q", "r", "s") @dataclass(frozen=True) class State: t: int = 0 port: str = "H" owners: tuple = ("H", "H", "H", "H") def initial(): return State() def onboard(state): return state.owners.count("boat") def apply(state, action): if not isinstance(action, tuple) or not action: return state, "invalid", () if action[0] == "toggle" and len(action) == 2: index = action[1] if type(index) is not int or not 0 <= index < len(PEOPLE): return state, "invalid", () owners = list(state.owners) if owners[index] == "boat": owners[index] = state.port return replace(state, owners=tuple(owners)), "unload", () if owners[index] != state.port: return state, "absent", () if onboard(state) >= CAPACITY: return state, "capacity", () owners[index] = "boat" # 船と港の二つのリストに入れず、一人につき所有者一つ。 return replace(state, owners=tuple(owners)), "load", () return state, "invalid", () class App: def __init__(self): self.reset() def reset(self): self.state = initial() self.selected = 0 self.locked = False self.reason = "ready" self.last_trace = () def choices(self): passengers = tuple(("toggle", i) for i in range(len(PEOPLE))) return passengers def perform(self, action): self.state, self.reason, self.last_trace = apply(self.state, action) self.selected = min(self.selected, len(self.choices()) - 1) def control(self, pressed, held): # Rの新押しは常に最優先。再開後も全キーを離すまで待つ。 if "R" in pressed: self.reset() self.locked = True return if self.locked: if not pressed and not held: self.locked = False return # 拒否した同時押しもロックする。残したキー+新しいキーを通さない。 if len(pressed | held) > 1: self.reason = "invalid" self.locked = True return if len(pressed) != 1: return key = next(iter(pressed)) if key in ("UP", "LEFT", "DOWN", "RIGHT"): direction = -1 if key in ("UP", "LEFT") else 1 self.selected = (self.selected + direction) % len(self.choices()) elif key == "Z": self.perform(self.choices()[self.selected]) else: return self.locked = True def draw(self, p): state = self.state p.cls(1) p.text(8, 8, "SMALL FERRY / STAGE 1", 10) p.text(8, 22, f"TIME {state.t} PORT {state.port} ON BOARD {onboard(state)}/{CAPACITY}", 7) points = {"H": (40, 68), "A": (192, 48), "B": (344, 68)} p.line(40, 68, 192, 48, 6) p.line(192, 48, 344, 68, 6) p.line(40, 68, 344, 68, 5) for port, (x, y) in points.items(): p.rect(x-10, y-9, 20, 18, 4) p.text(x-2, y-3, port, 7) if port == state.port: p.tri(x-14, y+12, x+14, y+12, x+8, y+19, 10) p.text(8, 102, "OWNERSHIP ONLY: FOUR PEOPLE WAIT AT H", 6) p.text(8, 116, "PERSON OWNER Z TO BOARD / UNBOARD", 13) for i, person in enumerate(PEOPLE): y = 130 + i*17 p.rect(6, y-2, 372, 16, 3 if self.selected == i else 1) label = f"{person} {state.owners[i]}" p.text(12, y, label, 11 if state.owners[i] == "done" else 7) p.text(8, 227, "Z CHANGES ONE OWNER. TIME STAYS AT 0.", 11) p.text(8, 240, "NO TRAVEL YET. A PERSON HAS ONE OWNER.", 13) messages = {"ready": "SELECT A PASSENGER", "load": "BOARDED / TIME +0", "unload": "UNBOARDED / TIME +0", "capacity": f"FULL: CAPACITY {CAPACITY}", "absent": "PASSENGER NOT AT THIS PORT", "tide": "TOO MANY FOR THIS TIDE: DEEP OR WAIT", "route": "NO SHALLOW ROUTE BETWEEN H AND B", "depart": "ARRIVED", "wait": "WAITED 1 TICK", "invalid": "INVALID ACTION", "terminal": "ALL DELIVERED", "sailing": "SAILING: NO ACTION", "undo": "UNDID ONE WHOLE DECISION", "empty": "NOTHING TO UNDO"} p.text(8, 258, messages.get(self.reason, self.reason.upper()), 10) p.text(8, 278, "ARROWS SELECT Z BOARD/UNBOARD", 7) p.text(8, 290, "R RESET ESC QUIT", 6) DEMO_KEYS = ('Z', 'RIGHT', 'Z', 'RIGHT', 'Z', 'RIGHT', 'Z') def main(headless=False, screenshot=None, demo=False, state_log=None): from pathlib import Path from dataclasses import asdict import json import pyxel as p app = App() p.init(384, 304, title="Small Ferry", fps=30, display_scale=2, headless=headless) keys = {key: getattr(p, "KEY_"+key) for key in ("UP", "DOWN", "LEFT", "RIGHT", "Z", "SPACE", "R", "X")} limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12 def update(): if headless and p.frame_count >= limit: if screenshot: path = Path(screenshot).resolve() path.parent.mkdir(parents=True, exist_ok=True) p.screenshot(str(path), scale=2) if state_log: path = Path(state_log).resolve() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps({"state": asdict(app.state), "reason": app.reason, "trace": [asdict(s) for s in app.last_trace], "frames": p.frame_count, "input": "synthetic controller keys" if demo else "none"}, ensure_ascii=False, indent=2), 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({key for key, code in keys.items() if p.btnp(code)}, {key for key, code in keys.items() if p.btn(code)}) p.run(update, lambda: app.draw(p)) return app if __name__ == "__main__": import argparse parser = argparse.ArgumentParser() parser.add_argument("--headless-smoke", action="store_true") parser.add_argument("--demo-checkpoint", action="store_true") parser.add_argument("--screenshot") parser.add_argument("--state-log") args = parser.parse_args() main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_log)