パズルLESSON 40意味入力の記録と時間協力パズル原作084

昨日の自分と配達の作り方

昨日の自分の命令が、今日の道を開く。
位置を録画した映像ではなく「どのキーで何をしたか(入力の意味)」を再生し、今日の自分と昨日の自分の2人を、同じ刻(時間の区切り)の始まりから一緒に動かす、協力パズルを作ります。

完成版の遊び方1人で記録した入力を、分身(昨日の自分)が再生します。待つ場所や扉の開け閉めを工夫して、荷物を届けます。

昨日の自分と配達の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の画面です。この講座では、仕組みがわかりやすいように小さくした「教材版」を、5つのステップで作ります。
PLAY / 試験公開

完成サンプルをブラウザーで遊ぶ

この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。

完成版の主な操作:矢印移動/Space待機/Z荷物、記録走Enter終了、X一刻戻す、Tab新記録確認

ボタンを押すと、ゲームの読み込みが始まります。

画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。

うまく表示されないとき

ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。

同じPyxelアプリをダウンロード(.pyxapp) / 別のタブで開く

記録した命令を、同じルールでもう一度実行する。

いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。

1. 1列に並べたマスの番号・盤のはし・行き先の検査・描画と状態を分ける
2. 書き換えない dataclass・tuple への追加・時刻・記録の確定と、待つ命令での穴埋め
3. 入力の意味をもう一度実行する・同時の更新・前の状態・記録と再生の切り替わり
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上、ターミナルと仮想環境 / 変数・if・for・関数・tuple・dict。dataclass と履歴は本文で説明します。
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
['全段階: 矢印=1マス、全操作キーを離して次へ。Escape=即終了。', '02以降: Space=待つ、Z=use、記録中Enter=確定。02/03のZは命令記録のみ。', '03以降: readyでSpace/Enter=再生開始。04以降Z=荷物を拾う/届ける。', '01〜04: R=全初期化。05: X=Undo、R→Enter=同じ記録で再試行、Tab→Enter=録り直し、V=前版、P=停止。', '05確認画面: Enter=確定、X/Escape=取消。']
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_walk.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。

教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。

STEP 01 / 05

1マスの移動を関数にする

1列に並べたマスの番号・盤のはし・行き先の検査・描画と状態を分ける。

7列×5行の盤のマスに、0〜34の番号をつけます。番号は y×7+x で、x は「番号 % 7」(7で割った余り)、y は「番号 // 7」(7で割った商)で求めます。右は +1、下は +7 ですが、6→7 は右どなりではなく、次の行です。moved は、盤の外・行をまたぐ左右の移動・壁・閉じた扉、の順に調べ、新しい番号と、動けなかった理由を返します。第1段階は、移動だけの実験台です。スイッチ・荷物・届け先の図形は目印で、まだ働きません。扉17は閉じたままです。

下のコードは 01_walk.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

01_walk.py / 注目する部分
    if action in ('left', 'right') and target // WIDTH != position // WIDTH:
        return position, 'edge'

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 01_walk.py
macOS / Linux
.venv/bin/python 01_walk.py
画面で見ること最初の位置は14です。右 → 右 で16、さらに右を押しても16のままで、EVENT は right、C は door(扉で止まった)になります。左はしの14から左を押すと、14のままで C は edge(盤のはし)です。時刻も記録の帯も進みません。R で14に戻れます。
  • 番号14は (0,2)
  • 上下は ±7、左右は ±1
  • 行き先だけを検査する
  • この段階には、記録も勝ち負けもない
つまずきやすいところ行き先の番号 target が0〜34の中でも、左右の折り返しを許すと、行の最後から次の行へ飛んでしまいます。動けなかった理由と位置は、別々に返します。また、閉じた扉から出ることと、扉に入ることは別のことです。
少し変えてみよう

右 → 右 → 右 で扉に止められること、R → 上 → 上 → 右 → 右 → 右 で壁3に止められることを確かめてみましょう。moved(6, "right", False) が6を返す理由を、% と // で説明してみてください。

