シミュレーションLESSON 28潮と乗客の運搬パズル原作042

小さな渡し船の作り方

乗っている人数で、通れる航路が変わる。
乗客がどこにいるか、潮の高さ、浅瀬と深い水路の所要時間を分けて考え、1回の航海をまとめて確定する船のパズルを作ります。

完成版の遊び方定員3人の船で、全員を目的の港へ運びます。出発するときの人数と潮に合わせて、近道の浅瀬か、時間のかかる深い水路かを選びます。

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

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

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

完成版の主な操作:矢印乗客/便、Z乗降/出航、Space一刻待つ、X戻す、R再開、P停止

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

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

うまく表示されないとき

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

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

出発するときの条件と、航海中の変化を分ける。

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

1. 書きかえない状態と、乗客の居場所の移しかえ
2. 1刻ぶんの処理の順番を、1つの関数にまとめる
3. 出発するときの条件と、時間がたつことの違い
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上と .venv / 関数・if・for・タプルとリスト / dataclass と replace は読みながら学べます
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:乗客・便を選ぶ / Z:乗降・出航 / Space:一刻待機(02以降) / X:一判断Undo(05のみ) / R:初期化 / Esc:終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_ownership.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

乗客の居場所を一つにする

書きかえない状態と、乗客の居場所の移しかえ。

最初は、港 H で4人が待っている状態から、乗り降りだけを練習します。元のゲームの状況 B では、s は時刻1に港 A に現れます。でもこの段階では、定員オーバーをすぐ試せるように、4人とも最初から H に置いています。まだ出航・待機・勝ち負けはありません。

owners は、乗客と同じ順番に「その人がどこにいるか」を並べたタプルです。H は港、boat は船です。Z を押すと、選んだ1人の居場所を変えます。もし「港で待つ人のリスト」と「船に乗っている人のリスト」を別々に持つと、片方から消し忘れて、同じ人を2回数えてしまいがちです。1人につき居場所を1つだけ持たせれば、この問題を防げます。

State は frozen=True の dataclass(あとから書きかえられないデータ)です。変えたい owners をいったん list にして、1人分だけ置きかえ、tuple に戻してから、replace で新しい State を返します。元の State はそのまま残ります。apply は「新しい状態」「理由」「航海の記録」を返します。この段階では、航海の記録は空です。

先に「その人が船に乗っているか」を調べるので、満員でも降りることはできます。乗るときだけ、人数が CAPACITY(定員)より少ないかを確かめます。乗り降りでは t(時刻)は変わりません。何回画面を描いても、どれだけ長く考えても、時刻は0のままです。

キーの同時押しは、「今押したキー」と「押し続けているキー」の両方を合わせて判定します。複数のキーが押されていたら受け付けずに locked(ロック)にし、全部のキーを離すまで、新しいキーも受け付けません。たとえば左と右を同時に押して受け付けられなかった直後に、左を押したまま Z を押しても、乗船しません。R だけは、押した瞬間に最優先で最初の状態に戻し、そのあとも全部のキーを離すまで待ちます。

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

01_ownership.py / 注目する部分
        owners[index] = "boat"
        # 船と港の二つのリストに入れず、一人につき所有者一つ。
        return replace(state, owners=tuple(owners)), "load", ()

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_ownership.py
macOS / Linux
.venv/bin/python 01_ownership.py
画面で見ることZ、右、Z、右、Z と押すと、3人が boat(船)に乗ります。さらに右、Z と押すと「FULL: CAPACITY 3」と出て、s は H のままです。左、Z と押すと r が H に戻り、船には2人、時刻は0です。
  • 1人の居場所はいつも1つ
  • 満員でも降りられる
  • 乗り降りでは時刻は0のまま
つまずきやすいところ船の人数だけを増やして、港にもその人を残したままにすると、同じ人が2か所にいることになります。また、定員のチェックを降りる処理より先に置くと、満員の船から降りられなくなります。
少し変えてみよう

CAPACITY = 3 を2に変えて、もう一度起動してみましょう。p と q を乗せたあと、r の乗船は受け付けられず、時刻0・船に2人・r と s は H のままになります。確かめたら3に戻します。

01_ownership.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(198行)
01_ownership.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。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace

CAPACITY = 3
PORTS = ("H", "A", "B")
PEOPLE = ("p", "q", "r", "s")


@dataclass(frozen=True)
class State:
    t: int = 0
    port: str = "H"
    owners: tuple = ("H", "H", "H", "H")


def initial():
    return State()


def onboard(state):
    return state.owners.count("boat")



def apply(state, action):
    if not isinstance(action, tuple) or not action:
        return state, "invalid", ()
    if action[0] == "toggle" and len(action) == 2:
        index = action[1]
        if type(index) is not int or not 0 <= index < len(PEOPLE):
            return state, "invalid", ()
        owners = list(state.owners)
        if owners[index] == "boat":
            owners[index] = state.port
            return replace(state, owners=tuple(owners)), "unload", ()
        if owners[index] != state.port:
            return state, "absent", ()
        if onboard(state) >= CAPACITY:
            return state, "capacity", ()
        owners[index] = "boat"
        # 船と港の二つのリストに入れず、一人につき所有者一つ。
        return replace(state, owners=tuple(owners)), "load", ()
    return state, "invalid", ()


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

    def reset(self):
        self.state = initial()
        self.selected = 0
        self.locked = False
        self.reason = "ready"
        self.last_trace = ()

    def choices(self):
        passengers = tuple(("toggle", i) for i in range(len(PEOPLE)))
        return passengers

    def perform(self, action):
        self.state, self.reason, self.last_trace = apply(self.state, action)
        self.selected = min(self.selected, len(self.choices()) - 1)

    def control(self, pressed, held):
        # Rの新押しは常に最優先。再開後も全キーを離すまで待つ。
        if "R" in pressed:
            self.reset()
            self.locked = True
            return
        if self.locked:
            if not pressed and not held:
                self.locked = False
            return
        # 拒否した同時押しもロックする。残したキー+新しいキーを通さない。
        if len(pressed | held) > 1:
            self.reason = "invalid"
            self.locked = True
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key in ("UP", "LEFT", "DOWN", "RIGHT"):
            direction = -1 if key in ("UP", "LEFT") else 1
            self.selected = (self.selected + direction) % len(self.choices())
        elif key == "Z":
            self.perform(self.choices()[self.selected])
        else:
            return
        self.locked = True

    def draw(self, p):
        state = self.state
        p.cls(1)
        p.text(8, 8, "SMALL FERRY / STAGE 1", 10)
        p.text(8, 22, f"TIME {state.t}   PORT {state.port}   ON BOARD {onboard(state)}/{CAPACITY}", 7)
        points = {"H": (40, 68), "A": (192, 48), "B": (344, 68)}
        p.line(40, 68, 192, 48, 6)
        p.line(192, 48, 344, 68, 6)
        p.line(40, 68, 344, 68, 5)
        for port, (x, y) in points.items():
            p.rect(x-10, y-9, 20, 18, 4)
            p.text(x-2, y-3, port, 7)
            if port == state.port:
                p.tri(x-14, y+12, x+14, y+12, x+8, y+19, 10)
        p.text(8, 102, "OWNERSHIP ONLY: FOUR PEOPLE WAIT AT H", 6)
        p.text(8, 116, "PERSON     OWNER         Z TO BOARD / UNBOARD", 13)
        for i, person in enumerate(PEOPLE):
            y = 130 + i*17
            p.rect(6, y-2, 372, 16, 3 if self.selected == i else 1)
            label = f"{person}          {state.owners[i]}"
            p.text(12, y, label, 11 if state.owners[i] == "done" else 7)
        p.text(8, 227, "Z CHANGES ONE OWNER. TIME STAYS AT 0.", 11)
        p.text(8, 240, "NO TRAVEL YET. A PERSON HAS ONE OWNER.", 13)
        messages = {"ready": "SELECT A PASSENGER", "load": "BOARDED / TIME +0",
                    "unload": "UNBOARDED / TIME +0", "capacity": f"FULL: CAPACITY {CAPACITY}",
                    "absent": "PASSENGER NOT AT THIS PORT", "tide": "TOO MANY FOR THIS TIDE: DEEP OR WAIT",
                    "route": "NO SHALLOW ROUTE BETWEEN H AND B", "depart": "ARRIVED",
                    "wait": "WAITED 1 TICK", "invalid": "INVALID ACTION", "terminal": "ALL DELIVERED",
                    "sailing": "SAILING: NO ACTION", "undo": "UNDID ONE WHOLE DECISION", "empty": "NOTHING TO UNDO"}
        p.text(8, 258, messages.get(self.reason, self.reason.upper()), 10)
        p.text(8, 278, "ARROWS SELECT  Z BOARD/UNBOARD", 7)
        p.text(8, 290, "R RESET  ESC QUIT", 6)

