パズルLESSON 20条件分岐で荷物を仕分ける同期パズル原作077

からくり郵便室の作り方

色と重さで分かれ道を選び、荷物を仕分ける。
小さな命令カードを、2つに枝分かれする木の形(二分木)に並べます。「1刻前の台の状態」をもとに、1刻分の動きをまとめて決める仕分けゲームです。

完成版の遊び方色や重さを見て左右に分ける3つの台を組み立て、荷物がどの道を通ったかを追いながら、6つの便を正しい箱に仕分けます。

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

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

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

完成版の主な操作:矢印キーで台を選んでZでカードを変える、Tabで台と箱の札を切り替える、Enterで試運転、Spaceで1刻進める、Pで自動運転・停止、Xで1手戻す

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

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

うまく表示されないとき

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

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

条件分岐と、まとめて更新する処理を組み合わせる。

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

1. 純粋関数と条件分岐
2. 決まった形の二分木と、番号の変換
3. まとめて更新する処理と、6便がなくならないことの確認
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Pythonの変数・if・関数・for / リストとタプルの基本 / dataclass と replace、書き換えられない状態は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
Z:01はカード、02は選んだ台のカード、04と05は選んだカード・箱の札を順番に変える / P:01と02は見本の荷物を変える / 矢印:02は左右で台を選ぶ、04と05は台・箱を選ぶ / Tab:04と05で台と箱の札を切り替える / Enter:03〜05の試運転を始める / Space:01は分かれ道、02は全部の道すじ、03〜05は1刻進める / X:05だけ、すべての状態をUndo / R:最初に戻す / Esc:終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_branch.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

1台のカードで左右を決める

状態を変えない関数と、条件分岐。

最初は、荷物1個と台 A だけです。Z でカードを COLOR(色)→WEIGHT(重さ)→LEFT(左)→RIGHT(右)の順に選び、P で見本の荷物を切り替え、Space で出口を計算します。画面の R は赤、B は青、重さ1は軽い、2は重いです。左右は画面上の位置であり、関数の中では左が0・右が1という数です。まだ時計・順番待ちの列・箱への配達・クリアの判定はありません。

branch(card, parcel) は、受け取った2つの値だけから答えを返す関数です。COLOR なら赤は左、青は右。WEIGHT なら重さ2は左、1は右です。LEFT と RIGHT は、荷物の色や重さを見ずに、そのまま決まった向きへ進める命令です。カードの番号0と1は条件で判定し、2と3は素通りなので、card-2 で出口の0か1に直せます。

Parcel は、番号・色・重さ・宛先を持つ dataclass です。frozen=True は「作った値をあとから書き換えない」という約束です。表示に使う State にカード・今の荷物の番号・結果をまとめ、編集するときは replace で新しい State を作ります。前の結果を-1に戻すのは、カードを変えたのに古い答えが見え続けて、勘違いするのを防ぐためです。

App.control はキーを意味のある操作に置きかえ、App.draw は State を描くだけです。branch の中では Pyxel を使わないので、画面を開かずにテストできます。押しっぱなしを連続した操作にせず、すべてのキーが離されてから次を受け付けます。

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

01_branch.py / 注目する部分
    if card == 0:
        return 0 if parcel.color == "R" else 1
    if card == 1:
        return 0 if parcel.weight == 2 else 1

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_branch.py
macOS / Linux
.venv/bin/python 01_branch.py
画面で見ること起動してすぐに Z を1回、P を1回、Space を押すと、荷物2は青・重さ2、カードは WEIGHT、結果は LEFT です。青い荷物でも、今読んでいるカードが「重さ」なので左になります。
  • 色と重さを別々に判定できる
  • LEFT と RIGHT は、色や重さに関係なく決まる
  • カードを変えると、前の結果が消える
つまずきやすいところいつも荷物の色を先に見てしまうと、WEIGHT や素通りのカードの意味がなくなります。まずカードの種類を決めてから、そのカードが必要とする情報だけを読みます。
少し変えてみよう

R で戻して Z を1回押します。荷物1(赤・重さ1)のまま Space を押すと RIGHT です。次に LEFT のカードに変えて、6種類の荷物がすべて左になることを確かめてください。

01_branch.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(171行)
01_branch.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.

"""からくり郵便室・教材1/5。単独で起動できます。"""
from dataclasses import dataclass, replace

CARD_NAMES = ("COLOR", "WEIGHT", "LEFT", "RIGHT")


@dataclass(frozen=True)
class Parcel:
    id: int
    color: str
    weight: int
    destination: str


# 原作Bの6便。番号の内部値は0始まり、画面だけ1始まり。
PARCELS = tuple(Parcel(i, color, weight, color+("H" if weight == 2 else "L"))
                for i, (color, weight) in enumerate(
                    (("R", 1), ("B", 2), ("R", 2), ("B", 1), ("B", 2), ("R", 1))))


def branch(card, parcel):
    # 出口は左=0、右=1。台番号や箱番号とは区別する。
    if card == 0:
        return 0 if parcel.color == "R" else 1
    if card == 1:
        return 0 if parcel.weight == 2 else 1
    return card-2


@dataclass(frozen=True)
class State:
    card: int = 0
    parcel: int = 0
    result: int = -1


def apply(state, action):
    if action == "card":
        return replace(state, card=(state.card+1)%4, result=-1)
    if action == "parcel":
        return replace(state, parcel=(state.parcel+1)%len(PARCELS), result=-1)
    if action == "route":
        return replace(state, result=branch(state.card, PARCELS[state.parcel]))
    return state


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = State()
        self.locked = False
        self.message = "CHOOSE A CARD AND PARCEL, THEN SPACE"

    def control(self, down):
        down = set(down)
        if self.locked:
            if not down:
                self.locked = False
            return
        if not down:
            return
        self.locked = True
        if len(down) != 1:
            self.message = "ONE KEY AT A TIME / RELEASE ALL"
            return
        key = next(iter(down))
        if key == "R":
            self.reset()
            self.locked = True
            return
        action = {"Z": "card", "P": "parcel", "SPACE": "route"}.get(key)
        if action:
            self.state = apply(self.state, action)
            self.message = "RESULT CLEARED AFTER EDIT" if action != "route" else "ONE BRANCH EVALUATED"

    def draw(self, p):
        s = self.state
        parcel = PARCELS[s.parcel]
        p.cls(1)
        p.text(8, 8, "CLOCKWORK POST / 1: ONE BRANCH", 10)
        p.text(8, 26, "PRACTICE / NO CLOCK, DELIVERY OR WIN YET", 6)
        p.text(8, 48, "PARCEL "+str(parcel.id+1)+" COLOR "+parcel.color+" WEIGHT "+str(parcel.weight), 7)
        p.text(8, 60, "Z CARD / P NEXT PARCEL / SPACE EVALUATE", 7)
        p.rect(140, 106, 120, 32, 2)
        p.text(160, 118, "A: "+CARD_NAMES[s.card], 10)
        p.line(200, 138, 88, 192, 5)
        p.line(200, 138, 312, 192, 5)
        for i, x in enumerate((44, 268)):
            p.rectb(x, 192, 88, 32, 11 if s.result == i else 5)
            p.text(x+20, 205, "LEFT = 0" if i == 0 else "RIGHT = 1", 7)
        p.text(8, 250, "RESULT: "+("NOT EVALUATED" if s.result < 0 else ("LEFT" if s.result == 0 else "RIGHT")), 11)
        p.text(8, 278, self.message, 10)
        p.text(8, 304, "COLOR R:LEFT B:RIGHT / WEIGHT 2:LEFT 1:RIGHT", 6)
        p.text(8, 315, "LEFT/RIGHT: UNCONDITIONAL / R:RED B:BLUE", 6)
        p.text(8, 326, "DEST RH:RED HEAVY RL:RED LIGHT BH/BL:BLUE", 6)
        p.text(8, 344, "R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT", 5)


DEMO_KEYS = ("Z", "P", "SPACE")


