# 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. # 教材は図形とPyxel標準ASCIIのみ。Noto字形データは同梱しません。 """昨日の自分と配達・教材5/5。日本語解説はLESSON.md。""" from dataclasses import asdict, dataclass, replace # 原作A「長く待つ昨日」: 7列×5行、左上0、右下34。 WIDTH, HEIGHT = 7, 5 START, SWITCH, DOOR, PARCEL, HOME = 14, 1, 17, 15, 20 WALLS = (3, 10, 24, 31) MOVES = {'up': -7, 'down': 7, 'left': -1, 'right': 1} def moved(position, action, opened): """行先だけを検査する。閉じた扉の中から出ることはできる。""" if action not in MOVES: return position, '' target = position + MOVES[action] if target not in range(WIDTH * HEIGHT): return position, 'edge' if action in ('left', 'right') and target // WIDTH != position // WIDTH: return position, 'edge' if target in WALLS: return position, 'wall' if target == DOOR and not opened: return position, 'door' return target, '' LIMIT = 24 ACTIONS = tuple(MOVES) + ('wait', 'use') @dataclass(frozen=True) class State: mode: str = 'record' t: int = 0 current: int = START ghost: int = -1 holding: bool = False delivered: bool = False record: tuple = () tape: tuple = () old_tape: tuple = () ended: bool = False reason: str = 'ready' ghost_reason: str = '' def new(): return State() def door_open(state): return SWITCH in (state.current, state.ghost) def step(state, action): if state.ended or action not in ACTIONS: return state # 最初に固定した扉を使う。移動してから扉を求め直さない。 opened = door_open(state) current, reason = moved(state.current, action, opened) ghost_action = (state.tape[state.t] if state.mode == 'replay' and state.t < len(state.tape) else 'wait') if state.mode == 'replay': ghost, ghost_reason = moved(state.ghost, ghost_action, opened) else: ghost, ghost_reason = -1, '' holding, delivered = state.holding, state.delivered # 荷物を扱うのは今日のuseだけ。昨日のuseは位置を変えない。 if action == 'use': if not holding and current == PARCEL: holding, reason = True, 'picked' elif holding and current == HOME: holding, delivered, reason = False, True, 'delivered' else: reason = 'use_here' reason = reason or ('waited' if action == 'wait' else 'moved') record = state.record + (action,) if state.mode == 'record' else state.record t = state.t + 1 ended = (delivered and state.mode == 'replay') or t >= LIMIT return replace(state, t=t, current=current, record=record, ended=ended, reason=reason, ghost=ghost, ghost_reason=ghost_reason, holding=holding, delivered=delivered) def finish_record(state): if state.mode != 'record': return state tape = state.record + ('wait',) * (LIMIT - len(state.record)) return replace(state, mode='ready', tape=tape, ended=True, reason='saved', old_tape=state.tape or state.old_tape) def start_replay(state): if not state.tape: return state # 再生は二人とも出発位置。記録時の荷物や位置を持ち越さない。 return State(mode='replay', ghost=START, tape=state.tape, reason='replay', old_tape=state.old_tape) def rerecord(state): # 前の記録を保持したまま、新しいrecordを空で始める。 return State(tape=state.tape, old_tape=state.old_tape) def swap_old(state): if not state.old_tape: return state return replace(start_replay(state), tape=state.old_tape, old_tape=state.tape) CONTROLS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'RETURN', 'X', 'R', 'TAB', 'V', 'P', 'ESCAPE') REPLAY = {'U':'UP', 'D':'DOWN', 'L':'LEFT', 'T':'RIGHT', '.':'SPACE', 'Z':'Z', 'E':'RETURN', 'X':'X', 'R':'R', 'B':'TAB', 'V':'V', 'P':'P'} class Controller: def __init__(self): self.state = new() self.previous = set() self.gate = False self.event = 'ready' self.quit = False self.history = [] self.dialog = '' self.paused = False def apply(self, action): before = self.state if action == 'undo': if self.history: self.state = self.history.pop() return 'undo' return 'ignored:undo' if action in ACTIONS: self.state = step(self.state, action) elif action == 'finish': self.state = finish_record(self.state) elif action == 'replay': self.state = start_replay(self.state) elif action == 'record': self.state = rerecord(self.state) elif action == 'old': self.state = swap_old(self.state) if self.state == before: return 'ignored:' + action self.history.append(before) if self.state.mode == 'record' and self.state.ended: self.state = finish_record(self.state) return action def feed(self, down=()): """押下状態を受け取り、新規押下と全キー解放を検出する。""" down = set(down) & set(CONTROLS) fresh = down - self.previous self.previous = down if self.gate: if not down: self.gate = False return None if not fresh: return None self.gate = True if len(fresh) != 1: self.event = 'reject:chord' return self.event key = next(iter(fresh)) self.event = self.dispatch(key) return self.event def dispatch(self, key): # 優先順位: 確認画面 → 終了 → 停止 → 履歴操作 → mode → 一刻。 if self.dialog: if key in ('X', 'ESCAPE'): self.dialog = '' return 'cancel' if key == 'RETURN': dialog, self.dialog = self.dialog, '' if dialog == 'rerecord': return self.apply('record') return self.apply('replay' if self.state.tape else 'record') return 'ignored:dialog' if key == 'ESCAPE': self.quit = True return 'quit' if key == 'P': self.paused = not self.paused return 'pause' if self.paused: return 'ignored:pause' if key == 'TAB': self.dialog = 'rerecord' return 'confirm:rerecord' if key == 'R': self.dialog = 'retry' return 'confirm:retry' if key == 'V': return self.apply('old') if key == 'X': return self.apply('undo') if self.state.mode == 'ready': if key in ('SPACE', 'RETURN'): return self.apply('replay') return 'ignored:ready' if self.state.ended: return 'ignored:result' if key == 'RETURN' and self.state.mode == 'record': return self.apply('finish') action = {'UP':'up', 'DOWN':'down', 'LEFT':'left', 'RIGHT':'right', 'SPACE':'wait', 'Z':'use'}.get(key) return self.apply(action) if action else 'ignored:key' def snapshot(self): result = asdict(self.state) result.update(door=door_open(self.state), event=self.event, gate=self.gate, previous=sorted(self.previous), quit=self.quit) result.update(history_depth=len(self.history), dialog=self.dialog, paused=self.paused) return result STAGE = 5 STAGE_TITLE = 'HISTORY AND RETRY' STAGE_HINT = '' def draw_board(pyxel, controller): pyxel.cls(1) s = controller.snapshot() pyxel.text(12, 12, f'YESTERDAY DELIVERY / STEP {STAGE}: {STAGE_TITLE}', 10) pyxel.text(12, 28, 'Original case A / hold switch / same 7 x 5 board', 6) for position in range(WIDTH * HEIGHT): x, y = 12 + position % WIDTH * 46, 56 + position // WIDTH * 46 pyxel.rect(x, y, 43, 43, 5 if position in WALLS else 13) pyxel.text(x+2, y+2, str(position), 1) if position == SWITCH: pyxel.rect(x+11, y+12, 22, 16, 3) pyxel.text(x+15, y+17, 'S', 7) if position == DOOR: pyxel.rectb(x+9, y+10, 26, 25, 11 if s['door'] else 8) if not s['door']: for off in (14, 21, 28): pyxel.line(x+off, y+12, x+off, y+33, 8) if position == PARCEL and not s.get('holding') and not s.get('delivered'): pyxel.rect(x+17, y+18, 11, 11, 9) if position == HOME: pyxel.rectb(x+9, y+14, 27, 21, 14) pyxel.text(x+16, y+21, 'H', 1) if position == START: pyxel.text(x+9, y+35, 'START', 1) for actor, color in (('ghost', 12), ('current', 10)): position = s.get(actor, -1) if position < 0: continue x, y = 12 + position % WIDTH * 46 + 22, 56 + position // WIDTH * 46 + 22 if actor == 'ghost': pyxel.rectb(x-16, y-16, 33, 33, color) pyxel.text(x-13, y-13, 'G', color) else: pyxel.circ(x, y, 8, color) pyxel.text(x-2, y-2, 'C', 1) if s.get('holding'): pyxel.rect(x+6, y+5, 8, 8, 9) mode = s.get('mode', 'walk') lines = [f"MODE: {mode.upper()}", f"TICK: {s.get('t', 0)} / 24" if STAGE > 1 else 'NO CLOCK YET', f"C (today): {s['current']}", f"G (yesterday): {s.get('ghost', -1)}", f"DOOR NEXT: {'OPEN' if s['door'] else 'CLOSED'}", f"HOLDING: {s.get('holding', False)}", f"DELIVERED: {s.get('delivered', False)}", f"EVENT: {s['event']}", f"C: {s['reason']}", f"G: {s.get('ghost_reason') or '-'}"] for index, line in enumerate(lines): pyxel.text(352, 58+index*17, line, 10 if index == 0 else 7) if s.get('delivered') and mode == 'replay': pyxel.rect(348, 236, 198, 29, 3) pyxel.text(358, 247, 'DELIVERED! TODAY + YESTERDAY', 10) elif s.get('ended') and mode == 'replay': pyxel.text(352, 245, '24 TICKS / TRY AGAIN', 8) pyxel.text(12, 298, 'COMMAND TAPE (U D L R . Z):', 6) tape = s.get('record', []) if mode == 'record' else s.get('tape', []) symbols = {'up':'U', 'down':'D', 'left':'L', 'right':'R', 'wait':'.', 'use':'Z'} for index in range(24): x = 12 + index * 22 pyxel.rect(x, 312, 20, 20, 2 if mode == 'replay' and index == s.get('t') else 5) pyxel.text(x+7, 319, symbols[tape[index]] if index < len(tape) else '-', 10) controls = ('ARROWS move / switch and parcel are only markers' if STAGE == 1 else 'ARROWS move / SPACE wait / Z command only / ENTER finish' if STAGE == 2 else 'ARROWS move / SPACE wait / Z command only / ENTER finish or replay' if STAGE == 3 else 'ARROWS move / SPACE wait / Z use / ENTER finish or replay') pyxel.text(12, 342, controls, 7) if STAGE == 5: pyxel.text(12, 358, 'X undo / R retry / TAB rerecord / V old tape / P pause / ESC quit', 6) pyxel.text(12, 379, f"HISTORY {s['history_depth']} / OLD TAPE {bool(s['old_tape'])}", 13) else: pyxel.text(12, 358, 'R reset / ESC quit / release all controls between actions', 6) pyxel.text(12, 379, STAGE_HINT, 13) if s.get('dialog') or s.get('paused'): pyxel.rect(95, 125, 370, 94, 1) pyxel.rectb(95, 125, 370, 94, 10) title = s.get('dialog', '').upper() or 'PAUSED' pyxel.text(118, 145, title, 10) pyxel.text(118, 169, 'ENTER confirm / X or ESC cancel' if s.get('dialog') else 'P resume / no ticks while paused', 7) pyxel.text(118, 193, 'Release all controls before the next press.', 6) def main(headless=False, actions='', screenshot=None, report=None): import json from pathlib import Path # Pyxel初期化が作業フォルダーを変えても、出力先は呼出時の場所を保つ。 screenshot = str(Path(screenshot).resolve()) if screenshot else None report = str(Path(report).resolve()) if report else None # Pyxelはここでだけ読み込む。modelとテストに画面環境は不要。 import pyxel controller = Controller() codes = {name: getattr(pyxel, 'KEY_'+name) for name in CONTROLS} frame, draws, events = 0, 0, [] last_frame = max(12, len(actions)*2+2) def update(): nonlocal frame frame += 1 if headless: index = (frame-1)//2 if frame % 2 and index < len(actions): controller.feed((REPLAY[actions[index]],)) events.append({'input':actions[index], 'snapshot':controller.snapshot()}) else: controller.feed(()) else: controller.feed(name for name, code in codes.items() if pyxel.btn(code)) if controller.quit: pyxel.quit() def draw(): nonlocal draws draws += 1 draw_board(pyxel, controller) if headless and frame >= last_frame: if screenshot: pyxel.screenshot(str(Path(screenshot).resolve()), scale=1) if report: Path(report).write_text(json.dumps({ 'label':'Actual Pyxel headless; synthetic Controller.feed; silent', 'stage':STAGE, 'frames':frame, 'draws':draws, 'actions':actions, 'events':events, 'snapshot':controller.snapshot(), }, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') pyxel.quit() pyxel.init(560, 400, title=f'Yesterday Delivery Step {STAGE}', fps=30, quit_key=pyxel.KEY_NONE, headless=headless) pyxel.run(update, draw) if __name__ == '__main__': import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless-smoke', action='store_true') parser.add_argument('--headless-replay', help='Synthetic keys: '+str(REPLAY)) parser.add_argument('--screenshot', help='PNG output; headless only') parser.add_argument('--report', help='UTF-8 JSON output; headless only') args = parser.parse_args() if args.headless_smoke and args.headless_replay is not None: parser.error('Choose smoke or replay') headless = args.headless_smoke or args.headless_replay is not None if not headless and (args.screenshot or args.report): parser.error('Outputs require headless mode') actions = args.headless_replay or '' if any(action not in REPLAY for action in actions): parser.error('Unsupported replay character') main(headless, actions, args.screenshot, args.report)