# 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。単独で起動できます。""" 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: card: int = 0 parcel: int = 0 result: int = -1 def apply(state, action): if action == "card": return replace(state, card=(state.card+1)%4, result=-1) if action == "parcel": return replace(state, parcel=(state.parcel+1)%len(PARCELS), result=-1) if action == "route": return replace(state, result=branch(state.card, PARCELS[state.parcel])) return state class App: def __init__(self): self.reset() def reset(self): self.state = State() self.locked = False self.message = "CHOOSE A CARD AND PARCEL, THEN SPACE" 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 action = {"Z": "card", "P": "parcel", "SPACE": "route"}.get(key) if action: self.state = apply(self.state, action) self.message = "RESULT CLEARED AFTER EDIT" if action != "route" else "ONE BRANCH EVALUATED" def draw(self, p): s = self.state parcel = PARCELS[s.parcel] p.cls(1) p.text(8, 8, "CLOCKWORK POST / 1: ONE BRANCH", 10) p.text(8, 26, "PRACTICE / NO CLOCK, DELIVERY OR WIN YET", 6) p.text(8, 48, "PARCEL "+str(parcel.id+1)+" COLOR "+parcel.color+" WEIGHT "+str(parcel.weight), 7) p.text(8, 60, "Z CARD / P NEXT PARCEL / SPACE EVALUATE", 7) p.rect(140, 106, 120, 32, 2) p.text(160, 118, "A: "+CARD_NAMES[s.card], 10) p.line(200, 138, 88, 192, 5) p.line(200, 138, 312, 192, 5) for i, x in enumerate((44, 268)): p.rectb(x, 192, 88, 32, 11 if s.result == i else 5) p.text(x+20, 205, "LEFT = 0" if i == 0 else "RIGHT = 1", 7) p.text(8, 250, "RESULT: "+("NOT EVALUATED" if s.result < 0 else ("LEFT" if s.result == 0 else "RIGHT")), 11) 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 = ("Z", "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)