def main(headless=False, screenshot=None, demo=False):
    from pathlib import Path
    import pyxel as p
    # Pyxelの初期化より先に、指定した保存先を絶対化する。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(400, 360, title="Clockwork Post", fps=30, display_scale=2,
           headless=headless)
    keys = {name: getattr(p, "KEY_"+name) for name in
            ("UP", "DOWN", "LEFT", "RIGHT", "TAB", "Z", "P", "RETURN", "SPACE", "X", "R")}
    limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12

    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                p.screenshot(screenshot, scale=2)
            from dataclasses import asdict
            import json
            print(json.dumps({"state": asdict(app.state), "message": app.message},
                             ensure_ascii=True, sort_keys=True), flush=True)
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in keys.items() if p.btn(value)}
        app.control(down)

    p.run(update, lambda: app.draw(p))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 02 / 05

分岐をつないで4つの箱へ運ぶ

決まった形の二分木と、番号の変換。

台を A・B・C の3つに増やし、A から左の B か右の C へ、さらに4つの箱へ進む、決まった形の二分木を作ります。台の中での番号は A=0、B=1、C=2 です。箱の中での番号は0〜3で、画面では1〜4と表示します。矢印キーで台を選び、Z でカードを変え、P で荷物を変えます。Space は、選んだ荷物が通る道すじを一度に全部調べる操作です。

route は、まず A の出口(0か1)に1を足して、次の台(1か2)を選びます。次にその台の出口を読み、(table-1)*2 を足して箱の番号を作ります。B の2つの箱は0と1、C の箱は2と3なので、この小さな式で左右の箱を区別できます。戻り値 (0, table, box) のうち、最後の値だけが箱の番号で、3つ全部が台の番号というわけではありません。

最初のカードは、A が COLOR、B と C が WEIGHT です。赤は B へ、青は C へ分けてから、重い便を左、軽い便を右へ送ります。図の箱の札 RH・RL・BH・BL は、赤重・赤軽・青重・青軽という意味です。ただし、この段階では札との照合や失敗はまだ作っていません。カードを変えて宛先と違う箱になっても、道すじを表示するだけです。

この Space は、元のゲームの「1刻」ではありません。時間を省いて、道すじを全部計算するための模型です。たくさんの荷物をここにそのままループで流すと、台がふさがっていることや、待っていることを表せません。次の段階で「前の状態」と「新しい状態」を分け、同じ荷物が1刻のうちに2つの台を通らない仕組みに置きかえます。

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

02_tree.py / 注目する部分
    table = 1+branch(cards[0], parcel)
    box = (table-1)*2+branch(cards[table], parcel)
    return (0, table, box)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_tree.py
macOS / Linux
.venv/bin/python 02_tree.py
画面で見ること最初の配置で P を1回押してから Space を押すと、荷物2(青・重さ2)は A > C > BOX 3 です。起動し直した直後の荷物1(赤・重さ1)は A > B > BOX 2 です。
  • 台の番号と箱の番号を区別する
  • A の左右で B か C を選べる
  • 道すじを全部計算するのは、1刻の動きではない
つまずきやすいところA の出口をそのまま台の番号にすると、左の0が A 自身を指してしまいます。次の台にずらす +1 と、箱の組をずらす *2 を、別々に確かめてください。
少し変えてみよう

R のあと P を2回押して荷物3(赤・重さ2)を選び、Space を押す前に道すじを紙に書いてみましょう。A > B > BOX 1 と同じになるか確かめます。そのあと C のカードだけを変えても、赤い荷物の道すじが変わらない理由を説明してください。

02_tree.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(193行)
02_tree.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.

"""からくり郵便室・教材2/5。単独で起動できます。"""
from dataclasses import dataclass, replace

CARD_NAMES = ("COLOR", "WEIGHT", "LEFT", "RIGHT")


@dataclass(frozen=True)
class Parcel:
    id: int
    color: str
    weight: int
    destination: str


# 原作Bの6便。番号の内部値は0始まり、画面だけ1始まり。
PARCELS = tuple(Parcel(i, color, weight, color+("H" if weight == 2 else "L"))
                for i, (color, weight) in enumerate(
                    (("R", 1), ("B", 2), ("R", 2), ("B", 1), ("B", 2), ("R", 1))))


def branch(card, parcel):
    # 出口は左=0、右=1。台番号や箱番号とは区別する。
    if card == 0:
        return 0 if parcel.color == "R" else 1
    if card == 1:
        return 0 if parcel.weight == 2 else 1
    return card-2


@dataclass(frozen=True)
class State:
    cards: tuple = (0, 1, 1)
    parcel: int = 0
    route: tuple = ()


def route(cards, parcel):
    # Aの0/1出口を、子台の1/2番へ写す。
    table = 1+branch(cards[0], parcel)
    box = (table-1)*2+branch(cards[table], parcel)
    return (0, table, box)


def apply(state, action):
    if action == "parcel":
        return replace(state, parcel=(state.parcel+1)%len(PARCELS), route=())
    if action == "route":
        return replace(state, route=route(state.cards, PARCELS[state.parcel]))
    if isinstance(action, tuple) and len(action) == 2 and action[0] == "card":
        index = action[1]
        if type(index) is int and 0 <= index < 3:
            cards = list(state.cards)
            cards[index] = (cards[index]+1)%4
            return replace(state, cards=tuple(cards), route=())
    return state


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = State()
        self.cursor = 0
        self.locked = False
        self.message = "FOLLOW TWO BRANCHES / THIS IS NOT A TICK"

    def control(self, down):
        down = set(down)
        if self.locked:
            if not down:
                self.locked = False
            return
        if not down:
            return
        self.locked = True
        if len(down) != 1:
            self.message = "ONE KEY AT A TIME / RELEASE ALL"
            return
        key = next(iter(down))
        if key == "R":
            self.reset()
            self.locked = True
            return
        if key in ("LEFT", "RIGHT"):
            self.cursor = (self.cursor+(-1 if key == "LEFT" else 1))%3
        elif key == "Z":
            self.state = apply(self.state, ("card", self.cursor))
        elif key in ("P", "SPACE"):
            self.state = apply(self.state, "parcel" if key == "P" else "route")

    def draw(self, p):
        s = self.state
        parcel = PARCELS[s.parcel]
        p.cls(1)
        p.text(8, 8, "CLOCKWORK POST / 2: FIXED BINARY TREE", 10)
        p.text(8, 25, "NO OCCUPANCY OR CLOCK / SPACE TRACES WHOLE ROUTE", 6)
        p.text(8, 42, "PARCEL "+str(parcel.id+1)+" COLOR "+parcel.color+" WEIGHT "+str(parcel.weight), 7)
        points = ((200, 86), (100, 144), (300, 144))
        for x in (100, 300):
            p.line(200, 101, x, 135, 5)
        for i, (x, y) in enumerate(points):
            p.rect(x-45, y-9, 90, 27, 2)
            p.rectb(x-45, y-9, 90, 27, 10 if self.cursor == i else 5)
            p.text(x-35, y+2, chr(65+i)+": "+CARD_NAMES[s.cards[i]], 7)
        labels = ("RH", "RL", "BH", "BL")
        for i, x in enumerate((50, 150, 250, 350)):
            p.line(100 if i < 2 else 300, 162, x, 192, 5)
            p.rectb(x-35, 192, 70, 26, 11 if s.route and s.route[2] == i else 5)
            p.text(x-28, 201, "BOX "+str(i+1)+" "+labels[i], 7)
        result = "NOT EVALUATED"
        if s.route:
            result = "A > "+chr(65+s.route[1])+" > BOX "+str(s.route[2]+1)
        p.text(8, 239, "ROUTE: "+result, 11)
        p.text(8, 255, "ARROWS SELECT TABLE / Z CARD / P PARCEL / SPACE TRACE", 7)
        p.text(8, 278, self.message, 10)
        p.text(8, 304, "COLOR R:LEFT B:RIGHT / WEIGHT 2:LEFT 1:RIGHT", 6)
        p.text(8, 315, "LEFT/RIGHT: UNCONDITIONAL / R:RED B:BLUE", 6)
        p.text(8, 326, "DEST RH:RED HEAVY RL:RED LIGHT BH/BL:BLUE", 6)
        p.text(8, 344, "R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT", 5)


