パズルLESSON 03配達経路パズル原作001

潮待ちポストの作り方

届けた荷物が、帰り道を変える。
潮の満ち引きと重い荷物によって、渡れる橋が変わります。移動・配達・帰り道を、いくつかの状態の組み合わせで作っていきます。

完成版の遊び方潮を切り替えたり、配達で荷物を軽くしたりしながら、必ず届けるA・Bと、寄り道のCへの道を選びます。

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

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

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

完成版の主な操作:矢印キーかWASDで移動、Spaceで潮を切り替え、Zで1手戻す、Rでやり直し、Tabで選択、Escで終了

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

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

うまく表示されないとき

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

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

潮と荷物で通れる道を変える。

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

1. マス目の位置と画面の位置を分け、進めない入力では手数を増やさない
2. 0と1の2つの状態で、渡れる橋を切り替える
3. 荷物があるかどうかで、通れるかを計算する
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・関数の基本
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:1マス移動 / SPACE:島の上で潮を切り替え(02から) / Z または BACKSPACE:1手戻す(05) / R:最初から / ESC:終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_grid.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

島と橋をマス目で歩く

マス目の位置と画面の位置を分け、進めない入力では手数を増やさない。

ROWS は、(列x, 行y) で場所を指す文字の地図です。S は出発の島、I はふつうの島、A・B は配達先の島、. は海です。- と | は動かない橋、a・b は潮で変わる橋、~ は細い橋ですが、01では練習のため、橋はすべて通れます。State には位置と手数を持たせ、マスの大きさ TILE を掛けてピクセルに直すのは、絵を描くときだけにします。btnp はキーを押した瞬間だけ反応するので、1回押すと1歩進みます。海や画面の外に向かう入力では同じ State を返すので、手数も増えません。まだ配達やゴールはありません。

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

01_grid.py / 注目する部分
dx, dy = DIRECTIONS[command]
x, y = state.x + dx, state.y + dy
if y < 0 or y >= len(ROWS) or x < 0 or x >= len(ROWS[y]) or ROWS[y][x] == ".":
    return state, "water"
return replace(state, x=x, y=y, used=state.used + 1), "move"

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_grid.py
macOS / Linux
.venv/bin/python 01_grid.py
画面で見ること最初に左を押しても、MOVES は0のままです。右を1回押すと、島 S から橋 - へ1マス動いて MOVES が1になります。もう一度右を押すと a へ進めます。R キーで最初に戻せます。
  • 左を押しても0手
  • 右は1マスずつ進む
  • 橋から海には入れない
つまずきやすいところROWS[x][y] では列と行が逆になります。読むときは ROWS[y][x] です。また、通れるかを確かめる前に used(手数)を増やすと、元のゲームと違って、進めない入力でも手数が増えてしまいます。
少し変えてみよう

左→右→下の順に押す前に、位置と手数を予想してみましょう。答えは (1,0) で1手です。下は海なので進めません。次に、TILE だけを変えると何が変わるか考えてみましょう。

01_grid.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(169行)
01_grid.py / 全文
# MIT License
#
# Copyright (c) 2026 Tidepost contributors
#
# 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.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.

"""潮待ちポスト・教材 1/5。原作「ふたつの潮」の基本便を段階化。
実行: python 01_grid.py
Cを通常の島Iに置換。日本語の解説はLESSON.md。画面はASCIIのみ。
原作ライセンスの字形例外は上に保持。教材はNoto字形を使用しない。
"""
from dataclasses import dataclass, replace

# (列, 行) で扱う。Cの追加便は教材に含めず、同じ位置をIにする。
ROWS = ("S-a-I-B", "|...b.|", "I~I-I-I", "b.|...b", "A-I...I")
DIRECTIONS = {"L": (-1, 0), "R": (1, 0), "U": (0, -1), "D": (0, 1)}
TILE = 28
STAGE = 1


def find(char):
    return next((x, y) for y, row in enumerate(ROWS)
                for x, cell in enumerate(row) if cell == char)


START = find("S")


@dataclass(frozen=True)
class State:
    x: int = START[0]
    y: int = START[1]
    used: int = 0


def step(state, command):
    if command not in DIRECTIONS:
        return state, "invalid"
    dx, dy = DIRECTIONS[command]
    x, y = state.x + dx, state.y + dy
    if y < 0 or y >= len(ROWS) or x < 0 or x >= len(ROWS[y]) or ROWS[y][x] == ".":
        return state, "water"
    return replace(state, x=x, y=y, used=state.used + 1), "move"


class Session:
    def __init__(self):
        self.state = State()
        self.event = "ready"

    def act(self, command):
        self.state, self.event = step(self.state, command)

    def restart(self):
        self.state = State()
        self.event = "ready"


