# 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。""" from dataclasses import dataclass, replace, asdict @dataclass(frozen=True) class State: segment: int = 0 stamina: int = 6 ended: str = '' def new(): return State() def walk(state): if state.ended or state.stamina < 1: return state stamina = state.stamina - 1 segment = state.segment + 1 # The last point of stamina can pay for arrival. ended = 'arrived' if segment == 6 else 'rescue' if stamina == 0 else '' return State(segment, stamina, ended) def step(state, action): return walk(state) if action == 'walk:0' else state STAGE = 1 KEYS = ('RETURN', 'R') DEMO_KEYS = ('RETURN', 'RETURN') class App: def __init__(self): self.reset() def reset(self, case=None, variant=None): self.state = new() self.person = 0 self.tool = 0 self.branch = 0 self.gated = False self.message = 'READY' def apply(self, action): old = self.state self.state = step(self.state, action) self.message = action if self.state != old else 'BLOCKED / NO CHANGE' if self.state.segment != old.segment: self.branch = 0 def control(self, pressed, held): pressed, held = set(pressed), set(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)) if key not in KEYS: return self.gated = True if self.state.ended: self.message = 'ENDED - R TO RESET' return if key == 'RETURN': self.apply('walk:0') def draw(self, p): state = self.state p.cls(1) p.text(12, 10, '090 NIGHT EXPEDITION / STAGE '+str(STAGE), 10) p.text(12, 25, 'SEGMENT '+str(state.segment)+'/6'+' '+(state.ended.upper() or 'WALKING'), 11) for i in range(6): p.rect(12+i*77, 43, 70, 21, 3 if i < state.segment else 2) p.text(18+i*77, 50, str(i+1)+':'+ '/'.join(('P',)), 7) p.rectb(12, 80, 456, 125, 5) p.text(24, 96, 'ONE WALKER / FLAT PATH / COST 1', 7) p.text(24, 119, 'STAMINA '+str(state.stamina)+'/6', 10) for i in range(6): p.rect(24+i*30, 142, 24, 22, 11 if i < state.stamina else 5) p.text(24, 184, 'ONLY AN ACCEPTED ENTER ADVANCES A SEGMENT.', 6) p.text(12, 229, 'ENTER WALK / R RESET / ESC QUIT', 7) p.text(12, 249, 'RELEASE THE KEY BEFORE THE NEXT WALK.', 6) p.text(12, 277, 'ARRIVAL IS CHECKED BEFORE ZERO-STAMINA RESCUE.', 13) p.text(12, 301, 'R RESET / ESC QUIT', 6) p.text(12, 337, 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='090 Night Expedition', 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()