DEMO_KEYS = ('Z', 'RIGHT', 'Z', 'RIGHT', 'Z', 'RIGHT', 'Z')


def main(headless=False, screenshot=None, demo=False, state_log=None):
    from pathlib import Path
    from dataclasses import asdict
    import json
    import pyxel as p
    app = App()
    p.init(384, 304, title="Small Ferry", fps=30, display_scale=2, headless=headless)
    keys = {key: getattr(p, "KEY_"+key)
            for key in ("UP", "DOWN", "LEFT", "RIGHT", "Z", "SPACE", "R", "X")}
    limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12
    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                path = Path(screenshot).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path), scale=2)
            if state_log:
                path = Path(state_log).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps({"state": asdict(app.state), "reason": app.reason,
                                            "trace": [asdict(s) for s in app.last_trace],
                                            "frames": p.frame_count,
                                            "input": "synthetic controller keys" if demo else "none"},
                                           ensure_ascii=False, indent=2), encoding="utf-8")
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            pressed = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
            app.control(pressed, pressed)
        else:
            app.control({key for key, code in keys.items() if p.btnp(code)},
                        {key for key, code in keys.items() if p.btn(code)})
    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")
    parser.add_argument("--state-log")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_log)
STEP 02 / 05

一刻の移動と客の出現を加える

1刻ぶんの処理の順番を、1つの関数にまとめる。

乗客のデータを、元のゲームの状況 B と同じ「出発する港・目的の港・現れる時刻」にそろえます。p・q・r は時刻0に H に、s は時刻1に A に現れます。画面では、まだ現れていない人は -、港で待っている人は港の名前、船に乗っている人は boat と表示されます。到着の希望時刻や、クリアの判定はまだ作りません。

矢印で選べるものに行き先を加え、Z で出航します。港で Space を押すと、1刻(ゲームの時間の1単位)待ちます。この段階では練習のために、どの港の間も1刻で移動できるルールにしています。元のゲームの「H と B の間は浅瀬を通れない」「深い水路は2刻かかる」は、次の段階で加えます。目的の港に着いても自動では降りないので、この段階では Z を押して、今いる港に手で降ろしてください。

tick は、時間を1刻進める関数です。時刻を1増やし、その時刻までに現れるはずの客を港に置き、航海の残り時間を減らし、残りが0になったら船を到着した港に移します。客が現れるのと船が着くのが同じ時刻なら、その操作が終わった時点で、新しい客も港にいます。客は現れただけでは自動で船に乗りません。

出航した直後は、port(今いる港)を空にし、target に行き先、remaining にかかる刻数を入れます。航海中に乗り降りなどの操作をしようとしても、sailing(航海中)として受け付けません。教材の画面には途中のアニメーションはありませんが、返り値の trace に、1刻ごとの状態を残しています。

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

02_travel.py / 注目する部分
    owners = tuple(p.source if owner == "-" and p.at <= time else owner
                   for owner, p in zip(state.owners, PEOPLE))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_travel.py
macOS / Linux
.venv/bin/python 02_travel.py
画面で見ることZ で p を乗せ、右を4回押して A 行きの便を選び、Z を押します。時刻1になり、船は A、p は boat、s は A にいます。s を選んで Z を押せば、同じ時刻1のまま乗せられます。
  • 客は、待っている間も航海中も現れる
  • 同じ時刻に現れた客は、船の到着後に乗れる
  • この段階は、どの航路も1刻で、降りるのは手動
つまずきやすいところ客が現れるかどうかを、船が今いる港だけで調べると、ほかの港の客が現れません。乗客全員を見て、居場所が -(まだ現れていない)の人だけ、現れる条件を判定します。
少し変えてみよう

PEOPLE にある s の at を1から2に変えてみましょう。p を乗せて A へ1刻で着いても、s はまだ - です。Space で時刻2に進めると A に現れ、Z で乗せても時刻は2のままです。確かめたら1に戻します。

02_travel.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(254行)
02_travel.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。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace

CAPACITY = 3
PORTS = ("H", "A", "B")

@dataclass(frozen=True)
class Passenger:
    name: str
    source: str
    destination: str
    at: int

PEOPLE = (
    Passenger("p", "H", "A", 0),
    Passenger("q", "H", "A", 0),
    Passenger("r", "H", "B", 0),
    Passenger("s", "A", "B", 1),
)


@dataclass(frozen=True)
class State:
    t: int
    port: str
    owners: tuple
    target: str = ""
    remaining: int = 0
    voyages: int = 0


def initial():
    owners = tuple(p.source if p.at == 0 else "-" for p in PEOPLE)
    return State(0, "H", owners)


def onboard(state):
    return state.owners.count("boat")


def tick(state):
    # 一刻: 時刻を増やす → 客を出す → 残りを減らす → 到着処理。
    time = state.t + 1
    owners = tuple(p.source if owner == "-" and p.at <= time else owner
                   for owner, p in zip(state.owners, PEOPLE))
    remaining = max(0, state.remaining - 1)
    port, target = state.port, state.target
    if target and remaining == 0:
        port, target = target, ""
    # この練習では自動降船・到着記録をまだ作らない。
    return replace(state, t=time, port=port, target=target,
                   remaining=remaining, owners=owners)


