# 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. """小さな渡し船・教材 4/5。Python 3.11+ / Pyxel 2.9.9。""" from dataclasses import dataclass, replace CAPACITY = 3 PORTS = ("H", "A", "B") @dataclass(frozen=True) class Passenger: name: str source: str destination: str at: int due: int PEOPLE = ( Passenger("p", "H", "A", 0, 2), Passenger("q", "H", "A", 0, 2), Passenger("r", "H", "B", 0, 4), Passenger("s", "A", "B", 1, 4), ) TIDES = (1, 2, 3, 2) @dataclass(frozen=True) class State: t: int port: str owners: tuple arrived: tuple target: str = "" remaining: int = 0 voyages: int = 0 outcome: str = "playing" def initial(): owners = tuple(p.source if p.at == 0 else "-" for p in PEOPLE) return State(0, "H", owners, tuple(-1 for _ in PEOPLE)) def onboard(state): return state.owners.count("boat") def tide(state): return TIDES[state.t % len(TIDES)] def shallow(source, target): return source != target and {source, target} in ({"H", "A"}, {"A", "B"}) def ontime(state): return sum(0 <= arrival <= person.due for arrival, person in zip(state.arrived, PEOPLE)) def tick(state): # 一刻: 時刻を増やす → 客を出す → 残りを減らす → 到着処理。 time = state.t + 1 owners = tuple(p.source if owner == "-" and p.at <= time else owner for owner, p in zip(state.owners, PEOPLE)) remaining = max(0, state.remaining - 1) port, target = state.port, state.target arrived = list(state.arrived) if target and remaining == 0: port, target = target, "" owners = list(owners) for i, person in enumerate(PEOPLE): if owners[i] == "boat" and person.destination == port: owners[i] = "done" arrived[i] = time outcome = "won" if all(owner == "done" for owner in owners) else "playing" return replace(state, t=time, port=port, target=target, remaining=remaining, owners=tuple(owners), arrived=tuple(arrived), outcome=outcome) def apply(state, action): if state.target: return state, "sailing", () if state.outcome == "won": return state, "terminal", () 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", () if action == ("wait",): next_state = tick(state) return next_state, "wait", (next_state,) if action[0] == "sail" and len(action) == 3: target, route = action[1:] if target not in PORTS or target == state.port or route not in ("shallow", "deep"): return state, "invalid", () if route == "shallow": if not shallow(state.port, target): return state, "route", () if onboard(state) > tide(state): return state, "tide", () # 潮の検査は出発時だけ。航行中に次の潮で罰しない。 duration = 2 if route == "deep" else 1 next_state = replace(state, port="", target=target, remaining=duration, voyages=state.voyages + 1) trace = [] for _ in range(duration): next_state = tick(next_state) trace.append(next_state) return next_state, "depart", tuple(trace) 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))) routes = tuple(("sail", p, route) for p in PORTS if p != self.state.port for route in ("shallow", "deep")) return passengers + routes 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 self.state.outcome == "won": 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]) elif key == "SPACE": self.perform(("wait",)) else: return self.locked = True def draw(self, p): state = self.state p.cls(1) p.text(8, 8, "SMALL FERRY / STAGE 4", 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, 90, "H-A / A-B: SHALLOW 1 OR DEEP 2 | H-B: DEEP 2", 6) p.text(8, 102, f"TIDE {TIDES} NOW SHALLOW LIMIT {tide(state)}", 6) p.text(8, 116, "PERSON FROM>TO SPAWN/DUE OWNER ARRIVAL", 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) arrival = state.arrived[i] record = "-" if arrival < 0 else f"{arrival} " + ("ON TIME" if arrival <= person.due else "LATE") label = f"{person.name} {person.source}>{person.destination} {person.at}/{person.due} {state.owners[i]:4} {record}" p.text(12, y, label, 11 if state.owners[i] == "done" else 7) for i, action in enumerate(self.choices()[len(PEOPLE):]): x = 6 + i*94 selected = self.selected == len(PEOPLE)+i p.rectb(x, 201, 89, 18, 10 if selected else 5) label = action[1] + (" SHALLOW 1" if action[2] == "shallow" else " DEEP 2") p.text(x+4, 207, label, 10 if selected else 7) status = "ALL DELIVERED" if state.outcome == "won" else "PLAYING" p.text(8, 227, f"{status} / ON TIME {ontime(state)}/{len(PEOPLE)} / VOYAGES {state.voyages}", 11) p.text(8, 240, "LATE ARRIVAL STILL COUNTS TOWARD COMPLETION.", 13) messages = {"ready": "SELECT A PASSENGER OR ROUTE", "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/SAIL SPACE WAIT", 7) p.text(8, 290, "R RESET ESC QUIT", 6) DEMO_KEYS = ('Z', 'RIGHT', 'Z', 'RIGHT', 'Z', 'RIGHT', 'RIGHT', 'Z', 'RIGHT', 'Z', 'LEFT', 'LEFT', 'Z', 'RIGHT', 'RIGHT', '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)