def main(headless=False, screenshot=None):
    import json
    import pyxel  # 純粋ルールのテストでは読み込まない。

    session = Session()
    draws = 0
    keys = ((pyxel.KEY_LEFT, "L"), (pyxel.KEY_RIGHT, "R"),
            (pyxel.KEY_UP, "U"), (pyxel.KEY_DOWN, "D"))

    def update():
        if headless and draws >= 12:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            print(json.dumps({"stage": STAGE, "rendered_frames": draws,
                              "screenshot": screenshot, "mode": "headless-smoke"}), flush=True)
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_ESCAPE):
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_R):
            session.restart()
            return
        for key, command in keys:
            if pyxel.btnp(key):
                session.act(command)
                break  # 一つのフレームに進めるのは一手だけ。

    def draw():
        nonlocal draws
        draws += 1
        state = session.state
        pyxel.cls(1)
        pyxel.text(12, 9, f"TIDEPOST / STEP {STAGE}", 10)
        pyxel.text(12, 22, f"MOVES {state.used}", 7)
        for y, row in enumerate(ROWS):
            for x, char in enumerate(row):
                px, py = 12 + x * TILE, 42 + y * TILE
                if char == ".":
                    pyxel.line(px + 8, py + 14, px + 15, py + 14, 5)
                    continue
                if char in "SIAB":
                    pyxel.rect(px + 1, py + 1, TILE - 2, TILE - 2, 3)
                    pyxel.rectb(px + 1, py + 1, TILE - 2, TILE - 2, 11)
                    pyxel.text(px + 4, py + 3, char, 7)
                else:
                    color = 12 if char == "a" else 9 if char == "b" else 10 if char == "~" else 4
                    active = True  # 01では橋の種類による制約をまだ入れない。
                    if y % 2 == 0:
                        box = (px, py + 9, TILE, 10)
                    else:
                        box = (px + 9, py, 10, TILE)
                    if active:
                        pyxel.rect(*box, color)
                    else:
                        pyxel.rectb(*box, color)
                    pyxel.text(px + 12, py + 11, char, 0 if active else color)
        px, py = 12 + state.x * TILE, 42 + state.y * TILE
        pyxel.circ(px + 14, py + 18, 5, 8)
        pyxel.pset(px + 15, py + 16, 7)
        pyxel.text(220, 62, "MAP PRACTICE", 6)
        pyxel.text(220, 77, "A/B: ISLANDS", 6)
        pyxel.text(220, 92, "NO PARCELS YET", 6)
        pyxel.text(220, 110, "ALL BRIDGES OPEN", 6)
        notices = {
            "ready": "READY", "move": "STEP", "water": "WATER: NO MOVE",
            "invalid": "UNKNOWN COMMAND", "tide": "TIDE CHANGED",
            "unsafe": "SWITCH TIDE ON AN ISLAND", "tide_block": "BRIDGE UNDER WATER",
            "heavy": "DELIVER B TO USE ~", "delivery": "DELIVERED!",
            "win": "CLEAR! BACK AT S", "done": "CLEAR! R: RESET", "undo": "ONE STEP BACK",
        }
        pyxel.text(12, 194, notices[session.event], 10)
        pyxel.text(12, 210, "ARROWS: STEP  R: RESET  ESC: QUIT", 7)
        pyxel.text(12, 222, "TIDE RULE STARTS IN STEP 2", 6)
        pyxel.text(12, 234, "UNDO STARTS IN STEP 5", 5)

    pyxel.init(364, 250, title=f"Tidepost - Stage {STAGE}", fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true",
                        help="Render 12 frames then quit; not a human playtest")
    parser.add_argument("--screenshot", metavar="PATH", help="PNG path for --headless-smoke")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    main(headless=args.headless_smoke, screenshot=args.screenshot)
STEP 02 / 05

島で潮を切り替える

0と1の2つの状態で、渡れる橋を切り替える。

State.tide は潮の状態で、0なら青い a の橋、1ならオレンジの b の橋が渡れます。SPACE キーは T という命令に置きかえます。島(S・I・A・B)の上にいるときは、1-state.tide で潮を切り替え、手数を1増やします。橋の上では切り替えられず、手数も増えません。移動するときは、移動先の橋と今の潮を比べて、渡れるかを決めます。潮は時間では変わりません。~ はこの段階ではいつでも通れ、A・B もふつうの島です。

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

02_tide.py / 注目する部分
if command == "T":
    if ROWS[state.y][state.x] not in "SIAB":
        return state, "unsafe"
    return replace(state, tide=1 - state.tide, used=state.used + 1), "tide"

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_tide.py
macOS / Linux
.venv/bin/python 02_tide.py
画面で見ること最初の位置で SPACE を押すと ORANGE になり、1手増えます。右で - へ進み、さらに右を押すと、青い a の橋には入れず BRIDGE UNDER WATER(橋が水の下)と表示されます。橋の上で SPACE を押しても切り替わりません。左で S に戻って SPACE で BLUE にすれば、右→右で a に入れます。
  • 島のSPACEで潮が変わる
  • 橋のSPACEは手数も変えない
  • 青aと橙bの通行が入れ替わる
つまずきやすいところ潮を毎フレーム切り替えたり、橋の上でも切り替えたりすると、別のルールになってしまいます。1-state.tide は、SPACE が有効なときだけ実行します。渡れるかどうかは、今いるマスではなく、移動先の target を見て決めます。
少し変えてみよう

R キーで最初に戻し、下→下→下と押すと、3回目に b の橋で止まります。そこで SPACE→下 と押すと進めます。SPACE も1手で、進めなかった下は0手です。合計は何手でしょうか。答えは4手です。

02_tide.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(179行)
02_tide.py / 全文
# MIT License
#
# Copyright (c) 2026 Tidepost contributors
#
# 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.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.

"""潮待ちポスト・教材 2/5。原作「ふたつの潮」の基本便を段階化。
実行: python 02_tide.py
Cを通常の島Iに置換。日本語の解説はLESSON.md。画面はASCIIのみ。
原作ライセンスの字形例外は上に保持。教材はNoto字形を使用しない。
"""
from dataclasses import dataclass, replace

# (列, 行) で扱う。Cの追加便は教材に含めず、同じ位置をIにする。
ROWS = ("S-a-I-B", "|...b.|", "I~I-I-I", "b.|...b", "A-I...I")
DIRECTIONS = {"L": (-1, 0), "R": (1, 0), "U": (0, -1), "D": (0, 1)}
TILE = 28
STAGE = 2


def find(char):
    return next((x, y) for y, row in enumerate(ROWS)
                for x, cell in enumerate(row) if cell == char)


START = find("S")


@dataclass(frozen=True)
class State:
    x: int = START[0]
    y: int = START[1]
    tide: int = 0
    used: int = 0


