# 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. """からくり島の航路・教材3/5。短い命令列を一日ずつ読む。""" from dataclasses import dataclass, replace STAGE = 3 NODES = 'HAB' CARDS = ('MA', 'MB', 'MH', 'L', 'U', 'W') EDGES = (('H', 'A', 1), ('H', 'B', 1)) CAPACITY = 2 STOCK_LIMIT = 4 INITIAL_STOCK = (4, 2) PERIOD = (3, 2) DEFAULT = ('MA', 'L', 'MH', 'U') @dataclass(frozen=True) class State: day: int = 0 at: str = "H" target: str = "" remaining: int = 0 cargo: int = 0 stock: tuple = INITIAL_STOCK warehouse: int = 0 program: tuple = DEFAULT pc: int = 0 halted: bool = False fault: bool = False last: tuple = () def initial(): return State() def edge(a, b): for left, right, days in EDGES: if {a, b} == {left, right}: return days return None def at_home(s): return s.at == "H" and not s.target def step(s, action): """現在のStateを変更せず、次のStateを返す。描画もここを変更しない。""" if not isinstance(action, str): raise ValueError("action must be text") if action.startswith("program:"): cards = tuple(action[8:].split(",")) if not at_home(s): return replace(s, last=("EDIT ONLY AT HOME",)) if not 1 <= len(cards) <= 6 or any(card not in CARDS for card in cards): return replace(s, last=("USE 1 TO 6 KNOWN CARDS",)) return replace(s, program=cards, pc=0, halted=False, fault=False, last=("PROGRAM SAVED: NO DAY COST",)) manual = action.startswith("manual:") if action != "day" and not manual: raise ValueError("unknown action") if manual and action[7:] not in CARDS: raise ValueError("unknown card") if s.halted and not manual and not s.target: return s card = action[7:] if manual else s.program[s.pc] day = s.day + 1 at, target, remaining = s.at, s.target, s.remaining events = [] cargo, stock, warehouse = s.cargo, list(s.stock), s.warehouse pc = s.pc halted = s.halted if s.target else False if target: # 出港済みなら航行だけ。到着してもカードを読まない。 remaining -= 1 if remaining == 0: at, target = target, "" events.append("ARRIVED: NEXT CARD TOMORROW") else: events.append("TRAVELLING: NO NEW CARD") else: if card.startswith("M"): destination = card[1:] distance = edge(at, destination) if distance is None: return replace(s, fault=True, halted=True, last=("NO ROUTE: NO DAY COST",)) target, remaining = destination, distance events.append("DEPARTED: ARRIVAL LATER") elif card == "L": if at != "H": i = NODES.index(at) - 1 amount = min(CAPACITY - cargo, stock[i]) cargo += amount stock[i] -= amount events.append("LOAD: EVEN EMPTY COSTS ONE DAY") elif card == "U": if at == "H": warehouse += cargo cargo = 0 else: i = NODES.index(at) - 1 amount = min(cargo, STOCK_LIMIT - stock[i]) cargo -= amount stock[i] += amount events.append("UNLOAD: EVEN EMPTY COSTS ONE DAY") else: events.append("WAITED ONE DAY") if not manual: pc += 1 if pc == len(s.program): pc = 0 # 積み降ろしの後に、この日の補給を一度だけ行う。 for i, period in enumerate(PERIOD): if period and day % period == 0: stock[i] = min(STOCK_LIMIT, stock[i] + 1) return replace(s, day=day, at=at, target=target, remaining=remaining, cargo=cargo, stock=tuple(stock), warehouse=warehouse, pc=pc, halted=halted, fault=False, last=tuple(events)) def forecast(s, card=None): """次の一手だけを公開情報で説明。未来のStateを返さない。""" if s.target: return "TRAVEL ONE DAY; NO CARD ON ARRIVAL" if s.halted and card is None: return "ROUTE FAULT: R RESET" card = s.program[s.pc] if card is None else card if card.startswith("M"): destination = card[1:] distance = edge(s.at, destination) if distance is None: return "NO ADJACENT ROUTE: ZERO DAYS" return "DEPART 1 + TRAVEL " + str(distance) + " DAYS" if card == "L": amount = 0 if s.at == "H" else min(CAPACITY - s.cargo, s.stock[NODES.index(s.at)-1]) return "LOAD " + str(amount) + "; THEN ISLAND SUPPLY" if card == "U": return "UNLOAD TO HOME" if s.at == "H" else "UNLOAD TO ISLAND SPACE" return "WAIT; THEN ISLAND SUPPLY" class App: def __init__(self): self.reset() def reset(self): self.state = initial() self.choice = 0 self.gated = False self.message = "READY" self.manual = False self.edit = False self.draft = [] def commit(self, action): self.state = step(self.state, action) self.message = self.state.last[-1] if self.state.last else "READY" def control(self, pressed, held): # 毎フレーム呼ぶ。全キーを離してから次の一操作へ。 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)) self.gated = True if self.edit: if key in ("LEFT", "RIGHT"): self.choice = (self.choice + (1 if key == "RIGHT" else -1)) % len(CARDS) elif key == "Z": if len(self.draft) < 6: self.draft.append(CARDS[self.choice]) else: self.message = "SIX CARDS MAX" elif key == "BACKSPACE" and self.draft: self.draft.pop() elif key == "E": self.edit = False self.message = "EDIT CANCELLED" elif key == "RETURN": if self.draft: self.commit("program:" + ",".join(self.draft)) self.edit = False else: self.message = "NEED AT LEAST ONE CARD" return if key == "E": if at_home(self.state): self.edit = True self.draft = list(self.state.program) self.message = "EDIT: NO TIME PASSES" else: self.message = "EDIT ONLY AT HOME" return if key == "M": if at_home(self.state): self.manual = not self.manual self.message = "MANUAL" if self.manual else "PROGRAM" else: self.message = "MODE CHANGE ONLY AT HOME" return if key in ("LEFT", "RIGHT"): self.choice = (self.choice + (1 if key == "RIGHT" else -1)) % len(CARDS) elif key == "SPACE": if self.state.fault: self.message = "ROUTE FAULT: R TO RESET" return self.commit(( "manual:" + CARDS[self.choice] if self.manual else "day")) def draw(self, p): p.cls(1) p.text(10, 8, "CLOCKWORK ROUTES / STAGE " + str(STAGE), 10) s = self.state p.text(10, 22, "DAY " + str(s.day) + " AT " + s.at + " TARGET " + (s.target or "-") + (" FAULT" if s.fault else ""), 7) p.text(10, 36, "CARGO " + str(s.cargo) + "/" + str(CAPACITY) + " HOME WAREHOUSE " + str(s.warehouse), 11) p.text(10, 50, "ISLAND SUPPLY IS STOCK, NOT FUEL", 6) positions = {"H": (74, 132), "A": (215, 96), "B": (215, 173), "C": (360, 96)} edges = tuple((a, b, str(d)) for a, b, d in EDGES) for a, b, label in edges: x1, y1 = positions[a] x2, y2 = positions[b] p.line(x1, y1, x2, y2, 5) p.text((x1+x2)//2-12, (y1+y2)//2-11, label + "d", 6) for node in NODES: x, y = positions[node] p.circ(x, y, 14, 3) p.circb(x, y, 14, 11) p.text(x-2, y-3, node, 7) if node != "H": i = NODES.index(node)-1 stock, period = s.stock[i], PERIOD[i] label = "STOCK " + ("?" if stock is None else str(stock)) cycle = "?" if period is None else "NONE" if period == 0 else str(period) + "d" p.text(x-18, y+19, label, 7) p.text(x-18, y+29, "SUP " + cycle, 6) x, y = positions[s.at] if s.target: tx, ty = positions[s.target] # 常に中点に置く。C航路の実距離を船の補間位置から漏らさない。 x, y = (x+tx)//2, (y+ty)//2 ship_y = y-26 if s.target else y-12 p.tri(x-5, ship_y, x+5, ship_y, x, ship_y+7, 10) travel = s.remaining p.text(10, 215, "TRAVEL LEFT " + ("?" if travel is None else str(travel)), 7) cards = self.draft if self.edit else s.program for i, card in enumerate(cards): x = 10 + i*49 p.rectb(x, 233, 43, 18, 10 if not self.edit and i == s.pc else 5) p.text(x+8, 239, card, 7) label = "EDIT" if self.edit else "MANUAL" if self.manual else "PROGRAM" p.text(322, 239, label, 10) card = CARDS[self.choice] if self.manual else None preview = "DRAFT ONLY: ENTER SAVE / E CANCEL" if self.edit else forecast(s, card) p.text(10, 263, preview, 6) p.text(10, 295, "SELECT " + CARDS[self.choice] + " " + self.message, 7) p.text(10, 315, "LEFT/RIGHT SELECT SPACE STEP R RESET ESC QUIT", 6) p.text(10, 329, "M MANUAL/PROGRAM E EDIT/CANCEL Z APPEND", 6) p.text(10, 343, "IN EDIT: BACKSPACE REMOVE LAST ENTER SAVE", 6) DEMO_KEYS = ('SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE') KEYS = ('LEFT', 'RIGHT', 'SPACE', 'R', 'M', 'E', 'Z', 'BACKSPACE', 'RETURN') def main(): # CLIの検査はPyxelをimportする前。通常ゲームはファイルを保存しない。 import argparse from pathlib import Path parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--headless-smoke", action="store_true") parser.add_argument("--demo-checkpoint", action="store_true") parser.add_argument("--frames", type=int, default=None) parser.add_argument("--screenshot") args = parser.parse_args() if args.demo_checkpoint and not args.headless_smoke: parser.error("--demo-checkpoint requires --headless-smoke") if args.frames is not None and (not args.headless_smoke or not 2 <= args.frames <= 600): parser.error("--frames requires headless mode and a value in 2..600") minimum = 2*len(DEMO_KEYS)+4 if args.demo_checkpoint else 4 if args.frames is not None and args.frames < minimum: parser.error("--frames too short for the requested checkpoint") frames = args.frames or minimum target = None if args.screenshot: if not args.headless_smoke: parser.error("--screenshot requires --headless-smoke") target = Path(args.screenshot).expanduser().resolve() if target.suffix.lower() != ".png" or target.exists() or not target.parent.is_dir(): parser.error("screenshot needs a NEW .png file in an existing directory") import pyxel as p app = App() p.init(448, 384, title="Clockwork Routes", fps=30, display_scale=2, headless=args.headless_smoke) keys = {key: getattr(p, "KEY_" + key) for key in KEYS} def update(): if args.headless_smoke and p.frame_count >= frames: if target: p.screenshot(str(target), scale=2) p.quit() return if args.headless_smoke: index = p.frame_count // 2 down = ({DEMO_KEYS[index]} if args.demo_checkpoint and p.frame_count % 2 == 0 and index < len(DEMO_KEYS) else set()) app.control(down, down) 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)) if __name__ == "__main__": main()