DEMO_KEYS = ("P", "SPACE")


def main(headless=False, screenshot=None, demo=False):
    from pathlib import Path
    import pyxel as p
    # Pyxelの初期化より先に、指定した保存先を絶対化する。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(400, 360, title="Clockwork Post", fps=30, display_scale=2,
           headless=headless)
    keys = {name: getattr(p, "KEY_"+name) for name in
            ("UP", "DOWN", "LEFT", "RIGHT", "TAB", "Z", "P", "RETURN", "SPACE", "X", "R")}
    limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12

    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                p.screenshot(screenshot, scale=2)
            from dataclasses import asdict
            import json
            print(json.dumps({"state": asdict(app.state), "message": app.message},
                             ensure_ascii=True, sort_keys=True), flush=True)
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in keys.items() if p.btn(value)}
        app.control(down)

    p.run(update, lambda: app.draw(p))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 03 / 05

古い占有状態で一刻を進める

まとめて更新する処理と、6便がなくならないことの確認。

この段階から、元のゲームの B の6便と、ゲームの中の時刻を使います。カードと札は正解の配置に固定しています。Enter で始め、Space を1回押すごとに1刻進みます。何も押さなかったり、画面を描いたりするだけでは進みません。便1は時刻0に入口にいて、便2〜6は時刻1〜5に1個ずつ届きます。next_id はまだ届いていない便の先頭の番号、queue は届いて待っている列、tables は3つの台にどの便があるかです。-1は空いている台を表します。

いちばん難しいところは、old = state.tables と tables = list(old) を分けるところです。「B・C から箱へ」「A から B・C へ」「入口から A へ」の順に動きの候補を計算しますが、動いてよいかどうかは、いつも old(前の状態)で決めます。B がこの刻に荷物を箱へ送って空いても、old の B に荷物があれば、A は待ちます。同じように、A の荷物が動いた刻に、入口の荷物がその空きを使うことはできません。順番に書いた Python でも、許可の判断を古いリストにそろえれば、「全部が同時に動く」ルールになります。

1刻目は、便1が A に入るだけです。2刻目に A から B へ進みますが、便2は空いた A にまだ入れません。3刻目に便1が箱2に届き、便2が A に入ります。その刻に新しく台に着いた便を、同じ刻にもう一度処理しないので、1つの便が1刻に動くのは最大1回です。比較版の「その刻に空いた台も使える」ルールは入れていません。

なくなっていないかの確認では、入口・台・配達済み・まだ届いていない便の番号を集めて、0〜5がちょうど1回ずつあるかを調べます。数が6であることだけを調べると、同じ便が2つに増えて別の便が消える、というバグを見逃してしまいます。箱に着いたら宛先と照合し、まちがっていたらその刻の分をすべて確定してから止まります。ここでは正解の配置に固定しているので、ふつうは13刻で全部の便が届きます。

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

03_sync.py / 注目する部分
        if old[target] < 0:
            tables[0] = -1
            tables[target] = pid

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_sync.py
macOS / Linux
.venv/bin/python 03_sync.py
画面で見ることEnter→Space を2回で TICK 2、A は空、B に便1、入口に便2と3、配達0件です。さらに Space を1回で TICK 3、箱2に便1、A に便2 になります。全部で13刻で DONE・6件です。
  • 1刻目に A から先へ飛ばない
  • 2刻目に、入口の便は A に入らない
  • すべての便の番号が、いつも1回ずつある
つまずきやすいところold[target] ではなく、書き換え途中の tables[target] を調べると、下流から処理する順番に結果が左右され、「その刻に空いた台も使える」という別のルールになってしまいます。新しく着いた便をもう一度処理するループもだめです。
少し変えてみよう

時刻0〜3の queue・tables・deliveries を紙に書き、1刻ずつ画面と見比べてみましょう。2刻目に便2が A に入れない理由を、old[0] を使って説明します。テストの test_downstream_vacancy_uses_old_state にある、2つの台がうまった状態も読んでみましょう。

03_sync.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(311行)
03_sync.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.

"""からくり郵便室・教材3/5。単独で起動できます。"""
from dataclasses import dataclass, replace

CARD_NAMES = ("COLOR", "WEIGHT", "LEFT", "RIGHT")


@dataclass(frozen=True)
class Parcel:
    id: int
    color: str
    weight: int
    destination: str


# 原作Bの6便。番号の内部値は0始まり、画面だけ1始まり。
PARCELS = tuple(Parcel(i, color, weight, color+("H" if weight == 2 else "L"))
                for i, (color, weight) in enumerate(
                    (("R", 1), ("B", 2), ("R", 2), ("B", 1), ("B", 2), ("R", 1))))


def branch(card, parcel):
    # 出口は左=0、右=1。台番号や箱番号とは区別する。
    if card == 0:
        return 0 if parcel.color == "R" else 1
    if card == 1:
        return 0 if parcel.weight == 2 else 1
    return card-2


CAP = 3
LABELS = ("RL", "RH", "BL", "BH")
LABEL_NAMES = {"RL": "赤・軽", "RH": "赤・重", "BL": "青・軽", "BH": "青・重"}


@dataclass(frozen=True)
class Delivery:
    parcel: int
    box: int
    tick: int
    correct: bool
    path: tuple


@dataclass(frozen=True)
class State:
    cards: tuple = (0, 1, 1)
    labels: tuple = ("RH", "RL", "BH", "BL")
    t: int = 0
    next_id: int = 1
    queue: tuple = (0,)
    tables: tuple = (-1, -1, -1)
    deliveries: tuple = ()
    paths: tuple = ((),)*6
    status: str = "edit"
    reason: str = "カードと箱札を編集して試運転。"
    moves: tuple = ()
    waits: tuple = ()


def condition(card, parcel):
    if card == 0:
        return "赤" if parcel.color == "R" else "青"
    if card == 1:
        return "重さ"+str(parcel.weight)
    return "そのまま"


def count_tests(state):
    return sum(card < 2 for card in state.cards)


def start(state):
    if state.status != "edit" or count_tests(state) > CAP:
        return state
    return replace(state, status="run", reason="試運転中。Spaceで一刻。",
                   moves=(), waits=())


def tick(state):
    if state.status != "run":
        return state
    t = state.t+1
    queue = list(state.queue)
    next_id = state.next_id
    if next_id < len(PARCELS) and next_id <= t:
        queue.append(next_id)
        next_id += 1
    # oldは読み取り専用。tablesだけを書き換えて最後に同時確定する。
    old = state.tables
    tables = list(old)
    paths = list(state.paths)
    deliveries = list(state.deliveries)
    moves, waits, faults = [], [], []
    for table in (1, 2):
        pid = old[table]
        if pid < 0:
            continue
        parcel = PARCELS[pid]
        exit_side = branch(state.cards[table], parcel)
        box = (table-1)*2+exit_side
        path = paths[pid]+(chr(65+table)+":"+condition(state.cards[table], parcel)+"→"+
                          ("左" if exit_side == 0 else "右"),
                          "箱"+str(box+1)+":"+LABEL_NAMES[state.labels[box]])
        paths[pid] = path
        correct = state.labels[box] == parcel.destination
        deliveries.append(Delivery(pid, box, t, correct, path))
        tables[table] = -1
        moves.append((pid, chr(65+table), "箱"+str(box+1)))
        if not correct:
            faults.append(pid)
    if old[0] >= 0:
        pid = old[0]
        parcel = PARCELS[pid]
        exit_side = branch(state.cards[0], parcel)
        target = 1+exit_side
        if old[target] < 0:
            tables[0] = -1
            tables[target] = pid
            paths[pid] += ("A:"+condition(state.cards[0], parcel)+"→"+
                           ("左" if exit_side == 0 else "右"),)
            moves.append((pid, "A", chr(65+target)))
        else:
            waits.append((pid, "この刻の初めは "+chr(65+target)+" が使用中"))
    if queue:
        if old[0] < 0:
            pid = queue.pop(0)
            tables[0] = pid
            moves.append((pid, "入口", "A"))
        else:
            waits.append((queue[0], "この刻の初めは A が使用中"))
    status = "fault" if faults else ("done" if len(deliveries) == 6 else
                                    ("timeout" if t >= 32 else "run"))
    reason = ("誤配: 荷物 "+",".join(str(pid+1) for pid in faults)+" の命令列を確認。" if faults else
              ("全6便の仕分け達成。空箱・重複札も正当な配置です。" if status == "done" else
               ("32刻で停止。" if status == "timeout" else "一刻の移動を同時に反映しました。")))
    return replace(state, t=t, next_id=next_id, queue=tuple(queue), tables=tuple(tables),
                   paths=tuple(paths), deliveries=tuple(deliveries), status=status,
                   reason=reason, moves=tuple(moves), waits=tuple(waits))