def step(state, command):
    if command == "T":
        if ROWS[state.y][state.x] not in "SIAB":
            return state, "unsafe"
        return replace(state, tide=1 - state.tide, used=state.used + 1), "tide"
    if command not in DIRECTIONS:
        return state, "invalid"
    dx, dy = DIRECTIONS[command]
    x, y = state.x + dx, state.y + dy
    if y < 0 or y >= len(ROWS) or x < 0 or x >= len(ROWS[y]) or ROWS[y][x] == ".":
        return state, "water"
    target = ROWS[y][x]
    if target in "ab" and target != "ab"[state.tide]:
        return state, "tide_block"
    return replace(state, x=x, y=y, used=state.used + 1), "move"


class Session:
    def __init__(self):
        self.state = State()
        self.event = "ready"

    def act(self, command):
        self.state, self.event = step(self.state, command)

    def restart(self):
        self.state = State()
        self.event = "ready"


def main(headless=False, screenshot=None):
    import json
    import pyxel  # 純粋ルールのテストでは読み込まない。

    session = Session()
    draws = 0
    keys = ((pyxel.KEY_LEFT, "L"), (pyxel.KEY_RIGHT, "R"),
            (pyxel.KEY_UP, "U"), (pyxel.KEY_DOWN, "D"))
    keys += ((pyxel.KEY_SPACE, "T"),)

    def update():
        if headless and draws >= 12:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            print(json.dumps({"stage": STAGE, "rendered_frames": draws,
                              "screenshot": screenshot, "mode": "headless-smoke"}), flush=True)
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_ESCAPE):
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_R):
            session.restart()
            return
        for key, command in keys:
            if pyxel.btnp(key):
                session.act(command)
                break  # 一つのフレームに進めるのは一手だけ。

    def draw():
        nonlocal draws
        draws += 1
        state = session.state
        pyxel.cls(1)
        pyxel.text(12, 9, f"TIDEPOST / STEP {STAGE}", 10)
        pyxel.text(12, 22, f"MOVES {state.used}", 7)
        pyxel.text(100, 22, "TIDE: " + ("BLUE (a)" if state.tide == 0 else "ORANGE (b)"), 12 if state.tide == 0 else 9)
        for y, row in enumerate(ROWS):
            for x, char in enumerate(row):
                px, py = 12 + x * TILE, 42 + y * TILE
                if char == ".":
                    pyxel.line(px + 8, py + 14, px + 15, py + 14, 5)
                    continue
                if char in "SIAB":
                    pyxel.rect(px + 1, py + 1, TILE - 2, TILE - 2, 3)
                    pyxel.rectb(px + 1, py + 1, TILE - 2, TILE - 2, 11)
                    pyxel.text(px + 4, py + 3, char, 7)
                else:
                    color = 12 if char == "a" else 9 if char == "b" else 10 if char == "~" else 4
                    active = char not in "ab" or char == "ab"[state.tide]
                    if y % 2 == 0:
                        box = (px, py + 9, TILE, 10)
                    else:
                        box = (px + 9, py, 10, TILE)
                    if active:
                        pyxel.rect(*box, color)
                    else:
                        pyxel.rectb(*box, color)
                    pyxel.text(px + 12, py + 11, char, 0 if active else color)
        px, py = 12 + state.x * TILE, 42 + state.y * TILE
        pyxel.circ(px + 14, py + 18, 5, 8)
        pyxel.pset(px + 15, py + 16, 7)
        pyxel.text(220, 62, "MAP PRACTICE", 6)
        pyxel.text(220, 77, "A/B: ISLANDS", 6)
        pyxel.text(220, 92, "NO PARCELS YET", 6)
        pyxel.text(220, 110, "~ ALWAYS OPEN", 6)
        notices = {
            "ready": "READY", "move": "STEP", "water": "WATER: NO MOVE",
            "invalid": "UNKNOWN COMMAND", "tide": "TIDE CHANGED",
            "unsafe": "SWITCH TIDE ON AN ISLAND", "tide_block": "BRIDGE UNDER WATER",
            "heavy": "DELIVER B TO USE ~", "delivery": "DELIVERED!",
            "win": "CLEAR! BACK AT S", "done": "CLEAR! R: RESET", "undo": "ONE STEP BACK",
        }
        pyxel.text(12, 194, notices[session.event], 10)
        pyxel.text(12, 210, "ARROWS: STEP  R: RESET  ESC: QUIT", 7)
        pyxel.text(12, 222, "SPACE: TIDE (ON ISLANDS ONLY)", 6)
        pyxel.text(12, 234, "UNDO STARTS IN STEP 5", 5)

    pyxel.init(364, 250, title=f"Tidepost - Stage {STAGE}", fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true",
                        help="Render 12 frames then quit; not a human playtest")
    parser.add_argument("--screenshot", metavar="PATH", help="PNG path for --headless-smoke")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    main(headless=args.headless_smoke, screenshot=args.screenshot)
STEP 03 / 05

配達して細橋を開ける

荷物があるかどうかで、通れるかを計算する。

A に届ける手紙と、B に届ける重い小包を持って出発します。delivered は配達済みの印で、A だけなら1、B だけなら2、両方なら3になります。1 << index で印を作り、|= で追加していきます(ビット演算という書き方です)。CONTRACT は3なので、A と B の両方が配達の対象です。heavy_left は B にまだ届けていないか(重い荷物を持っているか)を調べ、重い間は細い橋 ~ に入れません。先に通れるかを判定し、移動先に着いてから配達済みにします。B に着くと小包がなくなり、次の移動から細い橋を使えます。この段階は配達の練習なので、全部届けたあとも動けます。

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

03_delivery.py / 注目する部分
if target == "~" and heavy_left(state):
    return state, "heavy"
delivered = state.delivered
event = "move"
# 通れることが確定してから、到着先の配達を処理する。
for index, destination in enumerate(DESTINATIONS):
    bit = 1 << index
    if (x, y) == destination and CONTRACT & bit and not delivered & bit:
        delivered |= bit
        event = "delivery"
