パズルLESSON 60短い命令列で資材輸送と未調査島の報告を組み立てる航路計画原作096

からくり島の航路の作り方

6枚までの命令で、船の1日が決まる。
港を回る命令の列を1枚ずつ読み、「航海」「荷物の積み降ろし」「島の補給」を1日ずつ進める、小さな実行のしくみを作ります。

完成版の遊び方6枚以内の命令で船を働かせ、24日で資材6個と、まだ調べていない島の報告を、家の港に持ち帰ります。

からくり島の航路の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Bの自動描画です。下の5ステップは原作Aの航路を題材にします。
PLAY / 試験公開

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

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

完成版の主な操作:Space一日/Enter自動再生、HでM手動/E編集、矢印候補と枠/Z追加、X戻す

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

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

うまく表示されないとき

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

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

命令と、1日ごとの状態の更新を、分けて実行する。

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

1. 状態の変化と、距離のカウントダウン
2. 入る量の制限と、1日の処理の順番
3. 次に読む命令の位置(プログラムカウンタ)と、安全な編集
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / タプル・リスト・辞書・割り算の余り / dataclass・replace・PC(次に読む命令の位置)・履歴は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右:手動/編集候補カードを選択 / Space:1日進行。01/02は手動、03以降は選択モード / M:03以降、H停泊中に手動/命令列を切替 / E:03以降、Hで編集/編集中は取消 / Z:編集列の末尾へ追加 / Backspace:編集列の末尾を削除 / Enter:編集を保存。05通常画面では無料再開 / X:05のUndo / B:05を初期化しLOOP/STOP切替 / R:同じLOOP/STOP設定で初期化 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_voyage.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

出港と到着を別の日にする

状態の変化と、距離のカウントダウン。

最初は H・A・B だけの地図です。←→で MA(A へ)、MB(B へ)、MH(H へ)、W(待つ)を選び、Space で1日進めます。起動した直後は MA が選ばれています。ゲームの day は判断した回数なので、画面をながめているだけの本当の時間では進みません。H が家、A と B が資材を扱う島になる予定です。この段階では、まだ積み荷も命令の列もありません。

State は、day(日)、今いる場所 at、行き先 target、残りの航海の日数 remaining をひとまとめにした値です。@dataclass(frozen=True) で直接の書きかえをできなくし、replace(s, ...) で次の値を作ります。画面を描く draw は State を読むだけです。見せる仕事と、1日を進める仕事を分けると、画面を描き直しても、2回動くことはありません。

出航では1日を使って、target と remaining を決めます。距離1なら、最初の Space で DAY 1・AT H・TARGET A・TRAVEL LEFT 1 です。次の Space で remaining を1減らし、0になってはじめて AT A です。at は航海中も出発した港のままなので、at だけで「港にいる」と判定してはいけません。家で編集できるかは、あとで at_home で「H にいて、行き先がない」と調べます。

すでに target がある場合の分かれ道を先に置き、そこでは選んだカードを実行しません。着いた日にカードを読み始めると、移動と積みこみが同じ日になってしまいます。H と A の往復だけでも、出航・到着・出航・到着の4日かかります。A と B は直接つながっていません。つながっていないカードは日数を使わずに FAULT(エラー)となり、この段階では R でやり直します。

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

01_voyage.py / 注目する部分
    if target:
        # 出港済みなら航行だけ。到着してもカードを読まない。
        remaining -= 1
        if remaining == 0:
            at, target = target, ""
            events.append("ARRIVED: NEXT CARD TOMORROW")

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_voyage.py
macOS / Linux
.venv/bin/python 01_voyage.py
画面で見ることMA のまま Space を押す → 離す → Space で DAY 2・AT A・TARGET - です。さらに ← を2回押して MH を選び、Space を2回で DAY 4・AT H です。A に着いた直後にもう一度 MA を実行すると、同じ島への航路はないので FAULT です。
  • 距離1でも、出航と到着で2日かかる
  • 航海中は、新しいカードを実行しない
  • 行けない航路では、日数は増えない
つまずきやすいところ出航した直後にも remaining を減らすと、距離1がその日のうちに着いてしまいます。if target ... else ... の形を保ち、出航した日に、航海の分かれ道に入らない作りにします。FAULT になったら R、キーが反応しなければ全部離してから押し直します。
少し変えてみよう

EDGES の H–A の距離だけを1から2に変えて、もう一度起動してみましょう。MA を選んで Space を3回押します。DAY 1 は残り2、DAY 2 は AT H・残り1、DAY 3 で AT A になります。確かめたら1に戻します。test_distance_two_mutation_exercise が、同じ変更をモデルの上で確かめています。

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

STAGE = 1
NODES = 'HAB'
CARDS = ('MA', 'MB', 'MH', 'W')
EDGES = (('H', 'A', 1), ('H', 'B', 1))

@dataclass(frozen=True)
class State:
    day: int = 0
    at: str = "H"
    target: str = ""
    remaining: int = 0
    fault: bool = False
    last: tuple = ()

def initial():
    return State()

def edge(a, b):
    for left, right, days in EDGES:
        if {a, b} == {left, right}:
            return days
    return None


def at_home(s):
    return s.at == "H" and not s.target