def apply(state, action):
    if state.target:
        return state, "sailing", ()
    if not isinstance(action, tuple) or not action:
        return state, "invalid", ()
    if action[0] == "toggle" and len(action) == 2:
        index = action[1]
        if type(index) is not int or not 0 <= index < len(PEOPLE):
            return state, "invalid", ()
        owners = list(state.owners)
        if owners[index] == "boat":
            owners[index] = state.port
            return replace(state, owners=tuple(owners)), "unload", ()
        if owners[index] != state.port:
            return state, "absent", ()
        if onboard(state) >= CAPACITY:
            return state, "capacity", ()
        owners[index] = "boat"
        # 船と港の二つのリストに入れず、一人につき所有者一つ。
        return replace(state, owners=tuple(owners)), "load", ()
    if action == ("wait",):
        next_state = tick(state)
        return next_state, "wait", (next_state,)
    if action[0] == "sail" and len(action) == 2:
        target = action[1]
        if target not in PORTS or target == state.port:
            return state, "invalid", ()
        # 練習用: 全航路を一刻とする。次の段階で浅瀬/深水を分ける。
        duration = 1
        next_state = replace(state, port="", target=target,
                             remaining=duration, voyages=state.voyages + 1)
        trace = []
        for _ in range(duration):
            next_state = tick(next_state)
            trace.append(next_state)
        return next_state, "depart", tuple(trace)
    return state, "invalid", ()


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

    def reset(self):
        self.state = initial()
        self.selected = 0
        self.locked = False
        self.reason = "ready"
        self.last_trace = ()

    def choices(self):
        passengers = tuple(("toggle", i) for i in range(len(PEOPLE)))
        return passengers + tuple(("sail", p) for p in PORTS if p != self.state.port)

    def perform(self, action):
        self.state, self.reason, self.last_trace = apply(self.state, action)
        self.selected = min(self.selected, len(self.choices()) - 1)

    def control(self, pressed, held):
        # Rの新押しは常に最優先。再開後も全キーを離すまで待つ。
        if "R" in pressed:
            self.reset()
            self.locked = True
            return
        if self.locked:
            if not pressed and not held:
                self.locked = False
            return
        # 拒否した同時押しもロックする。残したキー+新しいキーを通さない。
        if len(pressed | held) > 1:
            self.reason = "invalid"
            self.locked = True
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key in ("UP", "LEFT", "DOWN", "RIGHT"):
            direction = -1 if key in ("UP", "LEFT") else 1
            self.selected = (self.selected + direction) % len(self.choices())
        elif key == "Z":
            self.perform(self.choices()[self.selected])
        elif key == "SPACE":
            self.perform(("wait",))
        else:
            return
        self.locked = True

    def draw(self, p):
        state = self.state
        p.cls(1)
        p.text(8, 8, "SMALL FERRY / STAGE 2", 10)
        p.text(8, 22, f"TIME {state.t}   PORT {state.port}   ON BOARD {onboard(state)}/{CAPACITY}", 7)
        points = {"H": (40, 68), "A": (192, 48), "B": (344, 68)}
        p.line(40, 68, 192, 48, 6)
        p.line(192, 48, 344, 68, 6)
        p.line(40, 68, 344, 68, 5)
        for port, (x, y) in points.items():
            p.rect(x-10, y-9, 20, 18, 4)
            p.text(x-2, y-3, port, 7)
            if port == state.port:
                p.tri(x-14, y+12, x+14, y+12, x+8, y+19, 10)
        p.text(8, 102, "TRAINING: ALL ROUTES 1 TICK / MANUAL UNLOAD", 6)
        p.text(8, 116, "PERSON   FROM>TO  SPAWN   OWNER", 13)
        for i, person in enumerate(PEOPLE):
            y = 130 + i*17
            p.rect(6, y-2, 372, 16, 3 if self.selected == i else 1)
            label = f"{person.name}        {person.source}>{person.destination}      {person.at}       {state.owners[i]}"
            p.text(12, y, label, 11 if state.owners[i] == "done" else 7)
        for i, action in enumerate(self.choices()[len(PEOPLE):]):
            x = 6 + i*94
            selected = self.selected == len(PEOPLE)+i
            p.rectb(x, 201, 89, 18, 10 if selected else 5)
            label = action[1] + " / 1 TICK"
            p.text(x+4, 207, label, 10 if selected else 7)
        p.text(8, 227, "LAST VOYAGE TICKS: " + ", ".join(str(s.t) for s in self.last_trace), 11)
        p.text(8, 240, "NO AUTO-DROPOFF OR WIN YET: OWNERSHIP PRACTICE", 13)
        messages = {"ready": "SELECT A PASSENGER OR ROUTE", "load": "BOARDED / TIME +0",
                    "unload": "UNBOARDED / TIME +0", "capacity": f"FULL: CAPACITY {CAPACITY}",
                    "absent": "PASSENGER NOT AT THIS PORT", "tide": "TOO MANY FOR THIS TIDE: DEEP OR WAIT",
                    "route": "NO SHALLOW ROUTE BETWEEN H AND B", "depart": "ARRIVED",
                    "wait": "WAITED 1 TICK", "invalid": "INVALID ACTION", "terminal": "ALL DELIVERED",
                    "sailing": "SAILING: NO ACTION", "undo": "UNDID ONE WHOLE DECISION", "empty": "NOTHING TO UNDO"}
        p.text(8, 258, messages.get(self.reason, self.reason.upper()), 10)
        p.text(8, 278, "ARROWS SELECT  Z BOARD/UNBOARD/SAIL  SPACE WAIT", 7)
        p.text(8, 290, "R RESET  ESC QUIT", 6)

DEMO_KEYS = ('Z', 'RIGHT', 'RIGHT', 'RIGHT', 'RIGHT', 'Z')


def main(headless=False, screenshot=None, demo=False, state_log=None):
    from pathlib import Path
    from dataclasses import asdict
    import json
    import pyxel as p
    app = App()
    p.init(384, 304, title="Small Ferry", fps=30, display_scale=2, headless=headless)
    keys = {key: getattr(p, "KEY_"+key)
            for key in ("UP", "DOWN", "LEFT", "RIGHT", "Z", "SPACE", "R", "X")}
    limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12
    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                path = Path(screenshot).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path), scale=2)
            if state_log:
                path = Path(state_log).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps({"state": asdict(app.state), "reason": app.reason,
                                            "trace": [asdict(s) for s in app.last_trace],
                                            "frames": p.frame_count,
                                            "input": "synthetic controller keys" if demo else "none"},
                                           ensure_ascii=False, indent=2), encoding="utf-8")
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            pressed = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
            app.control(pressed, pressed)
        else:
            app.control({key for key, code in keys.items() if p.btnp(code)},
                        {key for key, code in keys.items() if p.btn(code)})
    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")
    parser.add_argument("--state-log")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_log)
STEP 03 / 05

定員と浅瀬の上限を分ける

出発するときの条件と、時間がたつことの違い。

船の定員は3人ですが、浅瀬を通れる人数は、出発するときの潮の高さで決まります。TIDES=(1, 2, 3, 2) を、t を4で割った余りでくり返し使います。時刻0では、船に3人乗せることはできても、浅瀬を通れるのは1人までなので、3人で浅瀬へ出る操作は受け付けられません。「船の定員」と「浅瀬を通れる人数の上限」を同じ変数にしないことが大切です。

H-A と A-B の間は、浅瀬なら1刻、深い水路なら2刻です。H-B の間は深い水路(2刻)しかありません。shallow は港の組を集合で調べるので、行きと帰りに同じルールを使えます。便の選択肢は、ほかの2つの港それぞれについて「浅瀬・深い水路」の順に並んでいます。存在しない H-B の浅瀬を選んだときは、その理由を表示します。

浅瀬では、出発する前の船の人数と潮を比べます。通れると決めたあとで、tick によって潮が変わっても、到着は取り消しません。深い水路では潮を調べず、tick を2回呼びます。1回目のあとは新しい客が現れていて、船はまだ航海中です。2回目で到着します。trace には、この2つの状態が入ります。

この段階でも、自動で降りる処理・希望時刻の記録・クリアの判定は省いています。港に着いたら、乗客を手で降ろす練習を続けます。潮が変わるのを待つ1刻の間にも客が現れるので、Space(待つ)と出航の両方で同じ tick を使うようにまとめた効果が見えます。

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

03_tides.py / 注目する部分
        # 潮の検査は出発時だけ。航行中に次の潮で罰しない。
        duration = 2 if route == "deep" else 1

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_tides.py
macOS / Linux
.venv/bin/python 03_tides.py
画面で見ることp・q・r を乗せて「A SHALLOW 1」を Z で選ぶと、時刻0のまま「TOO MANY」(人数が多すぎる)と出ます。右の「A DEEP 2」を選ぶと、時刻2に A に着き、船には3人、s は A にいます。「LAST VOYAGE TICKS」は 1, 2 です。
  • 定員3と、潮で決まる人数の上限は別のもの
  • 深い水路は2刻かかり、潮は関係ない
  • 浅瀬の人数チェックは出発するときだけ
