# 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。Python 3.11+ / Pyxel 2.9.9。""" COMMANDS = ('safe', 'bridge', 'stop') 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 goal(world): if world.mode == 'stop': return '待機' if world.mode == 'join': return 'J' return 'T→H' if world.mode == 'bridge' and world.via else 'H' def instruct(world, command): if world.pos == 'H' or command == 'ask': return world return replace(world, mode=command, via=(command == 'bridge' and world.pos != 'T')) def walk(world, level, tick): if world.pos == 'H' or world.mode == 'stop': return world target = ('J' if world.mode == 'join' else 'T' if world.mode == 'bridge' and world.via else 'H') path = route(level, world.pos, target, safe=world.mode == 'safe') if len(path) < 2: return world if set((world.pos, path[1])) == {'J', 'T'} and tick not in level.opens: return replace(world, blocked=world.blocked + 1) return replace(world, pos=path[1], via=world.via and path[1] != 'T') INITIAL_MODE = 'join' @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 action in COMMANDS: actual = instruct(actual, action) sent += 1 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', '2': 'safe', '3': 'bridge', '4': 'stop'} KEYS = tuple(KEY_TO_ACTION) + ('R',) DEMO_KEYS = ('3', 'SPACE', 'SPACE', 'SPACE', '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 2', 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 AT: 4,6', 10) p.text(310, 110, 'RINGS: '+','.join(locations), 6) p.text(310, 135, 'INSTANT COMMANDS', 7) p.text(310, 150, '2 SAFE / 3 BRIDGE', 7) p.text(310, 165, '4 STOP', 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()