new_state = replace(state, x=x, y=y, delivered=delivered, used=state.used + 1)
return new_state, event

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_delivery.py
macOS / Linux
.venv/bin/python 03_delivery.py
画面で見ることR→下→下→右と押すと、HEAVY B(重い小包を持っている)なので細い橋で止まり、2手のままです。R→右を6回押すと B:OK になり、LIGHT(軽い)に変わります。そのあと左を6回、下を2回、右を1回押すと、さっき入れなかった ~ に入れます。
  • B未配達なら細橋に入れない
  • B到着でLIGHTになる
  • Bを再訪しても配達は重複しない
つまずきやすいところ荷物の重さを別の変数 heavy にも保存すると、配達済みの印と食い違いやすくなります。この教材では、重さを delivered から毎回計算しています。また、橋に入れるかを確かめる前に、配達の処理をしないようにします。
少し変えてみよう

B に届けたあと、左→右で B をもう一度訪ねてみましょう。手数は2増えますが、配達済みの印は2のままです。次に、not delivered & bit の条件を消したと仮定して、|= と += では2回目に訪ねたときの数値がどう違うか、紙に書いて比べてみましょう。

03_delivery.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(204行)
03_delivery.py / 全文
# MIT License
#
# Copyright (c) 2026 Tidepost contributors
#
# 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.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.

"""潮待ちポスト・教材 3/5。原作「ふたつの潮」の基本便を段階化。
実行: python 03_delivery.py
Cを通常の島Iに置換。日本語の解説はLESSON.md。画面はASCIIのみ。
原作ライセンスの字形例外は上に保持。教材はNoto字形を使用しない。
"""
from dataclasses import dataclass, replace

# (列, 行) で扱う。Cの追加便は教材に含めず、同じ位置をIにする。
ROWS = ("S-a-I-B", "|...b.|", "I~I-I-I", "b.|...b", "A-I...I")
DIRECTIONS = {"L": (-1, 0), "R": (1, 0), "U": (0, -1), "D": (0, 1)}
TILE = 28
STAGE = 3


def find(char):
    return next((x, y) for y, row in enumerate(ROWS)
                for x, cell in enumerate(row) if cell == char)


START = find("S")
DESTINATIONS = (find("A"), find("B"))
CONTRACT = 3  # 2進数11: Aは1、Bは2。両方を届ける。


def heavy_left(state):
    return bool(CONTRACT & 2 & ~state.delivered)


@dataclass(frozen=True)
class State:
    x: int = START[0]
    y: int = START[1]
    tide: int = 0
    delivered: int = 0
    used: int = 0


def step(state, command):
    if command == "T":
        if ROWS[state.y][state.x] not in "SIAB":
            return state, "unsafe"
        return replace(state, tide=1 - state.tide, used=state.used + 1), "tide"
    if command not in DIRECTIONS:
        return state, "invalid"
    dx, dy = DIRECTIONS[command]
    x, y = state.x + dx, state.y + dy
    if y < 0 or y >= len(ROWS) or x < 0 or x >= len(ROWS[y]) or ROWS[y][x] == ".":
        return state, "water"
    target = ROWS[y][x]
    if target in "ab" and target != "ab"[state.tide]:
        return state, "tide_block"
    if target == "~" and heavy_left(state):
        return state, "heavy"
    delivered = state.delivered
    event = "move"
    # 通れることが確定してから、到着先の配達を処理する。
    for index, destination in enumerate(DESTINATIONS):
        bit = 1 << index
        if (x, y) == destination and CONTRACT & bit and not delivered & bit:
            delivered |= bit
            event = "delivery"
    new_state = replace(state, x=x, y=y, delivered=delivered, used=state.used + 1)
    return new_state, event


class Session:
    def __init__(self):
        self.state = State()
        self.event = "ready"

    def act(self, command):
        self.state, self.event = step(self.state, command)

    def restart(self):
        self.state = State()
        self.event = "ready"