def parcel_conservation(state):
    ids = list(state.queue)+[pid for pid in state.tables if pid >= 0]
    ids += [delivery.parcel for delivery in state.deliveries]
    ids += list(range(state.next_id, len(PARCELS)))
    return sorted(ids) == list(range(len(PARCELS)))


def apply(state, action):
    if action == "start":
        return start(state)
    if action == "tick":
        return tick(state)
    return state


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = State()
        self.cursor = 0
        self.section = 0
        self.locked = False
        self.message = "ENTER START / SPACE ONE TICK"

    def commit(self, action):
        before = self.state
        self.state = apply(self.state, action)
        if self.state == before:
            self.message = "NO CHANGE / CHECK MODE, CAP OR HISTORY LIMIT"
        elif action == "undo":
            self.message = "UNDO WHOLE STATE / START CAN ALSO BE UNDONE"
        else:
            self.message = "OLD OCCUPANCY ONLY / ONE TABLE PER PARCEL PER TICK"

    def control(self, down):
        down = set(down)
        if self.locked:
            if not down:
                self.locked = False
            return
        if not down:
            return
        self.locked = True
        if len(down) != 1:
            self.message = "ONE KEY AT A TIME / RELEASE ALL"
            return
        key = next(iter(down))
        if key == "R":
            self.reset()
            self.locked = True
            return
        if key == "RETURN":
            self.commit("start")
        elif key == "SPACE":
            self.commit("tick")

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(8, 8, "CLOCKWORK POST / STAGE 3 / ORIGINAL B", 10)
        p.text(8, 22, "TICK "+str(s.t)+"/32   STATUS "+s.status.upper()+"   DELIVERED "+str(len(s.deliveries))+"/6", 7)
        p.text(8, 34, "TEST CARDS "+str(count_tests(s))+"/"+str(CAP)+"   OLD-STATE SYNCHRONOUS TICKS", 6)
        for i, parcel in enumerate(PARCELS):
            p.text(8+i*65, 49, str(i+1)+":"+parcel.color+str(parcel.weight)+">"+parcel.destination, 8 if parcel.color == "R" else 12)
        queue = " ".join(str(pid+1) for pid in s.queue) or "EMPTY"
        p.text(8, 63, "QUEUE: "+queue+"   NOT ARRIVED: "+str(6-s.next_id), 7)
        points = ((200, 96), (100, 149), (300, 149))
        for x in (100, 300):
            p.line(200, 116, x, 137, 5)
        for i, (x, y) in enumerate(points):
            p.rect(x-49, y-12, 98, 39, 2)
            p.rectb(x-49, y-12, 98, 39, 10 if self.section == 0 and self.cursor == i else 5)
            p.text(x-40, y-5, chr(65+i)+": "+CARD_NAMES[s.cards[i]], 7)
            pid = s.tables[i]
            p.text(x-40, y+10, "EMPTY" if pid < 0 else "PARCEL "+str(pid+1), 6 if pid < 0 else 11)
        for i, x in enumerate((50, 150, 250, 350)):
            p.line(100 if i < 2 else 300, 176, x, 193, 5)
            p.rect(x-37, 193, 74, 35, 4)
            p.rectb(x-37, 193, 74, 35, 10 if self.section == 1 and self.cursor == i else 9)
            p.text(x-31, 199, "BOX "+str(i+1)+" "+s.labels[i], 7)
            received = [str(d.parcel+1)+("!" if not d.correct else "") for d in s.deliveries if d.box == i]
            p.text(x-31, 213, " ".join(received) or "EMPTY", 11)
        waits = ",".join(str(pid+1) for pid, _ in s.waits) or "NONE"
        p.text(8, 237, "WAITED THIS TICK: "+waits+" / ALL 6 CONSERVED: "+str(parcel_conservation(s)), 6)
        if s.deliveries:
            d = s.deliveries[-1]
            p.text(8, 249, "LAST: "+str(d.parcel+1)+" > A > "+("B" if d.box < 2 else "C")+" > BOX "+str(d.box+1)+" "+("OK" if d.correct else "WRONG / STOP"), 11 if d.correct else 8)
        else:
            p.text(8, 249, "ARRIVED NOW? READ ITS CARD NEXT TICK.", 6)
        p.text(8, 264, self.message, 10)
        p.text(8, 278, "ENTER START / SPACE ONE TICK / R RESET", 7)
        p.text(8, 289, "FIXED SOLUTION / NO CONFIGURATION EDITING YET", 7)
        p.text(8, 304, "COLOR R:LEFT B:RIGHT / WEIGHT 2:LEFT 1:RIGHT", 6)
        p.text(8, 315, "LEFT/RIGHT: UNCONDITIONAL / R:RED B:BLUE", 6)
        p.text(8, 326, "DEST RH:RED HEAVY RL:RED LIGHT BH/BL:BLUE", 6)
        p.text(8, 344, "R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT", 5)


DEMO_KEYS = ('RETURN', 'SPACE', 'SPACE')


def main(headless=False, screenshot=None, demo=False):
    from pathlib import Path
    import pyxel as p
    # Pyxelの初期化より先に、指定した保存先を絶対化する。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(400, 360, title="Clockwork Post", fps=30, display_scale=2,
           headless=headless)
    keys = {name: getattr(p, "KEY_"+name) for name in
            ("UP", "DOWN", "LEFT", "RIGHT", "TAB", "Z", "P", "RETURN", "SPACE", "X", "R")}
    limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12

    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                p.screenshot(screenshot, scale=2)
            from dataclasses import asdict
            import json
            print(json.dumps({"state": asdict(app.state), "message": app.message},
                             ensure_ascii=True, sort_keys=True), flush=True)
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in keys.items() if p.btn(value)}
        app.control(down)

    p.run(update, lambda: app.draw(p))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 04 / 05

カードと箱札を編集して試運転する

「編集中」と「実行中」の状態を分ける。

元のゲームの B「四つの小包」を、自分で解く段階です。最初のカードは3つの台ともすべて LEFT、4つの箱の札はすべて RL です。矢印キーで台を選び、Z でカードを順番に変えます。Tab で箱の札に切り替え、矢印キーで箱を選び、Z で RL→RH→BL→BH の順に変えます。編集しているときは、時刻も荷物も動きません。Enter で試運転に移ると、カードと札は固定され、Space で1刻ずつ進めます。

apply は、操作を受け取る窓口です。("card", 台の番号, 値) や ("label", 箱の番号, 値) を確かめ、編集中のときだけ新しい State を返します。実行中に札だけを書き換えると、同じ便が同じ命令を通っても、途中で結果が変わってしまいます。失敗や成功から直したいときは、この段階では R で最初からやり直します。

判定カード(COLOR・WEIGHT)は、元のゲームの B と同じく3枚までです。台も3つなので、B ではこの上限だけで受け付けられない組み合わせはありません。上限のチェックは start に残しておき、あとで A(1枚)や C(2枚)という別の状況を学ぶ準備にします。LEFT と RIGHT は枚数に数えません。同じ札を重ねたり、空の箱を作ったりすることも禁止せず、箱に着いたときに宛先と合っているかだけを、正しいかどうかの基準にします。