01_walk.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(274行)
01_walk.py / 全文
# 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字形データは同梱しません。

"""昨日の自分と配達・教材1/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, ''


@dataclass(frozen=True)
class State:
    current: int = START
    reason: str = 'ready'


def new():
    return State()


def door_open(state):
    # 第1段階の扉は閉じたまま。スイッチとの接続は次の段階。
    return False


def step(state, action):
    if action not in MOVES:
        return state
    current, reason = moved(state.current, action, False)
    return replace(state, current=current, reason=reason or 'moved')


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

    def apply(self, action):
        before = self.state
        if action in MOVES:
            self.state = step(self.state, action)
        if self.state == before:
            return 'ignored:' + action
        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):
        if key == 'ESCAPE':
            self.quit = True
            return 'quit'
        if key == 'R':
            self.state = new()
            return 'reset'
        action = {'UP':'up', 'DOWN':'down', 'LEFT':'left', 'RIGHT':'right'}.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)
        return result


STAGE = 1
STAGE_TITLE = 'WALK THE GRID'
STAGE_HINT = 'Walk lab: switch, clock, parcel and replay are not active.'


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)
STEP 02 / 05

座標でなく操作の意味を残す

書き換えない dataclass・tuple への追加・時刻・記録の確定と、待つ命令での穴埋め。

State に、mode(場面)、t(刻)、record(記録中の命令)、tape(確定した命令の列)を加えます。矢印、Space、Z を、up・wait・use などの意味のある入力として、1刻ずつ残します。壁にぶつかった up も、動かない wait も1刻です。Z は、ここでは use という命令を残すだけで、荷物は扱いません。S=1 を踏んでいる間だけ D=17 が開く、というルールもつなぎます。Enter で記録を終えると、足りない分を wait で埋め、24個の命令の tape として確定します。24刻を使い切っても、自動で同じ ready(再生の準備ができた状態)になります。記録するときの目的は、未来のために行動を残すことです。配達や成功は求めていません。

下のコードは 02_record.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

02_record.py / 注目する部分
    tape = state.record + ('wait',) * (LIMIT - len(state.record))

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 02_record.py
macOS / Linux
.venv/bin/python 02_record.py
画面で見ること上 → 上 → 右 で、current(今日の位置)=1、t=3、record=(up, up, right) です。Enter で ready になり、t は3のまま、tape は24個の命令になります。最初の3つが歩く命令、残りの21個が wait です。ready のときは、矢印や Space を押しても進みません。この段階では、R で新しい記録に戻ります。
  • record は、まだ確定していない命令の列
  • tape は、確定した24個の命令
  • ぶつかっても、1刻を使う
  • 途中で終えた記録も、空の記録も有効
つまずきやすいところtape に、毎フレームの座標や、押されているキーを追加しないでください。ゲームの1刻は、受け付けた意味のある入力1つで、画面を描くフレームとは別のものです。wait での穴埋めは、位置を止めておく映像ではなく、「待つ」という命令です。
少し変えてみよう

上 → 上 → 右 → Enter で24個の枠を確かめ、R → Enter だけでも、24個の wait を作れることを確かめましょう。R → 左 では、位置14のまま t=1 で、record に left が残ります。

02_record.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(308行)
02_record.py / 全文
# 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字形データは同梱しません。

"""昨日の自分と配達・教材2/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
    record: tuple = ()
    tape: tuple = ()
    ended: bool = False
    reason: str = 'ready'


def new():
    return State()


def door_open(state):
    return state.current == SWITCH


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)
    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 = t >= LIMIT
    return replace(state, t=t, current=current, record=record,
                   ended=ended, reason=reason)


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')


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

    def apply(self, action):
        before = self.state
        if action in ACTIONS:
            self.state = step(self.state, action)
        elif action == 'finish':
            self.state = finish_record(self.state)
        if self.state == before:
            return 'ignored:' + action
        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):
        if key == 'ESCAPE':
            self.quit = True
            return 'quit'
        if key == 'R':
            self.state = new()
            return 'reset'
        if self.state.mode == 'ready':
            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)
        return result