def main(headless=False, screenshot=None):
    import json
    import pyxel  # 純粋ルールのテストでは読み込まない。

    session = Session()
    draws = 0
    keys = ((pyxel.KEY_LEFT, "L"), (pyxel.KEY_RIGHT, "R"),
            (pyxel.KEY_UP, "U"), (pyxel.KEY_DOWN, "D"))
    keys += ((pyxel.KEY_SPACE, "T"),)

    def update():
        if headless and draws >= 12:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            print(json.dumps({"stage": STAGE, "rendered_frames": draws,
                              "screenshot": screenshot, "mode": "headless-smoke"}), flush=True)
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_ESCAPE):
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_R):
            session.restart()
            return
        for key, command in keys:
            if pyxel.btnp(key):
                session.act(command)
                break  # 一つのフレームに進めるのは一手だけ。

    def draw():
        nonlocal draws
        draws += 1
        state = session.state
        pyxel.cls(1)
        pyxel.text(12, 9, f"TIDEPOST / STEP {STAGE}", 10)
        pyxel.text(12, 22, f"MOVES {state.used}", 7)
        pyxel.text(100, 22, "TIDE: " + ("BLUE (a)" if state.tide == 0 else "ORANGE (b)"), 12 if state.tide == 0 else 9)
        for y, row in enumerate(ROWS):
            for x, char in enumerate(row):
                px, py = 12 + x * TILE, 42 + y * TILE
                if char == ".":
                    pyxel.line(px + 8, py + 14, px + 15, py + 14, 5)
                    continue
                if char in "SIAB":
                    pyxel.rect(px + 1, py + 1, TILE - 2, TILE - 2, 3)
                    pyxel.rectb(px + 1, py + 1, TILE - 2, TILE - 2, 11)
                    pyxel.text(px + 4, py + 3, char, 7)
                    if char in "AB" and state.delivered & (1 << "AB".index(char)):
                        pyxel.text(px + 18, py + 3, "+", 10)
                else:
                    color = 12 if char == "a" else 9 if char == "b" else 10 if char == "~" else 4
                    active = char not in "ab" or char == "ab"[state.tide]
                    if y % 2 == 0:
                        box = (px, py + 9, TILE, 10)
                    else:
                        box = (px + 9, py, 10, TILE)
                    if active:
                        pyxel.rect(*box, color)
                    else:
                        pyxel.rectb(*box, color)
                    pyxel.text(px + 12, py + 11, char, 0 if active else color)
                    if char == "~" and heavy_left(state):
                        pyxel.line(px + 5, py + 5, px + 22, py + 22, 8)
                        pyxel.line(px + 22, py + 5, px + 5, py + 22, 8)
        px, py = 12 + state.x * TILE, 42 + state.y * TILE
        pyxel.circ(px + 14, py + 18, 5, 8)
        pyxel.pset(px + 15, py + 16, 7)
        parcels = "  ".join(name + (":OK" if state.delivered & (1 << i) else ":TODO")
                            for i, name in enumerate("AB"))
        pyxel.text(220, 62, parcels, 7)
        pyxel.text(220, 77, "HEAVY B" if heavy_left(state) else "LIGHT: ~ OPEN", 10)
        pyxel.text(220, 100, "DELIVERY PRACTICE", 6)
        pyxel.text(220, 112, "GOAL IN STEP 4", 6)
        notices = {
            "ready": "READY", "move": "STEP", "water": "WATER: NO MOVE",
            "invalid": "UNKNOWN COMMAND", "tide": "TIDE CHANGED",
            "unsafe": "SWITCH TIDE ON AN ISLAND", "tide_block": "BRIDGE UNDER WATER",
            "heavy": "DELIVER B TO USE ~", "delivery": "DELIVERED!",
            "win": "CLEAR! BACK AT S", "done": "CLEAR! R: RESET", "undo": "ONE STEP BACK",
        }
        pyxel.text(12, 194, notices[session.event], 10)
        pyxel.text(12, 210, "ARROWS: STEP  R: RESET  ESC: QUIT", 7)
        pyxel.text(12, 222, "SPACE: TIDE (ON ISLANDS ONLY)", 6)
        pyxel.text(12, 234, "UNDO STARTS IN STEP 5", 5)

    pyxel.init(364, 250, title=f"Tidepost - Stage {STAGE}", fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true",
                        help="Render 12 frames then quit; not a human playtest")
    parser.add_argument("--screenshot", metavar="PATH", help="PNG path for --headless-smoke")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    main(headless=args.headless_smoke, screenshot=args.screenshot)
STEP 04 / 05

全配達して灯台へ帰る

荷物と位置の両方がそろったら、クリアにする。

won は、A と B の両方に届け終わっていて、しかも出発地点 S にいるときに True になります。step の最初で、もうクリアしていたら入力を受け付けないようにします。移動できたら配達を処理して新しい State を作り、その State でクリアかどうかを調べます。配達先に着いただけでは終わりません。潮の切り替え・通れるかの判定・配達の順番は、前の段階から変えていません。時間制限や手数の上限、ゲームオーバーはありません。

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

04_return.py / 注目する部分
def won(state):
    return (state.delivered & CONTRACT) == CONTRACT and (state.x, state.y) == START

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_return.py
macOS / Linux
.venv/bin/python 04_return.py
画面で見ることR のあと、右6回→左6回→下2回→SPACE→下2回で、A と B の両方が OK になります。ただし、まだ A にいるので CLEAR にはなりません。上に4回進んで S に帰ると、21手で CLEAR です。そのあとは移動も潮の切り替えも止まり、R キーで始め直せます。
  • 両方配達してもAでは終わらない
  • Sへ戻った瞬間にCLEAR
  • CLEAR後はRまで止まる
つまずきやすいところdelivered == 3 だけを調べると、最後の配達先に着いた時点でクリアになってしまいます。出発地点に帰ったかという位置の条件も必要です。また、移動する前の State でクリアを調べると、帰った瞬間にクリアを表示できません。
少し変えてみよう

「配達済みで S 以外にいる」「まだ届けていなくて S にいる」「配達済みで S にいる」の3つの状態を作り、won が True になるのはどれか選んでみましょう。21手のルート以外でもクリアできるか、探してみてください。

04_return.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(213行)
04_return.py / 全文
# MIT License
#
# Copyright (c) 2026 Tidepost contributors
#
# 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.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.

"""潮待ちポスト・教材 4/5。原作「ふたつの潮」の基本便を段階化。
実行: python 04_return.py
Cを通常の島Iに置換。日本語の解説はLESSON.md。画面はASCIIのみ。
原作ライセンスの字形例外は上に保持。教材はNoto字形を使用しない。
"""
from dataclasses import dataclass, replace

# (列, 行) で扱う。Cの追加便は教材に含めず、同じ位置をIにする。
ROWS = ("S-a-I-B", "|...b.|", "I~I-I-I", "b.|...b", "A-I...I")
DIRECTIONS = {"L": (-1, 0), "R": (1, 0), "U": (0, -1), "D": (0, 1)}
TILE = 28
STAGE = 4


def find(char):
    return next((x, y) for y, row in enumerate(ROWS)
                for x, cell in enumerate(row) if cell == char)


START = find("S")
DESTINATIONS = (find("A"), find("B"))
CONTRACT = 3  # 2進数11: Aは1、Bは2。両方を届ける。


def heavy_left(state):
    return bool(CONTRACT & 2 & ~state.delivered)


def won(state):
    return (state.delivered & CONTRACT) == CONTRACT and (state.x, state.y) == START


@dataclass(frozen=True)
class State:
    x: int = START[0]
    y: int = START[1]
    tide: int = 0
    delivered: int = 0
    used: int = 0


