# 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。""" COMMANDS = () from dataclasses import dataclass, replace, asdict from collections import deque @dataclass(frozen=True) class Level: edges: tuple = ('SJ', 'KU', 'UJ', 'JM', 'MN', 'NH', 'JT', 'TH') starts: tuple = ('S', 'K') hidden_start: str = 'S' opens: tuple = (4, 6) initial_mode: str = 'join' limit: int = 12 LEVEL = Level() @dataclass(frozen=True) class World: origin: str pos: str mode: str via: bool = False blocked: int = 0 def route(level, start, target, safe=False): """幅優先探索。同じ長さなら地点名順。安全路はJTを除く。""" if start == target: return (start,) queue = deque([(start,)]) seen = {start} while queue: path = queue.popleft() near = sorted({ edge[1] if edge[0] == path[-1] else edge[0] for edge in level.edges if path[-1] in edge and not (safe and set(edge) == {'J', 'T'}) }) for node in near: if node in seen: continue next_path = path + (node,) if node == target: return next_path queue.append(next_path) seen.add(node) return () def walk(world, level, tick): if world.pos == 'H': return world path = route(level, world.pos, 'H', safe=True) return replace(world, pos=path[1]) if len(path) >= 2 else world INITIAL_MODE = 'safe' @dataclass(frozen=True) class State: t: int actual: World sent: int = 0 outcome: str = 'running' def initial(level=LEVEL): return State(0, World('S', 'S', INITIAL_MODE)) def advance(state, action, level=LEVEL): if state.outcome != 'running': return state, 'ended' if action not in COMMANDS + ('wait',): return state, 'unknown' tick = state.t + 1 actual = state.actual sent = state.sent if tick % 2 == 0: actual = walk(actual, level, tick) outcome = ('won' if actual.pos == 'H' else 'late' if tick >= level.limit else 'running') return State(tick, actual, sent, outcome), 'advanced' XY = {'S': (36, 105), 'K': (36, 187), 'U': (94, 187), 'J': (159, 142), 'M': (159, 228), 'N': (215, 228), 'H': (274, 228), 'T': (274, 142)} KEY_TO_ACTION = {'SPACE': 'wait'} KEYS = tuple(KEY_TO_ACTION) + ('R',) DEMO_KEYS = ('SPACE', 'SPACE') class App: def __init__(self): self.reset() def reset(self): self.state = initial() self.gated = False self.message = 'READY' def control(self, pressed, held): # R is a fresh press; holding it does not reset every frame. 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 action = KEY_TO_ACTION.get(next(iter(pressed))) if action is None: return self.state, self.message = advance(self.state, action) self.gated = True def draw(self, p): state = self.state p.cls(1) p.text(12, 12, '061 DELAYED RADIO / STAGE 1', 10) p.text(12, 29, 'T '+str(state.t)+'/12 SENT '+str(state.sent) +' '+state.outcome.upper(), 11) p.text(12, 48, 'YELLOW RING = KNOWN POSITION', 6) p.rectb(12, 72, 288, 178, 5) for edge in LEVEL.edges: x, y = XY[edge[0]] u, v = XY[edge[1]] bridge = set(edge) == {'J', 'T'} p.line(x, y, u, v, 10 if bridge else 5) if bridge: p.line(x, y+3, u, v+3, 10) locations = (state.actual.pos,) for node, (x, y) in XY.items(): p.circ(x, y, 9, 3 if node == 'H' else 1) p.circb(x, y, 9, 11 if node == 'H' else 6) p.text(x-2, y-2, node, 7) if node in locations: p.circb(x, y, 14, 10) p.text(180, 126, 'BRIDGE JT', 10) p.text(310, 78, 'NEXT WALK: '+str(state.t+2-state.t%2), 7) p.text(310, 94, 'BRIDGE NOT USED', 10) p.text(310, 110, 'RINGS: '+','.join(locations), 6) p.text(310, 135, 'SAFE ROUTE ONLY', 7) p.text(310, 150, 'S-J-M-N-H', 7) p.text(310, 165, 'NO RADIO YET', 7) p.text(310, 196, 'SPACE WAIT', 6) p.text(310, 211, 'RELEASE EACH KEY', 6) p.text(310, 226, 'R RESET / ESC QUIT', 6) p.rect(12, 260, 456, 57, 2) p.text(20, 270, 'A LOGICAL TICK ADVANCES ONLY ON INPUT.', 7) p.text(20, 287, 'TWO TICKS = ONE WALK. READING IS FREE.', 6) p.text(20, 303, 'RELEASE ALL KEYS BEFORE THE NEXT ACTION.', 13) p.text(12, 328, self.message.upper(), 11) def parse_args(argv=None): """すべての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) parser.add_argument('--screenshot') parser.add_argument('--state-output') args = parser.parse_args(argv) if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_output): parser.error('QA options require --headless-smoke') minimum = 2 * len(DEMO_KEYS) + 4 if args.demo_checkpoint else 4 if args.frames is None: args.frames = minimum if not minimum <= args.frames <= 600: parser.error('--frames must be between '+str(minimum)+' and 600') for name, suffix in (('screenshot', '.png'), ('state_output', '.json')): value = getattr(args, name) if value is not None and (not value.strip() or Path(value).suffix.lower() != suffix): parser.error('--'+name.replace('_', '-')+' requires a '+suffix+' file path') return args def main(argv=None): args = parse_args(argv) from pathlib import Path import json import pyxel as p app = App() p.init(480, 352, title='061 Delayed Radio', fps=30, display_scale=2, headless=args.headless_smoke) keys = {key: getattr(p, 'KEY_'+key) for key in KEYS} finished = False def update(): if args.headless_smoke: index = p.frame_count // 2 pressed = ({DEMO_KEYS[index]} if args.demo_checkpoint and p.frame_count % 2 == 0 and index < len(DEMO_KEYS) else set()) app.control(pressed, pressed) 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)}) def draw(): nonlocal finished app.draw(p) if args.headless_smoke and p.frame_count >= args.frames-1 and not finished: finished = True if args.screenshot: path = Path(args.screenshot).resolve() path.parent.mkdir(parents=True, exist_ok=True) p.screenshot(str(path), scale=2) if args.state_output: path = Path(args.state_output).resolve() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(asdict(app.state), ensure_ascii=False, indent=2)+'\n', encoding='utf-8') p.quit() p.run(update, draw) if __name__ == '__main__': main()