STAGE = 2
STAGE_TITLE = 'RECORD COMMANDS'
STAGE_HINT = 'Record lab: switch active; ENTER pads waits; no replay yet.'


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)
STEP 03 / 05

同じ刻の扉で二人を動かす

入力の意味をもう一度実行する・同時の更新・前の状態・記録と再生の切り替わり。

ready で Enter か Space を押すと、今日の自分 current と昨日の自分 ghost が、どちらも14に戻り、t=0 から再生が始まります。今日の自分が命令を1つ出すたびに、昨日の自分は tape[t] を1つ実行します。刻の最初に door_open(state)(扉が開いているか)を一度だけ求め、2人の moved に同じ値を渡します。こうすると、昨日の自分が今スイッチに乗った効果は、次の刻から現れます。昨日の自分の座標を記録どおりに無理やり置くのではないので、今回の扉の状態によっては、記録したときと違う位置に進むことがあります。2人は重なることができ、押し合いません。

下のコードは 03_replay.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

03_replay.py / 注目する部分
    opened = door_open(state)
    current, reason = moved(state.current, action, opened)

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_replay.py
macOS / Linux
.venv/bin/python 03_replay.py
画面で見ること上 → 上 → 右 → Enter → Enter で、再生の0刻になります。右 → Space → 右 と進めると、3刻目で今日の自分が16、昨日の自分が1で、DOOR NEXT(次の刻の扉)は OPEN です。さらに右で、今日の自分は17に入れます。再生が始まった直後に Enter や Space を長押ししても、勝手に刻は進みません。荷物と配達は、まだ作っていません。
  • 今日の1回の操作と、昨日の1つの命令で、同時に1刻
  • 扉は、更新の前に一度だけ計算する
  • 昨日の自分にも、壁と扉が効く
  • 再生が終わっても、テープをくり返さない
つまずきやすいところ今日の自分を動かしたあとで扉を求め、昨日の自分だけ新しい扉の状態を使うと、更新の順番で結果が変わってしまいます。画面の DOOR NEXT は、更新したあとの位置から見た、次の刻の扉です。今終わった移動に使った扉と同じとは限りません。
少し変えてみよう

R → 上 → 上 → 右 → Enter → Enter → 右 → Space → 右 → 右 と試してみましょう。次に、new、finish_record、start_replay を使って、current=16 / ghost=0 / tape の最初が right という状態を作り、最初の右では今日16・昨日1、次の右で今日17になることを確かめます。

03_replay.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(330行)
03_replay.py / 全文
# 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字形データは同梱しません。

