# 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。""" from dataclasses import dataclass, replace, asdict NAMES = ('AKI', 'FUMI', 'RUU') SKILLS = ('C', 'D', 'W') LAND = {'D': 'DARK', 'C': 'CLIFF', 'W': 'COLD', 'P': 'PLAIN'} SCENERY = {'D': '夜の花', 'C': '町の灯り', 'W': '星と霜', 'P': '草原'} BASE = {'D': 2, 'C': 3, 'W': 2, 'P': 1} ROUTE = ('D', 'C', 'P', 'W', 'D', 'P') @dataclass(frozen=True) class State: segment: int = 0 hp: tuple = (5, 5, 5) order: tuple = (0, 1, 2) path: tuple = () log: tuple = () ended: str = '' def new(): return State() def terrain(state, branch=0): return ROUTE[state.segment] def burden(state, branch=0): land = terrain(state, branch) leader = state.order[0] expert = int(SKILLS[leader] == land) cost = max(0, BASE[land] - expert) return land, cost, expert def walk(state, branch=0): if state.ended or branch != 0 or state.segment >= 6: return state land, cost, expert, *detail = burden(state) leader = state.order[0] if state.hp[leader] == 0 or state.hp[leader] < cost: return state hp = list(state.hp) hp[leader] -= cost segment = state.segment + 1 ended = 'arrived' if segment == 6 else 'rescue' if not any(hp) else '' entry = (segment, land, leader, cost, expert, *detail, tuple(hp)) return replace(state, segment=segment, hp=tuple(hp), path=state.path + (land,), log=state.log + (entry,), ended=ended) def lead(state, person): if state.ended or person not in range(3) or state.hp[person] == 0: return state order = (person,) + tuple(p for p in state.order if p != person) return replace(state, order=order) def step(state, action): # Invalid UI/API actions are harmless; parse before indexing. if not isinstance(action, str): return state bits = action.split(':') try: if len(bits) == 2 and bits[0] == 'walk': return walk(state, int(bits[1])) if len(bits) == 2 and bits[0] == 'lead': return lead(state, int(bits[1])) except (ValueError, TypeError): pass return state STAGE = 2 KEYS = ('RETURN', 'R', '1', '2', '3', 'L') DEMO_KEYS = ('2', 'L', 'RETURN', '1', 'L', '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 in ('1', '2', '3'): self.person = int(key)-1 self.message = 'SELECTED '+NAMES[self.person] elif key == 'L': self.apply('lead:'+str(self.person)) elif 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((ROUTE[i],)), 7) for person in range(3): x = 12+person*156 p.rectb(x, 78, 144, 106, 10 if person == self.person else 5) p.text(x+8, 88, str(person+1)+' '+NAMES[person], 7) p.text(x+8, 103, 'LEADER' if person == state.order[0] else 'REAR', 11) p.text(x+8, 118, 'HP '+str(state.hp[person])+'/5 SKILL '+SKILLS[person], 6) for n in range(5): p.rect(x+8+n*24, 133, 18, 9, 11 if n < state.hp[person] else 5) p.text(x+8, 160, 'NO TOOLS YET', 13) if not state.ended: land, cost, expert, *details = burden(state, self.branch) p.text(12, 199, 'NEXT '+LAND[land]+' COST '+str(cost)+' EXPERT -'+str(expert), 7) p.text(12, 213, 'ONLY THE LEADER PAYS / NO CAMP YET', 6) p.text(12, 229, 'ENTER WALKS / 1 2 3 SELECT / L MAKE LEADER', 6) p.text(12, 250, 'AKI: CLIFF / FUMI: DARK / RUU: COLD', 10) p.text(12, 265, 'SELECTING A PERSON DOES NOT CHANGE THE LEADER.', 6) p.text(12, 281, 'READING AND SELECTION DO NOT ADVANCE THE PATH.', 7) 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()