つまずきやすいところ浅瀬を通れるかの判定に、到着したあとの潮を使うと、出発するときには通れたはずの便が受け付けられなくなります。人数と潮を確かめるのは、航海を始める前です。
少し変えてみよう

TIDES を (3, 2, 1, 2) に変えてみましょう。p・q・r の3人が、時刻0から A 行きの浅瀬の便に乗れて、時刻1に A に着きます。到着したときの上限が2でも、罰はありません。確かめたら (1, 2, 3, 2) に戻します。

03_tides.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(271行)
03_tides.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。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace

CAPACITY = 3
PORTS = ("H", "A", "B")

@dataclass(frozen=True)
class Passenger:
    name: str
    source: str
    destination: str
    at: int

PEOPLE = (
    Passenger("p", "H", "A", 0),
    Passenger("q", "H", "A", 0),
    Passenger("r", "H", "B", 0),
    Passenger("s", "A", "B", 1),
)
TIDES = (1, 2, 3, 2)


@dataclass(frozen=True)
class State:
    t: int
    port: str
    owners: tuple
    target: str = ""
    remaining: int = 0
    voyages: int = 0


def initial():
    owners = tuple(p.source if p.at == 0 else "-" for p in PEOPLE)
    return State(0, "H", owners)


def onboard(state):
    return state.owners.count("boat")


def tide(state):
    return TIDES[state.t % len(TIDES)]


def shallow(source, target):
    return source != target and {source, target} in ({"H", "A"}, {"A", "B"})


def tick(state):
    # 一刻: 時刻を増やす → 客を出す → 残りを減らす → 到着処理。
    time = state.t + 1
    owners = tuple(p.source if owner == "-" and p.at <= time else owner
                   for owner, p in zip(state.owners, PEOPLE))
    remaining = max(0, state.remaining - 1)
    port, target = state.port, state.target
    if target and remaining == 0:
        port, target = target, ""
    # この練習では自動降船・到着記録をまだ作らない。
    return replace(state, t=time, port=port, target=target,
                   remaining=remaining, owners=owners)


def apply(state, action):
    if state.target:
        return state, "sailing", ()
    if not isinstance(action, tuple) or not action:
        return state, "invalid", ()
    if action[0] == "toggle" and len(action) == 2:
        index = action[1]
        if type(index) is not int or not 0 <= index < len(PEOPLE):
            return state, "invalid", ()
        owners = list(state.owners)
        if owners[index] == "boat":
            owners[index] = state.port
            return replace(state, owners=tuple(owners)), "unload", ()
        if owners[index] != state.port:
            return state, "absent", ()
        if onboard(state) >= CAPACITY:
            return state, "capacity", ()
        owners[index] = "boat"
        # 船と港の二つのリストに入れず、一人につき所有者一つ。
        return replace(state, owners=tuple(owners)), "load", ()
    if action == ("wait",):
        next_state = tick(state)
        return next_state, "wait", (next_state,)
    if action[0] == "sail" and len(action) == 3:
        target, route = action[1:]
        if target not in PORTS or target == state.port or route not in ("shallow", "deep"):
            return state, "invalid", ()
        if route == "shallow":
            if not shallow(state.port, target):
                return state, "route", ()
            if onboard(state) > tide(state):
                return state, "tide", ()
        # 潮の検査は出発時だけ。航行中に次の潮で罰しない。
        duration = 2 if route == "deep" else 1
        next_state = replace(state, port="", target=target,
                             remaining=duration, voyages=state.voyages + 1)
        trace = []
        for _ in range(duration):
            next_state = tick(next_state)
            trace.append(next_state)
        return next_state, "depart", tuple(trace)
    return state, "invalid", ()


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

    def reset(self):
        self.state = initial()
        self.selected = 0
        self.locked = False
        self.reason = "ready"
        self.last_trace = ()

    def choices(self):
        passengers = tuple(("toggle", i) for i in range(len(PEOPLE)))
        routes = tuple(("sail", p, route) for p in PORTS if p != self.state.port
                       for route in ("shallow", "deep"))
        return passengers + routes

    def perform(self, action):
        self.state, self.reason, self.last_trace = apply(self.state, action)
        self.selected = min(self.selected, len(self.choices()) - 1)

    def control(self, pressed, held):
        # Rの新押しは常に最優先。再開後も全キーを離すまで待つ。
        if "R" in pressed:
            self.reset()
            self.locked = True
            return
        if self.locked:
            if not pressed and not held:
                self.locked = False
            return
        # 拒否した同時押しもロックする。残したキー+新しいキーを通さない。
        if len(pressed | held) > 1:
            self.reason = "invalid"
            self.locked = True
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key in ("UP", "LEFT", "DOWN", "RIGHT"):
            direction = -1 if key in ("UP", "LEFT") else 1
            self.selected = (self.selected + direction) % len(self.choices())
        elif key == "Z":
            self.perform(self.choices()[self.selected])
        elif key == "SPACE":
            self.perform(("wait",))
        else:
            return
        self.locked = True

    def draw(self, p):
        state = self.state
        p.cls(1)
        p.text(8, 8, "SMALL FERRY / STAGE 3", 10)
        p.text(8, 22, f"TIME {state.t}   PORT {state.port}   ON BOARD {onboard(state)}/{CAPACITY}", 7)
        points = {"H": (40, 68), "A": (192, 48), "B": (344, 68)}
        p.line(40, 68, 192, 48, 6)
        p.line(192, 48, 344, 68, 6)
        p.line(40, 68, 344, 68, 5)
        for port, (x, y) in points.items():
            p.rect(x-10, y-9, 20, 18, 4)
            p.text(x-2, y-3, port, 7)
            if port == state.port:
                p.tri(x-14, y+12, x+14, y+12, x+8, y+19, 10)
        p.text(8, 90, "H-A / A-B: SHALLOW 1 OR DEEP 2  |  H-B: DEEP 2", 6)
        p.text(8, 102, f"TIDE {TIDES}  NOW SHALLOW LIMIT {tide(state)}", 6)
        p.text(8, 116, "PERSON   FROM>TO  SPAWN   OWNER", 13)
        for i, person in enumerate(PEOPLE):
            y = 130 + i*17
            p.rect(6, y-2, 372, 16, 3 if self.selected == i else 1)
            label = f"{person.name}        {person.source}>{person.destination}      {person.at}       {state.owners[i]}"
            p.text(12, y, label, 11 if state.owners[i] == "done" else 7)
        for i, action in enumerate(self.choices()[len(PEOPLE):]):
            x = 6 + i*94
            selected = self.selected == len(PEOPLE)+i
            p.rectb(x, 201, 89, 18, 10 if selected else 5)
            label = action[1] + (" SHALLOW 1" if action[2] == "shallow" else " DEEP 2")
            p.text(x+4, 207, label, 10 if selected else 7)
        p.text(8, 227, "LAST VOYAGE TICKS: " + ", ".join(str(s.t) for s in self.last_trace), 11)
        p.text(8, 240, "NO AUTO-DROPOFF OR WIN YET: OWNERSHIP PRACTICE", 13)
        messages = {"ready": "SELECT A PASSENGER OR ROUTE", "load": "BOARDED / TIME +0",
                    "unload": "UNBOARDED / TIME +0", "capacity": f"FULL: CAPACITY {CAPACITY}",
                    "absent": "PASSENGER NOT AT THIS PORT", "tide": "TOO MANY FOR THIS TIDE: DEEP OR WAIT",
                    "route": "NO SHALLOW ROUTE BETWEEN H AND B", "depart": "ARRIVED",
                    "wait": "WAITED 1 TICK", "invalid": "INVALID ACTION", "terminal": "ALL DELIVERED",
                    "sailing": "SAILING: NO ACTION", "undo": "UNDID ONE WHOLE DECISION", "empty": "NOTHING TO UNDO"}
        p.text(8, 258, messages.get(self.reason, self.reason.upper()), 10)
        p.text(8, 278, "ARROWS SELECT  Z BOARD/UNBOARD/SAIL  SPACE WAIT", 7)
        p.text(8, 290, "R RESET  ESC QUIT", 6)