正解の例は、A=COLOR、B と C=WEIGHT、箱1〜4=RH・RL・BH・BL です。最初の状態のまま始めると、最初の赤軽は届きますが、青重の便2が5刻目にまちがった箱に届き、FAULT(失敗)になります。その刻に決まったほかの動きも反映してから、そのあとの Space では動かなくなります。失敗した便の番号・箱・道すじは State に残るので、どの条件を読みまちがえたのかを追えます。

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

04_mission.py / 注目する部分
    if state.status == "edit" and isinstance(action, tuple) and len(action) == 3:
        kind, index, value = action

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_mission.py
macOS / Linux
.venv/bin/python 04_mission.py
画面で見ること正解の配置で Enter→Space を13回押すと DONE になり、箱1に便3、箱2に便1と6、箱3に便2と5、箱4に便4が入ります。最初の配置のまま試すと、5刻目に FAULT になり、便2が箱1にまちがって届き、便3は A に残ります。
  • 編集中は時刻を進めない
  • 始めたあとは、カードと札を変えられない
  • まちがって届いた刻を確定してから、全体を止める
つまずきやすいところ「箱が4つだから、4種類の札を1枚ずつ」と編集のときに決めつけるのは、元のゲームにない制限です。同じ札を重ねてもよく、実際に荷物が届いたときに正しいかを調べます。
少し変えてみよう

別の答えを試してみましょう。A=WEIGHT、B と C=COLOR、札=RH・BH・RL・BL でも、13刻で DONE になります。次に、コードの CAP を2に変えると、判定カードが3枚のまま Enter を押しても EDIT(編集中)のままです。試したら CAP=3 に戻してください。

04_mission.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(334行)
04_mission.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.

"""からくり郵便室・教材4/5。単独で起動できます。"""
from dataclasses import dataclass, replace

CARD_NAMES = ("COLOR", "WEIGHT", "LEFT", "RIGHT")


@dataclass(frozen=True)
class Parcel:
    id: int
    color: str
    weight: int
    destination: str


# 原作Bの6便。番号の内部値は0始まり、画面だけ1始まり。
PARCELS = tuple(Parcel(i, color, weight, color+("H" if weight == 2 else "L"))
                for i, (color, weight) in enumerate(
                    (("R", 1), ("B", 2), ("R", 2), ("B", 1), ("B", 2), ("R", 1))))


def branch(card, parcel):
    # 出口は左=0、右=1。台番号や箱番号とは区別する。
    if card == 0:
        return 0 if parcel.color == "R" else 1
    if card == 1:
        return 0 if parcel.weight == 2 else 1
    return card-2


CAP = 3
LABELS = ("RL", "RH", "BL", "BH")
LABEL_NAMES = {"RL": "赤・軽", "RH": "赤・重", "BL": "青・軽", "BH": "青・重"}


@dataclass(frozen=True)
class Delivery:
    parcel: int
    box: int
    tick: int
    correct: bool
    path: tuple


@dataclass(frozen=True)
class State:
    cards: tuple = (2, 2, 2)
    labels: tuple = ("RL",)*4
    t: int = 0
    next_id: int = 1
    queue: tuple = (0,)
    tables: tuple = (-1, -1, -1)
    deliveries: tuple = ()
    paths: tuple = ((),)*6
    status: str = "edit"
    reason: str = "カードと箱札を編集して試運転。"
    moves: tuple = ()
    waits: tuple = ()


def condition(card, parcel):
    if card == 0:
        return "赤" if parcel.color == "R" else "青"
    if card == 1:
        return "重さ"+str(parcel.weight)
    return "そのまま"


def count_tests(state):
    return sum(card < 2 for card in state.cards)


def start(state):
    if state.status != "edit" or count_tests(state) > CAP:
        return state
    return replace(state, status="run", reason="試運転中。Spaceで一刻。",
                   moves=(), waits=())


def tick(state):
    if state.status != "run":
        return state
    t = state.t+1
    queue = list(state.queue)
    next_id = state.next_id
    if next_id < len(PARCELS) and next_id <= t:
        queue.append(next_id)
        next_id += 1
    # oldは読み取り専用。tablesだけを書き換えて最後に同時確定する。
    old = state.tables
    tables = list(old)
    paths = list(state.paths)
    deliveries = list(state.deliveries)
    moves, waits, faults = [], [], []
    for table in (1, 2):
        pid = old[table]
        if pid < 0:
            continue
        parcel = PARCELS[pid]
        exit_side = branch(state.cards[table], parcel)
        box = (table-1)*2+exit_side
        path = paths[pid]+(chr(65+table)+":"+condition(state.cards[table], parcel)+"→"+
                          ("左" if exit_side == 0 else "右"),
                          "箱"+str(box+1)+":"+LABEL_NAMES[state.labels[box]])
        paths[pid] = path
        correct = state.labels[box] == parcel.destination
        deliveries.append(Delivery(pid, box, t, correct, path))
        tables[table] = -1
        moves.append((pid, chr(65+table), "箱"+str(box+1)))
        if not correct:
            faults.append(pid)
    if old[0] >= 0:
        pid = old[0]
        parcel = PARCELS[pid]
        exit_side = branch(state.cards[0], parcel)
        target = 1+exit_side
        if old[target] < 0:
            tables[0] = -1
            tables[target] = pid
            paths[pid] += ("A:"+condition(state.cards[0], parcel)+"→"+
                           ("左" if exit_side == 0 else "右"),)
            moves.append((pid, "A", chr(65+target)))
        else:
            waits.append((pid, "この刻の初めは "+chr(65+target)+" が使用中"))
    if queue:
        if old[0] < 0:
            pid = queue.pop(0)
            tables[0] = pid
            moves.append((pid, "入口", "A"))
        else:
            waits.append((queue[0], "この刻の初めは A が使用中"))
    status = "fault" if faults else ("done" if len(deliveries) == 6 else
                                    ("timeout" if t >= 32 else "run"))
    reason = ("誤配: 荷物 "+",".join(str(pid+1) for pid in faults)+" の命令列を確認。" if faults else
              ("全6便の仕分け達成。空箱・重複札も正当な配置です。" if status == "done" else
               ("32刻で停止。" if status == "timeout" else "一刻の移動を同時に反映しました。")))
    return replace(state, t=t, next_id=next_id, queue=tuple(queue), tables=tuple(tables),
                   paths=tuple(paths), deliveries=tuple(deliveries), status=status,
                   reason=reason, moves=tuple(moves), waits=tuple(waits))


def parcel_conservation(state):
    ids = list(state.queue)+[pid for pid in state.tables if pid >= 0]
    ids += [delivery.parcel for delivery in state.deliveries]
    ids += list(range(state.next_id, len(PARCELS)))
    return sorted(ids) == list(range(len(PARCELS)))


def apply(state, action):
    if action == "start":
        return start(state)
    if action == "tick":
        return tick(state)
    if state.status == "edit" and isinstance(action, tuple) and len(action) == 3:
        kind, index, value = action
        if type(index) is not int:
            return state
        if kind == "card" and 0 <= index < 3 and type(value) is int and 0 <= value < 4:
            return replace(state, cards=tuple(value if i == index else card
                                             for i, card in enumerate(state.cards)))
        if kind == "label" and 0 <= index < 4 and value in LABELS:
            return replace(state, labels=tuple(value if i == index else label
                                              for i, label in enumerate(state.labels)))
    return state