"""昨日の自分と配達・教材3/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
    record: tuple = ()
    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, ''
    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 = t >= LIMIT
    return replace(state, t=t, current=current, record=record,
                   ended=ended, reason=reason, ghost=ghost,
                   ghost_reason=ghost_reason)


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')


def start_replay(state):
    if not state.tape:
        return state
    # 再生は二人とも出発位置。記録時の荷物や位置を持ち越さない。
    return State(mode='replay', ghost=START, tape=state.tape,
                 reason='replay')


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

    def apply(self, action):
        before = self.state
        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)
        if self.state == before:
            return 'ignored:' + action
        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):
        if key == 'ESCAPE':
            self.quit = True
            return 'quit'
        if key == 'R':
            self.state = new()
            return 'reset'
        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)
        return result


STAGE = 3
STAGE_TITLE = 'REPLAY TOGETHER'
STAGE_HINT = 'Replay lab: both actors use the SAME old door; no parcel yet.'


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)
STEP 04 / 05

荷物と24刻の成功条件を足す

荷物の状態・はっきりした操作・期限と成功を判定する順番・終わったら止める。

今日の自分が PARCEL=15 で Z を押すと、holding=True(荷物を持っている)になります。HOME=20 へ運んだだけではまだ配達ではなく、そこで Z を押すと delivered=True(配達済み)になります。昨日の自分の use は動かないだけで、荷物にはさわりません。記録しているときも今日の自分の Z は働きますが、配達しても、そこで早く終わることはありません。再生を始めると、荷物の状態は最初に戻ります。再生では、配達するか、24刻に着くと終わります。24刻目の Z も先に実行してから終わりを判定するので、締め切りちょうどの配達も有効です。

下のコードは 04_delivery.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

04_delivery.py / 注目する部分
    ended = (delivered and state.mode == 'replay') or t >= LIMIT

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 04_delivery.py
macOS / Linux
.venv/bin/python 04_delivery.py
画面で見ること上 → 上 → 右 → Enter → Enter で、昨日の自分を準備します。今日の自分は、右 → Z → 右 → 右 → 右 → 右 → 右 → Z で、8刻で配達します。7刻目は20にいて、荷物を持ったままです。8刻目で holding=False、delivered=True、ended=True(終わり)です。終わったあとは、矢印・Space・Z を押しても状態は変わりません。この段階の R は、全部を消して記録に戻ります。
  • 拾うのにも届けるのにも、Z が必要
  • 昨日の自分は、荷物を扱わない
  • 記録中に配達しても、早く終わらない
  • 24刻目の配達も有効
つまずきやすいところ届け先に入っただけで勝ちにしたり、t が24になった瞬間に、Z より先に失敗を決めたりしないでください。また、昨日の自分と今日の自分が、同じ荷物を別々に持てる作りにはしません。
少し変えてみよう

上の手順で、7刻目と8刻目を比べてみましょう。配達したあと、左 → Space と押しても、位置20・t=8 が変わらないことを確かめ、R で記録も消えることを確かめてください。

04_delivery.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(341行)
04_delivery.py / 全文
# 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字形データは同梱しません。

"""昨日の自分と配達・教材4/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 = ()
    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')


def start_replay(state):
    if not state.tape:
        return state
    # 再生は二人とも出発位置。記録時の荷物や位置を持ち越さない。
    return State(mode='replay', ghost=START, tape=state.tape,
                 reason='replay')


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

    def apply(self, action):
        before = self.state
        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)
        if self.state == before:
            return 'ignored:' + action
        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):
        if key == 'ESCAPE':
            self.quit = True
            return 'quit'
        if key == 'R':
            self.state = new()
            return 'reset'
        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)
        return result


STAGE = 4
STAGE_TITLE = 'DELIVER ON TIME'
STAGE_HINT = 'Delivery: Z picks up or delivers. Tick 24 still counts.'


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)
STEP 05 / 05

二人と記録をまとめて巻き戻す

状態全体の履歴・Undo・確認の画面・記録の版・入力の優先順位。

変わる直前の State を history に保存し、X でまるごと戻します。2人の位置だけでなく、t、荷物、record、tape、old_tape(1つ前のテープ)、mode、終わったかどうかも、同時に戻ります。断られた操作は履歴に追加しませんが、壁にぶつかった意味のある入力は刻を使うので、履歴に残ります。R → Enter は同じ確定したテープで最初から再生、Tab → Enter は確定したテープを残したまま、新しく記録し直します。新しい記録を確定すると、古いテープは old_tape になり、V で2つを入れかえて、再生の0刻に戻ります。P は一時停止、確認の画面の間の X は、Undo ではなく取り消しです。

下のコードは 05_history.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

05_history.py / 注目する部分
        if self.state == before:
            return 'ignored:' + action
        self.history.append(before)

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 05_history.py
macOS / Linux
.venv/bin/python 05_history.py
画面で見ること04 の8刻での配達を再現したあと、X で7刻目の「今日の自分が20・荷物あり・まだ配達していない」状態に戻ります。Z で、もう一度配達できます。R → Enter で、同じ24個の命令のまま、再生の0刻に戻ります。Tab → Enter → 下 → Enter で新しいテープを作り、V で「上・上・右」の古いテープに戻して再生します。X で、V より前の ready にも戻せます。
  • 状態全体を、1つのまとまりとして戻す
  • R は同じテープ、Tab は記録し直し
  • V は、確定したテープの入れかえ
  • 確認の画面・一時停止・終わりより優先する操作を、はっきり決める