DEMO_KEYS = ('Z', 'RIGHT', 'Z', 'RIGHT', 'Z', 'RIGHT', 'RIGHT', 'Z', 'RIGHT', 'Z')


def main(headless=False, screenshot=None, demo=False, state_log=None):
    from pathlib import Path
    from dataclasses import asdict
    import json
    import pyxel as p
    app = App()
    p.init(384, 304, title="Small Ferry", fps=30, display_scale=2, headless=headless)
    keys = {key: getattr(p, "KEY_"+key)
            for key in ("UP", "DOWN", "LEFT", "RIGHT", "Z", "SPACE", "R", "X")}
    limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12
    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                path = Path(screenshot).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path), scale=2)
            if state_log:
                path = Path(state_log).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps({"state": asdict(app.state), "reason": app.reason,
                                            "trace": [asdict(s) for s in app.last_trace],
                                            "frames": p.frame_count,
                                            "input": "synthetic controller keys" if demo else "none"},
                                           ensure_ascii=False, indent=2), encoding="utf-8")
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            pressed = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
            app.control(pressed, pressed)
        else:
            app.control({key for key, code in keys.items() if p.btnp(code)},
                        {key for key, code in keys.items() if p.btn(code)})
    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")
    parser.add_argument("--state-log")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_log)
STEP 04 / 05

全員到着と希望内の記録を分ける

自動で降りる処理と、クリアの条件を整える。

ここから、元のゲームの状況 B「三人は深い道へ」の、ふつうのルールで最後まで遊べます。乗客には due(到着の希望時刻)を、State には arrived(到着した時刻)と outcome(結果)を加えます。arrived は、まだ着いていない人は -1、着いた人は実際に着いた時刻です。

船が港に着いたら、船に乗っていて、目的の港がその港の人だけを done(到着済み)にして、到着時刻を書きこみます。港で待っている人をまとめて done にしたり、別の港へ行く人まで降ろしたりはしません。たとえば深い水路で A に来た p と q は時刻2に到着しますが、B へ行く r は船に残ります。時刻1から A で待っている s は、次の操作で乗せられます。

全員が done になったら won(クリア)です。希望時刻ちょうどに着いた場合も「希望どおり」に数えますが、遅れてもゲームオーバーにはなりません。希望時刻を過ぎても客は消えず、全員を届ければクリアです。「希望どおりに着いた人数」と「全員が着くまでの時間」は別の記録なので、outcome の計算に due を混ぜません。

クリアしたあとは、乗り降り・待機・出航ができなくなり、R でやり直せます。04 には、まだ Undo(1つ前に戻す)がありません。画面の「ALL DELIVERED」は元のゲームの「全員到着」、「ON TIME」は希望どおりに着いた人数にあたります。元のゲームの状況 A と C、表紙、説明画面と一時停止、日本語の文字、航海のアニメーションは省いています。

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

04_arrivals.py / 注目する部分
    outcome = "won" if all(owner == "done" for owner in owners) else "playing"

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_arrivals.py
macOS / Linux
.venv/bin/python 04_arrivals.py
画面で見ること03 と同じように3人を乗せて A 行きの深い水路の便で着くと、p と q は done、r は boat、s は A にいます。左を2回押して s を選んで Z、右を3回押して B 行きの浅瀬の便を選んで Z を押します。時刻3・全員 done・ON TIME 4/4・VOYAGES 2 になります。
  • 目的の港に着いた乗客だけが自動で降りる
  • 希望時刻ちょうどは「希望どおり」
  • 遅れても、全員を運べばクリア
つまずきやすいところdue を過ぎたらゲームオーバー、にするのは元のゲームと違います。クリアは「全員が done かどうか」で決め、0 <= 到着時刻 <= due の人数は別に数えます。
少し変えてみよう

p と q の due だけを2から1に変えて、同じように「深い水路 → 浅瀬」の順で運んでみましょう。到着時刻は 2,2,3,3 のままで、ALL DELIVERED・ON TIME 2/4 になります。確かめたら2に戻します。

04_arrivals.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(292行)
04_arrivals.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。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace

CAPACITY = 3
PORTS = ("H", "A", "B")

@dataclass(frozen=True)
class Passenger:
    name: str
    source: str
    destination: str
    at: int
    due: int

PEOPLE = (
    Passenger("p", "H", "A", 0, 2),
    Passenger("q", "H", "A", 0, 2),
    Passenger("r", "H", "B", 0, 4),
    Passenger("s", "A", "B", 1, 4),
)
TIDES = (1, 2, 3, 2)


@dataclass(frozen=True)
class State:
    t: int
    port: str
    owners: tuple
    arrived: tuple
    target: str = ""
    remaining: int = 0
    voyages: int = 0
    outcome: str = "playing"


def initial():
    owners = tuple(p.source if p.at == 0 else "-" for p in PEOPLE)
    return State(0, "H", owners, tuple(-1 for _ in PEOPLE))


def onboard(state):
    return state.owners.count("boat")


def tide(state):
    return TIDES[state.t % len(TIDES)]


def shallow(source, target):
    return source != target and {source, target} in ({"H", "A"}, {"A", "B"})


def ontime(state):
    return sum(0 <= arrival <= person.due
               for arrival, person in zip(state.arrived, PEOPLE))


def tick(state):
    # 一刻: 時刻を増やす → 客を出す → 残りを減らす → 到着処理。
    time = state.t + 1
    owners = tuple(p.source if owner == "-" and p.at <= time else owner
                   for owner, p in zip(state.owners, PEOPLE))
    remaining = max(0, state.remaining - 1)
    port, target = state.port, state.target
    arrived = list(state.arrived)
    if target and remaining == 0:
        port, target = target, ""
        owners = list(owners)
        for i, person in enumerate(PEOPLE):
            if owners[i] == "boat" and person.destination == port:
                owners[i] = "done"
                arrived[i] = time
    outcome = "won" if all(owner == "done" for owner in owners) else "playing"
    return replace(state, t=time, port=port, target=target, remaining=remaining,
                   owners=tuple(owners), arrived=tuple(arrived), outcome=outcome)