def step(state, command):
    if won(state):
        return state, "done"
    if command == "T":
        if ROWS[state.y][state.x] not in "SIAB":
            return state, "unsafe"
        return replace(state, tide=1 - state.tide, used=state.used + 1), "tide"
    if command not in DIRECTIONS:
        return state, "invalid"
    dx, dy = DIRECTIONS[command]
    x, y = state.x + dx, state.y + dy
    if y < 0 or y >= len(ROWS) or x < 0 or x >= len(ROWS[y]) or ROWS[y][x] == ".":
        return state, "water"
    target = ROWS[y][x]
    if target in "ab" and target != "ab"[state.tide]:
        return state, "tide_block"
    if target == "~" and heavy_left(state):
        return state, "heavy"
    delivered = state.delivered
    event = "move"
    # 通れることが確定してから、到着先の配達を処理する。
    for index, destination in enumerate(DESTINATIONS):
        bit = 1 << index
        if (x, y) == destination and CONTRACT & bit and not delivered & bit:
            delivered |= bit
            event = "delivery"
    new_state = replace(state, x=x, y=y, delivered=delivered, used=state.used + 1)
    return new_state, "win" if won(new_state) else event


class Session:
    def __init__(self):
        self.state = State()
        self.event = "ready"

    def act(self, command):
        self.state, self.event = step(self.state, command)

    def restart(self):
        self.state = State()
        self.event = "ready"


def main(headless=False, screenshot=None):
    import json
    import pyxel  # 純粋ルールのテストでは読み込まない。

    session = Session()
    draws = 0
    keys = ((pyxel.KEY_LEFT, "L"), (pyxel.KEY_RIGHT, "R"),
            (pyxel.KEY_UP, "U"), (pyxel.KEY_DOWN, "D"))
    keys += ((pyxel.KEY_SPACE, "T"),)

    def update():
        if headless and draws >= 12:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            print(json.dumps({"stage": STAGE, "rendered_frames": draws,
                              "screenshot": screenshot, "mode": "headless-smoke"}), flush=True)
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_ESCAPE):
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_R):
            session.restart()
            return
        for key, command in keys:
            if pyxel.btnp(key):
                session.act(command)
                break  # 一つのフレームに進めるのは一手だけ。

    def draw():
        nonlocal draws
        draws += 1
        state = session.state
        pyxel.cls(1)
        pyxel.text(12, 9, f"TIDEPOST / STEP {STAGE}", 10)
        pyxel.text(12, 22, f"MOVES {state.used}", 7)
        pyxel.text(100, 22, "TIDE: " + ("BLUE (a)" if state.tide == 0 else "ORANGE (b)"), 12 if state.tide == 0 else 9)
        for y, row in enumerate(ROWS):
            for x, char in enumerate(row):
                px, py = 12 + x * TILE, 42 + y * TILE
                if char == ".":
                    pyxel.line(px + 8, py + 14, px + 15, py + 14, 5)
                    continue
                if char in "SIAB":
                    pyxel.rect(px + 1, py + 1, TILE - 2, TILE - 2, 3)
                    pyxel.rectb(px + 1, py + 1, TILE - 2, TILE - 2, 11)
                    pyxel.text(px + 4, py + 3, char, 7)
                    if char in "AB" and state.delivered & (1 << "AB".index(char)):
                        pyxel.text(px + 18, py + 3, "+", 10)
                else:
                    color = 12 if char == "a" else 9 if char == "b" else 10 if char == "~" else 4
                    active = char not in "ab" or char == "ab"[state.tide]
                    if y % 2 == 0:
                        box = (px, py + 9, TILE, 10)
                    else:
                        box = (px + 9, py, 10, TILE)
                    if active:
                        pyxel.rect(*box, color)
                    else:
                        pyxel.rectb(*box, color)
                    pyxel.text(px + 12, py + 11, char, 0 if active else color)
                    if char == "~" and heavy_left(state):
                        pyxel.line(px + 5, py + 5, px + 22, py + 22, 8)
                        pyxel.line(px + 22, py + 5, px + 5, py + 22, 8)
        px, py = 12 + state.x * TILE, 42 + state.y * TILE
        pyxel.circ(px + 14, py + 18, 5, 8)
        pyxel.pset(px + 15, py + 16, 7)
        parcels = "  ".join(name + (":OK" if state.delivered & (1 << i) else ":TODO")
                            for i, name in enumerate("AB"))
        pyxel.text(220, 62, parcels, 7)
        pyxel.text(220, 77, "HEAVY B" if heavy_left(state) else "LIGHT: ~ OPEN", 10)
        pyxel.text(220, 94, "DELIVER A+B", 6)
        pyxel.text(220, 106, "THEN RETURN TO S", 6)
        if won(state):
            pyxel.rect(217, 125, 134, 28, 0)
            pyxel.text(224, 136, "CLEAR!  R: RESET", 11)
        notices = {
            "ready": "READY", "move": "STEP", "water": "WATER: NO MOVE",
            "invalid": "UNKNOWN COMMAND", "tide": "TIDE CHANGED",
            "unsafe": "SWITCH TIDE ON AN ISLAND", "tide_block": "BRIDGE UNDER WATER",
            "heavy": "DELIVER B TO USE ~", "delivery": "DELIVERED!",
            "win": "CLEAR! BACK AT S", "done": "CLEAR! R: RESET", "undo": "ONE STEP BACK",
        }
        pyxel.text(12, 194, notices[session.event], 10)
        pyxel.text(12, 210, "ARROWS: STEP  R: RESET  ESC: QUIT", 7)
        pyxel.text(12, 222, "SPACE: TIDE (ON ISLANDS ONLY)", 6)
        pyxel.text(12, 234, "UNDO STARTS IN STEP 5", 5)

    pyxel.init(364, 250, title=f"Tidepost - Stage {STAGE}", fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true",
                        help="Render 12 frames then quit; not a human playtest")
    parser.add_argument("--screenshot", metavar="PATH", help="PNG path for --headless-smoke")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    main(headless=args.headless_smoke, screenshot=args.screenshot)