class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = State()
        self.cursor = 0
        self.section = 0
        self.locked = False
        self.message = "EDIT CARDS AND LABELS, THEN ENTER"

    def commit(self, action):
        before = self.state
        self.state = apply(self.state, action)
        if self.state == before:
            self.message = "NO CHANGE / CHECK MODE, CAP OR HISTORY LIMIT"
        elif action == "undo":
            self.message = "UNDO WHOLE STATE / START CAN ALSO BE UNDONE"
        else:
            self.message = "OLD OCCUPANCY ONLY / ONE TABLE PER PARCEL PER TICK"

    def control(self, down):
        down = set(down)
        if self.locked:
            if not down:
                self.locked = False
            return
        if not down:
            return
        self.locked = True
        if len(down) != 1:
            self.message = "ONE KEY AT A TIME / RELEASE ALL"
            return
        key = next(iter(down))
        if key == "R":
            self.reset()
            self.locked = True
            return
        if key == "RETURN":
            self.commit("start")
        elif key == "SPACE":
            self.commit("tick")
        elif self.state.status == "edit":
            if key == "TAB":
                self.section = 1-self.section
                self.cursor = 0
            elif key in ("LEFT", "RIGHT", "UP", "DOWN"):
                step = -1 if key in ("LEFT", "UP") else 1
                self.cursor = (self.cursor+step)%(3 if self.section == 0 else 4)
            elif key == "Z":
                if self.section == 0:
                    self.commit(("card", self.cursor, (self.state.cards[self.cursor]+1)%4))
                else:
                    value = LABELS[(LABELS.index(self.state.labels[self.cursor])+1)%4]
                    self.commit(("label", self.cursor, value))

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(8, 8, "CLOCKWORK POST / STAGE 4 / ORIGINAL B", 10)
        p.text(8, 22, "TICK "+str(s.t)+"/32   STATUS "+s.status.upper()+"   DELIVERED "+str(len(s.deliveries))+"/6", 7)
        p.text(8, 34, "TEST CARDS "+str(count_tests(s))+"/"+str(CAP)+"   OLD-STATE SYNCHRONOUS TICKS", 6)
        for i, parcel in enumerate(PARCELS):
            p.text(8+i*65, 49, str(i+1)+":"+parcel.color+str(parcel.weight)+">"+parcel.destination, 8 if parcel.color == "R" else 12)
        queue = " ".join(str(pid+1) for pid in s.queue) or "EMPTY"
        p.text(8, 63, "QUEUE: "+queue+"   NOT ARRIVED: "+str(6-s.next_id), 7)
        points = ((200, 96), (100, 149), (300, 149))
        for x in (100, 300):
            p.line(200, 116, x, 137, 5)
        for i, (x, y) in enumerate(points):
            p.rect(x-49, y-12, 98, 39, 2)
            p.rectb(x-49, y-12, 98, 39, 10 if self.section == 0 and self.cursor == i else 5)
            p.text(x-40, y-5, chr(65+i)+": "+CARD_NAMES[s.cards[i]], 7)
            pid = s.tables[i]
            p.text(x-40, y+10, "EMPTY" if pid < 0 else "PARCEL "+str(pid+1), 6 if pid < 0 else 11)
        for i, x in enumerate((50, 150, 250, 350)):
            p.line(100 if i < 2 else 300, 176, x, 193, 5)
            p.rect(x-37, 193, 74, 35, 4)
            p.rectb(x-37, 193, 74, 35, 10 if self.section == 1 and self.cursor == i else 9)
            p.text(x-31, 199, "BOX "+str(i+1)+" "+s.labels[i], 7)
            received = [str(d.parcel+1)+("!" if not d.correct else "") for d in s.deliveries if d.box == i]
            p.text(x-31, 213, " ".join(received) or "EMPTY", 11)
        waits = ",".join(str(pid+1) for pid, _ in s.waits) or "NONE"
        p.text(8, 237, "WAITED THIS TICK: "+waits+" / ALL 6 CONSERVED: "+str(parcel_conservation(s)), 6)
        if s.deliveries:
            d = s.deliveries[-1]
            p.text(8, 249, "LAST: "+str(d.parcel+1)+" > A > "+("B" if d.box < 2 else "C")+" > BOX "+str(d.box+1)+" "+("OK" if d.correct else "WRONG / STOP"), 11 if d.correct else 8)
        else:
            p.text(8, 249, "ARRIVED NOW? READ ITS CARD NEXT TICK.", 6)
        p.text(8, 264, self.message, 10)
        p.text(8, 278, "ARROWS SELECT / TAB TABLE-BOX / Z CYCLE / ENTER START", 7)
        p.text(8, 289, "SPACE TICK / R RESET / EDIT ONLY IN EDIT MODE", 7)
        p.text(8, 304, "COLOR R:LEFT B:RIGHT / WEIGHT 2:LEFT 1:RIGHT", 6)
        p.text(8, 315, "LEFT/RIGHT: UNCONDITIONAL / R:RED B:BLUE", 6)
        p.text(8, 326, "DEST RH:RED HEAVY RL:RED LIGHT BH/BL:BLUE", 6)
        p.text(8, 344, "R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT", 5)


DEMO_KEYS = ('Z', 'Z', 'RIGHT', 'Z', 'Z', 'Z', 'RIGHT', 'Z', 'Z', 'Z', 'TAB', 'Z', 'RIGHT', 'RIGHT', 'Z', 'Z', 'Z', 'RIGHT', 'Z', 'Z', 'RETURN', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE')


def main(headless=False, screenshot=None, demo=False):
    from pathlib import Path
    import pyxel as p
    # Pyxelの初期化より先に、指定した保存先を絶対化する。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(400, 360, title="Clockwork Post", fps=30, display_scale=2,
           headless=headless)
    keys = {name: getattr(p, "KEY_"+name) for name in
            ("UP", "DOWN", "LEFT", "RIGHT", "TAB", "Z", "P", "RETURN", "SPACE", "X", "R")}
    limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12

    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                p.screenshot(screenshot, scale=2)
            from dataclasses import asdict
            import json
            print(json.dumps({"state": asdict(app.state), "message": app.message},
                             ensure_ascii=True, sort_keys=True), flush=True)
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in keys.items() if p.btn(value)}
        app.control(down)

    p.run(update, lambda: app.draw(p))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 05 / 05

失敗も成功も丸ごと戻せる完成版

書き換えられない状態の履歴と、Undo。

X キーで1手戻す機能を加えます。Timeline は、今の State、過去の State のタプル past、受け付けた操作の記録 actions を持ちます。編集・開始・1刻進める、で状態が本当に変わったときだけ、古い State を past に積みます。同じ札を指定し直す操作や、始める前の Space などでは、履歴を増やしません。

Undo は、past の最後を今の state に戻し、その最後を取り除きます。時計だけでなく、入れた便の数・順番待ちの列・3つの台・カード・札・配達の結果・すべての便の道すじ・状態・前の刻の動きや待ちも、一緒に元に戻します。タプルと書き換えられない dataclass を使っているので、保存した過去があとから書き換わることはありません。選んでいる枠や操作の案内は画面の状態なので、ゲームの履歴には入れません。

DONE から X を1回押すと、時刻12・配達5件・便6が B にいる状態に戻ります。FAULT からも戻せますが、まだ RUN(実行中)なので、カードは編集できません。試運転の開始まで取り消して EDIT に戻ると、直せます。たとえば最初の配置でまちがって届いた場合は、5刻分と開始の合計6回 X を押すと EDIT に戻り、カードや札を直して再開できます。R は、編集した内容と履歴も含めて最初に戻します。

元のゲームと同じく、記録した操作の数が512に達すると新しい操作を止めますが、Undo は使い続けられます。Undo も actions に記録するので、戻しただけでは操作の枠は回復せず、R でやり直します。最後の教材は、元のゲームの B のふつうの「同時に動く」ルールを使っています。A と C の選択、同じ刻に空いた台を使う比較モード、連続運転、予告、日本語フォント、再現記録の保存は省きました。学び終わったら、元のゲームの forecast・act・Controller へ進みましょう。

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

05_undo.py / 注目する部分
    state = apply(timeline.state, action)
    if state == timeline.state:
        return timeline
    return Timeline(state, timeline.past+(timeline.state,), timeline.actions+(action,))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること04の正解で DONE にしたあと、X を1回押すと TICK 12 / RUN / DELIVERED 5/6 / B に便6 になります。Space を押すとまた DONE です。R のあとに X を押しても何も変わりません。最初の配置で5刻目に FAULT になったときは、X を6回押すと EDIT に戻れます。
  • DONE や FAULT から戻せる
  • 受け付けなかった操作では、履歴を増やさない
  • 開始を取り消すと、編集に戻れる
  • R キーで履歴と操作の数も最初に戻る