def apply(state, action):
    if state.target:
        return state, "sailing", ()
    if state.outcome == "won":
        return state, "terminal", ()
    if not isinstance(action, tuple) or not action:
        return state, "invalid", ()
    if action[0] == "toggle" and len(action) == 2:
        index = action[1]
        if type(index) is not int or not 0 <= index < len(PEOPLE):
            return state, "invalid", ()
        owners = list(state.owners)
        if owners[index] == "boat":
            owners[index] = state.port
            return replace(state, owners=tuple(owners)), "unload", ()
        if owners[index] != state.port:
            return state, "absent", ()
        if onboard(state) >= CAPACITY:
            return state, "capacity", ()
        owners[index] = "boat"
        # 船と港の二つのリストに入れず、一人につき所有者一つ。
        return replace(state, owners=tuple(owners)), "load", ()
    if action == ("wait",):
        next_state = tick(state)
        return next_state, "wait", (next_state,)
    if action[0] == "sail" and len(action) == 3:
        target, route = action[1:]
        if target not in PORTS or target == state.port or route not in ("shallow", "deep"):
            return state, "invalid", ()
        if route == "shallow":
            if not shallow(state.port, target):
                return state, "route", ()
            if onboard(state) > tide(state):
                return state, "tide", ()
        # 潮の検査は出発時だけ。航行中に次の潮で罰しない。
        duration = 2 if route == "deep" else 1
        next_state = replace(state, port="", target=target,
                             remaining=duration, voyages=state.voyages + 1)
        trace = []
        for _ in range(duration):
            next_state = tick(next_state)
            trace.append(next_state)
        return next_state, "depart", tuple(trace)
    return state, "invalid", ()


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

    def reset(self):
        self.state = initial()
        self.selected = 0
        self.locked = False
        self.reason = "ready"
        self.last_trace = ()

    def choices(self):
        passengers = tuple(("toggle", i) for i in range(len(PEOPLE)))
        routes = tuple(("sail", p, route) for p in PORTS if p != self.state.port
                       for route in ("shallow", "deep"))
        return passengers + routes

    def perform(self, action):
        self.state, self.reason, self.last_trace = apply(self.state, action)
        self.selected = min(self.selected, len(self.choices()) - 1)

    def control(self, pressed, held):
        # Rの新押しは常に最優先。再開後も全キーを離すまで待つ。
        if "R" in pressed:
            self.reset()
            self.locked = True
            return
        if self.locked:
            if not pressed and not held:
                self.locked = False
            return
        # 拒否した同時押しもロックする。残したキー+新しいキーを通さない。
        if len(pressed | held) > 1:
            self.reason = "invalid"
            self.locked = True
            return
        if self.state.outcome == "won":
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key in ("UP", "LEFT", "DOWN", "RIGHT"):
            direction = -1 if key in ("UP", "LEFT") else 1
            self.selected = (self.selected + direction) % len(self.choices())
        elif key == "Z":
            self.perform(self.choices()[self.selected])
        elif key == "SPACE":
            self.perform(("wait",))
        else:
            return
        self.locked = True

    def draw(self, p):
        state = self.state
        p.cls(1)
        p.text(8, 8, "SMALL FERRY / STAGE 4", 10)
        p.text(8, 22, f"TIME {state.t}   PORT {state.port}   ON BOARD {onboard(state)}/{CAPACITY}", 7)
        points = {"H": (40, 68), "A": (192, 48), "B": (344, 68)}
        p.line(40, 68, 192, 48, 6)
        p.line(192, 48, 344, 68, 6)
        p.line(40, 68, 344, 68, 5)
        for port, (x, y) in points.items():
            p.rect(x-10, y-9, 20, 18, 4)
            p.text(x-2, y-3, port, 7)
            if port == state.port:
                p.tri(x-14, y+12, x+14, y+12, x+8, y+19, 10)
        p.text(8, 90, "H-A / A-B: SHALLOW 1 OR DEEP 2  |  H-B: DEEP 2", 6)
        p.text(8, 102, f"TIDE {TIDES}  NOW SHALLOW LIMIT {tide(state)}", 6)
        p.text(8, 116, "PERSON   FROM>TO  SPAWN/DUE   OWNER    ARRIVAL", 13)
        for i, person in enumerate(PEOPLE):
            y = 130 + i*17
            p.rect(6, y-2, 372, 16, 3 if self.selected == i else 1)
            arrival = state.arrived[i]
            record = "-" if arrival < 0 else f"{arrival} " + ("ON TIME" if arrival <= person.due else "LATE")
            label = f"{person.name}        {person.source}>{person.destination}      {person.at}/{person.due}       {state.owners[i]:4}     {record}"
            p.text(12, y, label, 11 if state.owners[i] == "done" else 7)
        for i, action in enumerate(self.choices()[len(PEOPLE):]):
            x = 6 + i*94
            selected = self.selected == len(PEOPLE)+i
            p.rectb(x, 201, 89, 18, 10 if selected else 5)
            label = action[1] + (" SHALLOW 1" if action[2] == "shallow" else " DEEP 2")
            p.text(x+4, 207, label, 10 if selected else 7)
        status = "ALL DELIVERED" if state.outcome == "won" else "PLAYING"
        p.text(8, 227, f"{status} / ON TIME {ontime(state)}/{len(PEOPLE)} / VOYAGES {state.voyages}", 11)
        p.text(8, 240, "LATE ARRIVAL STILL COUNTS TOWARD COMPLETION.", 13)
        messages = {"ready": "SELECT A PASSENGER OR ROUTE", "load": "BOARDED / TIME +0",
                    "unload": "UNBOARDED / TIME +0", "capacity": f"FULL: CAPACITY {CAPACITY}",
                    "absent": "PASSENGER NOT AT THIS PORT", "tide": "TOO MANY FOR THIS TIDE: DEEP OR WAIT",
                    "route": "NO SHALLOW ROUTE BETWEEN H AND B", "depart": "ARRIVED",
                    "wait": "WAITED 1 TICK", "invalid": "INVALID ACTION", "terminal": "ALL DELIVERED",
                    "sailing": "SAILING: NO ACTION", "undo": "UNDID ONE WHOLE DECISION", "empty": "NOTHING TO UNDO"}
        p.text(8, 258, messages.get(self.reason, self.reason.upper()), 10)
        p.text(8, 278, "ARROWS SELECT  Z BOARD/UNBOARD/SAIL  SPACE WAIT", 7)
        p.text(8, 290, "R RESET  ESC QUIT", 6)

DEMO_KEYS = ('Z', 'RIGHT', 'Z', 'RIGHT', 'Z', 'RIGHT', 'RIGHT', 'Z', 'RIGHT', 'Z', 'LEFT', 'LEFT', 'Z', 'RIGHT', 'RIGHT', 'RIGHT', 'Z')


def main(headless=False, screenshot=None, demo=False, state_log=None):
    from pathlib import Path
    from dataclasses import asdict
    import json
    import pyxel as p
    app = App()
    p.init(384, 304, title="Small Ferry", fps=30, display_scale=2, headless=headless)
    keys = {key: getattr(p, "KEY_"+key)
            for key in ("UP", "DOWN", "LEFT", "RIGHT", "Z", "SPACE", "R", "X")}
    limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12
    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                path = Path(screenshot).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path), scale=2)
            if state_log:
                path = Path(state_log).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps({"state": asdict(app.state), "reason": app.reason,
                                            "trace": [asdict(s) for s in app.last_trace],
                                            "frames": p.frame_count,
                                            "input": "synthetic controller keys" if demo else "none"},
                                           ensure_ascii=False, indent=2), encoding="utf-8")
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            pressed = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
            app.control(pressed, pressed)
        else:
            app.control({key for key, code in keys.items() if p.btnp(code)},
                        {key for key, code in keys.items() if p.btn(code)})
    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")
    parser.add_argument("--state-log")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_log)
STEP 05 / 05

一便を丸ごと戻し、次の結果を予告する

すべての状態を記録し、何も変えずに次を予想する。

Timeline に、今の State と、過去の State を並べたタプルを持たせます。step は apply で操作の結果を計算し、状態が変わったときだけ、操作する前の State を履歴に追加します。時間が0の乗り降りも1つの判断なので1件、待機も1件、2刻かかる深い水路の便もまとめて1件として記録します。

