# 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. """からくり郵便室・教材2/5。単独で起動できます。""" from dataclasses import dataclass, replace CARD_NAMES = ("COLOR", "WEIGHT", "LEFT", "RIGHT") @dataclass(frozen=True) class Parcel: id: int color: str weight: int destination: str # 原作Bの6便。番号の内部値は0始まり、画面だけ1始まり。 PARCELS = tuple(Parcel(i, color, weight, color+("H" if weight == 2 else "L")) for i, (color, weight) in enumerate( (("R", 1), ("B", 2), ("R", 2), ("B", 1), ("B", 2), ("R", 1)))) def branch(card, parcel): # 出口は左=0、右=1。台番号や箱番号とは区別する。 if card == 0: return 0 if parcel.color == "R" else 1 if card == 1: return 0 if parcel.weight == 2 else 1 return card-2 @dataclass(frozen=True) class State: cards: tuple = (0, 1, 1) parcel: int = 0 route: tuple = () def route(cards, parcel): # Aの0/1出口を、子台の1/2番へ写す。 table = 1+branch(cards[0], parcel) box = (table-1)*2+branch(cards[table], parcel) return (0, table, box) def apply(state, action): if action == "parcel": return replace(state, parcel=(state.parcel+1)%len(PARCELS), route=()) if action == "route": return replace(state, route=route(state.cards, PARCELS[state.parcel])) if isinstance(action, tuple) and len(action) == 2 and action[0] == "card": index = action[1] if type(index) is int and 0 <= index < 3: cards = list(state.cards) cards[index] = (cards[index]+1)%4 return replace(state, cards=tuple(cards), route=()) return state class App: def __init__(self): self.reset() def reset(self): self.state = State() self.cursor = 0 self.locked = False self.message = "FOLLOW TWO BRANCHES / THIS IS NOT A TICK" def control(self, down): down = set(down) if self.locked: if not down: self.locked = False return if not down: return self.locked = True if len(down) != 1: self.message = "ONE KEY AT A TIME / RELEASE ALL" return key = next(iter(down)) if key == "R": self.reset() self.locked = True return if key in ("LEFT", "RIGHT"): self.cursor = (self.cursor+(-1 if key == "LEFT" else 1))%3 elif key == "Z": self.state = apply(self.state, ("card", self.cursor)) elif key in ("P", "SPACE"): self.state = apply(self.state, "parcel" if key == "P" else "route") def draw(self, p): s = self.state parcel = PARCELS[s.parcel] p.cls(1) p.text(8, 8, "CLOCKWORK POST / 2: FIXED BINARY TREE", 10) p.text(8, 25, "NO OCCUPANCY OR CLOCK / SPACE TRACES WHOLE ROUTE", 6) p.text(8, 42, "PARCEL "+str(parcel.id+1)+" COLOR "+parcel.color+" WEIGHT "+str(parcel.weight), 7) points = ((200, 86), (100, 144), (300, 144)) for x in (100, 300): p.line(200, 101, x, 135, 5) for i, (x, y) in enumerate(points): p.rect(x-45, y-9, 90, 27, 2) p.rectb(x-45, y-9, 90, 27, 10 if self.cursor == i else 5) p.text(x-35, y+2, chr(65+i)+": "+CARD_NAMES[s.cards[i]], 7) labels = ("RH", "RL", "BH", "BL") for i, x in enumerate((50, 150, 250, 350)): p.line(100 if i < 2 else 300, 162, x, 192, 5) p.rectb(x-35, 192, 70, 26, 11 if s.route and s.route[2] == i else 5) p.text(x-28, 201, "BOX "+str(i+1)+" "+labels[i], 7) result = "NOT EVALUATED" if s.route: result = "A > "+chr(65+s.route[1])+" > BOX "+str(s.route[2]+1) p.text(8, 239, "ROUTE: "+result, 11) p.text(8, 255, "ARROWS SELECT TABLE / Z CARD / P PARCEL / SPACE TRACE", 7) p.text(8, 278, self.message, 10) p.text(8, 304, "COLOR R:LEFT B:RIGHT / WEIGHT 2:LEFT 1:RIGHT", 6) p.text(8, 315, "LEFT/RIGHT: UNCONDITIONAL / R:RED B:BLUE", 6) p.text(8, 326, "DEST RH:RED HEAVY RL:RED LIGHT BH/BL:BLUE", 6) p.text(8, 344, "R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT", 5) DEMO_KEYS = ("P", "SPACE") def main(headless=False, screenshot=None, demo=False): from pathlib import Path 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(400, 360, title="Clockwork Post", fps=30, display_scale=2, headless=headless) keys = {name: getattr(p, "KEY_"+name) for name in ("UP", "DOWN", "LEFT", "RIGHT", "TAB", "Z", "P", "RETURN", "SPACE", "X", "R")} limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12 def update(): if headless and p.frame_count >= limit: if screenshot: p.screenshot(screenshot, scale=2) from dataclasses import asdict import json print(json.dumps({"state": asdict(app.state), "message": app.message}, ensure_ascii=True, sort_keys=True), flush=True) p.quit() return if headless and demo: index = p.frame_count//2 down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set() else: down = {key for key, value in keys.items() if p.btn(value)} app.control(down) 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") args = parser.parse_args() main(args.headless_smoke, args.screenshot, args.demo_checkpoint)