def step(s, action):
    """現在のStateを変更せず、次のStateを返す。描画もここを変更しない。"""
    if not isinstance(action, str):
        raise ValueError("action must be text")
    if action not in CARDS:
        raise ValueError("unknown card")
    if s.fault:
        return s
    card = action
    day = s.day + 1
    at, target, remaining = s.at, s.target, s.remaining
    events = []
    if target:
        # 出港済みなら航行だけ。到着してもカードを読まない。
        remaining -= 1
        if remaining == 0:
            at, target = target, ""
            events.append("ARRIVED: NEXT CARD TOMORROW")
        else:
            events.append("TRAVELLING: NO NEW CARD")
    else:
        if card.startswith("M"):
            destination = card[1:]
            distance = edge(at, destination)
            if distance is None:
                return replace(s, fault=True, last=("NO ROUTE: NO DAY COST",))
            target, remaining = destination, distance
            events.append("DEPARTED: ARRIVAL LATER")
        else:
            events.append("WAITED ONE DAY")
    return replace(s, day=day, at=at, target=target, remaining=remaining,
                   fault=False, last=tuple(events))


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

    def reset(self):
        self.state = initial()
        self.choice = 0
        self.gated = False
        self.message = "READY"

    def commit(self, action):
        self.state = step(self.state, action)
        self.message = self.state.last[-1] if self.state.last else "READY"

    def control(self, pressed, held):
        # 毎フレーム呼ぶ。全キーを離してから次の一操作へ。
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = "RELEASE ALL KEYS"
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        self.gated = True
        if key in ("LEFT", "RIGHT"):
            self.choice = (self.choice + (1 if key == "RIGHT" else -1)) % len(CARDS)
        elif key == "SPACE":
            if self.state.fault:
                self.message = "ROUTE FAULT: R TO RESET"
                return
            self.commit(CARDS[self.choice])

    def draw(self, p):
        p.cls(1)
        p.text(10, 8, "CLOCKWORK ROUTES / STAGE " + str(STAGE), 10)
        s = self.state
        p.text(10, 22, "DAY " + str(s.day) + "   AT " + s.at + "   TARGET " + (s.target or "-") + ("  FAULT" if s.fault else ""), 7)
        p.text(10, 36, "DEPARTURE AND ARRIVAL ARE SEPARATE DAYS", 6)
        positions = {"H": (74, 132), "A": (215, 96), "B": (215, 173), "C": (360, 96)}
        edges = tuple((a, b, str(d)) for a, b, d in EDGES)
        for a, b, label in edges:
            x1, y1 = positions[a]
            x2, y2 = positions[b]
            p.line(x1, y1, x2, y2, 5)
            p.text((x1+x2)//2-12, (y1+y2)//2-11, label + "d", 6)
        for node in NODES:
            x, y = positions[node]
            p.circ(x, y, 14, 3)
            p.circb(x, y, 14, 11)
            p.text(x-2, y-3, node, 7)
        x, y = positions[s.at]
        if s.target:
            tx, ty = positions[s.target]
            # 常に中点に置く。C航路の実距離を船の補間位置から漏らさない。
            x, y = (x+tx)//2, (y+ty)//2
        ship_y = y-26 if s.target else y-12
        p.tri(x-5, ship_y, x+5, ship_y, x, ship_y+7, 10)
        travel = s.remaining
        p.text(10, 215, "TRAVEL LEFT " + ("?" if travel is None else str(travel)), 7)
        p.text(10, 240, "SPACE: TRAVEL IF SAILING, ELSE SELECTED CARD", 6)
        p.text(10, 295, "SELECT " + CARDS[self.choice] + "   " + self.message, 7)
        p.text(10, 315, "LEFT/RIGHT SELECT   SPACE STEP   R RESET   ESC QUIT", 6)
        p.text(10, 333, "M* MOVE  W WAIT", 6)


DEMO_KEYS = ('SPACE', 'SPACE')
KEYS = ('LEFT', 'RIGHT', 'SPACE', 'R')


def main():
    # CLIの検査はPyxelをimportする前。通常ゲームはファイルを保存しない。
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int, default=None)
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error("--demo-checkpoint requires --headless-smoke")
    if args.frames is not None and (not args.headless_smoke or not 2 <= args.frames <= 600):
        parser.error("--frames requires headless mode and a value in 2..600")
    minimum = 2*len(DEMO_KEYS)+4 if args.demo_checkpoint else 4
    if args.frames is not None and args.frames < minimum:
        parser.error("--frames too short for the requested checkpoint")
    frames = args.frames or minimum
    target = None
    if args.screenshot:
        if not args.headless_smoke:
            parser.error("--screenshot requires --headless-smoke")
        target = Path(args.screenshot).expanduser().resolve()
        if target.suffix.lower() != ".png" or target.exists() or not target.parent.is_dir():
            parser.error("screenshot needs a NEW .png file in an existing directory")
    import pyxel as p
    app = App()
    p.init(448, 384, title="Clockwork Routes", fps=30, display_scale=2,
           headless=args.headless_smoke)
    keys = {key: getattr(p, "KEY_" + key) for key in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= frames:
            if target:
                p.screenshot(str(target), scale=2)
            p.quit()
            return
        if args.headless_smoke:
            index = p.frame_count // 2
            down = ({DEMO_KEYS[index]} if args.demo_checkpoint and p.frame_count % 2 == 0
                    and index < len(DEMO_KEYS) else set())
            app.control(down, down)
        else:
            app.control({k for k, v in keys.items() if p.btnp(v)},
                        {k for k, v in keys.items() if p.btn(v)})
    p.run(update, lambda: app.draw(p))


if __name__ == "__main__":
    main()
STEP 02 / 05

積み荷と島の補給を数える

入る量の制限と、1日の処理の順番。

L(積む)と U(降ろす)を加えます。船の荷台 cargo は最大2、島の在庫 stock は最大4、H の倉庫 warehouse は届けた資材の合計です。A の最初の在庫は4、B は2です。ここでいう「補給」は、島の在庫が1個増えることです。燃料はありません。移動で資材を使うことはなく、家の倉庫から積み直すこともできません。

積む量は min(CAPACITY-cargo, stock[i]) です。船の空きと島の在庫の、小さいほうだけを移し、同じ量を cargo に足して stock から引きます。片方だけを変えると、資材が増えたり消えたりしてしまいます。U は、H なら船の荷台の全部を倉庫に移し、島なら島の空きの分まで戻します。島がいっぱいなら、戻しきれない荷物は船に残ります。

補給は、カードか航海の処理が終わってから、1回だけ行います。A の周期は3日、B は2日で、day % period == 0(日を周期で割った余りが0)の日が補給の日です。上限の4はこえません。たとえば DAY 2 に A の在庫0から L をすると、その日の積みこみは0です。DAY 3 の補給で在庫が1になりますが、それを同じ操作にさかのぼって船に積んではいけません。

空のまま積む・空のまま降ろす・満載で積む・W で待つ、のどれにも1日かかります。待つ日や着いた日にも島の補給は進みますが、航路のエラーでは日そのものが進まないので、補給もしません。State の stock は tuple で、計算する間だけ list にして1つの島を変え、最後に tuple に戻すので、元の State は保たれます。まだ依頼の期限や成功の表示はありません。

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

02_cargo.py / 注目する部分
                amount = min(CAPACITY - cargo, stock[i])
                cargo += amount
                stock[i] -= amount

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_cargo.py
macOS / Linux
.venv/bin/python 02_cargo.py
画面で見ることMA で Space を2回、L を選んで Space、MH で Space を2回、U で Space と押します。DAY 6・AT H・CARGO 0・HOME WAREHOUSE 2 です。DAY 3 の積みこみの直後は A の在庫が3、DAY 6 には A の在庫は4に戻っています。
  • 積み荷と島の在庫を、同じ量だけ逆向きに更新する
  • 積み降ろしのあとで、島の周期の補給をする
  • 空の操作は1日、航路のエラーは0日
つまずきやすいところ「補給」を、船の燃料の回復と読みまちがえないようにしましょう。SUP 3d は、A の在庫が増える周期です。空の操作でも DAY が進むのは、決めたとおりのルールです。島で U をしても、H に届けた数は増えません。
少し変えてみよう

CAPACITY を2から1に変えて、同じ6日間の往復をしてみましょう。倉庫は1個です。上限の変更は、min の1か所だけでなく、表示にも反映されます。確かめたら2に戻します。test_capacity_exercise と、資材の合計・上限のチェックで確かめています。

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

STAGE = 2
NODES = 'HAB'
CARDS = ('MA', 'MB', 'MH', 'L', 'U', 'W')
EDGES = (('H', 'A', 1), ('H', 'B', 1))
CAPACITY = 2
STOCK_LIMIT = 4
INITIAL_STOCK = (4, 2)
PERIOD = (3, 2)

@dataclass(frozen=True)
class State:
    day: int = 0
    at: str = "H"
    target: str = ""
    remaining: int = 0
    cargo: int = 0
    stock: tuple = INITIAL_STOCK
    warehouse: int = 0
    fault: bool = False
    last: tuple = ()

def initial():
    return State()

def edge(a, b):
    for left, right, days in EDGES:
        if {a, b} == {left, right}:
            return days
    return None


def at_home(s):
    return s.at == "H" and not s.target


def step(s, action):
    """現在のStateを変更せず、次のStateを返す。描画もここを変更しない。"""
    if not isinstance(action, str):
        raise ValueError("action must be text")
    if action not in CARDS:
        raise ValueError("unknown card")
    if s.fault:
        return s
    card = action
    day = s.day + 1
    at, target, remaining = s.at, s.target, s.remaining
    events = []
    cargo, stock, warehouse = s.cargo, list(s.stock), s.warehouse
    if target:
        # 出港済みなら航行だけ。到着してもカードを読まない。
        remaining -= 1
        if remaining == 0:
            at, target = target, ""
            events.append("ARRIVED: NEXT CARD TOMORROW")
        else:
            events.append("TRAVELLING: NO NEW CARD")
    else:
        if card.startswith("M"):
            destination = card[1:]
            distance = edge(at, destination)
            if distance is None:
                return replace(s, fault=True, last=("NO ROUTE: NO DAY COST",))
            target, remaining = destination, distance
            events.append("DEPARTED: ARRIVAL LATER")
        elif card == "L":
            if at != "H":
                i = NODES.index(at) - 1
                amount = min(CAPACITY - cargo, stock[i])
                cargo += amount
                stock[i] -= amount
            events.append("LOAD: EVEN EMPTY COSTS ONE DAY")
        elif card == "U":
            if at == "H":
                warehouse += cargo
                cargo = 0
            else:
                i = NODES.index(at) - 1
                amount = min(cargo, STOCK_LIMIT - stock[i])
                cargo -= amount
                stock[i] += amount
            events.append("UNLOAD: EVEN EMPTY COSTS ONE DAY")
        else:
            events.append("WAITED ONE DAY")
    # 積み降ろしの後に、この日の補給を一度だけ行う。
    for i, period in enumerate(PERIOD):
        if period and day % period == 0:
            stock[i] = min(STOCK_LIMIT, stock[i] + 1)
    return replace(s, day=day, at=at, target=target, remaining=remaining,
                   cargo=cargo, stock=tuple(stock), warehouse=warehouse,
                   fault=False, last=tuple(events))


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

    def reset(self):
        self.state = initial()
        self.choice = 0
        self.gated = False
        self.message = "READY"

    def commit(self, action):
        self.state = step(self.state, action)
        self.message = self.state.last[-1] if self.state.last else "READY"

    def control(self, pressed, held):
        # 毎フレーム呼ぶ。全キーを離してから次の一操作へ。
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = "RELEASE ALL KEYS"
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        self.gated = True
        if key in ("LEFT", "RIGHT"):
            self.choice = (self.choice + (1 if key == "RIGHT" else -1)) % len(CARDS)
        elif key == "SPACE":
            if self.state.fault:
                self.message = "ROUTE FAULT: R TO RESET"
                return
            self.commit(CARDS[self.choice])

    def draw(self, p):
        p.cls(1)
        p.text(10, 8, "CLOCKWORK ROUTES / STAGE " + str(STAGE), 10)
        s = self.state
        p.text(10, 22, "DAY " + str(s.day) + "   AT " + s.at + "   TARGET " + (s.target or "-") + ("  FAULT" if s.fault else ""), 7)
        p.text(10, 36, "CARGO " + str(s.cargo) + "/" + str(CAPACITY) + "   HOME WAREHOUSE " + str(s.warehouse), 11)
        p.text(10, 50, "ISLAND SUPPLY IS STOCK, NOT FUEL", 6)
        positions = {"H": (74, 132), "A": (215, 96), "B": (215, 173), "C": (360, 96)}
        edges = tuple((a, b, str(d)) for a, b, d in EDGES)
        for a, b, label in edges:
            x1, y1 = positions[a]
            x2, y2 = positions[b]
            p.line(x1, y1, x2, y2, 5)
            p.text((x1+x2)//2-12, (y1+y2)//2-11, label + "d", 6)
        for node in NODES:
            x, y = positions[node]
            p.circ(x, y, 14, 3)
            p.circb(x, y, 14, 11)
            p.text(x-2, y-3, node, 7)
            if node != "H":
                i = NODES.index(node)-1
                stock, period = s.stock[i], PERIOD[i]
                label = "STOCK " + ("?" if stock is None else str(stock))
                cycle = "?" if period is None else "NONE" if period == 0 else str(period) + "d"
                p.text(x-18, y+19, label, 7)
                p.text(x-18, y+29, "SUP " + cycle, 6)
        x, y = positions[s.at]
        if s.target:
            tx, ty = positions[s.target]
            # 常に中点に置く。C航路の実距離を船の補間位置から漏らさない。
            x, y = (x+tx)//2, (y+ty)//2
        ship_y = y-26 if s.target else y-12
        p.tri(x-5, ship_y, x+5, ship_y, x, ship_y+7, 10)
        travel = s.remaining
        p.text(10, 215, "TRAVEL LEFT " + ("?" if travel is None else str(travel)), 7)
        p.text(10, 240, "SPACE: TRAVEL IF SAILING, ELSE SELECTED CARD", 6)
        p.text(10, 295, "SELECT " + CARDS[self.choice] + "   " + self.message, 7)
        p.text(10, 315, "LEFT/RIGHT SELECT   SPACE STEP   R RESET   ESC QUIT", 6)
        p.text(10, 333, "M* MOVE  W WAIT  L LOAD  U UNLOAD", 6)


DEMO_KEYS = ('SPACE', 'SPACE', 'RIGHT', 'RIGHT', 'RIGHT', 'SPACE', 'LEFT', 'SPACE', 'SPACE', 'RIGHT', 'RIGHT', 'SPACE')
KEYS = ('LEFT', 'RIGHT', 'SPACE', 'R')


def main():
    # CLIの検査はPyxelをimportする前。通常ゲームはファイルを保存しない。
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int, default=None)
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error("--demo-checkpoint requires --headless-smoke")
    if args.frames is not None and (not args.headless_smoke or not 2 <= args.frames <= 600):
        parser.error("--frames requires headless mode and a value in 2..600")
    minimum = 2*len(DEMO_KEYS)+4 if args.demo_checkpoint else 4
    if args.frames is not None and args.frames < minimum:
        parser.error("--frames too short for the requested checkpoint")
    frames = args.frames or minimum
    target = None
    if args.screenshot:
        if not args.headless_smoke:
            parser.error("--screenshot requires --headless-smoke")
        target = Path(args.screenshot).expanduser().resolve()
        if target.suffix.lower() != ".png" or target.exists() or not target.parent.is_dir():
            parser.error("screenshot needs a NEW .png file in an existing directory")
    import pyxel as p
    app = App()
    p.init(448, 384, title="Clockwork Routes", fps=30, display_scale=2,
           headless=args.headless_smoke)
    keys = {key: getattr(p, "KEY_" + key) for key in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= frames:
            if target:
                p.screenshot(str(target), scale=2)
            p.quit()
            return
        if args.headless_smoke:
            index = p.frame_count // 2
            down = ({DEMO_KEYS[index]} if args.demo_checkpoint and p.frame_count % 2 == 0
                    and index < len(DEMO_KEYS) else set())
            app.control(down, down)
        else:
            app.control({k for k, v in keys.items() if p.btnp(v)},
                        {k for k, v in keys.items() if p.btn(v)})
    p.run(update, lambda: app.draw(p))


if __name__ == "__main__":
    main()
STEP 03 / 05

短い命令列を一日ずつ読む

次に読む命令の位置(プログラムカウンタ)と、安全な編集。

MA・L・MH・U を program というタプルに保存し、pc が「次に読むカード」の番号を指します。最初は PROGRAM モードです。Space は1日だけ進み、続けて自動で動かすことはしません。出航のカードを実行した時点で pc が次に移り、航海中や着いた日には pc を進めません。なので、4枚の列でも1周は6日です。

pc は、カードを実行したあとだけ1増やし、列の長さと同じになったら0に戻します。最後の U を終えると、また MA です。Space を12回押すと2周して、H の倉庫は4個です。M を押すと手動に切りかえられ、←→で選んだカードを Space で実行します。手動で実行しても pc は変わりません。モードを変えられるのは、H に止まっている日の区切りだけです。

E で編集の画面に入り、今の列を draft(下書き)にコピーします。←→で候補を選び、Z で最後に追加、Backspace で最後を削除、Enter で保存、E で取り消しです。元のゲームの「選んだ枠を置きかえる」方法は教材では省き、最後から直すやり方にしました。編集の画面で Space を押しても、日は進みません。取り消せば、State も列もそのままです。

命令は、1〜6枚の、知っているカードだけです。空の列では Enter を受け付けず、7枚目は追加しません。モデルの step でも同じ条件を調べるので、画面を通さずに呼び出しても守られます。保存すると pc は0に戻り、日数や資材は使いません。H を出航した直後は、at が H でも target があるので、編集できません。航路のエラーでは、pc も在庫も進めずに止まります。今の段階では R で戻り、最後の段階で Undo を加えます。

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

03_program.py / 注目する部分
        if not manual:
            pc += 1
            if pc == len(s.program):
                pc = 0

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_program.py
macOS / Linux
.venv/bin/python 03_program.py
画面で見ること何も編集せずに Space を6回押すと、DAY 6・H の倉庫2・pc=0(MA の枠)です。12回で倉庫4 です。E を開いて何秒待っても DAY は変わらず、E で取り消すと元の4枚です。
  • 航海中は pc を進めない
  • 1〜6枚のチェックを、画面とモデルの両方に置く
  • H に止まっている間だけ、日を使わずに編集できる
つまずきやすいところ毎日 pc を進めると、着く前に L を飛ばしてしまいます。カードを実際に実行した else の中だけで更新します。M を押しても H 以外で変わらないのは、「安全に編集できる区切り」と同じ考え方です。
少し変えてみよう

H で E を押します。Backspace を4回押して空にし、MA・L・W・MH・U をこの順に Z で追加して Enter を押します。Space を7回押すと倉庫は2、pc=0 に戻ります。W を足した1日分だけおそくなることを、test_program_wait_exercise でも確かめています。

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

STAGE = 3
NODES = 'HAB'
CARDS = ('MA', 'MB', 'MH', 'L', 'U', 'W')
EDGES = (('H', 'A', 1), ('H', 'B', 1))
CAPACITY = 2
STOCK_LIMIT = 4
INITIAL_STOCK = (4, 2)
PERIOD = (3, 2)
DEFAULT = ('MA', 'L', 'MH', 'U')

@dataclass(frozen=True)
class State:
    day: int = 0
    at: str = "H"
    target: str = ""
    remaining: int = 0
    cargo: int = 0
    stock: tuple = INITIAL_STOCK
    warehouse: int = 0
    program: tuple = DEFAULT
    pc: int = 0
    halted: bool = False
    fault: bool = False
    last: tuple = ()

def initial():
    return State()

def edge(a, b):
    for left, right, days in EDGES:
        if {a, b} == {left, right}:
            return days
    return None


def at_home(s):
    return s.at == "H" and not s.target


def step(s, action):
    """現在のStateを変更せず、次のStateを返す。描画もここを変更しない。"""
    if not isinstance(action, str):
        raise ValueError("action must be text")
    if action.startswith("program:"):
        cards = tuple(action[8:].split(","))
        if not at_home(s):
            return replace(s, last=("EDIT ONLY AT HOME",))
        if not 1 <= len(cards) <= 6 or any(card not in CARDS for card in cards):
            return replace(s, last=("USE 1 TO 6 KNOWN CARDS",))
        return replace(s, program=cards, pc=0, halted=False, fault=False,
                       last=("PROGRAM SAVED: NO DAY COST",))
    manual = action.startswith("manual:")
    if action != "day" and not manual:
        raise ValueError("unknown action")
    if manual and action[7:] not in CARDS:
        raise ValueError("unknown card")
    if s.halted and not manual and not s.target:
        return s
    card = action[7:] if manual else s.program[s.pc]
    day = s.day + 1
    at, target, remaining = s.at, s.target, s.remaining
    events = []
    cargo, stock, warehouse = s.cargo, list(s.stock), s.warehouse
    pc = s.pc
    halted = s.halted if s.target else False
    if target:
        # 出港済みなら航行だけ。到着してもカードを読まない。
        remaining -= 1
        if remaining == 0:
            at, target = target, ""
            events.append("ARRIVED: NEXT CARD TOMORROW")
        else:
            events.append("TRAVELLING: NO NEW CARD")
    else:
        if card.startswith("M"):
            destination = card[1:]
            distance = edge(at, destination)
            if distance is None:
                return replace(s, fault=True, halted=True, last=("NO ROUTE: NO DAY COST",))
            target, remaining = destination, distance
            events.append("DEPARTED: ARRIVAL LATER")
        elif card == "L":
            if at != "H":
                i = NODES.index(at) - 1
                amount = min(CAPACITY - cargo, stock[i])
                cargo += amount
                stock[i] -= amount
            events.append("LOAD: EVEN EMPTY COSTS ONE DAY")
        elif card == "U":
            if at == "H":
                warehouse += cargo
                cargo = 0
            else:
                i = NODES.index(at) - 1
                amount = min(cargo, STOCK_LIMIT - stock[i])
                cargo -= amount
                stock[i] += amount
            events.append("UNLOAD: EVEN EMPTY COSTS ONE DAY")
        else:
            events.append("WAITED ONE DAY")
        if not manual:
            pc += 1
            if pc == len(s.program):
                pc = 0
    # 積み降ろしの後に、この日の補給を一度だけ行う。
    for i, period in enumerate(PERIOD):
        if period and day % period == 0:
            stock[i] = min(STOCK_LIMIT, stock[i] + 1)
    return replace(s, day=day, at=at, target=target, remaining=remaining,
                   cargo=cargo, stock=tuple(stock), warehouse=warehouse,
                   pc=pc, halted=halted,
                   fault=False, last=tuple(events))


def forecast(s, card=None):
    """次の一手だけを公開情報で説明。未来のStateを返さない。"""
    if s.target:
        return "TRAVEL ONE DAY; NO CARD ON ARRIVAL"
    if s.halted and card is None:
        return "ROUTE FAULT: R RESET"
    card = s.program[s.pc] if card is None else card
    if card.startswith("M"):
        destination = card[1:]
        distance = edge(s.at, destination)
        if distance is None:
            return "NO ADJACENT ROUTE: ZERO DAYS"
        return "DEPART 1 + TRAVEL " + str(distance) + " DAYS"
    if card == "L":
        amount = 0 if s.at == "H" else min(CAPACITY - s.cargo, s.stock[NODES.index(s.at)-1])
        return "LOAD " + str(amount) + "; THEN ISLAND SUPPLY"
    if card == "U":
        return "UNLOAD TO HOME" if s.at == "H" else "UNLOAD TO ISLAND SPACE"
    return "WAIT; THEN ISLAND SUPPLY"


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

    def reset(self):
        self.state = initial()
        self.choice = 0
        self.gated = False
        self.message = "READY"
        self.manual = False
        self.edit = False
        self.draft = []

    def commit(self, action):
        self.state = step(self.state, action)
        self.message = self.state.last[-1] if self.state.last else "READY"

    def control(self, pressed, held):
        # 毎フレーム呼ぶ。全キーを離してから次の一操作へ。
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = "RELEASE ALL KEYS"
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        self.gated = True
        if self.edit:
            if key in ("LEFT", "RIGHT"):
                self.choice = (self.choice + (1 if key == "RIGHT" else -1)) % len(CARDS)
            elif key == "Z":
                if len(self.draft) < 6:
                    self.draft.append(CARDS[self.choice])
                else:
                    self.message = "SIX CARDS MAX"
            elif key == "BACKSPACE" and self.draft:
                self.draft.pop()
            elif key == "E":
                self.edit = False
                self.message = "EDIT CANCELLED"
            elif key == "RETURN":
                if self.draft:
                    self.commit("program:" + ",".join(self.draft))
                    self.edit = False
                else:
                    self.message = "NEED AT LEAST ONE CARD"
            return
        if key == "E":
            if at_home(self.state):
                self.edit = True
                self.draft = list(self.state.program)
                self.message = "EDIT: NO TIME PASSES"
            else:
                self.message = "EDIT ONLY AT HOME"
            return
        if key == "M":
            if at_home(self.state):
                self.manual = not self.manual
                self.message = "MANUAL" if self.manual else "PROGRAM"
            else:
                self.message = "MODE CHANGE ONLY AT HOME"
            return
        if key in ("LEFT", "RIGHT"):
            self.choice = (self.choice + (1 if key == "RIGHT" else -1)) % len(CARDS)
        elif key == "SPACE":
            if self.state.fault:
                self.message = "ROUTE FAULT: R TO RESET"
                return
            self.commit(( "manual:" + CARDS[self.choice] if self.manual else "day"))

    def draw(self, p):
        p.cls(1)
        p.text(10, 8, "CLOCKWORK ROUTES / STAGE " + str(STAGE), 10)
        s = self.state
        p.text(10, 22, "DAY " + str(s.day) + "   AT " + s.at + "   TARGET " + (s.target or "-") + ("  FAULT" if s.fault else ""), 7)
        p.text(10, 36, "CARGO " + str(s.cargo) + "/" + str(CAPACITY) + "   HOME WAREHOUSE " + str(s.warehouse), 11)
        p.text(10, 50, "ISLAND SUPPLY IS STOCK, NOT FUEL", 6)
        positions = {"H": (74, 132), "A": (215, 96), "B": (215, 173), "C": (360, 96)}
        edges = tuple((a, b, str(d)) for a, b, d in EDGES)
        for a, b, label in edges:
            x1, y1 = positions[a]
            x2, y2 = positions[b]
            p.line(x1, y1, x2, y2, 5)
            p.text((x1+x2)//2-12, (y1+y2)//2-11, label + "d", 6)
        for node in NODES:
            x, y = positions[node]
            p.circ(x, y, 14, 3)
            p.circb(x, y, 14, 11)
            p.text(x-2, y-3, node, 7)
            if node != "H":
                i = NODES.index(node)-1
                stock, period = s.stock[i], PERIOD[i]
                label = "STOCK " + ("?" if stock is None else str(stock))
                cycle = "?" if period is None else "NONE" if period == 0 else str(period) + "d"
                p.text(x-18, y+19, label, 7)
                p.text(x-18, y+29, "SUP " + cycle, 6)
        x, y = positions[s.at]
        if s.target:
            tx, ty = positions[s.target]
            # 常に中点に置く。C航路の実距離を船の補間位置から漏らさない。
            x, y = (x+tx)//2, (y+ty)//2
        ship_y = y-26 if s.target else y-12
        p.tri(x-5, ship_y, x+5, ship_y, x, ship_y+7, 10)
        travel = s.remaining
        p.text(10, 215, "TRAVEL LEFT " + ("?" if travel is None else str(travel)), 7)
        cards = self.draft if self.edit else s.program
        for i, card in enumerate(cards):
            x = 10 + i*49
            p.rectb(x, 233, 43, 18, 10 if not self.edit and i == s.pc else 5)
            p.text(x+8, 239, card, 7)
        label = "EDIT" if self.edit else "MANUAL" if self.manual else "PROGRAM"
        p.text(322, 239, label, 10)
        card = CARDS[self.choice] if self.manual else None
        preview = "DRAFT ONLY: ENTER SAVE / E CANCEL" if self.edit else forecast(s, card)
        p.text(10, 263, preview, 6)
        p.text(10, 295, "SELECT " + CARDS[self.choice] + "   " + self.message, 7)
        p.text(10, 315, "LEFT/RIGHT SELECT   SPACE STEP   R RESET   ESC QUIT", 6)
        p.text(10, 329, "M MANUAL/PROGRAM  E EDIT/CANCEL  Z APPEND", 6)
        p.text(10, 343, "IN EDIT: BACKSPACE REMOVE LAST  ENTER SAVE", 6)


DEMO_KEYS = ('SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE')
KEYS = ('LEFT', 'RIGHT', 'SPACE', 'R', 'M', 'E', 'Z', 'BACKSPACE', 'RETURN')


def main():
    # CLIの検査はPyxelをimportする前。通常ゲームはファイルを保存しない。
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int, default=None)
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error("--demo-checkpoint requires --headless-smoke")
    if args.frames is not None and (not args.headless_smoke or not 2 <= args.frames <= 600):
        parser.error("--frames requires headless mode and a value in 2..600")
    minimum = 2*len(DEMO_KEYS)+4 if args.demo_checkpoint else 4
    if args.frames is not None and args.frames < minimum:
        parser.error("--frames too short for the requested checkpoint")
    frames = args.frames or minimum
    target = None
    if args.screenshot:
        if not args.headless_smoke:
            parser.error("--screenshot requires --headless-smoke")
        target = Path(args.screenshot).expanduser().resolve()
        if target.suffix.lower() != ".png" or target.exists() or not target.parent.is_dir():
            parser.error("screenshot needs a NEW .png file in an existing directory")
    import pyxel as p
    app = App()
    p.init(448, 384, title="Clockwork Routes", fps=30, display_scale=2,
           headless=args.headless_smoke)
    keys = {key: getattr(p, "KEY_" + key) for key in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= frames:
            if target:
                p.screenshot(str(target), scale=2)
            p.quit()
            return
        if args.headless_smoke:
            index = p.frame_count // 2
            down = ({DEMO_KEYS[index]} if args.demo_checkpoint and p.frame_count % 2 == 0
                    and index < len(DEMO_KEYS) else set())
            app.control(down, down)
        else:
            app.control({k for k, v in keys.items() if p.btnp(v)},
                        {k for k, v in keys.items() if p.btn(v)})
    p.run(update, lambda: app.draw(p))


if __name__ == "__main__":
    main()
STEP 04 / 05

未知の島を訪ねて報告する

中の状態・見せてよい情報・目標の判定を分ける。

A から、まだ調べていない C へ向かう MC を加えます。元のゲームの海域 A をもとにした、「24日以内に H の倉庫を6個以上にして、C の報告をそろえる」依頼です。C の本当の在庫・補給の周期・航路の距離は決まっていますが、家に帰るまでは stock/period を「?」、距離を「1〜2日」の範囲で表示します。画面の上で分からないことと、モデルが持っている本当の状態は、別のものです。

C に着くと observed=True になり、船が行ったことを記録します。それだけでは reported(報告済み)にはなりません。H に帰った日の、カードと補給の処理が終わったあとで reported=True に変わり、机に報告が届きます。visited は船が見たこと、reported は家に届いた情報です。船の荷台の数は自分の船が知っている値として表示しますが、島の在庫の合計は、帰るまでかくします。

public(s) を画面への入り口にし、C についての値は reported に合わせて見せます。C に関係する航海中の remaining も「?」です。船を本当の距離どおりに動かすと、残りの日数が図からもれてしまうので、航海中はいつも線のまん中に描きます。forecast は、次のカードの見せてよい説明だけを作り、かくれた在庫で、ループ全体の利益を先読みしません。C での L の予告も、在庫はふせます。

判定は報告のあとで、warehouse>=6 and reported and day<=LIMIT です。24日目ちょうども成功です。25日目以降に条件をそろえても達成の記録は作りませんが、船や資材を失わせず、観察は続けられます。一度記録した achieved の日付は、そのあと何日進めても残ります。上の期限の表示が終わりになっても Space で動けるのは、不具合ではありません。

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

04_report.py / 注目する部分
    if at == "H" and not target and observed and not reported:
        reported = True
        events.append("C REPORT DELIVERED HOME")
    achieved = s.achieved or (day if warehouse >= GOAL and reported and day <= LIMIT else 0)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_report.py
macOS / Linux
.venv/bin/python 04_report.py
画面で見ること最初の4枚で Space を12回(H の倉庫4)。H で MA・MC・L・MA・MH・U に編集して、Space を10回押します。全体で DAY 16 に C に行って未報告、DAY 21 に H に帰って報告、DAY 22 で倉庫6・ACHIEVED ON DAY 22(22日目に達成)です。
  • C に行ったことと、H への報告を分ける
  • 予告・地図・在庫・航海の表示のどれでも、かくす情報を守る
  • 24日目は達成できる。期限のあとも観察は続けられる
つまずきやすいところC に行った瞬間に情報を見せないようにしましょう。observed と reported は別々の印にします。期限をこえたら強制的に負けにする、ということもしません。達成の判定より前に、日数だけで return すると、24日目の成功を取りこぼしてしまいます。
少し変えてみよう

LIMIT を22にして、上の計画をもう一度実行すると、最後の日ちょうどで成功します。21なら、DAY 22 で報告と倉庫6がそろっても achieved は0です。その先も進められます。確かめたら24に戻します。別に作った状態のテストで、元のゲームの「24日目は成功、25日目は未達成」も確かめています。

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

STAGE = 4
NODES = 'HABC'
CARDS = ('MA', 'MB', 'MC', 'MH', 'L', 'U', 'W')
EDGES = (('H', 'A', 1), ('H', 'B', 1), ('A', 'C', 1))
CAPACITY = 2
STOCK_LIMIT = 4
INITIAL_STOCK = (4, 2, 4)
PERIOD = (3, 2, 0)
DEFAULT = ('MA', 'L', 'MH', 'U')
LIMIT = 24
GOAL = 6
C_BOUNDS = (1, 2)

@dataclass(frozen=True)
class State:
    day: int = 0
    at: str = "H"
    target: str = ""
    remaining: int = 0
    cargo: int = 0
    stock: tuple = INITIAL_STOCK
    warehouse: int = 0
    program: tuple = DEFAULT
    pc: int = 0
    halted: bool = False
    observed: bool = False
    reported: bool = False
    achieved: int = 0
    fault: bool = False
    last: tuple = ()

def initial():
    return State()

def edge(a, b):
    for left, right, days in EDGES:
        if {a, b} == {left, right}:
            return days
    return None


def at_home(s):
    return s.at == "H" and not s.target


def step(s, action):
    """現在のStateを変更せず、次のStateを返す。描画もここを変更しない。"""
    if not isinstance(action, str):
        raise ValueError("action must be text")
    if action.startswith("program:"):
        cards = tuple(action[8:].split(","))
        if not at_home(s):
            return replace(s, last=("EDIT ONLY AT HOME",))
        if not 1 <= len(cards) <= 6 or any(card not in CARDS for card in cards):
            return replace(s, last=("USE 1 TO 6 KNOWN CARDS",))
        return replace(s, program=cards, pc=0, halted=False, fault=False,
                       last=("PROGRAM SAVED: NO DAY COST",))
    manual = action.startswith("manual:")
    if action != "day" and not manual:
        raise ValueError("unknown action")
    if manual and action[7:] not in CARDS:
        raise ValueError("unknown card")
    if s.halted and not manual and not s.target:
        return s
    card = action[7:] if manual else s.program[s.pc]
    day = s.day + 1
    at, target, remaining = s.at, s.target, s.remaining
    events = []
    cargo, stock, warehouse = s.cargo, list(s.stock), s.warehouse
    pc = s.pc
    halted = s.halted if s.target else False
    observed, reported = s.observed, s.reported
    if target:
        # 出港済みなら航行だけ。到着してもカードを読まない。
        remaining -= 1
        if remaining == 0:
            at, target = target, ""
            events.append("ARRIVED: NEXT CARD TOMORROW")
            if at == "C":
                observed = True
        else:
            events.append("TRAVELLING: NO NEW CARD")
    else:
        if card.startswith("M"):
            destination = card[1:]
            distance = edge(at, destination)
            if distance is None:
                return replace(s, fault=True, halted=True, last=("NO ROUTE: NO DAY COST",))
            target, remaining = destination, distance
            events.append("DEPARTED: ARRIVAL LATER")
        elif card == "L":
            if at != "H":
                i = NODES.index(at) - 1
                amount = min(CAPACITY - cargo, stock[i])
                cargo += amount
                stock[i] -= amount
            events.append("LOAD: EVEN EMPTY COSTS ONE DAY")
        elif card == "U":
            if at == "H":
                warehouse += cargo
                cargo = 0
            else:
                i = NODES.index(at) - 1
                amount = min(cargo, STOCK_LIMIT - stock[i])
                cargo -= amount
                stock[i] += amount
            events.append("UNLOAD: EVEN EMPTY COSTS ONE DAY")
        else:
            events.append("WAITED ONE DAY")
        if not manual:
            pc += 1
            if pc == len(s.program):
                pc = 0
    # 積み降ろしの後に、この日の補給を一度だけ行う。
    for i, period in enumerate(PERIOD):
        if period and day % period == 0:
            stock[i] = min(STOCK_LIMIT, stock[i] + 1)
    if at == "H" and not target and observed and not reported:
        reported = True
        events.append("C REPORT DELIVERED HOME")
    achieved = s.achieved or (day if warehouse >= GOAL and reported and day <= LIMIT else 0)
    return replace(s, day=day, at=at, target=target, remaining=remaining,
                   cargo=cargo, stock=tuple(stock), warehouse=warehouse,
                   pc=pc, halted=halted,
                   observed=observed, reported=reported, achieved=achieved,
                   fault=False, last=tuple(events))


def public(s):
    """内部の確定値と、帰港済みの机で読める情報を分ける。"""
    edges = tuple((a, b, str(days) if s.reported or "C" not in (a, b)
                   else str(C_BOUNDS[0]) + "~" + str(C_BOUNDS[1]))
                  for a, b, days in EDGES)
    return {"edges": edges,
            "stock": (s.stock[0], s.stock[1], s.stock[2] if s.reported else None),
            "period": (PERIOD[0], PERIOD[1], PERIOD[2] if s.reported else None),
            "travel": s.remaining if s.reported or "C" not in (s.at, s.target) else None,
            "scene": "WATERWHEELS IN A BRIGHT BAY" if s.reported else "SEALED UNTIL HOME"}


def status(s):
    if s.achieved:
        return "ACHIEVED ON DAY " + str(s.achieved)
    if s.day >= LIMIT:
        return "DEADLINE ENDED / OBSERVATION CONTINUES"
    return "GOAL: " + str(GOAL) + " IN HOME + C REPORT BY DAY " + str(LIMIT)


def forecast(s, card=None):
    """次の一手だけを公開情報で説明。未来のStateを返さない。"""
    if s.target:
        return "TRAVEL ONE DAY; NO CARD ON ARRIVAL"
    if s.halted and card is None:
        return "ROUTE FAULT: R RESET"
    card = s.program[s.pc] if card is None else card
    if card.startswith("M"):
        destination = card[1:]
        distance = edge(s.at, destination)
        if distance is None:
            return "NO ADJACENT ROUTE: ZERO DAYS"
        if "C" in (s.at, destination) and not s.reported:
            return "DEPART 1 + TRAVEL " + str(C_BOUNDS[0]) + "~" + str(C_BOUNDS[1]) + " DAYS; STOCK ?"
        return "DEPART 1 + TRAVEL " + str(distance) + " DAYS"
    if card == "L":
        if s.at == "C" and not s.reported:
            return "LOAD TO CAPACITY; C STOCK SEALED"
        amount = 0 if s.at == "H" else min(CAPACITY - s.cargo, s.stock[NODES.index(s.at)-1])
        return "LOAD " + str(amount) + "; THEN ISLAND SUPPLY"
    if card == "U":
        return "UNLOAD TO HOME" if s.at == "H" else "UNLOAD TO ISLAND SPACE"
    return "WAIT; THEN ISLAND SUPPLY"


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

    def reset(self):
        self.state = initial()
        self.choice = 0
        self.gated = False
        self.message = "READY"
        self.manual = False
        self.edit = False
        self.draft = []

    def commit(self, action):
        self.state = step(self.state, action)
        self.message = self.state.last[-1] if self.state.last else "READY"

    def control(self, pressed, held):
        # 毎フレーム呼ぶ。全キーを離してから次の一操作へ。
        if "R" in pressed:
            self.reset()
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = "RELEASE ALL KEYS"
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        self.gated = True
        if self.edit:
            if key in ("LEFT", "RIGHT"):
                self.choice = (self.choice + (1 if key == "RIGHT" else -1)) % len(CARDS)
            elif key == "Z":
                if len(self.draft) < 6:
                    self.draft.append(CARDS[self.choice])
                else:
                    self.message = "SIX CARDS MAX"
            elif key == "BACKSPACE" and self.draft:
                self.draft.pop()
            elif key == "E":
                self.edit = False
                self.message = "EDIT CANCELLED"
            elif key == "RETURN":
                if self.draft:
                    self.commit("program:" + ",".join(self.draft))
                    self.edit = False
                else:
                    self.message = "NEED AT LEAST ONE CARD"
            return
        if key == "E":
            if at_home(self.state):
                self.edit = True
                self.draft = list(self.state.program)
                self.message = "EDIT: NO TIME PASSES"
            else:
                self.message = "EDIT ONLY AT HOME"
            return
        if key == "M":
            if at_home(self.state):
                self.manual = not self.manual
                self.message = "MANUAL" if self.manual else "PROGRAM"
            else:
                self.message = "MODE CHANGE ONLY AT HOME"
            return
        if key in ("LEFT", "RIGHT"):
            self.choice = (self.choice + (1 if key == "RIGHT" else -1)) % len(CARDS)
        elif key == "SPACE":
            if self.state.fault:
                self.message = "ROUTE FAULT: R TO RESET"
                return
            self.commit(( "manual:" + CARDS[self.choice] if self.manual else "day"))

    def draw(self, p):
        p.cls(1)
        p.text(10, 8, "CLOCKWORK ROUTES / STAGE " + str(STAGE), 10)
        s = self.state
        p.text(10, 22, "DAY " + str(s.day) + "   AT " + s.at + "   TARGET " + (s.target or "-") + ("  FAULT" if s.fault else ""), 7)
        p.text(10, 36, "CARGO " + str(s.cargo) + "/" + str(CAPACITY) + "   HOME WAREHOUSE " + str(s.warehouse), 11)
        p.text(10, 50, status(s), 10)
        view = public(s)
        positions = {"H": (74, 132), "A": (215, 96), "B": (215, 173), "C": (360, 96)}
        edges = view["edges"]
        for a, b, label in edges:
            x1, y1 = positions[a]
            x2, y2 = positions[b]
            p.line(x1, y1, x2, y2, 5)
            p.text((x1+x2)//2-12, (y1+y2)//2-11, label + "d", 6)
        for node in NODES:
            x, y = positions[node]
            p.circ(x, y, 14, 3)
            p.circb(x, y, 14, 11)
            p.text(x-2, y-3, node, 7)
            if node != "H":
                i = NODES.index(node)-1
                stock, period = view["stock"][i], view["period"][i]
                label = "STOCK " + ("?" if stock is None else str(stock))
                cycle = "?" if period is None else "NONE" if period == 0 else str(period) + "d"
                p.text(x-18, y+19, label, 7)
                p.text(x-18, y+29, "SUP " + cycle, 6)
        x, y = positions[s.at]
        if s.target:
            tx, ty = positions[s.target]
            # 常に中点に置く。C航路の実距離を船の補間位置から漏らさない。
            x, y = (x+tx)//2, (y+ty)//2
        ship_y = y-26 if s.target else y-12
        p.tri(x-5, ship_y, x+5, ship_y, x, ship_y+7, 10)
        travel = view["travel"]
        p.text(10, 215, "TRAVEL LEFT " + ("?" if travel is None else str(travel)), 7)
        p.text(155, 215, "C VISITED " + str(s.observed) + "  REPORTED " + str(s.reported), 11)
        cards = self.draft if self.edit else s.program
        for i, card in enumerate(cards):
            x = 10 + i*49
            p.rectb(x, 233, 43, 18, 10 if not self.edit and i == s.pc else 5)
            p.text(x+8, 239, card, 7)
        label = "EDIT" if self.edit else "MANUAL" if self.manual else "PROGRAM"
        p.text(322, 239, label, 10)
        card = CARDS[self.choice] if self.manual else None
        preview = "DRAFT ONLY: ENTER SAVE / E CANCEL" if self.edit else forecast(s, card)
        p.text(10, 263, preview, 6)
        p.text(10, 277, public(s)["scene"], 6)
        p.text(10, 295, "SELECT " + CARDS[self.choice] + "   " + self.message, 7)
        p.text(10, 315, "LEFT/RIGHT SELECT   SPACE STEP   R RESET   ESC QUIT", 6)
        p.text(10, 329, "M MANUAL/PROGRAM  E EDIT/CANCEL  Z APPEND", 6)
        p.text(10, 343, "IN EDIT: BACKSPACE REMOVE LAST  ENTER SAVE", 6)


DEMO_KEYS = ('SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'E', 'BACKSPACE', 'BACKSPACE', 'BACKSPACE', 'BACKSPACE', 'Z', 'RIGHT', 'RIGHT', 'Z', 'RIGHT', 'RIGHT', 'Z', 'LEFT', 'LEFT', 'LEFT', 'LEFT', 'Z', 'RIGHT', 'RIGHT', 'RIGHT', 'Z', 'RIGHT', 'RIGHT', 'Z', 'RETURN', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE')
KEYS = ('LEFT', 'RIGHT', 'SPACE', 'R', 'M', 'E', 'Z', 'BACKSPACE', 'RETURN')


def main():
    # CLIの検査はPyxelをimportする前。通常ゲームはファイルを保存しない。
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int, default=None)
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error("--demo-checkpoint requires --headless-smoke")
    if args.frames is not None and (not args.headless_smoke or not 2 <= args.frames <= 600):
        parser.error("--frames requires headless mode and a value in 2..600")
    minimum = 2*len(DEMO_KEYS)+4 if args.demo_checkpoint else 4
    if args.frames is not None and args.frames < minimum:
        parser.error("--frames too short for the requested checkpoint")
    frames = args.frames or minimum
    target = None
    if args.screenshot:
        if not args.headless_smoke:
            parser.error("--screenshot requires --headless-smoke")
        target = Path(args.screenshot).expanduser().resolve()
        if target.suffix.lower() != ".png" or target.exists() or not target.parent.is_dir():
            parser.error("screenshot needs a NEW .png file in an existing directory")
    import pyxel as p
    app = App()
    p.init(448, 384, title="Clockwork Routes", fps=30, display_scale=2,
           headless=args.headless_smoke)
    keys = {key: getattr(p, "KEY_" + key) for key in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= frames:
            if target:
                p.screenshot(str(target), scale=2)
            p.quit()
            return
        if args.headless_smoke:
            index = p.frame_count // 2
            down = ({DEMO_KEYS[index]} if args.demo_checkpoint and p.frame_count % 2 == 0
                    and index < len(DEMO_KEYS) else set())
            app.control(down, down)
        else:
            app.control({k for k, v in keys.items() if p.btnp(v)},
                        {k for k, v in keys.items() if p.btn(v)})
    p.run(update, lambda: app.draw(p))


if __name__ == "__main__":
    main()
STEP 05 / 05

履歴で戻して停止と反復を比べる

状態を丸ごと記録したものと、列の最後での制御。

Timeline は、今の State と、過去の State を並べたタプルを持ちます。進める前の State を保存し、X で最後を取り出せば、日数・場所・残りの航海・積み荷・すべての島の在庫・倉庫・pc・列・訪問・報告・達成・停止・エラーを、まとめて戻せます。ただ day を1減らすやり方では、資材や報告が未来のまま残ってしまいます。

元のゲームと同じく、「日が進んだ操作」「変化が起きる命令の編集」「航路のエラー」を履歴の対象にします。航路のエラーは0日でも状態が変わるので、Undo できます。E を開く・候補を動かす・取り消すだけでは、履歴は増えません。画面で受け付けなかった空の列や7枚目も、State を変えません。モデルに直接渡して受け付けなかった program の操作は、説明の文を変えることがあり、その変化も元のゲームの方針で履歴の対象です。日を使わない再開は、履歴に足しません。

B は、全部の状態を最初に戻しながら LOOP(くり返す)と STOP(止まる)を切りかえる、教材用の操作です。今の航海を残したまま切りかえるものではありません。STOP は、列の最後のカードを読んだときに halted=True(停止)になります。ただし、最後のカードが出航なら、航海中の Space で残りの移動を続けます。着いてから止まり、Enter で pc=0 から、日を使わずに再開します。航路のエラーには Enter は使えず、X で戻して組み直します。

H に帰れない列を実行した場合は、エラーだけを戻しても、編集できる場所にいないかもしれません。必要な回数 X を押して、H に止まっているところまで戻します。表示用のカーソルや手動モードは State の外にあるので、Undo の対象ではありません。「状態だけを戻す」「再開は履歴に足さない」ということを文章で決めて、予想できる操作にしています。ファイルへのセーブとロード、元のゲームの続けての自動再生と一時停止は省いています。

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

05_undo.py / 注目する部分
    record = (new != old and (new.day != old.day or
              action.startswith("program:") or new.fault != old.fault))
    return Timeline(new, timeline.history + (old,)) if record else Timeline(new, timeline.history)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること04 と同じ22日の計画で成功したあとに X を押すと、DAY 21・倉庫4・C は報告済み・達成0 に戻ります。Space で、DAY 22 の同じ成功になります。B で STOP にして最初に戻し、最初の4枚を6日実行すると、H の倉庫2で止まります。Enter では日は増えず、次の Space でまた出航します。
  • 資材・報告・達成もふくめた State 全体を戻す
  • 0日の航路のエラーや、編集も戻す
  • 列の最後で止まっても航海は終え、再開は日を使わない
つまずきやすいところ停止の判定を、先に条件なしで return すると、最後が移動のカードの船が海の上で固まってしまいます。halted and not target を確かめます。B は最初に戻すのといっしょの比較です。X は E で編集中には使わず、E で取り消してから押します。
少し変えてみよう

B で STOP にして、H で列を MA 1枚に編集します。Space で DAY 1 に出航・停止の印が ON、もう一度 Space で DAY 2 に A に着きます。3回目は DAY 2 のままです。Enter で日を使わずに再開して MA を実行すると、A→A は行けないので、0日で FAULT です。X でそのエラーを取り消せます。test_loop_stop_last_departure_and_free_resume などで確かめています。

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

STAGE = 5
NODES = 'HABC'
CARDS = ('MA', 'MB', 'MC', 'MH', 'L', 'U', 'W')
EDGES = (('H', 'A', 1), ('H', 'B', 1), ('A', 'C', 1))
CAPACITY = 2
STOCK_LIMIT = 4
INITIAL_STOCK = (4, 2, 4)
PERIOD = (3, 2, 0)
DEFAULT = ('MA', 'L', 'MH', 'U')
LIMIT = 24
GOAL = 6
C_BOUNDS = (1, 2)

@dataclass(frozen=True)
class State:
    day: int = 0
    at: str = "H"
    target: str = ""
    remaining: int = 0
    cargo: int = 0
    stock: tuple = INITIAL_STOCK
    warehouse: int = 0
    program: tuple = DEFAULT
    pc: int = 0
    halted: bool = False
    observed: bool = False
    reported: bool = False
    achieved: int = 0
    loop: bool = True
    fault: bool = False
    last: tuple = ()

def initial(loop=True):
    return State(loop=loop)

def edge(a, b):
    for left, right, days in EDGES:
        if {a, b} == {left, right}:
            return days
    return None


def at_home(s):
    return s.at == "H" and not s.target


def step(s, action):
    """現在のStateを変更せず、次のStateを返す。描画もここを変更しない。"""
    if not isinstance(action, str):
        raise ValueError("action must be text")
    if action == "resume":
        if s.halted and not s.fault:
            return replace(s, pc=0, halted=False, last=("RESUMED: NO DAY COST",))
        return s
    if action.startswith("program:"):
        cards = tuple(action[8:].split(","))
        if not at_home(s):
            return replace(s, last=("EDIT ONLY AT HOME",))
        if not 1 <= len(cards) <= 6 or any(card not in CARDS for card in cards):
            return replace(s, last=("USE 1 TO 6 KNOWN CARDS",))
        return replace(s, program=cards, pc=0, halted=False, fault=False,
                       last=("PROGRAM SAVED: NO DAY COST",))
    manual = action.startswith("manual:")
    if action != "day" and not manual:
        raise ValueError("unknown action")
    if manual and action[7:] not in CARDS:
        raise ValueError("unknown card")
    if s.halted and not manual and not s.target:
        return s
    card = action[7:] if manual else s.program[s.pc]
    day = s.day + 1
    at, target, remaining = s.at, s.target, s.remaining
    events = []
    cargo, stock, warehouse = s.cargo, list(s.stock), s.warehouse
    pc = s.pc
    halted = s.halted if s.target else False
    observed, reported = s.observed, s.reported
    if target:
        # 出港済みなら航行だけ。到着してもカードを読まない。
        remaining -= 1
        if remaining == 0:
            at, target = target, ""
            events.append("ARRIVED: NEXT CARD TOMORROW")
            if at == "C":
                observed = True
        else:
            events.append("TRAVELLING: NO NEW CARD")
    else:
        if card.startswith("M"):
            destination = card[1:]
            distance = edge(at, destination)
            if distance is None:
                return replace(s, fault=True, halted=True, last=("NO ROUTE: NO DAY COST",))
            target, remaining = destination, distance
            events.append("DEPARTED: ARRIVAL LATER")
        elif card == "L":
            if at != "H":
                i = NODES.index(at) - 1
                amount = min(CAPACITY - cargo, stock[i])
                cargo += amount
                stock[i] -= amount
            events.append("LOAD: EVEN EMPTY COSTS ONE DAY")
        elif card == "U":
            if at == "H":
                warehouse += cargo
                cargo = 0
            else:
                i = NODES.index(at) - 1
                amount = min(cargo, STOCK_LIMIT - stock[i])
                cargo -= amount
                stock[i] += amount
            events.append("UNLOAD: EVEN EMPTY COSTS ONE DAY")
        else:
            events.append("WAITED ONE DAY")
        if not manual:
            pc += 1
            if pc == len(s.program):
                pc = 0
                if not s.loop:
                    halted = True
                    events.append("END OF PROGRAM: ENTER TO RESUME")
    # 積み降ろしの後に、この日の補給を一度だけ行う。
    for i, period in enumerate(PERIOD):
        if period and day % period == 0:
            stock[i] = min(STOCK_LIMIT, stock[i] + 1)
    if at == "H" and not target and observed and not reported:
        reported = True
        events.append("C REPORT DELIVERED HOME")
    achieved = s.achieved or (day if warehouse >= GOAL and reported and day <= LIMIT else 0)
    return replace(s, day=day, at=at, target=target, remaining=remaining,
                   cargo=cargo, stock=tuple(stock), warehouse=warehouse,
                   pc=pc, halted=halted,
                   observed=observed, reported=reported, achieved=achieved,
                   fault=False, last=tuple(events))


def public(s):
    """内部の確定値と、帰港済みの机で読める情報を分ける。"""
    edges = tuple((a, b, str(days) if s.reported or "C" not in (a, b)
                   else str(C_BOUNDS[0]) + "~" + str(C_BOUNDS[1]))
                  for a, b, days in EDGES)
    return {"edges": edges,
            "stock": (s.stock[0], s.stock[1], s.stock[2] if s.reported else None),
            "period": (PERIOD[0], PERIOD[1], PERIOD[2] if s.reported else None),
            "travel": s.remaining if s.reported or "C" not in (s.at, s.target) else None,
            "scene": "WATERWHEELS IN A BRIGHT BAY" if s.reported else "SEALED UNTIL HOME"}


def status(s):
    if s.achieved:
        return "ACHIEVED ON DAY " + str(s.achieved)
    if s.day >= LIMIT:
        return "DEADLINE ENDED / OBSERVATION CONTINUES"
    return "GOAL: " + str(GOAL) + " IN HOME + C REPORT BY DAY " + str(LIMIT)


def forecast(s, card=None):
    """次の一手だけを公開情報で説明。未来のStateを返さない。"""
    if s.target:
        return "TRAVEL ONE DAY; NO CARD ON ARRIVAL"
    if s.halted and card is None:
        return "ROUTE FAULT: X UNDO OR R RESET" if s.fault else "STOPPED: ENTER RESUMES FOR FREE"
    card = s.program[s.pc] if card is None else card
    if card.startswith("M"):
        destination = card[1:]
        distance = edge(s.at, destination)
        if distance is None:
            return "NO ADJACENT ROUTE: ZERO DAYS"
        if "C" in (s.at, destination) and not s.reported:
            return "DEPART 1 + TRAVEL " + str(C_BOUNDS[0]) + "~" + str(C_BOUNDS[1]) + " DAYS; STOCK ?"
        return "DEPART 1 + TRAVEL " + str(distance) + " DAYS"
    if card == "L":
        if s.at == "C" and not s.reported:
            return "LOAD TO CAPACITY; C STOCK SEALED"
        amount = 0 if s.at == "H" else min(CAPACITY - s.cargo, s.stock[NODES.index(s.at)-1])
        return "LOAD " + str(amount) + "; THEN ISLAND SUPPLY"
    if card == "U":
        return "UNLOAD TO HOME" if s.at == "H" else "UNLOAD TO ISLAND SPACE"
    return "WAIT; THEN ISLAND SUPPLY"


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


def apply(timeline, action):
    if action == "undo":
        if not timeline.history:
            return timeline
        return Timeline(timeline.history[-1], timeline.history[:-1])
    old = timeline.state
    new = step(old, action)
    # 原作の履歴対象: 日、命令編集、航路エラー。無料再開は対象外。
    record = (new != old and (new.day != old.day or
              action.startswith("program:") or new.fault != old.fault))
    return Timeline(new, timeline.history + (old,)) if record else Timeline(new, timeline.history)


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

    def reset(self, loop=True):
        self.timeline = Timeline(initial(loop))
        self.choice = 0
        self.gated = False
        self.message = "READY"
        self.manual = False
        self.edit = False
        self.draft = []

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

    def commit(self, action):
        self.timeline = apply(self.timeline, action)
        self.message = self.state.last[-1] if self.state.last else "READY"

    def control(self, pressed, held):
        # 毎フレーム呼ぶ。全キーを離してから次の一操作へ。
        if "R" in pressed:
            self.reset(self.state.loop)
            self.gated = True
            return
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = "RELEASE ALL KEYS"
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        self.gated = True
        if self.edit:
            if key in ("LEFT", "RIGHT"):
                self.choice = (self.choice + (1 if key == "RIGHT" else -1)) % len(CARDS)
            elif key == "Z":
                if len(self.draft) < 6:
                    self.draft.append(CARDS[self.choice])
                else:
                    self.message = "SIX CARDS MAX"
            elif key == "BACKSPACE" and self.draft:
                self.draft.pop()
            elif key == "E":
                self.edit = False
                self.message = "EDIT CANCELLED"
            elif key == "RETURN":
                if self.draft:
                    self.commit("program:" + ",".join(self.draft))
                    self.edit = False
                else:
                    self.message = "NEED AT LEAST ONE CARD"
            return
        if key == "E":
            if at_home(self.state):
                self.edit = True
                self.draft = list(self.state.program)
                self.message = "EDIT: NO TIME PASSES"
            else:
                self.message = "EDIT ONLY AT HOME"
            return
        if key == "M":
            if at_home(self.state):
                self.manual = not self.manual
                self.message = "MANUAL" if self.manual else "PROGRAM"
            else:
                self.message = "MODE CHANGE ONLY AT HOME"
            return
        if key == "X":
            self.timeline = apply(self.timeline, "undo")
            self.message = "UNDO" if self.state.last else "BACK TO START"
            return
        if key == "B":
            self.reset(not self.state.loop)
            self.gated = True
            self.message = "RESET WITH " + ("LOOP" if self.state.loop else "STOP")
            return
        if key == "RETURN":
            if not self.manual:
                self.commit("resume")
            return
        if key in ("LEFT", "RIGHT"):
            self.choice = (self.choice + (1 if key == "RIGHT" else -1)) % len(CARDS)
        elif key == "SPACE":
            if self.state.fault:
                self.message = "ROUTE FAULT: X TO UNDO / R TO RESET"
                return
            self.commit(( "manual:" + CARDS[self.choice] if self.manual else "day"))

    def draw(self, p):
        p.cls(1)
        p.text(10, 8, "CLOCKWORK ROUTES / STAGE " + str(STAGE), 10)
        s = self.state
        p.text(10, 22, "DAY " + str(s.day) + "   AT " + s.at + "   TARGET " + (s.target or "-") + ("  FAULT" if s.fault else ""), 7)
        p.text(10, 36, "CARGO " + str(s.cargo) + "/" + str(CAPACITY) + "   HOME WAREHOUSE " + str(s.warehouse), 11)
        p.text(10, 50, status(s), 10)
        view = public(s)
        positions = {"H": (74, 132), "A": (215, 96), "B": (215, 173), "C": (360, 96)}
        edges = view["edges"]
        for a, b, label in edges:
            x1, y1 = positions[a]
            x2, y2 = positions[b]
            p.line(x1, y1, x2, y2, 5)
            p.text((x1+x2)//2-12, (y1+y2)//2-11, label + "d", 6)
        for node in NODES:
            x, y = positions[node]
            p.circ(x, y, 14, 3)
            p.circb(x, y, 14, 11)
            p.text(x-2, y-3, node, 7)
            if node != "H":
                i = NODES.index(node)-1
                stock, period = view["stock"][i], view["period"][i]
                label = "STOCK " + ("?" if stock is None else str(stock))
                cycle = "?" if period is None else "NONE" if period == 0 else str(period) + "d"
                p.text(x-18, y+19, label, 7)
                p.text(x-18, y+29, "SUP " + cycle, 6)
        x, y = positions[s.at]
        if s.target:
            tx, ty = positions[s.target]
            # 常に中点に置く。C航路の実距離を船の補間位置から漏らさない。
            x, y = (x+tx)//2, (y+ty)//2
        ship_y = y-26 if s.target else y-12
        p.tri(x-5, ship_y, x+5, ship_y, x, ship_y+7, 10)
        travel = view["travel"]
        p.text(10, 215, "TRAVEL LEFT " + ("?" if travel is None else str(travel)), 7)
        p.text(155, 215, "C VISITED " + str(s.observed) + "  REPORTED " + str(s.reported), 11)
        cards = self.draft if self.edit else s.program
        for i, card in enumerate(cards):
            x = 10 + i*49
            p.rectb(x, 233, 43, 18, 10 if not self.edit and i == s.pc else 5)
            p.text(x+8, 239, card, 7)
        label = "EDIT" if self.edit else "MANUAL" if self.manual else "PROGRAM"
        p.text(322, 239, label, 10)
        card = CARDS[self.choice] if self.manual else None
        preview = "DRAFT ONLY: ENTER SAVE / E CANCEL" if self.edit else forecast(s, card)
        p.text(10, 263, preview, 6)
        p.text(10, 277, public(s)["scene"], 6)
        p.text(10, 295, "SELECT " + CARDS[self.choice] + "   " + self.message, 7)
        p.text(10, 315, "LEFT/RIGHT SELECT   SPACE STEP   R RESET   ESC QUIT", 6)
        p.text(10, 329, "M MANUAL/PROGRAM  E EDIT/CANCEL  Z APPEND", 6)
        p.text(10, 343, "IN EDIT: BACKSPACE REMOVE LAST  ENTER SAVE", 6)
        p.text(10, 362, "X UNDO  ENTER RESUME  B RESET LOOP/STOP  " + ("LOOP" if s.loop else "STOP") + " H:" + str(len(self.timeline.history)), 10)


DEMO_KEYS = ('SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'E', 'BACKSPACE', 'BACKSPACE', 'BACKSPACE', 'BACKSPACE', 'Z', 'RIGHT', 'RIGHT', 'Z', 'RIGHT', 'RIGHT', 'Z', 'LEFT', 'LEFT', 'LEFT', 'LEFT', 'Z', 'RIGHT', 'RIGHT', 'RIGHT', 'Z', 'RIGHT', 'RIGHT', 'Z', 'RETURN', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'X', 'SPACE')
KEYS = ('LEFT', 'RIGHT', 'SPACE', 'R', 'M', 'E', 'Z', 'BACKSPACE', 'RETURN', 'X', 'B')


def main():
    # CLIの検査はPyxelをimportする前。通常ゲームはファイルを保存しない。
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int, default=None)
    parser.add_argument("--screenshot")
    args = parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error("--demo-checkpoint requires --headless-smoke")
    if args.frames is not None and (not args.headless_smoke or not 2 <= args.frames <= 600):
        parser.error("--frames requires headless mode and a value in 2..600")
    minimum = 2*len(DEMO_KEYS)+4 if args.demo_checkpoint else 4
    if args.frames is not None and args.frames < minimum:
        parser.error("--frames too short for the requested checkpoint")
    frames = args.frames or minimum
    target = None
    if args.screenshot:
        if not args.headless_smoke:
            parser.error("--screenshot requires --headless-smoke")
        target = Path(args.screenshot).expanduser().resolve()
        if target.suffix.lower() != ".png" or target.exists() or not target.parent.is_dir():
            parser.error("screenshot needs a NEW .png file in an existing directory")
    import pyxel as p
    app = App()
    p.init(448, 384, title="Clockwork Routes", fps=30, display_scale=2,
           headless=args.headless_smoke)
    keys = {key: getattr(p, "KEY_" + key) for key in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= frames:
            if target:
                p.screenshot(str(target), scale=2)
            p.quit()
            return
        if args.headless_smoke:
            index = p.frame_count // 2
            down = ({DEMO_KEYS[index]} if args.demo_checkpoint and p.frame_count % 2 == 0
                    and index < len(DEMO_KEYS) else set())
            app.control(down, down)
        else:
            app.control({k for k, v in keys.items() if p.btnp(v)},
                        {k for k, v in keys.items() if p.btn(v)})
    p.run(update, lambda: app.draw(p))


if __name__ == "__main__":
    main()
AFTER STEP 05

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

元のゲームの海域 A です。H–A/H–B/A–C の距離は1、C は帰るまで「1〜2日」の範囲で表示します。積み荷の上限は2、島の在庫の上限は4、在庫は 4/2/4・周期は 3/2/0 です。出航に1日と、距離の日数がかかり、着いた日には次のカードを読みません。24日以内に、H の倉庫6個と C の報告をそろえます。燃料はありません。期限のあとも観察を続けられます。

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

比べる点5ステップの教材元の完成版
段階01 は H/A/B の手動の航海、02 は在庫、03 は命令の列、04 は元のゲームの海域 A の探索と目標、05 は Undo と列の最後での停止です。A・B・C の3つの海域と、手動・自動の続けての再生があります。
操作Space で1日ずつ進めます。編集は、最後に追加・最後を削除です。B は最初に戻して停止を比べます。R はすぐに最初に戻し、Esc はすぐ終了します。枠を選ぶ・置きかえる・削除する編集、Enter での自動再生と P での一時停止、やり直しと終了の確認の画面があります。
表示文字(ASCII)と図形です。C の情報は public と forecast を通して見せ、船は航海中の線のまん中に描きます。日本語の文字と、島の報告の画面があります。
記録05 は、メモリの中だけの Undo です。ファイルへのセーブとロードはありません。テスト用の画像は、コマンドではっきり指定したときだけ出し、すでにあるファイルは受け付けません。Undo、UTF-8 での保存と読みこみ、保存のチェックがあります。

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

  1. model.py: step の出港→航行→カード→補給→報告→達成
  2. model.py: public / forecast の未報告情報の封印
  3. controller.py: apply の日・編集・エラー履歴
  4. experiments.py の手動と命令列の比較、A/B/Cと反復/停止
自分のゲームに変えるなら

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

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