trace の1刻ごとの状態を全部履歴に入れると、X を1回押しただけで、深い水路の途中に戻ってしまいます。「航海の途中の記録」と「戻るための判断の履歴」は分けましょう。出航する前の State に戻せば、船の位置・時刻・居場所・まだ現れていない客・到着の記録・便の数・クリア状態が、全部いっしょに元に戻ります。受け付けられなかった浅瀬の便などでは、状態も履歴も変わりません。

Undo かどうかの判定は、apply より先に行います。クリアしたあとでも、X を押せば最後の便の前に戻れます。R は今の状態と履歴を、最初の状態に戻します。カーソルや理由の表示は画面のための補助なので、ゲームの「過去」とは分けて持ちます。

forecast(予報)は、同じ apply を呼んで、次の State を計算するだけです。State を直接書きかえないので、毎フレーム予告を表示しても、時間や履歴は進みません。NEXT には、予想される時刻・船の人数・到着した人数を、実行できない操作ならその理由を表示します。元のゲームにある「あと何刻待てば浅瀬に乗れるか」というアドバイスは省いています。

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

05_undo.py / 注目する部分
    # 深水の二刻も、履歴へ積むのは出発前の一状態だけ。
    return Timeline(state, timeline.history + (timeline.state,)), reason, trace

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること04 でクリアしたあとに X を押すと、時刻2で、p と q は done、r と s は boat に戻ります。もう一度 X を押すと s は A に、さらに X を押すと、時刻0・3人が boat・s はまだ現れていない状態に戻ります。2刻の航海が、X 1回で戻ります。
  • 1回の便につき、Undo は1回
  • 時間0の乗り降りも履歴に入る
  • 予告しても、時間も履歴も変わらない
つまずきやすいところ時刻だけを戻して、居場所や arrived を残したままにすると、客が2人に増えたり、到着の記録が未来の時刻になったりします。State 全体を保存し、trace を履歴に広げないようにします。
少し変えてみよう

R を押してから、Z で p を乗せ、同じ p を Z で降ろして、X を押してみましょう。時刻0のまま、p が boat に戻ります。時間0の操作も、1つずつ別の履歴になることを確かめます。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(327行)
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。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace

CAPACITY = 3
PORTS = ("H", "A", "B")

@dataclass(frozen=True)
class Passenger:
    name: str
    source: str
    destination: str
    at: int
    due: int

PEOPLE = (
    Passenger("p", "H", "A", 0, 2),
    Passenger("q", "H", "A", 0, 2),
    Passenger("r", "H", "B", 0, 4),
    Passenger("s", "A", "B", 1, 4),
)
TIDES = (1, 2, 3, 2)


@dataclass(frozen=True)
class State:
    t: int
    port: str
    owners: tuple
    arrived: tuple
    target: str = ""
    remaining: int = 0
    voyages: int = 0
    outcome: str = "playing"


def initial():
    owners = tuple(p.source if p.at == 0 else "-" for p in PEOPLE)
    return State(0, "H", owners, tuple(-1 for _ in PEOPLE))


def onboard(state):
    return state.owners.count("boat")


def tide(state):
    return TIDES[state.t % len(TIDES)]


def shallow(source, target):
    return source != target and {source, target} in ({"H", "A"}, {"A", "B"})


def ontime(state):
    return sum(0 <= arrival <= person.due
               for arrival, person in zip(state.arrived, PEOPLE))


def tick(state):
    # 一刻: 時刻を増やす → 客を出す → 残りを減らす → 到着処理。
    time = state.t + 1
    owners = tuple(p.source if owner == "-" and p.at <= time else owner
                   for owner, p in zip(state.owners, PEOPLE))
    remaining = max(0, state.remaining - 1)
    port, target = state.port, state.target
    arrived = list(state.arrived)
    if target and remaining == 0:
        port, target = target, ""
        owners = list(owners)
        for i, person in enumerate(PEOPLE):
            if owners[i] == "boat" and person.destination == port:
                owners[i] = "done"
                arrived[i] = time
    outcome = "won" if all(owner == "done" for owner in owners) else "playing"
    return replace(state, t=time, port=port, target=target, remaining=remaining,
                   owners=tuple(owners), arrived=tuple(arrived), outcome=outcome)


def apply(state, action):
    if state.target:
        return state, "sailing", ()
    if state.outcome == "won":
        return state, "terminal", ()
    if not isinstance(action, tuple) or not action:
        return state, "invalid", ()
    if action[0] == "toggle" and len(action) == 2:
        index = action[1]
        if type(index) is not int or not 0 <= index < len(PEOPLE):
            return state, "invalid", ()
        owners = list(state.owners)
        if owners[index] == "boat":
            owners[index] = state.port
            return replace(state, owners=tuple(owners)), "unload", ()
        if owners[index] != state.port:
            return state, "absent", ()
        if onboard(state) >= CAPACITY:
            return state, "capacity", ()
        owners[index] = "boat"
        # 船と港の二つのリストに入れず、一人につき所有者一つ。
        return replace(state, owners=tuple(owners)), "load", ()
    if action == ("wait",):
        next_state = tick(state)
        return next_state, "wait", (next_state,)
    if action[0] == "sail" and len(action) == 3:
        target, route = action[1:]
        if target not in PORTS or target == state.port or route not in ("shallow", "deep"):
            return state, "invalid", ()
        if route == "shallow":
            if not shallow(state.port, target):
                return state, "route", ()
            if onboard(state) > tide(state):
                return state, "tide", ()
        # 潮の検査は出発時だけ。航行中に次の潮で罰しない。
        duration = 2 if route == "deep" else 1
        next_state = replace(state, port="", target=target,
                             remaining=duration, voyages=state.voyages + 1)
        trace = []
        for _ in range(duration):
            next_state = tick(next_state)
            trace.append(next_state)
        return next_state, "depart", tuple(trace)
    return state, "invalid", ()


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


def step(timeline, action):
    # 成功後でもUndoできるよう、applyの終端判定より先に扱う。
    if action == ("undo",):
        if not timeline.history:
            return timeline, "empty", ()
        return Timeline(timeline.history[-1], timeline.history[:-1]), "undo", ()
    state, reason, trace = apply(timeline.state, action)
    if state == timeline.state:
        return timeline, reason, trace
    # 深水の二刻も、履歴へ積むのは出発前の一状態だけ。
    return Timeline(state, timeline.history + (timeline.state,)), reason, trace