つまずきやすいところhistory に、あとから書き換えられるオブジェクトの参照だけを入れると、過去の記録まであとで書き換わってしまいます。書き換えない(frozen)dataclass と tuple で、それぞれの時点を残します。確認の画面に入ったキーを押し続けたまま、別のキーを足しても、確定しません。操作キーを一度全部離します。
少し変えてみよう

配達 → X → Z、R → Enter、Tab → Enter → 下 → Enter → V → X の順に試してみましょう。R の確認の画面の間に右を押しても動かず、X では取り消しだけになることも確かめてください。最後に、P → 右 → P と押して、一時停止中に刻が増えていないことを確かめます。

05_history.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(391行)
05_history.py / 全文
# 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)
AFTER STEP 05

仕組みがわかったら、元の完成版を読んでみよう。

元のゲームの状況 A「長く待つ昨日」の、押し続け式(スイッチを踏んでいる間だけ扉が開く)。7×5の配置、24刻、意味のある入力のテープ、前の状態で決める扉、同時の移動、重なり、今日の自分だけが持つ荷物、記録・準備・再生、1つ前の版と Undo をそのまま持っています。

ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。

比べる点5ステップの教材元の完成版
面と扉状況 A の押し続け式だけ。05 のルールは、この範囲を元のゲームと直接照らし合わせています。状況 A/B/C の3つの面と、A/B の latch(一度踏むと開いたまま)との比較、C の4刻だけ開く扉があります。
段階01 は移動だけで扉は閉じたまま、02 は1人での記録と、踏んでいる間だけ開く扉、03 で同時の再生、04 で荷物と配達、05 で履歴と版の管理。01〜03 は完成したゲームではありません。最初から、ルール全部を使えます。
入力と画面タイトルなしで、記録から始まります。01〜04 の R は全部を消す、05 の R/Tab の確認と X/V/P は元のゲームに合わせています。Escape は確認なしですぐ終了(確認の画面の間は取り消し)。560×400の英数字(ASCII)の図形、音なし。日本語で1024×720、タイトルと面の選択、H/P での説明、M で表紙、終了の確認、F12 など。
記録の保管05 は、起動している間の確定したテープと、1つ前の版だけ。終了すると消えます。自由に使える --report は、組み立てたキー入力と状態の動作確認(QA)用の出力で、読みこめるセーブではありません。自動でのコードの写し・操作ごとのファイル保存・GIF・F12 は省いています。起動している間の1本+1つ前の版。起動し直したあとのためのセーブ・ロードはなし。media フォルダーに、意味のある入力、状態、最初の設定、そのときのコードと SHA256(内容から作る確認用の値)を、動作確認の証拠として自動で保存。
予測と表示の手助けDOOR NEXT、今の位置、命令の帯だけ。3刻先の予測と、記録したときの道すじとの比較は省略。「今日の自分が待つ」と仮定した3刻先の予測、1人で記録したテープの道すじ、再生のときの位置のずれの注意。
権利とQANoto フォントの文字はなし。元のゲームの MIT ライセンスの全文と、参照するライセンスを残しています。実際の Pyxel を画面なし(headless)で動かした確認と、組み立てた controller の入力の確認。Noto フォントの日本語の文字と、Pyxel の権利の文書。元のゲームの26のテストとデモの結果は、教材の動作確認の結果には含めていません。

完成版のコードで、次に読むところ

  1. 状況 B/C を加えて、時間で閉まる扉を状態に持たせます。C は、移動したあとで timer を4にする、という順番を守ります。
  2. hold(踏んでいる間だけ)と latch(一度踏むと開いたまま)を比べ、同じテープ・同じ入力に固定して、1つのルールだけを変えます。
  3. 「今日の自分が待つ」という仮定をはっきり書いた、3刻先の予測を作ります。
  4. ロードの機能を作るなら、形式の版、最初の条件、入力の確認、コードの出どころを設計します。位置を記録した動画とは区別します。
自分のゲームに変えるなら

最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。

教材をダウンロードする

無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について

検証について

どこまで動作を確認したかを見る

2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。

この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る