つまずきやすいところ時刻と台だけを戻すと、配達済みの便やまだ届いていない番号と食い違い、荷物が2つに増えてしまいます。State 全体を保存し、戻した直後も「どの便も1回ずつある」ことを確かめてください。
少し変えてみよう

最初の配置で5刻目のまちがいを起こし、X で4刻に戻ります。まだ Z で編集できないことを確かめましょう。さらに X を5回押して EDIT に戻り、正解の配置に変えて13刻まで進めます。

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

"""からくり郵便室・教材5/5。単独で起動できます。"""
from dataclasses import dataclass, replace

CARD_NAMES = ("COLOR", "WEIGHT", "LEFT", "RIGHT")


@dataclass(frozen=True)
class Parcel:
    id: int
    color: str
    weight: int
    destination: str


# 原作Bの6便。番号の内部値は0始まり、画面だけ1始まり。
PARCELS = tuple(Parcel(i, color, weight, color+("H" if weight == 2 else "L"))
                for i, (color, weight) in enumerate(
                    (("R", 1), ("B", 2), ("R", 2), ("B", 1), ("B", 2), ("R", 1))))


def branch(card, parcel):
    # 出口は左=0、右=1。台番号や箱番号とは区別する。
    if card == 0:
        return 0 if parcel.color == "R" else 1
    if card == 1:
        return 0 if parcel.weight == 2 else 1
    return card-2


CAP = 3
LABELS = ("RL", "RH", "BL", "BH")
LABEL_NAMES = {"RL": "赤・軽", "RH": "赤・重", "BL": "青・軽", "BH": "青・重"}


@dataclass(frozen=True)
class Delivery:
    parcel: int
    box: int
    tick: int
    correct: bool
    path: tuple


@dataclass(frozen=True)
class State:
    cards: tuple = (2, 2, 2)
    labels: tuple = ("RL",)*4
    t: int = 0
    next_id: int = 1
    queue: tuple = (0,)
    tables: tuple = (-1, -1, -1)
    deliveries: tuple = ()
    paths: tuple = ((),)*6
    status: str = "edit"
    reason: str = "カードと箱札を編集して試運転。"
    moves: tuple = ()
    waits: tuple = ()


def condition(card, parcel):
    if card == 0:
        return "赤" if parcel.color == "R" else "青"
    if card == 1:
        return "重さ"+str(parcel.weight)
    return "そのまま"


def count_tests(state):
    return sum(card < 2 for card in state.cards)


def start(state):
    if state.status != "edit" or count_tests(state) > CAP:
        return state
    return replace(state, status="run", reason="試運転中。Spaceで一刻。",
                   moves=(), waits=())


def tick(state):
    if state.status != "run":
        return state
    t = state.t+1
    queue = list(state.queue)
    next_id = state.next_id
    if next_id < len(PARCELS) and next_id <= t:
        queue.append(next_id)
        next_id += 1
    # oldは読み取り専用。tablesだけを書き換えて最後に同時確定する。
    old = state.tables
    tables = list(old)
    paths = list(state.paths)
    deliveries = list(state.deliveries)
    moves, waits, faults = [], [], []
    for table in (1, 2):
        pid = old[table]
        if pid < 0:
            continue
        parcel = PARCELS[pid]
        exit_side = branch(state.cards[table], parcel)
        box = (table-1)*2+exit_side
        path = paths[pid]+(chr(65+table)+":"+condition(state.cards[table], parcel)+"→"+
                          ("左" if exit_side == 0 else "右"),
                          "箱"+str(box+1)+":"+LABEL_NAMES[state.labels[box]])
        paths[pid] = path
        correct = state.labels[box] == parcel.destination
        deliveries.append(Delivery(pid, box, t, correct, path))
        tables[table] = -1
        moves.append((pid, chr(65+table), "箱"+str(box+1)))
        if not correct:
            faults.append(pid)
    if old[0] >= 0:
        pid = old[0]
        parcel = PARCELS[pid]
        exit_side = branch(state.cards[0], parcel)
        target = 1+exit_side
        if old[target] < 0:
            tables[0] = -1
            tables[target] = pid
            paths[pid] += ("A:"+condition(state.cards[0], parcel)+"→"+
                           ("左" if exit_side == 0 else "右"),)
            moves.append((pid, "A", chr(65+target)))
        else:
            waits.append((pid, "この刻の初めは "+chr(65+target)+" が使用中"))
    if queue:
        if old[0] < 0:
            pid = queue.pop(0)
            tables[0] = pid
            moves.append((pid, "入口", "A"))
        else:
            waits.append((queue[0], "この刻の初めは A が使用中"))
    status = "fault" if faults else ("done" if len(deliveries) == 6 else
                                    ("timeout" if t >= 32 else "run"))
    reason = ("誤配: 荷物 "+",".join(str(pid+1) for pid in faults)+" の命令列を確認。" if faults else
              ("全6便の仕分け達成。空箱・重複札も正当な配置です。" if status == "done" else
               ("32刻で停止。" if status == "timeout" else "一刻の移動を同時に反映しました。")))
    return replace(state, t=t, next_id=next_id, queue=tuple(queue), tables=tuple(tables),
                   paths=tuple(paths), deliveries=tuple(deliveries), status=status,
                   reason=reason, moves=tuple(moves), waits=tuple(waits))


def parcel_conservation(state):
    ids = list(state.queue)+[pid for pid in state.tables if pid >= 0]
    ids += [delivery.parcel for delivery in state.deliveries]
    ids += list(range(state.next_id, len(PARCELS)))
    return sorted(ids) == list(range(len(PARCELS)))


def apply(state, action):
    if action == "start":
        return start(state)
    if action == "tick":
        return tick(state)
    if state.status == "edit" and isinstance(action, tuple) and len(action) == 3:
        kind, index, value = action
        if type(index) is not int:
            return state
        if kind == "card" and 0 <= index < 3 and type(value) is int and 0 <= value < 4:
            return replace(state, cards=tuple(value if i == index else card
                                             for i, card in enumerate(state.cards)))
        if kind == "label" and 0 <= index < 4 and value in LABELS:
            return replace(state, labels=tuple(value if i == index else label
                                              for i, label in enumerate(state.labels)))
    return state


@dataclass(frozen=True)
class Timeline:
    state: State = State()
    past: tuple = ()
    actions: tuple = ()


def act(timeline, action):
    if action == "undo":
        if not timeline.past:
            return timeline
        return Timeline(timeline.past[-1], timeline.past[:-1], timeline.actions+(action,))
    if len(timeline.actions) >= 512:
        return timeline
    state = apply(timeline.state, action)
    if state == timeline.state:
        return timeline
    return Timeline(state, timeline.past+(timeline.state,), timeline.actions+(action,))


