完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:矢印乗客/便、Z乗降/出航、Space一刻待つ、X戻す、R再開、P停止
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
出発するときの条件と、航海中の変化を分ける。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、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:終了
- 難易度の目安
- 初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
乗客の居場所を一つにする
書きかえない状態と、乗客の居場所の移しかえ。
最初は、港 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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
owners[index] = "boat"
# 船と港の二つのリストに入れず、一人につき所有者一つ。
return replace(state, owners=tuple(owners)), "load", ()実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_ownership.py.venv/bin/python 01_ownership.pyboat(船)に乗ります。さらに右、Z と押すと「FULL: CAPACITY 3」と出て、s は H のままです。左、Z と押すと r が H に戻り、船には2人、時刻は0です。- 1人の居場所はいつも1つ
- 満員でも降りられる
- 乗り降りでは時刻は0のまま
CAPACITY = 3 を2に変えて、もう一度起動してみましょう。p と q を乗せたあと、r の乗船は受け付けられず、時刻0・船に2人・r と s は H のままになります。確かめたら3に戻します。
このステップのコード全文(198行)
# 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)一刻の移動と客の出現を加える
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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
owners = tuple(p.source if owner == "-" and p.at <= time else owner
for owner, p in zip(state.owners, PEOPLE))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_travel.py.venv/bin/python 02_travel.pyboat、s は A にいます。s を選んで Z を押せば、同じ時刻1のまま乗せられます。- 客は、待っている間も航海中も現れる
- 同じ時刻に現れた客は、船の到着後に乗れる
- この段階は、どの航路も1刻で、降りるのは手動
-(まだ現れていない)の人だけ、現れる条件を判定します。PEOPLE にある s の at を1から2に変えてみましょう。p を乗せて A へ1刻で着いても、s はまだ - です。Space で時刻2に進めると A に現れ、Z で乗せても時刻は2のままです。確かめたら1に戻します。
このステップのコード全文(254行)
# 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)定員と浅瀬の上限を分ける
出発するときの条件と、時間がたつことの違い。
船の定員は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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
# 潮の検査は出発時だけ。航行中に次の潮で罰しない。
duration = 2 if route == "deep" else 1実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_tides.py.venv/bin/python 03_tides.py- 定員3と、潮で決まる人数の上限は別のもの
- 深い水路は2刻かかり、潮は関係ない
- 浅瀬の人数チェックは出発するときだけ
TIDES を (3, 2, 1, 2) に変えてみましょう。p・q・r の3人が、時刻0から A 行きの浅瀬の便に乗れて、時刻1に A に着きます。到着したときの上限が2でも、罰はありません。確かめたら (1, 2, 3, 2) に戻します。
このステップのコード全文(271行)
# 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)全員到着と希望内の記録を分ける
自動で降りる処理と、クリアの条件を整える。
ここから、元のゲームの状況 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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
outcome = "won" if all(owner == "done" for owner in owners) else "playing"実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_arrivals.py.venv/bin/python 04_arrivals.pydone、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に戻します。
このステップのコード全文(292行)
# 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)一便を丸ごと戻し、次の結果を予告する
すべての状態を記録し、何も変えずに次を予想する。
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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
# 深水の二刻も、履歴へ積むのは出発前の一状態だけ。
return Timeline(state, timeline.history + (timeline.state,)), reason, trace実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_undo.py.venv/bin/python 05_undo.pydone、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つずつ別の履歴になることを確かめます。
このステップのコード全文(327行)
# 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)仕組みがわかったら、元の完成版を読んでみよう。
元のゲームは状況 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に固定して比べるモード、いくつかの自動デモと保存の機能があります。 |
完成版のコードで、次に読むところ
- model.py: tick の出現→到着処理
- model.py: apply の出発時制約とtrace
- model.py: step の航行単位のUndo
- model.py: ontime / invariant / LEVELS AとC
- 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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