def forecast(state, action):
    # 不変のStateなので、同じ計算を読取専用の予告にも使える。
    return apply(state, action)


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

    def reset(self):
        self.timeline = Timeline(initial())
        self.selected = 0
        self.locked = False
        self.reason = "ready"
        self.last_trace = ()

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

    def choices(self):
        passengers = tuple(("toggle", i) for i in range(len(PEOPLE)))
        routes = tuple(("sail", p, route) for p in PORTS if p != self.state.port
                       for route in ("shallow", "deep"))
        return passengers + routes

    def perform(self, action):
        self.timeline, self.reason, self.last_trace = step(self.timeline, action)
        self.selected = min(self.selected, len(self.choices()) - 1)

    def control(self, pressed, held):
        # Rの新押しは常に最優先。再開後も全キーを離すまで待つ。
        if "R" in pressed:
            self.reset()
            self.locked = True
            return
        if self.locked:
            if not pressed and not held:
                self.locked = False
            return
        # 拒否した同時押しもロックする。残したキー+新しいキーを通さない。
        if len(pressed | held) > 1:
            self.reason = "invalid"
            self.locked = True
            return
        if "X" in pressed:
            self.perform(("undo",))
            self.locked = True
            return
        if self.state.outcome == "won":
            return
        if len(pressed) != 1:
            return
        key = next(iter(pressed))
        if key in ("UP", "LEFT", "DOWN", "RIGHT"):
            direction = -1 if key in ("UP", "LEFT") else 1
            self.selected = (self.selected + direction) % len(self.choices())
        elif key == "Z":
            self.perform(self.choices()[self.selected])
        elif key == "SPACE":
            self.perform(("wait",))
        else:
            return
        self.locked = True

    def draw(self, p):
        state = self.state
        p.cls(1)
        p.text(8, 8, "SMALL FERRY / STAGE 5", 10)
        p.text(8, 22, f"TIME {state.t}   PORT {state.port}   ON BOARD {onboard(state)}/{CAPACITY}", 7)
        points = {"H": (40, 68), "A": (192, 48), "B": (344, 68)}
        p.line(40, 68, 192, 48, 6)
        p.line(192, 48, 344, 68, 6)
        p.line(40, 68, 344, 68, 5)
        for port, (x, y) in points.items():
            p.rect(x-10, y-9, 20, 18, 4)
            p.text(x-2, y-3, port, 7)
            if port == state.port:
                p.tri(x-14, y+12, x+14, y+12, x+8, y+19, 10)
        p.text(8, 90, "H-A / A-B: SHALLOW 1 OR DEEP 2  |  H-B: DEEP 2", 6)
        p.text(8, 102, f"TIDE {TIDES}  NOW SHALLOW LIMIT {tide(state)}", 6)
        p.text(8, 116, "PERSON   FROM>TO  SPAWN/DUE   OWNER    ARRIVAL", 13)
        for i, person in enumerate(PEOPLE):
            y = 130 + i*17
            p.rect(6, y-2, 372, 16, 3 if self.selected == i else 1)
            arrival = state.arrived[i]
            record = "-" if arrival < 0 else f"{arrival} " + ("ON TIME" if arrival <= person.due else "LATE")
            label = f"{person.name}        {person.source}>{person.destination}      {person.at}/{person.due}       {state.owners[i]:4}     {record}"
            p.text(12, y, label, 11 if state.owners[i] == "done" else 7)
        for i, action in enumerate(self.choices()[len(PEOPLE):]):
            x = 6 + i*94
            selected = self.selected == len(PEOPLE)+i
            p.rectb(x, 201, 89, 18, 10 if selected else 5)
            label = action[1] + (" SHALLOW 1" if action[2] == "shallow" else " DEEP 2")
            p.text(x+4, 207, label, 10 if selected else 7)
        status = "ALL DELIVERED" if state.outcome == "won" else "PLAYING"
        p.text(8, 227, f"{status} / ON TIME {ontime(state)}/{len(PEOPLE)} / VOYAGES {state.voyages}", 11)
        following, reason, trace = forecast(state, self.choices()[self.selected])
        preview = (f"NEXT: T={following.t} BOARD={onboard(following)} DONE={following.owners.count('done')}"
                   if following != state else "NEXT: " + reason.upper())
        p.text(8, 240, preview, 13)
        messages = {"ready": "SELECT A PASSENGER OR ROUTE", "load": "BOARDED / TIME +0",
                    "unload": "UNBOARDED / TIME +0", "capacity": f"FULL: CAPACITY {CAPACITY}",
                    "absent": "PASSENGER NOT AT THIS PORT", "tide": "TOO MANY FOR THIS TIDE: DEEP OR WAIT",
                    "route": "NO SHALLOW ROUTE BETWEEN H AND B", "depart": "ARRIVED",
                    "wait": "WAITED 1 TICK", "invalid": "INVALID ACTION", "terminal": "ALL DELIVERED",
                    "sailing": "SAILING: NO ACTION", "undo": "UNDID ONE WHOLE DECISION", "empty": "NOTHING TO UNDO"}
        p.text(8, 258, messages.get(self.reason, self.reason.upper()), 10)
        p.text(8, 278, "ARROWS SELECT  Z BOARD/UNBOARD/SAIL  SPACE WAIT", 7)
        p.text(8, 290, "R RESET  X UNDO  ESC QUIT", 6)

DEMO_KEYS = ('Z', 'RIGHT', 'Z', 'RIGHT', 'Z', 'RIGHT', 'RIGHT', 'Z', 'RIGHT', 'Z', 'LEFT', 'LEFT', 'Z', 'RIGHT', 'RIGHT', 'RIGHT', 'Z')


def main(headless=False, screenshot=None, demo=False, state_log=None):
    from pathlib import Path
    from dataclasses import asdict
    import json
    import pyxel as p
    app = App()
    p.init(384, 304, title="Small Ferry", fps=30, display_scale=2, headless=headless)
    keys = {key: getattr(p, "KEY_"+key)
            for key in ("UP", "DOWN", "LEFT", "RIGHT", "Z", "SPACE", "R", "X")}
    limit = max(12, len(DEMO_KEYS)*2+3) if demo else 12
    def update():
        if headless and p.frame_count >= limit:
            if screenshot:
                path = Path(screenshot).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path), scale=2)
            if state_log:
                path = Path(state_log).resolve()
                path.parent.mkdir(parents=True, exist_ok=True)
                path.write_text(json.dumps({"state": asdict(app.state), "reason": app.reason,
                                            "trace": [asdict(s) for s in app.last_trace],
                                            "frames": p.frame_count,
                                            "input": "synthetic controller keys" if demo else "none"},
                                           ensure_ascii=False, indent=2), encoding="utf-8")
            p.quit()
            return
        if headless and demo:
            index = p.frame_count//2
            pressed = {DEMO_KEYS[index]} if p.frame_count%2 == 0 and index < len(DEMO_KEYS) else set()
            app.control(pressed, pressed)
        else:
            app.control({key for key, code in keys.items() if p.btnp(code)},
                        {key for key, code in keys.items() if p.btn(code)})
    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")
    parser.add_argument("--state-log")
    args = parser.parse_args()
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_log)
AFTER STEP 05

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

元のゲームは状況 B を使います。定員3人で、乗り降りに時間はかかりません。H-A と A-B は浅瀬1刻・深い水路2刻、H-B は深い水路だけです。浅瀬を通れる人数は、出発するときの潮 (1,2,3,2) で決まります。1刻ごとに「客が現れる → 残り時間を減らす → 到着して自動で降りる」の順に処理し、全員が着いたらクリアです。希望どおりに着いた人数は、別に記録します。

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

比べる点5ステップの教材元の完成版
段階と状況01 は全員が H にいる状態での乗り降り、02 はどの航路も1刻で手で降ろす、03 は潮と航路を追加します。04 と 05 は状況 B のふつうのルールです。状況 A・B・C の3つ。到着と希望時刻の記録、航海中の表示を含みます。
表示と入力文字(ASCII)と図形で表示し、最初の状態から始めます。キーが複数押されたら受け付けず、全部離すまで待ちます。R を押した瞬間は常に最優先、X は単独で押したときだけ。航海の途中は trace に記録しますが、アニメーションはしません。日本語の文字、表紙、状況の選択、説明と一時停止、キーの押し始めの判定、航海のアニメーション中の入力制限があります。
予告と比較05 で、選んだ操作の結果を予告します。潮はふつうのルールに固定し、学習用の確認ポイントを用意しています。「あと何刻待てば浅瀬を通れるか」のアドバイス、浅瀬の上限だけを3に固定して比べるモード、いくつかの自動デモと保存の機能があります。

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

  1. model.py: tick の出現→到着処理
  2. model.py: apply の出発時制約とtrace
  3. model.py: step の航行単位のUndo
  4. model.py: ontime / invariant / LEVELS AとC
  5. app.py: update のanimationと入力抑制、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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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