class App:
    def __init__(self):
        self.reset()

    @property
    def state(self):
        return self.timeline.state

    def reset(self):
        self.timeline = Timeline()
        self.cursor = 0
        self.section = 0
        self.locked = False
        self.message = "EDIT CARDS AND LABELS, THEN ENTER"

    def commit(self, action):
        before = self.state
        self.timeline = act(self.timeline, action)
        if self.state == before:
            self.message = "NO CHANGE / CHECK MODE, CAP OR HISTORY LIMIT"
        elif action == "undo":
            self.message = "UNDO WHOLE STATE / START CAN ALSO BE UNDONE"
        else:
            self.message = "OLD OCCUPANCY ONLY / ONE TABLE PER PARCEL PER TICK"

    def control(self, down):
        down = set(down)
        if self.locked:
            if not down:
                self.locked = False
            return
        if not down:
            return
        self.locked = True
        if len(down) != 1:
            self.message = "ONE KEY AT A TIME / RELEASE ALL"
            return
        key = next(iter(down))
        if key == "R":
            self.reset()
            self.locked = True
            return
        if key == "X":
            self.commit("undo")
            return
        if key == "RETURN":
            self.commit("start")
        elif key == "SPACE":
            self.commit("tick")
        elif self.state.status == "edit":
            if key == "TAB":
                self.section = 1-self.section
                self.cursor = 0
            elif key in ("LEFT", "RIGHT", "UP", "DOWN"):
                step = -1 if key in ("LEFT", "UP") else 1
                self.cursor = (self.cursor+step)%(3 if self.section == 0 else 4)
            elif key == "Z":
                if self.section == 0:
                    self.commit(("card", self.cursor, (self.state.cards[self.cursor]+1)%4))
                else:
                    value = LABELS[(LABELS.index(self.state.labels[self.cursor])+1)%4]
                    self.commit(("label", self.cursor, value))

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(8, 8, "CLOCKWORK POST / STAGE 5 / ORIGINAL B", 10)
        p.text(8, 22, "TICK "+str(s.t)+"/32   STATUS "+s.status.upper()+"   DELIVERED "+str(len(s.deliveries))+"/6", 7)
        p.text(8, 34, "TEST CARDS "+str(count_tests(s))+"/"+str(CAP)+"   OLD-STATE SYNCHRONOUS TICKS", 6)
        for i, parcel in enumerate(PARCELS):
            p.text(8+i*65, 49, str(i+1)+":"+parcel.color+str(parcel.weight)+">"+parcel.destination, 8 if parcel.color == "R" else 12)
        queue = " ".join(str(pid+1) for pid in s.queue) or "EMPTY"
        p.text(8, 63, "QUEUE: "+queue+"   NOT ARRIVED: "+str(6-s.next_id), 7)
        points = ((200, 96), (100, 149), (300, 149))
        for x in (100, 300):
            p.line(200, 116, x, 137, 5)
        for i, (x, y) in enumerate(points):
            p.rect(x-49, y-12, 98, 39, 2)
            p.rectb(x-49, y-12, 98, 39, 10 if self.section == 0 and self.cursor == i else 5)
            p.text(x-40, y-5, chr(65+i)+": "+CARD_NAMES[s.cards[i]], 7)
            pid = s.tables[i]
            p.text(x-40, y+10, "EMPTY" if pid < 0 else "PARCEL "+str(pid+1), 6 if pid < 0 else 11)
        for i, x in enumerate((50, 150, 250, 350)):
            p.line(100 if i < 2 else 300, 176, x, 193, 5)
            p.rect(x-37, 193, 74, 35, 4)
            p.rectb(x-37, 193, 74, 35, 10 if self.section == 1 and self.cursor == i else 9)
            p.text(x-31, 199, "BOX "+str(i+1)+" "+s.labels[i], 7)
            received = [str(d.parcel+1)+("!" if not d.correct else "") for d in s.deliveries if d.box == i]
            p.text(x-31, 213, " ".join(received) or "EMPTY", 11)
        waits = ",".join(str(pid+1) for pid, _ in s.waits) or "NONE"
        p.text(8, 237, "WAITED THIS TICK: "+waits+" / ALL 6 CONSERVED: "+str(parcel_conservation(s)), 6)
        if s.deliveries:
            d = s.deliveries[-1]
            p.text(8, 249, "LAST: "+str(d.parcel+1)+" > A > "+("B" if d.box < 2 else "C")+" > BOX "+str(d.box+1)+" "+("OK" if d.correct else "WRONG / STOP"), 11 if d.correct else 8)
        else:
            p.text(8, 249, "ARRIVED NOW? READ ITS CARD NEXT TICK.", 6)
        p.text(8, 264, self.message, 10)
        p.text(8, 278, "ARROWS SELECT / TAB TABLE-BOX / Z CYCLE / ENTER START", 7)
        p.text(8, 289, "SPACE TICK / X UNDO / EDIT ONLY IN EDIT MODE", 7)
        p.text(8, 304, "COLOR R:LEFT B:RIGHT / WEIGHT 2:LEFT 1:RIGHT", 6)
        p.text(8, 315, "LEFT/RIGHT: UNCONDITIONAL / R:RED B:BLUE", 6)
        p.text(8, 326, "DEST RH:RED HEAVY RL:RED LIGHT BH/BL:BLUE", 6)
        p.text(8, 344, "R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT", 5)


DEMO_KEYS = ('Z', 'Z', 'RIGHT', 'Z', 'Z', 'Z', 'RIGHT', 'Z', 'Z', 'Z', 'TAB', 'Z', 'RIGHT', 'RIGHT', 'Z', 'Z', 'Z', 'RIGHT', 'Z', 'Z', 'RETURN', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'X')


def main(headless=False, screenshot=None, demo=False):
    from pathlib import Path
    import pyxel as p
    # Pyxelの初期化より先に、指定した保存先を絶対化する。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    if screenshot:
        Path(screenshot).parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(400, 360, title="Clockwork Post", fps=30, display_scale=2,
           headless=headless)
    keys = {name: getattr(p, "KEY_"+name) for name in
            ("UP", "DOWN", "LEFT", "RIGHT", "TAB", "Z", "P", "RETURN", "SPACE", "X", "R")}
    limit = max(12, 2*len(DEMO_KEYS)+3) if demo else 12

    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                p.screenshot(screenshot, scale=2)
            from dataclasses import asdict
            import json
            print(json.dumps({"state": asdict(app.state), "message": app.message},
                             ensure_ascii=True, sort_keys=True), flush=True)
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            down = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in keys.items() if p.btn(value)}
        app.control(down)

    p.run(update, lambda: app.draw(p))
    return app


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
AFTER STEP 05

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

最後の教材の範囲は、元のゲームの B のふつうのモードです。決まった形の二分木で「入口→A→B か C→4つの箱」と進み、6便は時刻0〜5に入ります。前の刻の状態だけを見て、全部が同時に動きます。台に着いた便の判定は次の刻です。COLOR と WEIGHT は合わせて3枚まで、素通りと同じ札の重ねは使えます。まちがって届いた刻を確定して止まり、6便すべてが正しく届けば成功、32刻で止まります。

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

比べる点5ステップの教材元の完成版
状況03はBの正解に固定。04と05は元のゲームのBの6便で、すべてのカードと札を編集できる。A・B・Cの3つの状況。判定カードの上限は1・3・2。
段階ごとの簡略化01は台1つだけ、02は時間のない道すじの模型。03からがふつうの「同時に動く」ルール。最初から順番待ちの列、3つの台、6便、まちがいの判定、便がなくならない確認がある。
台のふさがり方と比較ふつうのモードだけ。比較版の「空いているか」の判定には変えない。Bキーで、同じ刻に空いた台も使う比較版を選べる。
表示と操作英語(ASCII)の画面。1段目から段階ごとのキー。XのUndoは05だけ。自動の連続運転・予告・表紙は省略。日本語の文字、連続運転、予告、説明と表紙、状況の切り替えなど。
確認した範囲04と05で、元のゲームのBの16384通りのすべての配置について、始める前・始めたあと・すべての刻の状態を比べた。表示用の reason だけは比べていない。比べたのは、純粋なモデルのBのふつうのモード。元の Controller や描画、AとC、比較モード全体が同じだとは言っていない。
外部の素材と記録それぞれの .py だけで動く。Noto由来の文字データなし。ふつうは保存も音もなし。指定したときだけ確認用のPNGを出す。Noto由来の文字データ、再現記録・画像・映像の補助機能がある。

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

  1. model.py の branch・condition・make_level で、A と C へ広げる方法
  2. model.py の tick の old(前の状態)の使い方と、flow(比較版)との違い
  3. model.py の act・Timeline・parcel_conservation(操作・履歴・便がなくならない確認)
  4. model.py の forecast と simulate(予告と、最後まで動かしてみる処理)
  5. controller.py の feed と automatic_tick、app.py の draw の予告と通り道の表示
自分のゲームに変えるなら

最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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