STEP 05 / 05

状態を丸ごと巻き戻す

書き換えられない State を、履歴に積み重ねる。

操作する前の State を Session.history に保存しておき、Z キーか BACKSPACE キーで pop(最後の1つを取り出す)して戻します。位置・潮・配達済みの印・手数をまとめて戻すので、配達を取り消すと重い小包も戻り、細い橋がまた通れなくなります。State は frozen=True なので、保存したあとに書き換わることはありません。進めなかった入力では State が変わらないので、履歴には積みません。クリアも State から計算しているので、クリアしたあとに undo すれば、直前のプレイに戻れます。R キーを押すと、履歴も空にします。

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

05_undo.py / 注目する部分
def act(self, command):
    next_state, self.event = step(self.state, command)
    if next_state != self.state:
        self.history.append(self.state)
        self.state = next_state

def undo(self):
    if self.history:
        self.state = self.history.pop()
        self.event = "undo"

def restart(self):
    self.state = State()
    self.history.clear()
    self.event = "ready"

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること右を6回押して B に届けたあと Z を押すと、B に着く直前に戻り、B:TODO と HEAVY B が元に戻ります。もう一度右を押すと、また届けられます。21手でクリアしたあとも Z で戻れ、上を押すとまた同じ21手でクリアになります。
  • 配達を戻すと重荷も戻る
  • 潮と手数も戻る
  • クリア後のZで再開できる
  • Rで履歴を空にする
つまずきやすいところ位置だけを戻すと、潮や荷物が元に戻らず、同じ入力をしても同じ結果になりません。また、進めなかった入力まで履歴に積むと、Z を押しても何も変わらない手が増えてしまいます。
少し変えてみよう

S の上で SPACE→Z と押し、潮と手数の両方が戻ることを確かめましょう。次に、左(進めない)→Z を試します。履歴が空なら、エラーにならずに止まります。R→Z で前のゲームに戻れない理由も説明してみましょう。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(226行)
05_undo.py / 全文
# MIT License
#
# Copyright (c) 2026 Tidepost contributors
#
# 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.
#
# Exception: assets/glyphs.json contains rasterized glyph data derived from
# Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt.
# Pyxel is an external dependency under its own MIT license.

"""潮待ちポスト・教材 5/5。原作「ふたつの潮」の基本便を段階化。
実行: python 05_undo.py
Cを通常の島Iに置換。日本語の解説はLESSON.md。画面はASCIIのみ。
原作ライセンスの字形例外は上に保持。教材はNoto字形を使用しない。
"""
from dataclasses import dataclass, replace

# (列, 行) で扱う。Cの追加便は教材に含めず、同じ位置をIにする。
ROWS = ("S-a-I-B", "|...b.|", "I~I-I-I", "b.|...b", "A-I...I")
DIRECTIONS = {"L": (-1, 0), "R": (1, 0), "U": (0, -1), "D": (0, 1)}
TILE = 28
STAGE = 5


def find(char):
    return next((x, y) for y, row in enumerate(ROWS)
                for x, cell in enumerate(row) if cell == char)


START = find("S")
DESTINATIONS = (find("A"), find("B"))
CONTRACT = 3  # 2進数11: Aは1、Bは2。両方を届ける。


def heavy_left(state):
    return bool(CONTRACT & 2 & ~state.delivered)


def won(state):
    return (state.delivered & CONTRACT) == CONTRACT and (state.x, state.y) == START


@dataclass(frozen=True)
class State:
    x: int = START[0]
    y: int = START[1]
    tide: int = 0
    delivered: int = 0
    used: int = 0


def step(state, command):
    if won(state):
        return state, "done"
    if command == "T":
        if ROWS[state.y][state.x] not in "SIAB":
            return state, "unsafe"
        return replace(state, tide=1 - state.tide, used=state.used + 1), "tide"
    if command not in DIRECTIONS:
        return state, "invalid"
    dx, dy = DIRECTIONS[command]
    x, y = state.x + dx, state.y + dy
    if y < 0 or y >= len(ROWS) or x < 0 or x >= len(ROWS[y]) or ROWS[y][x] == ".":
        return state, "water"
    target = ROWS[y][x]
    if target in "ab" and target != "ab"[state.tide]:
        return state, "tide_block"
    if target == "~" and heavy_left(state):
        return state, "heavy"
    delivered = state.delivered
    event = "move"
    # 通れることが確定してから、到着先の配達を処理する。
    for index, destination in enumerate(DESTINATIONS):
        bit = 1 << index
        if (x, y) == destination and CONTRACT & bit and not delivered & bit:
            delivered |= bit
            event = "delivery"
    new_state = replace(state, x=x, y=y, delivered=delivered, used=state.used + 1)
    return new_state, "win" if won(new_state) else event


class Session:
    def __init__(self):
        self.state = State()
        self.history = []
        self.event = "ready"

    def act(self, command):
        next_state, self.event = step(self.state, command)
        if next_state != self.state:
            self.history.append(self.state)
            self.state = next_state

    def undo(self):
        if self.history:
            self.state = self.history.pop()
            self.event = "undo"

    def restart(self):
        self.state = State()
        self.history.clear()
        self.event = "ready"


