# 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. """一夜の探検隊・教材 5/5。Python 3.11+ / Pyxel 2.9.9。""" from dataclasses import dataclass, replace, asdict NAMES = ('AKI', 'FUMI', 'RUU') JAPANESE_NAMES = ('アキ', 'フミ', 'ルウ') TOOLS = ('LANTERN', 'ROPE', 'BLANKET') JAPANESE_TOOLS = ('ランタン', 'ロープ', '毛布') SKILLS = ('C', 'D', 'W') LAND = {'D': 'DARK', 'C': 'CLIFF', 'W': 'COLD', 'P': 'PLAIN'} JAPANESE_LAND = {'D': '暗道', 'C': '崖', 'W': '寒道', 'P': '平地'} SCENERY = {'D': '夜の花', 'C': '町の灯り', 'W': '星と霜', 'P': '草原'} BASE = {'D': 2, 'C': 3, 'W': 2, 'P': 1} TOOL = {'D': 0, 'C': 1, 'W': 2} ROUTES = { 'A': (('D',), ('C',), ('P',), ('W',), ('D',), ('P',)), 'B': (('C',), ('C',), ('D', 'W'), ('C',), ('P', 'D'), ('W',)), 'C': (('W',), ('D',), ('C', 'P'), ('W',), ('D', 'C'), ('P',)), } INITIAL = {'A': (0, 1, 2), 'B': (2, 0, 1), 'C': (1, 2, 0)} @dataclass(frozen=True) class State: case: str = 'A' variant: str = 'rear' segment: int = 0 hp: tuple = (5, 5, 5) order: tuple = (0, 1, 2) owners: tuple = (0, 1, 2) rest_pending: bool = False rested: tuple = () path: tuple = () log: tuple = () ended: str = '' def new(case='A', variant='rear'): if case not in ROUTES or variant not in ('rear', 'self'): raise ValueError('invalid configuration') return State(case=case, variant=variant, owners=INITIAL[case]) def editable(state): return not state.ended and state.segment in (0, 2, 4) def terrain(state, branch=0): return ROUTES[state.case][state.segment][branch] def burden(state, branch=0): land = terrain(state, branch) leader = state.order[0] expert = int(SKILLS[leader] == land) tool = TOOL.get(land) owner = state.owners[tool] if tool is not None else -1 support = int(tool is not None and (owner != leader or state.variant == 'self')) cost = max(0, BASE[land] - expert - support) return land, cost, expert, support, owner, tool def walk(state, branch=0): if (state.ended or state.rest_pending or state.segment >= 6 or branch not in range(len(ROUTES[state.case][state.segment]))): return state land, cost, expert, support, owner, tool = burden(state, branch) 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 entry = (segment, land, SCENERY[land], leader, cost, expert, support, owner, tool, tuple(hp)) ended = ('arrived' if segment == 6 else 'rescue' if not any(hp) else '') return replace(state, segment=segment, hp=tuple(hp), rest_pending=segment in (2, 4) and not ended, 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 give(state, tool, person): if not editable(state) or tool not in range(3) or person not in range(3): return state owners = list(state.owners) owners[tool] = person if owners.count(person) > 2: return state return replace(state, owners=tuple(owners)) def rest(state, person): if state.ended or not state.rest_pending or person not in range(3): return state hp = list(state.hp) hp[person] = min(5, hp[person] + 2) return replace(state, hp=tuple(hp), rest_pending=False, rested=state.rested + ((state.segment, person),)) 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])) if len(bits) == 3 and bits[0] == 'give': return give(state, int(bits[1]), int(bits[2])) if len(bits) == 2 and bits[0] == 'rest': return rest(state, int(bits[1])) except (ValueError, TypeError): pass return state def voices(state): """A line must be backed by the walk log, not the current inventory.""" result = [] for person in range(3): ahead = [row for row in state.log if row[3] == person] behind = [row for row in state.log if row[6] and row[7] == person and row[3] != person] if behind: tool = max(range(3), key=lambda j: sum(row[8] == j for row in behind)) count = sum(row[8] == tool for row in behind) line = JAPANESE_TOOLS[tool] + 'で後ろから' + str(count) + '回支えたよ' elif ahead: line = JAPANESE_LAND[ahead[-1][1]] + 'で先頭を歩いたよ' else: line = '一緒に夜明けを見に来たよ' result.append(JAPANESE_NAMES[person] + '「' + line + '」') return tuple(result) @dataclass(frozen=True) class Timeline: state: State history: tuple = () def act(timeline, action): # Undo is checked before the terminal-state guard in step/walk. if action == 'undo': if not timeline.history: return timeline return Timeline(timeline.history[-1], timeline.history[:-1]) state = step(timeline.state, action) if state == timeline.state: return timeline history = timeline.history if action.startswith('walk:'): history += (timeline.state,) return Timeline(state, history) STAGE = 5 KEYS = ('RETURN', 'R', '1', '2', '3', 'L', 'T', 'G', 'UP', 'C', 'X', 'V') DEMO_KEYS = ('RETURN', 'RETURN', 'RETURN', '2', 'T', 'G', 'X') class App: def __init__(self): self.reset() def reset(self, case=None, variant=None): old = getattr(self, 'state', None) self.state = new(case or (old.case if old else 'B'), variant or (old.variant if old else 'rear')) self.timeline = Timeline(self.state) self.person = 0 self.tool = 0 self.branch = 0 self.gated = False self.message = 'READY' def apply(self, action): old = self.state self.timeline = act(self.timeline, action) self.state = self.timeline.state 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 key == 'C': self.reset('ABC'[('ABC'.index(self.state.case)+1)%3]) self.gated = True return if key == 'V': self.reset(variant='self' if self.state.variant == 'rear' else 'rear') self.gated = True return if key == 'X': self.apply('undo') return 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 == 'T': self.tool = (self.tool+1)%3 self.message = 'SELECTED '+TOOLS[self.tool] elif key == 'G': self.apply('give:'+str(self.tool)+':'+str(self.person)) elif key == 'UP': self.branch = (self.branch+1)%len(ROUTES[self.state.case][self.state.segment]) self.message = 'BRANCH '+str(self.branch+1) elif key == 'RETURN': action = ('rest:'+str(self.person) if self.state.rest_pending else 'walk:'+str(self.branch)) self.apply(action) 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.case+' / '+state.variant.upper()+' '+(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(ROUTES[state.case][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) owned = ''.join('LRB'[j] for j in range(3) if state.owners[j] == person) p.text(x+8, 160, 'TOOLS '+(owned or '-'), 10) 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, 'SUPPORT -'+str(details[0])+' / ONLY THE LEADER PAYS', 6) if state.rest_pending: p.text(12, 229, 'CAMP: ENTER HEALS SELECTED PERSON +2 (MAX 5)', 10) elif state.ended: p.text(12, 229, 'LOGGED WALKS: '+str(len(state.log))+' / '+state.ended.upper(), 10) else: p.text(12, 229, 'ENTER WALKS / UP SELECTS PUBLIC BRANCH', 6) p.text(12, 247, 'TOOL '+TOOLS[self.tool]+' / '+('GIVE OPEN' if editable(state) else 'GIVE LOCKED'), 10) p.text(12, 263, 'T NEXT TOOL / G GIVE TO SELECTED / LIMIT 2', 6) p.text(12, 281, '1 2 3 SELECT / L LEADER / C NEXT ROUTE (RESET)', 7) p.text(12, 301, 'X UNDO WALK / V RULE+RESET / R RESET / ESC QUIT', 6) p.text(12, 316, 'WALK CHECKPOINTS '+str(len(self.timeline.history)), 13) 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()