def main(headless=False, screenshot=None):
    import json
    import pyxel  # 純粋ルールのテストでは読み込まない。

    session = Session()
    draws = 0
    keys = ((pyxel.KEY_LEFT, "L"), (pyxel.KEY_RIGHT, "R"),
            (pyxel.KEY_UP, "U"), (pyxel.KEY_DOWN, "D"))
    keys += ((pyxel.KEY_SPACE, "T"),)

    def update():
        if headless and draws >= 12:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            print(json.dumps({"stage": STAGE, "rendered_frames": draws,
                              "screenshot": screenshot, "mode": "headless-smoke"}), flush=True)
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_ESCAPE):
            pyxel.quit()
            return
        if pyxel.btnp(pyxel.KEY_R):
            session.restart()
            return
        if pyxel.btnp(pyxel.KEY_Z) or pyxel.btnp(pyxel.KEY_BACKSPACE):
            session.undo()
            return
        for key, command in keys:
            if pyxel.btnp(key):
                session.act(command)
                break  # 一つのフレームに進めるのは一手だけ。

    def draw():
        nonlocal draws
        draws += 1
        state = session.state
        pyxel.cls(1)
        pyxel.text(12, 9, f"TIDEPOST / STEP {STAGE}", 10)
        pyxel.text(12, 22, f"MOVES {state.used}", 7)
        pyxel.text(100, 22, "TIDE: " + ("BLUE (a)" if state.tide == 0 else "ORANGE (b)"), 12 if state.tide == 0 else 9)
        for y, row in enumerate(ROWS):
            for x, char in enumerate(row):
                px, py = 12 + x * TILE, 42 + y * TILE
                if char == ".":
                    pyxel.line(px + 8, py + 14, px + 15, py + 14, 5)
                    continue
                if char in "SIAB":
                    pyxel.rect(px + 1, py + 1, TILE - 2, TILE - 2, 3)
                    pyxel.rectb(px + 1, py + 1, TILE - 2, TILE - 2, 11)
                    pyxel.text(px + 4, py + 3, char, 7)
                    if char in "AB" and state.delivered & (1 << "AB".index(char)):
                        pyxel.text(px + 18, py + 3, "+", 10)
                else:
                    color = 12 if char == "a" else 9 if char == "b" else 10 if char == "~" else 4
                    active = char not in "ab" or char == "ab"[state.tide]
                    if y % 2 == 0:
                        box = (px, py + 9, TILE, 10)
                    else:
                        box = (px + 9, py, 10, TILE)
                    if active:
                        pyxel.rect(*box, color)
                    else:
                        pyxel.rectb(*box, color)
                    pyxel.text(px + 12, py + 11, char, 0 if active else color)
                    if char == "~" and heavy_left(state):
                        pyxel.line(px + 5, py + 5, px + 22, py + 22, 8)
                        pyxel.line(px + 22, py + 5, px + 5, py + 22, 8)
        px, py = 12 + state.x * TILE, 42 + state.y * TILE
        pyxel.circ(px + 14, py + 18, 5, 8)
        pyxel.pset(px + 15, py + 16, 7)
        parcels = "  ".join(name + (":OK" if state.delivered & (1 << i) else ":TODO")
                            for i, name in enumerate("AB"))
        pyxel.text(220, 62, parcels, 7)
        pyxel.text(220, 77, "HEAVY B" if heavy_left(state) else "LIGHT: ~ OPEN", 10)
        pyxel.text(220, 94, "DELIVER A+B", 6)
        pyxel.text(220, 106, "THEN RETURN TO S", 6)
        if won(state):
            pyxel.rect(217, 125, 134, 28, 0)
            pyxel.text(224, 136, "CLEAR!  R: RESET", 11)
        notices = {
            "ready": "READY", "move": "STEP", "water": "WATER: NO MOVE",
            "invalid": "UNKNOWN COMMAND", "tide": "TIDE CHANGED",
            "unsafe": "SWITCH TIDE ON AN ISLAND", "tide_block": "BRIDGE UNDER WATER",
            "heavy": "DELIVER B TO USE ~", "delivery": "DELIVERED!",
            "win": "CLEAR! BACK AT S", "done": "CLEAR! R: RESET", "undo": "ONE STEP BACK",
        }
        pyxel.text(12, 194, notices[session.event], 10)
        pyxel.text(12, 210, "ARROWS: STEP  R: RESET  ESC: QUIT", 7)
        pyxel.text(12, 222, "SPACE: TIDE (ON ISLANDS ONLY)", 6)
        pyxel.text(12, 234, "Z / BACKSPACE: UNDO", 6)

    pyxel.init(364, 250, title=f"Tidepost - Stage {STAGE}", fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true",
                        help="Render 12 frames then quit; not a human playtest")
    parser.add_argument("--screenshot", metavar="PATH", help="PNG path for --headless-smoke")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    main(headless=args.headless_smoke, screenshot=args.screenshot)
AFTER STEP 05

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

A に手紙、B に重い小包を届けて、出発した灯台の S に帰ります。B に届けるまでは、細い橋 ~ を通れません。島の上で SPACE を押すと、青い a の橋とオレンジの b の橋が切り替わります。動けた移動と潮の切り替えは1手、進めなかった入力は0手と数えます。

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

比べる点5ステップの教材元の完成版
変更点 1元のゲームにある3つの地図のうち、2枚目「ふたつの潮」の基本の配達1つだけにしぼっています。C の場所はふつうの島 I に置きかえ、追加の配達は省いています。
変更点 201はすべての橋を通れ、潮・荷物・ゴールはありません。02で潮、03で配達、04で帰ってくるゴールを足し、05で undo(1手戻す)が完成します。
変更点 3元のゲームにある地図や依頼の選択、最短手順の表示、自己ベスト、効果音、アニメーション、日本語の文字表示(bitmap)、WASD での操作は省いています。
変更点 4最後の05の基本の配達では、状態の変わり方が元のゲームの2枚目と同じです。ただし、教材は元のゲーム全体を作り直したものではありません。

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

  1. model.py の Board・BOARDS・CONTRACTS(地図と配達の依頼のデータ)
  2. model.py の heavy_left・won・step(重さ・クリア・1手の処理)
  3. model.py の solve(幅優先探索で最短の手順を探す処理)
  4. app.py の App.act・App.undo・App.update・App.draw_map(操作・取り消し・更新・地図の描画)
自分のゲームに変えるなら

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

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