パズルLESSON 50言葉の性質を交換する三章の絵本パズル原作098

一冊の動く絵本の作り方

ことばを交換すると、ものの動きも変わります。
「浮く」「長い」「風に運ばれる」。2つのものの性質を表すことばを交換して、章ごとに完成した絵を作っていく絵本を作ります。

完成版の遊び方2つのものの間で、性質を表すことばを交換します。交換すると、景色が同時に変わります。3つの章で景色を変えながら、好きな完成した絵を残します。

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

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

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

完成版の主な操作:Tabで歩くモードと札(性質を表すことば)のモードを切り替える、矢印キーで対象を選ぶ、Zで操作・選択、Enterで交換、Spaceで待つ、Nで次の章、Qで前の章、Xで戻す

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

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

うまく表示されないとき

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

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

ものに付いたことば(属性)から、動きと完成の条件を決める。

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

1. tuple、書き換えない dataclass、カーソルと選んだものの集合、選ぶことと決めることを分ける
2. 行き先を先に調べる、陸と水面、乗り物といっしょに動く、安全な戻り先、ほかの値から導くルール(派生ルール)
3. 章ごとのルール、条件の組み合わせ、完成をそのまま保つ、状態の一部だけの更新、章の移動
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.10以上と、ターミナルと仮想環境の基本。実際の確認は Python 3.12.14 と Pyxel 2.9.9 で行いました。 / 変数・if・for・関数・tuple・list・dict の基本。書き換えない dataclass、replace、履歴、JSON は本文で説明します。
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
全段階:札のモードでは、矢印で対象を切り替え、Zで選ぶ・解除する、2つの対象を選んで Enter で交換。キーを全部離してから次の入力を受け付ける / 02以降:Tab で歩くモードと札のモードを切り替え、左右で歩く、Z で寄せる・旗を上げる・降りる、Space で1段階待つ / 03以降:完成したあと N で次の章、Q で前の章。R→Enter で今の章だけやり直し、R→X で取り消し / 05:X で直前の意味のある操作を Undo(元に戻す)、S と L で --save-book で指定した JSON に保存・読み込み。Escape ですぐに終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_word_pairs.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

二つの物と札を分け、交換前の選択を作る

tuple、書き換えない dataclass、カーソルと選んだものの集合、選ぶことと決めることを分ける。

元のゲームの第1章から、舟Aと石B、最初の位置2と4、札(ものに付いたことば)「重い」「軽い」を使います。Page は世界の状態です。App の cursor と pair は、入力している途中の状態です。cursor は今指している対象、pair は Z で選んだ対象の番号です。矢印キーで0と1を切り替えます。同じ対象をもう一度 Z で選ぶと、選択を解除します。Enter は、2つの対象がそろったときだけ swap を呼びます。ものの名前や位置を交換するのではありません。2つの tags を逆の順にして、同時に付け替えます。浮くか沈むかは「軽い」かどうかから求めるので、専用の float フラグ(印)は保存しません。こうすると、札だけが変わって浮き沈みのフラグが古いまま、という食いちがいを防げます。この段階では、旅人はセル1(マス目の1番)に固定しています。移動・待機・完成の判定は使えません。交換すると ticks(時間の数)は1増えますが、対象を選んだりカーソルを動かしたりしても増えません。画面の英語の LIGHT と HEAVY は、日本語の「軽い」と「重い」に当たります。

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

01_word_pairs.py / 注目する部分
            elif key == 'Z':
                if self.cursor in self.pair:
                    self.pair.remove(self.cursor)
                else:
                    self.pair.append(self.cursor)
                self.notice = 'select:pair'
            elif key == 'RETURN':
                if len(self.pair) == 2:
                    return self.apply('swap')
                self.notice = 'select_two_targets'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_word_pairs.py
macOS / Linux
.venv/bin/python 01_word_pairs.py
画面で見ること起動したときは、舟が HEAVY で水の底、石が LIGHT で水面にあります。Z→右→Z と押すと、選択の印が2つになります。舟も石も元の高さのままで、TICK 0 のままです。Enter を押すと、舟が LIGHT、石が HEAVY になります。この1回の操作で、舟が浮いて石が沈みます。TICK 1 になり、選択の印は両方消えます。旅人とものの、セルの番号は変わりません。自動の確認用の画面(checkpoint)も、本物の Pyxel でこの状態を描いています。
  • cursor と pair を、世界の Page から分ける
  • 同じ対象でもう一度Zを押すと、解除になる
  • 2つの対象がそろってから Enter
  • 札の集合を保存し、同時に交換する
  • 選んでも時間は進まない。交換は1手と数える
つまずきやすいところカーソルを動かしただけで「選んだ」ことにしてはいけません。最初の Z で、浮き沈みなどの動き(物理)を変えてもいけません。Z を2回押しても、同じ対象なら2つ選んだことにはなりません。tags をその場で書き換えずに、replace で新しい Page を作るのは、あとで正確な Undo(元に戻す)を作るためでもあります。
少し変えてみよう

起動してすぐに Enter を押すと、select_two_targets(対象を2つ選ぶ)と出て、TICK 0 のままです。Z→Z→Enter でも同じです。Z→右→Z→Enter で交換したら、カーソルは石を指したままなので、Z→左→Z→Enter で元に戻してみましょう。TICK 2 で、舟は HEAVY、石は LIGHT になります。2回交換しても、時間までは元に戻らないことを確かめます。

01_word_pairs.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(347行)
01_word_pairs.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""098 一冊の動く絵本・日本語教材 1/5。"""
from dataclasses import dataclass, replace, asdict
import json

WIDTH, HEIGHT = 800, 560
TITLES = ('渡し舟のページ', '橋のページ', '風のページ')
NAMES = (('舟', '石'), ('橋', '旗'), ('葉舟', '看板'))
LAND = ((0, 1, 5, 6), (0, 1, 5, 6), (0, 1, 3, 4, 6))
TAGS = (('重い', '軽い'), ('短い', '長い'), ('軽い', '重い'))
EN_NAMES = (('BOAT', 'STONE'), ('BRIDGE', 'FLAG'), ('LEAF BOAT', 'SIGN'))
EN_TAGS = {'重い':'HEAVY', '軽い':'LIGHT', '短い':'SHORT', '長い':'LONG'}
EN_SCENES = {'浮く舟、右岸の旅人':'FLOAT BOAT / TRAVELER ON RIGHT', '浮く石、右岸の旅人':'FLOAT STONE / TRAVELER ON RIGHT', '長い橋と短い旗':'LONG BRIDGE / SHORT FLAG', '短い橋と長い旗':'SHORT BRIDGE / LONG FLAG', '軽い葉舟の空':'LIGHT LEAF BOAT SKY', '軽い看板の空':'LIGHT SIGN SKY', '葉舟で風の丘':'LEAF BOAT WIND HILL', '看板で風の丘':'SIGN WIND HILL'}

STAGE = 1
PAGE_COUNT = 1
TITLE = 'SELECT TWO WORD TARGETS'

@dataclass(frozen=True)
class Page:
    hero: int
    objects: tuple
    tags: tuple
    riding: int = -1
    last_land: int = 0
    raised: bool = False
    complete: bool = False
    ticks: int = 0
    cause: str = ''

@dataclass(frozen=True)
class State:
    page: int
    pages: tuple
    memories: tuple
    variant: str = 'causes'
    events: tuple = ()

def fresh(i):
    return Page((1, 0, 0)[i], ((2, 4), (1, 6), (1, 4))[i], TAGS[i], last_land=(1, 0, 0)[i])

def new(variant='causes'):
    return State(0, tuple((fresh(i) for i in range(PAGE_COUNT))), tuple(('' for _ in range(PAGE_COUNT))), variant)

def finish(s, p, cause):
    i = s.page
    complete = p.complete
    p = replace(p, complete=complete, ticks=p.ticks + 1, cause=cause)
    pages = list(s.pages)
    pages[i] = p
    mem = list(s.memories)
    return replace(s, pages=tuple(pages), memories=tuple(mem), events=(cause,))

def swap(s):
    i = s.page
    p = s.pages[i]
    tags = tuple(reversed(p.tags))
    p = replace(p, tags=tags)
    cause = ' / '.join((NAMES[i][j] + 'が' + tags[j] for j in (0, 1)))
    if i == 0:
        cause += ' → 浮く物と沈む物が交代'
        if p.riding >= 0 and tags[p.riding] == '重い':
            p = replace(p, hero=p.last_land, riding=-1)
            cause += '。旅人は泡で陸へ'
    return finish(s, p, cause)

def step(s, action):
    if action == 'swap':
        return swap(s)
    if action == 'restart':
        return new(s.variant)
    return s

class App:
    def __init__(self, save_path=None):
        self.s = new()
        self.history = []
        self.mode = 'words' if STAGE == 1 else 'walk'
        self.cursor = 0
        self.pair = []
        self.previous = set()
        self.gate = False
        self.notice = 'ready'
        self.save_path = save_path
        self.last_save = None
        self.dialog = ''

    def apply(self, action):
        if action == 'undo':
            if STAGE < 5 or not self.history:
                self.notice = 'no_undo'
                return self.notice
            self.s = self.history.pop()
            self.pair = []
            self.notice = 'undo'
            return self.notice
        old = self.s
        self.s = step(old, action)
        if self.s == old:
            self.notice = 'blocked:' + action
            return self.notice
        if STAGE >= 5:
            self.history.append(old)
            self.history = self.history[-1000:]
        if action == 'swap':
            self.pair = []
        if action in ('next', 'prev', 'restart'):
            self.mode = 'words' if STAGE == 1 else 'walk'
            self.pair = []
        self.notice = action
        return action

    def feed(self, down):
        # 選択だけでは状態/時間を変えない。原作と同じ全解放ゲート。
        down = set(down) & set(CONTROL_KEYS)
        fresh = down - self.previous
        self.previous = down
        if not down:
            self.gate = False
        if self.gate or not fresh:
            return None
        self.gate = True
        if len(fresh) > 1:
            self.notice = 'ignored:multiple'
            return self.notice
        key = next(iter(fresh))
        if self.dialog:
            if key == 'X':
                self.dialog = ''
                self.notice = 'cancel'
            elif key == 'RETURN':
                self.dialog = ''
                self.apply('restart')
            return self.notice
        if key == 'R':
            self.dialog = 'restart'
            self.notice = 'confirm:restart'
            return self.notice
        if key == 'X':
            return self.apply('undo')
        if key in ('S', 'L') and STAGE >= 5:
            if not self.save_path:
                self.notice = 'use_--save-book'
                return self.notice
            try:
                if key == 'S':
                    self.last_save = str(save_book(self.save_path, self.s, self.history))
                    self.notice = 'saved'
                else:
                    state, history = load_book(self.save_path)
                    self.s, self.history = state, list(history)
                    self.mode, self.pair = 'walk', []
                    self.notice = 'loaded'
            except (OSError, ValueError, KeyError, TypeError, RecursionError):
                self.notice = 'save_failed' if key == 'S' else 'load_failed'
            return self.notice
        if key == 'N':
            return self.apply('next')
        if key == 'Q':
            return self.apply('prev')
        if key == 'TAB' and STAGE >= 2:
            self.mode = 'words' if self.mode == 'walk' else 'walk'
            self.pair = []
            self.notice = 'mode'
            return self.notice
        if key == 'SPACE':
            return self.apply('wait')
        if self.mode == 'words':
            if key in ('LEFT','RIGHT','UP','DOWN'):
                self.cursor = 1 - self.cursor
                self.notice = 'select:object'
            elif key == 'Z':
                if self.cursor in self.pair:
                    self.pair.remove(self.cursor)
                else:
                    self.pair.append(self.cursor)
                self.notice = 'select:pair'
            elif key == 'RETURN':
                if len(self.pair) == 2:
                    return self.apply('swap')
                self.notice = 'select_two_targets'
        else:
            if key in ('LEFT','RIGHT'):
                return self.apply(key.lower())
            if key == 'Z':
                return self.apply('interact')
        return self.notice

    def draw(self, p):
        s = self.s
        i = s.page
        page = s.pages[i]
        p.cls(1)
        p.rect(0, 0, WIDTH, 47, 2)
        p.text(16, 10, 'LIVING STORYBOOK / STAGE ' + str(STAGE), 10)
        p.text(16, 28, TITLE, 7)
        p.text(540, 10, 'CHAPTER '+str(i+1)+' / '+str(PAGE_COUNT), 6)
        p.text(540, 28, 'TICK '+str(page.ticks)+' / HISTORY '+str(len(self.history)), 6)
        p.text(16, 59, ('WORDS: CHOOSE TWO TARGETS' if self.mode == 'words' else 'WALK: MOVE / INTERACT'), 10)
        p.text(16, 75, 'SELECTING DOES NOT ADVANCE PHYSICS OR TIME.', 6)
        for j in range(2):
            x = 16 + j * 390
            p.rect(x, 98, 376, 76, 2)
            p.rectb(x, 98, 376, 76, 10 if self.cursor == j and self.mode == 'words' else 5)
            p.text(x+12, 110, ('[X] ' if j in self.pair else '[ ] ') + EN_NAMES[i][j], 11 if j in self.pair else 7)
            p.text(x+12, 130, 'WORD: '+EN_TAGS[page.tags[j]], 10)
            effect = ('FLOATS' if page.tags[j] == '軽い' else 'SINKS') if i == 0 else ('SIZE 3' if page.tags[j] == '長い' else 'SIZE 1') if i == 1 else ('WIND +1 CELL' if page.tags[j] == '軽い' else 'STAYS HERE')
            p.text(x+12, 150, 'EFFECT: '+effect, 6)
        p.text(16, 190, ('FERRY: REACH LAND CELL 5' if i == 0 else 'BRIDGE: REACH 6, THEN Z TO RAISE FLAG' if i == 1 else 'WIND: REACH 6 RIDING, THEN Z TO GET OFF'), 7)
        if STAGE == 1:
            p.text(16, 206, 'STAGE 1: OBSERVE ONLY. WALK / GOAL START IN STAGE 2.', 9)
        for cell in range(7):
            x = 64 + cell * 100
            p.text(x+26, 230, str(cell), 6)
            p.rect(x, 304, 88, 37, 4 if cell in LAND[i] else 12 if i == 0 else 0)
            p.rectb(x, 304, 88, 37, 5)
            p.text(x+18, 326, 'LAND' if cell in LAND[i] else 'WATER' if i == 0 else 'GAP', 7)
        if i == 1:
            p.rect(258, 294, 288 if page.tags[0] == '長い' else 88, 9, 9)
            p.rect(718, 254, 3, 51, 6)
            flag_y = 253 if page.raised else 285
            p.rect(721, flag_y, 42 if page.tags[1] == '長い' else 14, 12, 8)
        else:
            for j, cell in enumerate(page.objects):
                x = 80 + cell * 100
                y = (281 if page.tags[j] == '軽い' else 349) if i == 0 else (263 if page.tags[j] == '軽い' else 286)
                if j == 0:
                    p.rect(x, y, 57, 13, 9)
                    p.line(x, y+13, x+57, y+13, 4)
                else:
                    p.rect(x+13, y-4, 27, 18, 13 if i == 0 else 4)
                p.text(x+16, y+22, 'A' if j == 0 else 'B', 7)
        hx = 108 + page.hero * 100
        hy = 243 if i == 2 and page.riding >= 0 and page.tags[page.riding] == '軽い' else 272
        p.circ(hx, hy, 5, 10)
        p.line(hx, hy+5, hx, hy+19, 7)
        p.line(hx-6, hy+12, hx+6, hy+12, 7)
        p.text(16, 387, 'HERO '+str(page.hero)+' / RIDING '+('-' if page.riding < 0 else EN_NAMES[i][page.riding])+' / LAST LAND '+str(page.last_land), 7)
        p.text(16, 404, 'OBSERVATION: WORDS -> FLOAT / SINK. NO GOAL YET.' if STAGE == 1 else 'PAGE GOAL: '+('COMPLETE' if page.complete else 'OPEN')+' / WORDS CAN STILL CHANGE THE ENDING.', 11 if page.complete else 6)
        p.rect(14, 423, 772, 61, 0)
        p.text(22, 429, 'REMEMBERED CHAPTER OUTCOMES (IN THIS BOOK)', 6)
        for n, item in enumerate(s.pages):
            memory = EN_SCENES.get(s.memories[n], '-')
            p.text(22, 441+n*12, str(n+1)+': '+memory, 11 if s.memories[n] else 5)
        p.text(16, 494, 'STATUS: '+self.notice+(' / ENTER RESTARTS PAGE, X CANCELS' if self.dialog else ''), 10)
        p.text(16, 511, 'ARROWS TARGET / Z SELECT / ENTER SWAP | R RETRY / ENTER CONFIRM / X CANCEL' if STAGE == 1 else 'WORDS: ARROWS TARGET / Z SELECT / ENTER SWAP | TAB WORDS-WALK', 7)
        p.text(16, 527, 'NO WALK / WAIT / CHAPTERS / UNDO / SAVE YET | ESC QUIT' if STAGE == 1 else 'WALK: LEFT-RIGHT / Z PULL | SPACE WAIT | R RETRY | ESC QUIT' if STAGE == 2 else 'WALK: LEFT-RIGHT / Z USE | SPACE WAIT | N NEXT / Q PREV | R RETRY', 6)
        p.text(16, 543, 'X UNDO / S SAVE / L LOAD (--save-book) | ESC QUIT' if STAGE == 5 else 'MEMORY IS IN RAM. STAGE 5 ADDS UNDO AND SAVE.', 6)

CONTROL_KEYS = ('LEFT','RIGHT','UP','DOWN','TAB','Z','RETURN','SPACE','N','Q','R','X','S','L')
SWAP_KEYS = ('TAB','Z','RIGHT','Z','RETURN','TAB')

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

def parse_args(argv=None):
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument('--headless-smoke', action='store_true')
    mode.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    if STAGE >= 5:
        parser.add_argument('--save-book', help='S/L use this explicit .json; valid tutorial book may be replaced')
    args = parser.parse_args(argv)
    if (args.screenshot or args.report) and not (args.headless_smoke or args.demo_checkpoint):
        parser.error('outputs require a headless mode')
    outputs = []
    for name, suffix in (('screenshot','.png'),('report','.json'),('save_book','.json')):
        value = getattr(args, name, None)
        if value:
            path = Path(value).expanduser().absolute()
            if path.suffix.lower() != suffix or path.is_symlink():
                parser.error(name+' needs a regular '+suffix+' path')
            outputs.append(path.resolve())
    if len(set(outputs)) != len(outputs):
        parser.error('output paths must be distinct')
    if Path(__file__).resolve() in outputs:
        parser.error('cannot overwrite this source')
    return args

def main(argv=None):
    from pathlib import Path
    args = parse_args(argv)
    # モデルのimportと試験にはPyxelが不要。CLIの検査後にだけ読み込む。
    import pyxel as p
    headless = args.headless_smoke or args.demo_checkpoint
    app = App(getattr(args, 'save_book', None))
    sequence = DEMO_KEYS if args.demo_checkpoint else ()
    limit = max(12, 2*len(sequence)+4)
    keys = {name:getattr(p,'KEY_'+name) for name in CONTROL_KEYS}
    p.init(WIDTH, HEIGHT, title='098 Storybook - Stage '+str(STAGE), fps=30,
           display_scale=1, headless=headless)
    finished = False
    def update():
        if headless:
            index = p.frame_count // 2
            app.feed({sequence[index]} if p.frame_count % 2 == 0 and index < len(sequence) else set())
        else:
            app.feed({name for name, key in keys.items() if p.btn(key)})
    def draw():
        nonlocal finished
        app.draw(p)
        if headless and p.frame_count >= limit and not finished:
            finished = True
            if args.screenshot:
                target = Path(args.screenshot).expanduser().absolute()
                target.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(target), scale=1)
            if args.report:
                target = Path(args.report).expanduser().absolute()
                target.parent.mkdir(parents=True, exist_ok=True)
                data = {'stage':STAGE,'kind':'actual Pyxel headless automated render','keys':sequence,
                        'state':asdict(app.s),'history_length':len(app.history),'notice':app.notice,
                        'os_input_tested':False,'human_play_tested':False,'audio_tested':False}
                target.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n',encoding='utf-8')
            p.quit()
    p.run(update, draw)
    return app

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

軽い物へ乗り、浮沈から渡し舟の移動を決める

行き先を先に調べる、陸と水面、乗り物といっしょに動く、安全な戻り先、ほかの値から導くルール(派生ルール)。

第1章に、旅人の移動と完成の判定を加えます。最初は歩くモードで、Tab で札のモードに切り替えます。世界は0〜6の7つのセルです。陸は0・1・5・6、水面は2・3・4です。歩くときは、先に行き先(target)を計算します。行き先が陸なら歩きます。軽いものが行き先と同じセルにあれば、それに乗ります。乗ったまま水の上を進むときは、hero(旅人)と、乗っている objects(もの)の位置を、同じ target に更新します。重い石は水の底の層にあるので、舟は同じセル4を通れます。舟を重くしてしまったら、旅人は最後にいた陸(last_land)に戻ります。舟の位置は戻しません。これは元のゲームの安全のための仕組みで、死んだり減点されたりするわけではありません。陸から距離2にある軽いものは、Z で1セル寄せられます。でも、最初の石は距離3なので届きません。右岸のセル5以上に着くと complete(完成)が立ちます。そして、浮いているものの名前を memories に残します。

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

02_ferry.py / 注目する部分
def walk(s, d):
    i = s.page
    p = s.pages[i]
    target = p.hero + d
    if d not in (-1, 1) or not 0 <= target <= 6:
        return s
    objects = list(p.objects)
    if i == 0:
        if p.riding >= 0:
            if target in LAND[i]:
                return finish(s, replace(p, hero=target, riding=-1, last_land=target), '舟から陸へ降りた' if p.riding == 0 else '石から陸へ降りた')
            if target in (2, 3, 4) and (not any((j != p.riding and objects[j] == target and (p.tags[j] == '軽い') for j in (0, 1)))):
                objects[p.riding] = target
                return finish(s, replace(p, hero=target, objects=tuple(objects)), '軽い' + NAMES[i][p.riding] + 'と水上を進んだ')
            return s
        if target in LAND[i]:
            return finish(s, replace(p, hero=target, last_land=target), '陸を歩いた')
        riders = [j for j in (0, 1) if objects[j] == target and p.tags[j] == '軽い']
        if riders:
            return finish(s, replace(p, hero=target, riding=riders[0]), '軽い' + NAMES[i][riders[0]] + 'へ乗った')
        return s
    return s

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_ferry.py
macOS / Linux
.venv/bin/python 02_ferry.py
画面で見ること起動してすぐに右や Z を押すと blocked(進めない)になり、TICK 0 のままです。Tab→Z→右→Z→Enter→Tab と押すと、舟を軽くして歩くモードに戻ります。右を4回押すと、「2で乗る→舟で3→舟で4→陸の5」と進みます。舟の位置は4、石の位置も4、旅人は5、RIDING -、TICK 5、PAGE GOAL COMPLETE です。完成した絵は「浮く舟、右岸の旅人」です。保存の機能はまだないので、終了すると消えます。
  • 行き先が範囲の中かを、先に調べる
  • 軽いものだけが、水面の足場になる
  • 乗っている人と乗り物を、同時に更新する
  • 水の底の石は、舟のじゃまをしない
  • 完成したあとも、札で結末を変えられる
つまずきやすいところ画面でものが重なったら、どれも「ぶつかった」として扱うと、水の底の石で舟が止まる、別のゲームになってしまいます。重くなったときに、舟まで最初の位置に戻すのも、元のゲームとはちがいます。第1章では、陸へ降りる操作は左右の移動です。第3章の、Z で降りる操作と混同しないようにしましょう。
少し変えてみよう

舟を軽くしてから右を2回押し、セル3でもう一度2つの対象を選んで交換してみましょう。舟はセル3のまま重くなり、旅人だけがセル1に戻ります。次に R→Enter で最初に戻します。渡り終えてから札を元に戻すと、完成は取り消されず、「浮く石、右岸の旅人」に変わります。選択のカーソルは交換のあとも残るので、毎回どちらに枠があるかを見ましょう。

02_ferry.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(405行)
02_ferry.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""098 一冊の動く絵本・日本語教材 2/5。"""
from dataclasses import dataclass, replace, asdict
import json

WIDTH, HEIGHT = 800, 560
TITLES = ('渡し舟のページ', '橋のページ', '風のページ')
NAMES = (('舟', '石'), ('橋', '旗'), ('葉舟', '看板'))
LAND = ((0, 1, 5, 6), (0, 1, 5, 6), (0, 1, 3, 4, 6))
TAGS = (('重い', '軽い'), ('短い', '長い'), ('軽い', '重い'))
EN_NAMES = (('BOAT', 'STONE'), ('BRIDGE', 'FLAG'), ('LEAF BOAT', 'SIGN'))
EN_TAGS = {'重い':'HEAVY', '軽い':'LIGHT', '短い':'SHORT', '長い':'LONG'}
EN_SCENES = {'浮く舟、右岸の旅人':'FLOAT BOAT / TRAVELER ON RIGHT', '浮く石、右岸の旅人':'FLOAT STONE / TRAVELER ON RIGHT', '長い橋と短い旗':'LONG BRIDGE / SHORT FLAG', '短い橋と長い旗':'SHORT BRIDGE / LONG FLAG', '軽い葉舟の空':'LIGHT LEAF BOAT SKY', '軽い看板の空':'LIGHT SIGN SKY', '葉舟で風の丘':'LEAF BOAT WIND HILL', '看板で風の丘':'SIGN WIND HILL'}

STAGE = 2
PAGE_COUNT = 1
TITLE = 'FLOAT / SINK / RIDE'

@dataclass(frozen=True)
class Page:
    hero: int
    objects: tuple
    tags: tuple
    riding: int = -1
    last_land: int = 0
    raised: bool = False
    complete: bool = False
    ticks: int = 0
    cause: str = ''

@dataclass(frozen=True)
class State:
    page: int
    pages: tuple
    memories: tuple
    variant: str = 'causes'
    events: tuple = ()

def fresh(i):
    return Page((1, 0, 0)[i], ((2, 4), (1, 6), (1, 4))[i], TAGS[i], last_land=(1, 0, 0)[i])

def new(variant='causes'):
    return State(0, tuple((fresh(i) for i in range(PAGE_COUNT))), tuple(('' for _ in range(PAGE_COUNT))), variant)

def scene(i, p):
    if i == 0:
        return '浮く' + NAMES[i][p.tags.index('軽い')] + '、右岸の旅人'
    return ('葉舟で風の丘' if p.riding == 0 else '看板で風の丘') if p.riding != -1 else '軽い葉舟の空' if p.tags[0] == '軽い' else '軽い看板の空'

def finish(s, p, cause):
    i = s.page
    complete = p.complete or (p.hero >= 5 if i == 0 else p.hero == 6 and p.raised if i == 1 else p.hero == 6 and p.riding == -1)
    p = replace(p, complete=complete, ticks=p.ticks + 1, cause=cause)
    pages = list(s.pages)
    pages[i] = p
    mem = list(s.memories)
    if complete:
        mem[i] = scene(i, p)
    return replace(s, pages=tuple(pages), memories=tuple(mem), events=(cause,))

def swap(s):
    i = s.page
    p = s.pages[i]
    tags = tuple(reversed(p.tags))
    p = replace(p, tags=tags)
    cause = ' / '.join((NAMES[i][j] + 'が' + tags[j] for j in (0, 1)))
    if i == 0:
        cause += ' → 浮く物と沈む物が交代'
        if p.riding >= 0 and tags[p.riding] == '重い':
            p = replace(p, hero=p.last_land, riding=-1)
            cause += '。旅人は泡で陸へ'
    return finish(s, p, cause)

def walk(s, d):
    i = s.page
    p = s.pages[i]
    target = p.hero + d
    if d not in (-1, 1) or not 0 <= target <= 6:
        return s
    objects = list(p.objects)
    if i == 0:
        if p.riding >= 0:
            if target in LAND[i]:
                return finish(s, replace(p, hero=target, riding=-1, last_land=target), '舟から陸へ降りた' if p.riding == 0 else '石から陸へ降りた')
            if target in (2, 3, 4) and (not any((j != p.riding and objects[j] == target and (p.tags[j] == '軽い') for j in (0, 1)))):
                objects[p.riding] = target
                return finish(s, replace(p, hero=target, objects=tuple(objects)), '軽い' + NAMES[i][p.riding] + 'と水上を進んだ')
            return s
        if target in LAND[i]:
            return finish(s, replace(p, hero=target, last_land=target), '陸を歩いた')
        riders = [j for j in (0, 1) if objects[j] == target and p.tags[j] == '軽い']
        if riders:
            return finish(s, replace(p, hero=target, riding=riders[0]), '軽い' + NAMES[i][riders[0]] + 'へ乗った')
        return s
    return s

def interact(s):
    i = s.page
    p = s.pages[i]
    if i == 0 and p.riding == -1 and (p.hero in LAND[0]):
        candidates = [j for j in (0, 1) if p.tags[j] == '軽い' and 1 < abs(p.objects[j] - p.hero) <= 2]
        if candidates:
            j = candidates[0]
            o = list(p.objects)
            o[j] += 1 if p.hero > o[j] else -1
            return finish(s, replace(p, objects=tuple(o)), '軽い' + NAMES[i][j] + 'を1セル手前へ寄せた')
    return s

def step(s, a):
    if a == 'swap':
        return swap(s)
    if a == 'left':
        return walk(s, -1)
    if a == 'right':
        return walk(s, 1)
    if a == 'interact':
        return interact(s)
    if a == 'wait':
        return finish(s, s.pages[s.page], '一段階待った。景色はそのまま')
    if a == 'next' and s.page < PAGE_COUNT - 1 and s.pages[s.page].complete:
        return replace(s, page=s.page + 1, events=('次のページへ。前の絵は残っています',))
    if a == 'prev' and s.page > 0:
        return replace(s, page=s.page - 1, events=('前のページへ。状態も完成絵も保持',))
    if a == 'restart':
        p = list(s.pages)
        p[s.page] = fresh(s.page)
        m = list(s.memories)
        m[s.page] = ''
        return replace(s, pages=tuple(p), memories=tuple(m), events=('このページだけ、はじめから',))
    return s

class App:
    def __init__(self, save_path=None):
        self.s = new()
        self.history = []
        self.mode = 'words' if STAGE == 1 else 'walk'
        self.cursor = 0
        self.pair = []
        self.previous = set()
        self.gate = False
        self.notice = 'ready'
        self.save_path = save_path
        self.last_save = None
        self.dialog = ''

    def apply(self, action):
        if action == 'undo':
            if STAGE < 5 or not self.history:
                self.notice = 'no_undo'
                return self.notice
            self.s = self.history.pop()
            self.pair = []
            self.notice = 'undo'
            return self.notice
        old = self.s
        self.s = step(old, action)
        if self.s == old:
            self.notice = 'blocked:' + action
            return self.notice
        if STAGE >= 5:
            self.history.append(old)
            self.history = self.history[-1000:]
        if action == 'swap':
            self.pair = []
        if action in ('next', 'prev', 'restart'):
            self.mode = 'words' if STAGE == 1 else 'walk'
            self.pair = []
        self.notice = action
        return action

    def feed(self, down):
        # 選択だけでは状態/時間を変えない。原作と同じ全解放ゲート。
        down = set(down) & set(CONTROL_KEYS)
        fresh = down - self.previous
        self.previous = down
        if not down:
            self.gate = False
        if self.gate or not fresh:
            return None
        self.gate = True
        if len(fresh) > 1:
            self.notice = 'ignored:multiple'
            return self.notice
        key = next(iter(fresh))
        if self.dialog:
            if key == 'X':
                self.dialog = ''
                self.notice = 'cancel'
            elif key == 'RETURN':
                self.dialog = ''
                self.apply('restart')
            return self.notice
        if key == 'R':
            self.dialog = 'restart'
            self.notice = 'confirm:restart'
            return self.notice
        if key == 'X':
            return self.apply('undo')
        if key in ('S', 'L') and STAGE >= 5:
            if not self.save_path:
                self.notice = 'use_--save-book'
                return self.notice
            try:
                if key == 'S':
                    self.last_save = str(save_book(self.save_path, self.s, self.history))
                    self.notice = 'saved'
                else:
                    state, history = load_book(self.save_path)
                    self.s, self.history = state, list(history)
                    self.mode, self.pair = 'walk', []
                    self.notice = 'loaded'
            except (OSError, ValueError, KeyError, TypeError, RecursionError):
                self.notice = 'save_failed' if key == 'S' else 'load_failed'
            return self.notice
        if key == 'N':
            return self.apply('next')
        if key == 'Q':
            return self.apply('prev')
        if key == 'TAB' and STAGE >= 2:
            self.mode = 'words' if self.mode == 'walk' else 'walk'
            self.pair = []
            self.notice = 'mode'
            return self.notice
        if key == 'SPACE':
            return self.apply('wait')
        if self.mode == 'words':
            if key in ('LEFT','RIGHT','UP','DOWN'):
                self.cursor = 1 - self.cursor
                self.notice = 'select:object'
            elif key == 'Z':
                if self.cursor in self.pair:
                    self.pair.remove(self.cursor)
                else:
                    self.pair.append(self.cursor)
                self.notice = 'select:pair'
            elif key == 'RETURN':
                if len(self.pair) == 2:
                    return self.apply('swap')
                self.notice = 'select_two_targets'
        else:
            if key in ('LEFT','RIGHT'):
                return self.apply(key.lower())
            if key == 'Z':
                return self.apply('interact')
        return self.notice

    def draw(self, p):
        s = self.s
        i = s.page
        page = s.pages[i]
        p.cls(1)
        p.rect(0, 0, WIDTH, 47, 2)
        p.text(16, 10, 'LIVING STORYBOOK / STAGE ' + str(STAGE), 10)
        p.text(16, 28, TITLE, 7)
        p.text(540, 10, 'CHAPTER '+str(i+1)+' / '+str(PAGE_COUNT), 6)
        p.text(540, 28, 'TICK '+str(page.ticks)+' / HISTORY '+str(len(self.history)), 6)
        p.text(16, 59, ('WORDS: CHOOSE TWO TARGETS' if self.mode == 'words' else 'WALK: MOVE / INTERACT'), 10)
        p.text(16, 75, 'SELECTING DOES NOT ADVANCE PHYSICS OR TIME.', 6)
        for j in range(2):
            x = 16 + j * 390
            p.rect(x, 98, 376, 76, 2)
            p.rectb(x, 98, 376, 76, 10 if self.cursor == j and self.mode == 'words' else 5)
            p.text(x+12, 110, ('[X] ' if j in self.pair else '[ ] ') + EN_NAMES[i][j], 11 if j in self.pair else 7)
            p.text(x+12, 130, 'WORD: '+EN_TAGS[page.tags[j]], 10)
            effect = ('FLOATS' if page.tags[j] == '軽い' else 'SINKS') if i == 0 else ('SIZE 3' if page.tags[j] == '長い' else 'SIZE 1') if i == 1 else ('WIND +1 CELL' if page.tags[j] == '軽い' else 'STAYS HERE')
            p.text(x+12, 150, 'EFFECT: '+effect, 6)
        p.text(16, 190, ('FERRY: REACH LAND CELL 5' if i == 0 else 'BRIDGE: REACH 6, THEN Z TO RAISE FLAG' if i == 1 else 'WIND: REACH 6 RIDING, THEN Z TO GET OFF'), 7)
        if STAGE == 1:
            p.text(16, 206, 'STAGE 1: OBSERVE ONLY. WALK / GOAL START IN STAGE 2.', 9)
        for cell in range(7):
            x = 64 + cell * 100
            p.text(x+26, 230, str(cell), 6)
            p.rect(x, 304, 88, 37, 4 if cell in LAND[i] else 12 if i == 0 else 0)
            p.rectb(x, 304, 88, 37, 5)
            p.text(x+18, 326, 'LAND' if cell in LAND[i] else 'WATER' if i == 0 else 'GAP', 7)
        if i == 1:
            p.rect(258, 294, 288 if page.tags[0] == '長い' else 88, 9, 9)
            p.rect(718, 254, 3, 51, 6)
            flag_y = 253 if page.raised else 285
            p.rect(721, flag_y, 42 if page.tags[1] == '長い' else 14, 12, 8)
        else:
            for j, cell in enumerate(page.objects):
                x = 80 + cell * 100
                y = (281 if page.tags[j] == '軽い' else 349) if i == 0 else (263 if page.tags[j] == '軽い' else 286)
                if j == 0:
                    p.rect(x, y, 57, 13, 9)
                    p.line(x, y+13, x+57, y+13, 4)
                else:
                    p.rect(x+13, y-4, 27, 18, 13 if i == 0 else 4)
                p.text(x+16, y+22, 'A' if j == 0 else 'B', 7)
        hx = 108 + page.hero * 100
        hy = 243 if i == 2 and page.riding >= 0 and page.tags[page.riding] == '軽い' else 272
        p.circ(hx, hy, 5, 10)
        p.line(hx, hy+5, hx, hy+19, 7)
        p.line(hx-6, hy+12, hx+6, hy+12, 7)
        p.text(16, 387, 'HERO '+str(page.hero)+' / RIDING '+('-' if page.riding < 0 else EN_NAMES[i][page.riding])+' / LAST LAND '+str(page.last_land), 7)
        p.text(16, 404, 'OBSERVATION: WORDS -> FLOAT / SINK. NO GOAL YET.' if STAGE == 1 else 'PAGE GOAL: '+('COMPLETE' if page.complete else 'OPEN')+' / WORDS CAN STILL CHANGE THE ENDING.', 11 if page.complete else 6)
        p.rect(14, 423, 772, 61, 0)
        p.text(22, 429, 'REMEMBERED CHAPTER OUTCOMES (IN THIS BOOK)', 6)
        for n, item in enumerate(s.pages):
            memory = EN_SCENES.get(s.memories[n], '-')
            p.text(22, 441+n*12, str(n+1)+': '+memory, 11 if s.memories[n] else 5)
        p.text(16, 494, 'STATUS: '+self.notice+(' / ENTER RESTARTS PAGE, X CANCELS' if self.dialog else ''), 10)
        p.text(16, 511, 'ARROWS TARGET / Z SELECT / ENTER SWAP | R RETRY / ENTER CONFIRM / X CANCEL' if STAGE == 1 else 'WORDS: ARROWS TARGET / Z SELECT / ENTER SWAP | TAB WORDS-WALK', 7)
        p.text(16, 527, 'NO WALK / WAIT / CHAPTERS / UNDO / SAVE YET | ESC QUIT' if STAGE == 1 else 'WALK: LEFT-RIGHT / Z PULL | SPACE WAIT | R RETRY | ESC QUIT' if STAGE == 2 else 'WALK: LEFT-RIGHT / Z USE | SPACE WAIT | N NEXT / Q PREV | R RETRY', 6)
        p.text(16, 543, 'X UNDO / S SAVE / L LOAD (--save-book) | ESC QUIT' if STAGE == 5 else 'MEMORY IS IN RAM. STAGE 5 ADDS UNDO AND SAVE.', 6)

CONTROL_KEYS = ('LEFT','RIGHT','UP','DOWN','TAB','Z','RETURN','SPACE','N','Q','R','X','S','L')
SWAP_KEYS = ('TAB','Z','RIGHT','Z','RETURN','TAB')

DEMO_KEYS = SWAP_KEYS + ('RIGHT',)*4

def parse_args(argv=None):
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument('--headless-smoke', action='store_true')
    mode.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    if STAGE >= 5:
        parser.add_argument('--save-book', help='S/L use this explicit .json; valid tutorial book may be replaced')
    args = parser.parse_args(argv)
    if (args.screenshot or args.report) and not (args.headless_smoke or args.demo_checkpoint):
        parser.error('outputs require a headless mode')
    outputs = []
    for name, suffix in (('screenshot','.png'),('report','.json'),('save_book','.json')):
        value = getattr(args, name, None)
        if value:
            path = Path(value).expanduser().absolute()
            if path.suffix.lower() != suffix or path.is_symlink():
                parser.error(name+' needs a regular '+suffix+' path')
            outputs.append(path.resolve())
    if len(set(outputs)) != len(outputs):
        parser.error('output paths must be distinct')
    if Path(__file__).resolve() in outputs:
        parser.error('cannot overwrite this source')
    return args

def main(argv=None):
    from pathlib import Path
    args = parse_args(argv)
    # モデルのimportと試験にはPyxelが不要。CLIの検査後にだけ読み込む。
    import pyxel as p
    headless = args.headless_smoke or args.demo_checkpoint
    app = App(getattr(args, 'save_book', None))
    sequence = DEMO_KEYS if args.demo_checkpoint else ()
    limit = max(12, 2*len(sequence)+4)
    keys = {name:getattr(p,'KEY_'+name) for name in CONTROL_KEYS}
    p.init(WIDTH, HEIGHT, title='098 Storybook - Stage '+str(STAGE), fps=30,
           display_scale=1, headless=headless)
    finished = False
    def update():
        if headless:
            index = p.frame_count // 2
            app.feed({sequence[index]} if p.frame_count % 2 == 0 and index < len(sequence) else set())
        else:
            app.feed({name for name, key in keys.items() if p.btn(key)})
    def draw():
        nonlocal finished
        app.draw(p)
        if headless and p.frame_count >= limit and not finished:
            finished = True
            if args.screenshot:
                target = Path(args.screenshot).expanduser().absolute()
                target.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(target), scale=1)
            if args.report:
                target = Path(args.report).expanduser().absolute()
                target.parent.mkdir(parents=True, exist_ok=True)
                data = {'stage':STAGE,'kind':'actual Pyxel headless automated render','keys':sequence,
                        'state':asdict(app.s),'history_length':len(app.history),'notice':app.notice,
                        'os_input_tested':False,'human_play_tested':False,'audio_tested':False}
                target.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n',encoding='utf-8')
            p.quit()
    p.run(update, draw)
    return app

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

長さで橋の足場を変え、章ごとの状態と完成絵を残す

章ごとのルール、条件の組み合わせ、完成をそのまま保つ、状態の一部だけの更新、章の移動。

第2章を追加します。短い橋は谷のセル2だけを、長い橋は2・3・4をおおいます。橋と旗の札「短い」「長い」を交換すると、橋だけでなく、旗を描く長さも同時に1単位か3単位に変わります。右はしの6まで歩くだけでは、まだ完成ではありません。Z で raised(旗を上げたか)を True にして、初めて complete になります。完成の判定は、「前の complete」または(OR)「新しい条件」です。そのため、右岸で札を元に戻して、短い橋・長い旗の完成した絵を作ることもできます。谷のセル3か4にいるときに橋を短くすると、last_land に戻ります。N は今の章が完成したときだけ次の章へ、Q は前の章へ移ります。章の移動は、page という参照先(今どの章を見ているか)を変えるだけです。pages と memories の前の章の分は捨てません。R→Enter は今の章だけを最初の状態(fresh)にし、その章の完成した絵だけを消します。01と02では1章分だった State が、ここでは2章分を持ちます。

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

03_bridge.py / 注目する部分
def finish(s, p, cause):
    i = s.page
    complete = p.complete or (p.hero >= 5 if i == 0 else p.hero == 6 and p.raised if i == 1 else p.hero == 6 and p.riding == -1)
    p = replace(p, complete=complete, ticks=p.ticks + 1, cause=cause)
    pages = list(s.pages)
    pages[i] = p
    mem = list(s.memories)
    if complete:
        mem[i] = scene(i, p)
    return replace(s, pages=tuple(pages), memories=tuple(mem), events=(cause,))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_bridge.py
macOS / Linux
.venv/bin/python 03_bridge.py
画面で見ること第1章を渡って N を押すと、第2章に進みます。旅人は0、橋は SHORT、旗は LONG です。前の章の完成した絵は残っています。札を交換して橋を LONG、旗を SHORT にしたら、歩くモードで右を6回押し、Z で旗を上げます。第2章は TICK 8、旅人は6、raised True で、2つの章とも完成です。Q→N と押しても、両方の章の状態は変わりません。自動の確認用の画面(checkpoint)には、長い橋と短い旗が表示されます。
  • 短い橋と長い橋で、支えるセルを分ける
  • 旗を上げることを、完成の条件に入れる
  • complete は、一度成り立ったらそのまま保つ
  • N は完成したあとだけ。Q で移っても、前の章の状態は保たれる
  • やり直す章のほかは、書き換えない
つまずきやすいところ完成の条件を hero==6 だけにすると、旗を上げる操作がいらなくなってしまいます。complete を毎回、新しい条件だけで置き換えると、完成したあとに絵を作り直したとき、達成が消えてしまいます。章を移るときに new を呼ぶと、前の章の位置・札・完成した絵も失われます。
少し変えてみよう

第2章で、短い橋のまま右を2回押してセル2まで歩きます。さらに右を押しても変わらないことを確かめましょう。札を交換してからセル3へ進み、そこでもう一度交換すると、最後にいた陸のセル1に戻ります。完成したあとに札を元に戻すと、旅人は6のまま「短い橋と長い旗」になります。R→X は取り消しです。R→Enter なら第2章だけが最初に戻り、第1章はそのまま残ります。

03_bridge.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(418行)
03_bridge.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""098 一冊の動く絵本・日本語教材 3/5。"""
from dataclasses import dataclass, replace, asdict
import json

WIDTH, HEIGHT = 800, 560
TITLES = ('渡し舟のページ', '橋のページ', '風のページ')
NAMES = (('舟', '石'), ('橋', '旗'), ('葉舟', '看板'))
LAND = ((0, 1, 5, 6), (0, 1, 5, 6), (0, 1, 3, 4, 6))
TAGS = (('重い', '軽い'), ('短い', '長い'), ('軽い', '重い'))
EN_NAMES = (('BOAT', 'STONE'), ('BRIDGE', 'FLAG'), ('LEAF BOAT', 'SIGN'))
EN_TAGS = {'重い':'HEAVY', '軽い':'LIGHT', '短い':'SHORT', '長い':'LONG'}
EN_SCENES = {'浮く舟、右岸の旅人':'FLOAT BOAT / TRAVELER ON RIGHT', '浮く石、右岸の旅人':'FLOAT STONE / TRAVELER ON RIGHT', '長い橋と短い旗':'LONG BRIDGE / SHORT FLAG', '短い橋と長い旗':'SHORT BRIDGE / LONG FLAG', '軽い葉舟の空':'LIGHT LEAF BOAT SKY', '軽い看板の空':'LIGHT SIGN SKY', '葉舟で風の丘':'LEAF BOAT WIND HILL', '看板で風の丘':'SIGN WIND HILL'}

STAGE = 3
PAGE_COUNT = 2
TITLE = 'BRIDGE LENGTH / PAGE MEMORY'

@dataclass(frozen=True)
class Page:
    hero: int
    objects: tuple
    tags: tuple
    riding: int = -1
    last_land: int = 0
    raised: bool = False
    complete: bool = False
    ticks: int = 0
    cause: str = ''

@dataclass(frozen=True)
class State:
    page: int
    pages: tuple
    memories: tuple
    variant: str = 'causes'
    events: tuple = ()

def fresh(i):
    return Page((1, 0, 0)[i], ((2, 4), (1, 6), (1, 4))[i], TAGS[i], last_land=(1, 0, 0)[i])

def new(variant='causes'):
    return State(0, tuple((fresh(i) for i in range(PAGE_COUNT))), tuple(('' for _ in range(PAGE_COUNT))), variant)

def scene(i, p):
    if i == 0:
        return '浮く' + NAMES[i][p.tags.index('軽い')] + '、右岸の旅人'
    if i == 1:
        return '長い橋と短い旗' if p.tags[0] == '長い' else '短い橋と長い旗'
    return ('葉舟で風の丘' if p.riding == 0 else '看板で風の丘') if p.riding != -1 else '軽い葉舟の空' if p.tags[0] == '軽い' else '軽い看板の空'

def finish(s, p, cause):
    i = s.page
    complete = p.complete or (p.hero >= 5 if i == 0 else p.hero == 6 and p.raised if i == 1 else p.hero == 6 and p.riding == -1)
    p = replace(p, complete=complete, ticks=p.ticks + 1, cause=cause)
    pages = list(s.pages)
    pages[i] = p
    mem = list(s.memories)
    if complete:
        mem[i] = scene(i, p)
    return replace(s, pages=tuple(pages), memories=tuple(mem), events=(cause,))

def swap(s):
    i = s.page
    p = s.pages[i]
    tags = tuple(reversed(p.tags))
    p = replace(p, tags=tags)
    cause = ' / '.join((NAMES[i][j] + 'が' + tags[j] for j in (0, 1)))
    if i == 0:
        cause += ' → 浮く物と沈む物が交代'
        if p.riding >= 0 and tags[p.riding] == '重い':
            p = replace(p, hero=p.last_land, riding=-1)
            cause += '。旅人は泡で陸へ'
    if i == 1:
        cause += ' → 長さが同時に変わる'
        if p.hero in (3, 4) and tags[0] == '短い':
            p = replace(p, hero=p.last_land)
            cause += '。旅人はしおりで陸へ'
    return finish(s, p, cause)

def walk(s, d):
    i = s.page
    p = s.pages[i]
    target = p.hero + d
    if d not in (-1, 1) or not 0 <= target <= 6:
        return s
    objects = list(p.objects)
    if i == 0:
        if p.riding >= 0:
            if target in LAND[i]:
                return finish(s, replace(p, hero=target, riding=-1, last_land=target), '舟から陸へ降りた' if p.riding == 0 else '石から陸へ降りた')
            if target in (2, 3, 4) and (not any((j != p.riding and objects[j] == target and (p.tags[j] == '軽い') for j in (0, 1)))):
                objects[p.riding] = target
                return finish(s, replace(p, hero=target, objects=tuple(objects)), '軽い' + NAMES[i][p.riding] + 'と水上を進んだ')
            return s
        if target in LAND[i]:
            return finish(s, replace(p, hero=target, last_land=target), '陸を歩いた')
        riders = [j for j in (0, 1) if objects[j] == target and p.tags[j] == '軽い']
        if riders:
            return finish(s, replace(p, hero=target, riding=riders[0]), '軽い' + NAMES[i][riders[0]] + 'へ乗った')
        return s
    if i == 1:
        if target not in LAND[i] and (not (target == 2 or p.tags[0] == '長い')):
            return s
        return finish(s, replace(p, hero=target, last_land=target if target in LAND[i] else p.last_land), '橋を歩いた' if target not in LAND[i] else '陸へ歩いた')
    return s

def interact(s):
    i = s.page
    p = s.pages[i]
    if i == 0 and p.riding == -1 and (p.hero in LAND[0]):
        candidates = [j for j in (0, 1) if p.tags[j] == '軽い' and 1 < abs(p.objects[j] - p.hero) <= 2]
        if candidates:
            j = candidates[0]
            o = list(p.objects)
            o[j] += 1 if p.hero > o[j] else -1
            return finish(s, replace(p, objects=tuple(o)), '軽い' + NAMES[i][j] + 'を1セル手前へ寄せた')
    if i == 1 and p.hero == 6 and (not p.raised):
        return finish(s, replace(p, raised=True), '旗が上がった。長さの違う結末も残せる')
    return s

def step(s, a):
    if a == 'swap':
        return swap(s)
    if a == 'left':
        return walk(s, -1)
    if a == 'right':
        return walk(s, 1)
    if a == 'interact':
        return interact(s)
    if a == 'wait':
        return finish(s, s.pages[s.page], '一段階待った。景色はそのまま')
    if a == 'next' and s.page < PAGE_COUNT - 1 and s.pages[s.page].complete:
        return replace(s, page=s.page + 1, events=('次のページへ。前の絵は残っています',))
    if a == 'prev' and s.page > 0:
        return replace(s, page=s.page - 1, events=('前のページへ。状態も完成絵も保持',))
    if a == 'restart':
        p = list(s.pages)
        p[s.page] = fresh(s.page)
        m = list(s.memories)
        m[s.page] = ''
        return replace(s, pages=tuple(p), memories=tuple(m), events=('このページだけ、はじめから',))
    return s

class App:
    def __init__(self, save_path=None):
        self.s = new()
        self.history = []
        self.mode = 'words' if STAGE == 1 else 'walk'
        self.cursor = 0
        self.pair = []
        self.previous = set()
        self.gate = False
        self.notice = 'ready'
        self.save_path = save_path
        self.last_save = None
        self.dialog = ''

    def apply(self, action):
        if action == 'undo':
            if STAGE < 5 or not self.history:
                self.notice = 'no_undo'
                return self.notice
            self.s = self.history.pop()
            self.pair = []
            self.notice = 'undo'
            return self.notice
        old = self.s
        self.s = step(old, action)
        if self.s == old:
            self.notice = 'blocked:' + action
            return self.notice
        if STAGE >= 5:
            self.history.append(old)
            self.history = self.history[-1000:]
        if action == 'swap':
            self.pair = []
        if action in ('next', 'prev', 'restart'):
            self.mode = 'words' if STAGE == 1 else 'walk'
            self.pair = []
        self.notice = action
        return action

    def feed(self, down):
        # 選択だけでは状態/時間を変えない。原作と同じ全解放ゲート。
        down = set(down) & set(CONTROL_KEYS)
        fresh = down - self.previous
        self.previous = down
        if not down:
            self.gate = False
        if self.gate or not fresh:
            return None
        self.gate = True
        if len(fresh) > 1:
            self.notice = 'ignored:multiple'
            return self.notice
        key = next(iter(fresh))
        if self.dialog:
            if key == 'X':
                self.dialog = ''
                self.notice = 'cancel'
            elif key == 'RETURN':
                self.dialog = ''
                self.apply('restart')
            return self.notice
        if key == 'R':
            self.dialog = 'restart'
            self.notice = 'confirm:restart'
            return self.notice
        if key == 'X':
            return self.apply('undo')
        if key in ('S', 'L') and STAGE >= 5:
            if not self.save_path:
                self.notice = 'use_--save-book'
                return self.notice
            try:
                if key == 'S':
                    self.last_save = str(save_book(self.save_path, self.s, self.history))
                    self.notice = 'saved'
                else:
                    state, history = load_book(self.save_path)
                    self.s, self.history = state, list(history)
                    self.mode, self.pair = 'walk', []
                    self.notice = 'loaded'
            except (OSError, ValueError, KeyError, TypeError, RecursionError):
                self.notice = 'save_failed' if key == 'S' else 'load_failed'
            return self.notice
        if key == 'N':
            return self.apply('next')
        if key == 'Q':
            return self.apply('prev')
        if key == 'TAB' and STAGE >= 2:
            self.mode = 'words' if self.mode == 'walk' else 'walk'
            self.pair = []
            self.notice = 'mode'
            return self.notice
        if key == 'SPACE':
            return self.apply('wait')
        if self.mode == 'words':
            if key in ('LEFT','RIGHT','UP','DOWN'):
                self.cursor = 1 - self.cursor
                self.notice = 'select:object'
            elif key == 'Z':
                if self.cursor in self.pair:
                    self.pair.remove(self.cursor)
                else:
                    self.pair.append(self.cursor)
                self.notice = 'select:pair'
            elif key == 'RETURN':
                if len(self.pair) == 2:
                    return self.apply('swap')
                self.notice = 'select_two_targets'
        else:
            if key in ('LEFT','RIGHT'):
                return self.apply(key.lower())
            if key == 'Z':
                return self.apply('interact')
        return self.notice

    def draw(self, p):
        s = self.s
        i = s.page
        page = s.pages[i]
        p.cls(1)
        p.rect(0, 0, WIDTH, 47, 2)
        p.text(16, 10, 'LIVING STORYBOOK / STAGE ' + str(STAGE), 10)
        p.text(16, 28, TITLE, 7)
        p.text(540, 10, 'CHAPTER '+str(i+1)+' / '+str(PAGE_COUNT), 6)
        p.text(540, 28, 'TICK '+str(page.ticks)+' / HISTORY '+str(len(self.history)), 6)
        p.text(16, 59, ('WORDS: CHOOSE TWO TARGETS' if self.mode == 'words' else 'WALK: MOVE / INTERACT'), 10)
        p.text(16, 75, 'SELECTING DOES NOT ADVANCE PHYSICS OR TIME.', 6)
        for j in range(2):
            x = 16 + j * 390
            p.rect(x, 98, 376, 76, 2)
            p.rectb(x, 98, 376, 76, 10 if self.cursor == j and self.mode == 'words' else 5)
            p.text(x+12, 110, ('[X] ' if j in self.pair else '[ ] ') + EN_NAMES[i][j], 11 if j in self.pair else 7)
            p.text(x+12, 130, 'WORD: '+EN_TAGS[page.tags[j]], 10)
            effect = ('FLOATS' if page.tags[j] == '軽い' else 'SINKS') if i == 0 else ('SIZE 3' if page.tags[j] == '長い' else 'SIZE 1') if i == 1 else ('WIND +1 CELL' if page.tags[j] == '軽い' else 'STAYS HERE')
            p.text(x+12, 150, 'EFFECT: '+effect, 6)
        p.text(16, 190, ('FERRY: REACH LAND CELL 5' if i == 0 else 'BRIDGE: REACH 6, THEN Z TO RAISE FLAG' if i == 1 else 'WIND: REACH 6 RIDING, THEN Z TO GET OFF'), 7)
        if STAGE == 1:
            p.text(16, 206, 'STAGE 1: OBSERVE ONLY. WALK / GOAL START IN STAGE 2.', 9)
        for cell in range(7):
            x = 64 + cell * 100
            p.text(x+26, 230, str(cell), 6)
            p.rect(x, 304, 88, 37, 4 if cell in LAND[i] else 12 if i == 0 else 0)
            p.rectb(x, 304, 88, 37, 5)
            p.text(x+18, 326, 'LAND' if cell in LAND[i] else 'WATER' if i == 0 else 'GAP', 7)
        if i == 1:
            p.rect(258, 294, 288 if page.tags[0] == '長い' else 88, 9, 9)
            p.rect(718, 254, 3, 51, 6)
            flag_y = 253 if page.raised else 285
            p.rect(721, flag_y, 42 if page.tags[1] == '長い' else 14, 12, 8)
        else:
            for j, cell in enumerate(page.objects):
                x = 80 + cell * 100
                y = (281 if page.tags[j] == '軽い' else 349) if i == 0 else (263 if page.tags[j] == '軽い' else 286)
                if j == 0:
                    p.rect(x, y, 57, 13, 9)
                    p.line(x, y+13, x+57, y+13, 4)
                else:
                    p.rect(x+13, y-4, 27, 18, 13 if i == 0 else 4)
                p.text(x+16, y+22, 'A' if j == 0 else 'B', 7)
        hx = 108 + page.hero * 100
        hy = 243 if i == 2 and page.riding >= 0 and page.tags[page.riding] == '軽い' else 272
        p.circ(hx, hy, 5, 10)
        p.line(hx, hy+5, hx, hy+19, 7)
        p.line(hx-6, hy+12, hx+6, hy+12, 7)
        p.text(16, 387, 'HERO '+str(page.hero)+' / RIDING '+('-' if page.riding < 0 else EN_NAMES[i][page.riding])+' / LAST LAND '+str(page.last_land), 7)
        p.text(16, 404, 'OBSERVATION: WORDS -> FLOAT / SINK. NO GOAL YET.' if STAGE == 1 else 'PAGE GOAL: '+('COMPLETE' if page.complete else 'OPEN')+' / WORDS CAN STILL CHANGE THE ENDING.', 11 if page.complete else 6)
        p.rect(14, 423, 772, 61, 0)
        p.text(22, 429, 'REMEMBERED CHAPTER OUTCOMES (IN THIS BOOK)', 6)
        for n, item in enumerate(s.pages):
            memory = EN_SCENES.get(s.memories[n], '-')
            p.text(22, 441+n*12, str(n+1)+': '+memory, 11 if s.memories[n] else 5)
        p.text(16, 494, 'STATUS: '+self.notice+(' / ENTER RESTARTS PAGE, X CANCELS' if self.dialog else ''), 10)
        p.text(16, 511, 'ARROWS TARGET / Z SELECT / ENTER SWAP | R RETRY / ENTER CONFIRM / X CANCEL' if STAGE == 1 else 'WORDS: ARROWS TARGET / Z SELECT / ENTER SWAP | TAB WORDS-WALK', 7)
        p.text(16, 527, 'NO WALK / WAIT / CHAPTERS / UNDO / SAVE YET | ESC QUIT' if STAGE == 1 else 'WALK: LEFT-RIGHT / Z PULL | SPACE WAIT | R RETRY | ESC QUIT' if STAGE == 2 else 'WALK: LEFT-RIGHT / Z USE | SPACE WAIT | N NEXT / Q PREV | R RETRY', 6)
        p.text(16, 543, 'X UNDO / S SAVE / L LOAD (--save-book) | ESC QUIT' if STAGE == 5 else 'MEMORY IS IN RAM. STAGE 5 ADDS UNDO AND SAVE.', 6)

CONTROL_KEYS = ('LEFT','RIGHT','UP','DOWN','TAB','Z','RETURN','SPACE','N','Q','R','X','S','L')
SWAP_KEYS = ('TAB','Z','RIGHT','Z','RETURN','TAB')

DEMO_KEYS = SWAP_KEYS + ('RIGHT',)*4 + ('N',) + SWAP_KEYS + ('RIGHT',)*6 + ('Z',)

def parse_args(argv=None):
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument('--headless-smoke', action='store_true')
    mode.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    if STAGE >= 5:
        parser.add_argument('--save-book', help='S/L use this explicit .json; valid tutorial book may be replaced')
    args = parser.parse_args(argv)
    if (args.screenshot or args.report) and not (args.headless_smoke or args.demo_checkpoint):
        parser.error('outputs require a headless mode')
    outputs = []
    for name, suffix in (('screenshot','.png'),('report','.json'),('save_book','.json')):
        value = getattr(args, name, None)
        if value:
            path = Path(value).expanduser().absolute()
            if path.suffix.lower() != suffix or path.is_symlink():
                parser.error(name+' needs a regular '+suffix+' path')
            outputs.append(path.resolve())
    if len(set(outputs)) != len(outputs):
        parser.error('output paths must be distinct')
    if Path(__file__).resolve() in outputs:
        parser.error('cannot overwrite this source')
    return args

def main(argv=None):
    from pathlib import Path
    args = parse_args(argv)
    # モデルのimportと試験にはPyxelが不要。CLIの検査後にだけ読み込む。
    import pyxel as p
    headless = args.headless_smoke or args.demo_checkpoint
    app = App(getattr(args, 'save_book', None))
    sequence = DEMO_KEYS if args.demo_checkpoint else ()
    limit = max(12, 2*len(sequence)+4)
    keys = {name:getattr(p,'KEY_'+name) for name in CONTROL_KEYS}
    p.init(WIDTH, HEIGHT, title='098 Storybook - Stage '+str(STAGE), fps=30,
           display_scale=1, headless=headless)
    finished = False
    def update():
        if headless:
            index = p.frame_count // 2
            app.feed({sequence[index]} if p.frame_count % 2 == 0 and index < len(sequence) else set())
        else:
            app.feed({name for name, key in keys.items() if p.btn(key)})
    def draw():
        nonlocal finished
        app.draw(p)
        if headless and p.frame_count >= limit and not finished:
            finished = True
            if args.screenshot:
                target = Path(args.screenshot).expanduser().absolute()
                target.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(target), scale=1)
            if args.report:
                target = Path(args.report).expanduser().absolute()
                target.parent.mkdir(parents=True, exist_ok=True)
                data = {'stage':STAGE,'kind':'actual Pyxel headless automated render','keys':sequence,
                        'state':asdict(app.s),'history_length':len(app.history),'notice':app.notice,
                        'os_input_tested':False,'human_play_tested':False,'audio_tested':False}
                target.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n',encoding='utf-8')
            p.quit()
    p.run(update, draw)
    return app

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

軽さと風を分け、三章の結末を作る

1段ずつ進む時間(離散時間)、選んだものだけの更新、はしで止める(clamp)、乗っている人もいっしょに動く、章ごとの安全のルール。

第3章を追加すると、元のゲームのABCの3つの章を、意味のある入力(意味入力)で動かすモデルになります。葉舟は軽くてセル1、看板は重くてセル4、旅人は0にいます。Space を1回押すと wind(風)を1回呼び、tags.index(軽い) で、動かすもの1つを決めます。target=min(6,位置+1) なので、右はしを越えません。旅人が乗っているのがその軽いものなら、旅人もいっしょに動きます。札を交換するだけでは風は進まず、ものの位置も変わりません。第3章では、乗り物を重くしてもその場にとどまります。落ちたり、第1章のような泡戻し(最後の陸へ戻ること)が起きたりはしません。軽い葉舟は空中の層を通るので、重い看板を越えられます。セル6に着いても、乗ったままではまだ完成ではありません。Z で陸へ降りる必要があります。章によって、目標は「右岸に着く」「旗を上げる」「降りる」とちがいます。見た目が共通でも、このちがいが埋もれないように作っています。

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

04_wind.py / 注目する部分
def wind(s):
    p = s.pages[s.page]
    if s.page != 2:
        return finish(s, p, '一段階待った。景色はそのまま')
    o = list(p.objects)
    j = p.tags.index('軽い')
    target = min(6, o[j] + 1)
    o[j] = target
    hero = o[j] if p.riding == j else p.hero
    return finish(s, replace(p, objects=tuple(o), hero=hero), '軽い' + NAMES[2][j] + 'を風が1セル運んだ' if target != p.objects[j] else '丘の端で風の乗り物が止まった')

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_wind.py
macOS / Linux
.venv/bin/python 04_wind.py
画面で見ること前の2つの章を終えて N を押し、右で葉舟に乗ると、セル1・riding 0 になります。Space を5回押すと、葉舟と旅人が6まで進みます。このときは、まだ完成ではありません。Z で降りると riding -1、TICK 7 になり、3つの章すべてが完成です。記憶の文は、降りたあとの元のゲームの場面(scene)に合わせて「軽い葉舟の空」です。画面では LIGHT LEAF BOAT SKY と表示します。自動入力では、22回の意味のある操作でこの状態になります。
  • Space 1回で、軽いものだけが1セル動く
  • 札の交換そのものでは、位置は動かない
  • 乗っているものが動くときだけ、旅人もいっしょに動く
  • 重いものはとどまる。見えない落下を勝手に加えない
  • セル6で降りることを、完成の条件に入れる
つまずきやすいところswap の中で wind まで呼ぶと、ことばの交換と時間の進みが混ざってしまいます。第1章の沈むルールを第3章でも使うと、元のゲームにはない落下が増えてしまいます。hero==6 だけで完成にせず、riding==-1(何にも乗っていない)も調べましょう。Space を押し続けても、キーを離すまでは1回だけと数えます。
少し変えてみよう

第3章で、右→Space を2回→Z と押して、葉舟からセル3の陸へ降ります。札を交換して看板を軽くし、歩くモードで右を押して、セル4の看板に乗ります。Space を2回→Z で、看板を使った道すじで完成です。葉舟はセル3にとどまり、記憶は「軽い看板の空」になります。元のゲームから作ったテスト用データ(fixture)の expressive は、前の章も別の絵にしたうえで、この道すじを通れることを示す入力の列です。

04_wind.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(438行)
04_wind.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""098 一冊の動く絵本・日本語教材 4/5。"""
from dataclasses import dataclass, replace, asdict
import json

WIDTH, HEIGHT = 800, 560
TITLES = ('渡し舟のページ', '橋のページ', '風のページ')
NAMES = (('舟', '石'), ('橋', '旗'), ('葉舟', '看板'))
LAND = ((0, 1, 5, 6), (0, 1, 5, 6), (0, 1, 3, 4, 6))
TAGS = (('重い', '軽い'), ('短い', '長い'), ('軽い', '重い'))
EN_NAMES = (('BOAT', 'STONE'), ('BRIDGE', 'FLAG'), ('LEAF BOAT', 'SIGN'))
EN_TAGS = {'重い':'HEAVY', '軽い':'LIGHT', '短い':'SHORT', '長い':'LONG'}
EN_SCENES = {'浮く舟、右岸の旅人':'FLOAT BOAT / TRAVELER ON RIGHT', '浮く石、右岸の旅人':'FLOAT STONE / TRAVELER ON RIGHT', '長い橋と短い旗':'LONG BRIDGE / SHORT FLAG', '短い橋と長い旗':'SHORT BRIDGE / LONG FLAG', '軽い葉舟の空':'LIGHT LEAF BOAT SKY', '軽い看板の空':'LIGHT SIGN SKY', '葉舟で風の丘':'LEAF BOAT WIND HILL', '看板で風の丘':'SIGN WIND HILL'}

STAGE = 4
PAGE_COUNT = 3
TITLE = 'WIND / THREE CHAPTERS'

@dataclass(frozen=True)
class Page:
    hero: int
    objects: tuple
    tags: tuple
    riding: int = -1
    last_land: int = 0
    raised: bool = False
    complete: bool = False
    ticks: int = 0
    cause: str = ''

@dataclass(frozen=True)
class State:
    page: int
    pages: tuple
    memories: tuple
    variant: str = 'causes'
    events: tuple = ()

def fresh(i):
    return Page((1, 0, 0)[i], ((2, 4), (1, 6), (1, 4))[i], TAGS[i], last_land=(1, 0, 0)[i])

def new(variant='causes'):
    return State(0, tuple((fresh(i) for i in range(PAGE_COUNT))), tuple('' for _ in range(PAGE_COUNT)), variant)

def scene(i, p):
    if i == 0:
        return '浮く' + NAMES[i][p.tags.index('軽い')] + '、右岸の旅人'
    if i == 1:
        return '長い橋と短い旗' if p.tags[0] == '長い' else '短い橋と長い旗'
    return ('葉舟で風の丘' if p.riding == 0 else '看板で風の丘') if p.riding != -1 else '軽い葉舟の空' if p.tags[0] == '軽い' else '軽い看板の空'

def finish(s, p, cause):
    i = s.page
    complete = p.complete or (p.hero >= 5 if i == 0 else p.hero == 6 and p.raised if i == 1 else p.hero == 6 and p.riding == -1)
    p = replace(p, complete=complete, ticks=p.ticks + 1, cause=cause)
    pages = list(s.pages)
    pages[i] = p
    mem = list(s.memories)
    if complete:
        mem[i] = scene(i, p)
    return replace(s, pages=tuple(pages), memories=tuple(mem), events=(cause,))

def swap(s):
    i = s.page
    p = s.pages[i]
    tags = tuple(reversed(p.tags))
    p = replace(p, tags=tags)
    cause = ' / '.join((NAMES[i][j] + 'が' + tags[j] for j in (0, 1)))
    if i == 0:
        cause += ' → 浮く物と沈む物が交代'
        if p.riding >= 0 and tags[p.riding] == '重い':
            p = replace(p, hero=p.last_land, riding=-1)
            cause += '。旅人は泡で陸へ'
    if i == 1:
        cause += ' → 長さが同時に変わる'
        if p.hero in (3, 4) and tags[0] == '短い':
            p = replace(p, hero=p.last_land)
            cause += '。旅人はしおりで陸へ'
    if i == 2:
        cause += ' → 風に動く物が交代'
    return finish(s, p, cause)

def walk(s, d):
    i = s.page
    p = s.pages[i]
    target = p.hero + d
    if d not in (-1, 1) or not 0 <= target <= 6:
        return s
    objects = list(p.objects)
    if i == 0:
        if p.riding >= 0:
            if target in LAND[i]:
                return finish(s, replace(p, hero=target, riding=-1, last_land=target), '舟から陸へ降りた' if p.riding == 0 else '石から陸へ降りた')
            if target in (2, 3, 4) and (not any((j != p.riding and objects[j] == target and (p.tags[j] == '軽い') for j in (0, 1)))):
                objects[p.riding] = target
                return finish(s, replace(p, hero=target, objects=tuple(objects)), '軽い' + NAMES[i][p.riding] + 'と水上を進んだ')
            return s
        if target in LAND[i]:
            return finish(s, replace(p, hero=target, last_land=target), '陸を歩いた')
        riders = [j for j in (0, 1) if objects[j] == target and p.tags[j] == '軽い']
        if riders:
            return finish(s, replace(p, hero=target, riding=riders[0]), '軽い' + NAMES[i][riders[0]] + 'へ乗った')
        return s
    if i == 1:
        if target not in LAND[i] and (not (target == 2 or p.tags[0] == '長い')):
            return s
        return finish(s, replace(p, hero=target, last_land=target if target in LAND[i] else p.last_land), '橋を歩いた' if target not in LAND[i] else '陸へ歩いた')
    riders = sorted((j for j in (0, 1) if objects[j] == target), key=lambda j: p.tags[j] != '軽い')
    if riders:
        return finish(s, replace(p, hero=target, riding=riders[0]), NAMES[i][riders[0]] + 'へ乗った')
    if target in LAND[i]:
        return finish(s, replace(p, hero=target, riding=-1, last_land=target), '陸へ歩いた')
    return s

def interact(s):
    i = s.page
    p = s.pages[i]
    if i == 0 and p.riding == -1 and (p.hero in LAND[0]):
        candidates = [j for j in (0, 1) if p.tags[j] == '軽い' and 1 < abs(p.objects[j] - p.hero) <= 2]
        if candidates:
            j = candidates[0]
            o = list(p.objects)
            o[j] += 1 if p.hero > o[j] else -1
            return finish(s, replace(p, objects=tuple(o)), '軽い' + NAMES[i][j] + 'を1セル手前へ寄せた')
    if i == 1 and p.hero == 6 and (not p.raised):
        return finish(s, replace(p, raised=True), '旗が上がった。長さの違う結末も残せる')
    if i == 2 and p.riding >= 0 and (p.hero in LAND[2]):
        return finish(s, replace(p, riding=-1, last_land=p.hero), '乗り物から陸へ降りた')
    return s

def wind(s):
    p = s.pages[s.page]
    if s.page != 2:
        return finish(s, p, '一段階待った。景色はそのまま')
    o = list(p.objects)
    j = p.tags.index('軽い')
    target = min(6, o[j] + 1)
    o[j] = target
    hero = o[j] if p.riding == j else p.hero
    return finish(s, replace(p, objects=tuple(o), hero=hero), '軽い' + NAMES[2][j] + 'を風が1セル運んだ' if target != p.objects[j] else '丘の端で風の乗り物が止まった')

def step(s, a):
    if a == 'swap':
        return swap(s)
    if a == 'left':
        return walk(s, -1)
    if a == 'right':
        return walk(s, 1)
    if a == 'interact':
        return interact(s)
    if a == 'wait':
        return wind(s)
    if a == 'next' and s.page < PAGE_COUNT - 1 and s.pages[s.page].complete:
        return replace(s, page=s.page + 1, events=('次のページへ。前の絵は残っています',))
    if a == 'prev' and s.page > 0:
        return replace(s, page=s.page - 1, events=('前のページへ。状態も完成絵も保持',))
    if a == 'restart':
        p = list(s.pages)
        p[s.page] = fresh(s.page)
        m = list(s.memories)
        m[s.page] = ''
        return replace(s, pages=tuple(p), memories=tuple(m), events=('このページだけ、はじめから',))
    return s

class App:
    def __init__(self, save_path=None):
        self.s = new()
        self.history = []
        self.mode = 'words' if STAGE == 1 else 'walk'
        self.cursor = 0
        self.pair = []
        self.previous = set()
        self.gate = False
        self.notice = 'ready'
        self.save_path = save_path
        self.last_save = None
        self.dialog = ''

    def apply(self, action):
        if action == 'undo':
            if STAGE < 5 or not self.history:
                self.notice = 'no_undo'
                return self.notice
            self.s = self.history.pop()
            self.pair = []
            self.notice = 'undo'
            return self.notice
        old = self.s
        self.s = step(old, action)
        if self.s == old:
            self.notice = 'blocked:' + action
            return self.notice
        if STAGE >= 5:
            self.history.append(old)
            self.history = self.history[-1000:]
        if action == 'swap':
            self.pair = []
        if action in ('next', 'prev', 'restart'):
            self.mode = 'words' if STAGE == 1 else 'walk'
            self.pair = []
        self.notice = action
        return action

    def feed(self, down):
        # 選択だけでは状態/時間を変えない。原作と同じ全解放ゲート。
        down = set(down) & set(CONTROL_KEYS)
        fresh = down - self.previous
        self.previous = down
        if not down:
            self.gate = False
        if self.gate or not fresh:
            return None
        self.gate = True
        if len(fresh) > 1:
            self.notice = 'ignored:multiple'
            return self.notice
        key = next(iter(fresh))
        if self.dialog:
            if key == 'X':
                self.dialog = ''
                self.notice = 'cancel'
            elif key == 'RETURN':
                self.dialog = ''
                self.apply('restart')
            return self.notice
        if key == 'R':
            self.dialog = 'restart'
            self.notice = 'confirm:restart'
            return self.notice
        if key == 'X':
            return self.apply('undo')
        if key in ('S', 'L') and STAGE >= 5:
            if not self.save_path:
                self.notice = 'use_--save-book'
                return self.notice
            try:
                if key == 'S':
                    self.last_save = str(save_book(self.save_path, self.s, self.history))
                    self.notice = 'saved'
                else:
                    state, history = load_book(self.save_path)
                    self.s, self.history = state, list(history)
                    self.mode, self.pair = 'walk', []
                    self.notice = 'loaded'
            except (OSError, ValueError, KeyError, TypeError, RecursionError):
                self.notice = 'save_failed' if key == 'S' else 'load_failed'
            return self.notice
        if key == 'N':
            return self.apply('next')
        if key == 'Q':
            return self.apply('prev')
        if key == 'TAB' and STAGE >= 2:
            self.mode = 'words' if self.mode == 'walk' else 'walk'
            self.pair = []
            self.notice = 'mode'
            return self.notice
        if key == 'SPACE':
            return self.apply('wait')
        if self.mode == 'words':
            if key in ('LEFT','RIGHT','UP','DOWN'):
                self.cursor = 1 - self.cursor
                self.notice = 'select:object'
            elif key == 'Z':
                if self.cursor in self.pair:
                    self.pair.remove(self.cursor)
                else:
                    self.pair.append(self.cursor)
                self.notice = 'select:pair'
            elif key == 'RETURN':
                if len(self.pair) == 2:
                    return self.apply('swap')
                self.notice = 'select_two_targets'
        else:
            if key in ('LEFT','RIGHT'):
                return self.apply(key.lower())
            if key == 'Z':
                return self.apply('interact')
        return self.notice

    def draw(self, p):
        s = self.s
        i = s.page
        page = s.pages[i]
        p.cls(1)
        p.rect(0, 0, WIDTH, 47, 2)
        p.text(16, 10, 'LIVING STORYBOOK / STAGE ' + str(STAGE), 10)
        p.text(16, 28, TITLE, 7)
        p.text(540, 10, 'CHAPTER '+str(i+1)+' / '+str(PAGE_COUNT), 6)
        p.text(540, 28, 'TICK '+str(page.ticks)+' / HISTORY '+str(len(self.history)), 6)
        p.text(16, 59, ('WORDS: CHOOSE TWO TARGETS' if self.mode == 'words' else 'WALK: MOVE / INTERACT'), 10)
        p.text(16, 75, 'SELECTING DOES NOT ADVANCE PHYSICS OR TIME.', 6)
        for j in range(2):
            x = 16 + j * 390
            p.rect(x, 98, 376, 76, 2)
            p.rectb(x, 98, 376, 76, 10 if self.cursor == j and self.mode == 'words' else 5)
            p.text(x+12, 110, ('[X] ' if j in self.pair else '[ ] ') + EN_NAMES[i][j], 11 if j in self.pair else 7)
            p.text(x+12, 130, 'WORD: '+EN_TAGS[page.tags[j]], 10)
            effect = ('FLOATS' if page.tags[j] == '軽い' else 'SINKS') if i == 0 else ('SIZE 3' if page.tags[j] == '長い' else 'SIZE 1') if i == 1 else ('WIND +1 CELL' if page.tags[j] == '軽い' else 'STAYS HERE')
            p.text(x+12, 150, 'EFFECT: '+effect, 6)
        p.text(16, 190, ('FERRY: REACH LAND CELL 5' if i == 0 else 'BRIDGE: REACH 6, THEN Z TO RAISE FLAG' if i == 1 else 'WIND: REACH 6 RIDING, THEN Z TO GET OFF'), 7)
        if STAGE == 1:
            p.text(16, 206, 'STAGE 1: OBSERVE ONLY. WALK / GOAL START IN STAGE 2.', 9)
        for cell in range(7):
            x = 64 + cell * 100
            p.text(x+26, 230, str(cell), 6)
            p.rect(x, 304, 88, 37, 4 if cell in LAND[i] else 12 if i == 0 else 0)
            p.rectb(x, 304, 88, 37, 5)
            p.text(x+18, 326, 'LAND' if cell in LAND[i] else 'WATER' if i == 0 else 'GAP', 7)
        if i == 1:
            p.rect(258, 294, 288 if page.tags[0] == '長い' else 88, 9, 9)
            p.rect(718, 254, 3, 51, 6)
            flag_y = 253 if page.raised else 285
            p.rect(721, flag_y, 42 if page.tags[1] == '長い' else 14, 12, 8)
        else:
            for j, cell in enumerate(page.objects):
                x = 80 + cell * 100
                y = (281 if page.tags[j] == '軽い' else 349) if i == 0 else (263 if page.tags[j] == '軽い' else 286)
                if j == 0:
                    p.rect(x, y, 57, 13, 9)
                    p.line(x, y+13, x+57, y+13, 4)
                else:
                    p.rect(x+13, y-4, 27, 18, 13 if i == 0 else 4)
                p.text(x+16, y+22, 'A' if j == 0 else 'B', 7)
        hx = 108 + page.hero * 100
        hy = 243 if i == 2 and page.riding >= 0 and page.tags[page.riding] == '軽い' else 272
        p.circ(hx, hy, 5, 10)
        p.line(hx, hy+5, hx, hy+19, 7)
        p.line(hx-6, hy+12, hx+6, hy+12, 7)
        p.text(16, 387, 'HERO '+str(page.hero)+' / RIDING '+('-' if page.riding < 0 else EN_NAMES[i][page.riding])+' / LAST LAND '+str(page.last_land), 7)
        p.text(16, 404, 'OBSERVATION: WORDS -> FLOAT / SINK. NO GOAL YET.' if STAGE == 1 else 'PAGE GOAL: '+('COMPLETE' if page.complete else 'OPEN')+' / WORDS CAN STILL CHANGE THE ENDING.', 11 if page.complete else 6)
        p.rect(14, 423, 772, 61, 0)
        p.text(22, 429, 'REMEMBERED CHAPTER OUTCOMES (IN THIS BOOK)', 6)
        for n, item in enumerate(s.pages):
            memory = EN_SCENES.get(s.memories[n], '-')
            p.text(22, 441+n*12, str(n+1)+': '+memory, 11 if s.memories[n] else 5)
        p.text(16, 494, 'STATUS: '+self.notice+(' / ENTER RESTARTS PAGE, X CANCELS' if self.dialog else ''), 10)
        p.text(16, 511, 'ARROWS TARGET / Z SELECT / ENTER SWAP | R RETRY / ENTER CONFIRM / X CANCEL' if STAGE == 1 else 'WORDS: ARROWS TARGET / Z SELECT / ENTER SWAP | TAB WORDS-WALK', 7)
        p.text(16, 527, 'NO WALK / WAIT / CHAPTERS / UNDO / SAVE YET | ESC QUIT' if STAGE == 1 else 'WALK: LEFT-RIGHT / Z PULL | SPACE WAIT | R RETRY | ESC QUIT' if STAGE == 2 else 'WALK: LEFT-RIGHT / Z USE | SPACE WAIT | N NEXT / Q PREV | R RETRY', 6)
        p.text(16, 543, 'X UNDO / S SAVE / L LOAD (--save-book) | ESC QUIT' if STAGE == 5 else 'MEMORY IS IN RAM. STAGE 5 ADDS UNDO AND SAVE.', 6)

CONTROL_KEYS = ('LEFT','RIGHT','UP','DOWN','TAB','Z','RETURN','SPACE','N','Q','R','X','S','L')
SWAP_KEYS = ('TAB','Z','RIGHT','Z','RETURN','TAB')

DEMO_KEYS = SWAP_KEYS + ('RIGHT',)*4 + ('N',) + SWAP_KEYS + ('RIGHT',)*6 + ('Z','N','RIGHT') + ('SPACE',)*5 + ('Z',)

def parse_args(argv=None):
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument('--headless-smoke', action='store_true')
    mode.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    if STAGE >= 5:
        parser.add_argument('--save-book', help='S/L use this explicit .json; valid tutorial book may be replaced')
    args = parser.parse_args(argv)
    if (args.screenshot or args.report) and not (args.headless_smoke or args.demo_checkpoint):
        parser.error('outputs require a headless mode')
    outputs = []
    for name, suffix in (('screenshot','.png'),('report','.json'),('save_book','.json')):
        value = getattr(args, name, None)
        if value:
            path = Path(value).expanduser().absolute()
            if path.suffix.lower() != suffix or path.is_symlink():
                parser.error(name+' needs a regular '+suffix+' path')
            outputs.append(path.resolve())
    if len(set(outputs)) != len(outputs):
        parser.error('output paths must be distinct')
    if Path(__file__).resolve() in outputs:
        parser.error('cannot overwrite this source')
    return args

def main(argv=None):
    from pathlib import Path
    args = parse_args(argv)
    # モデルのimportと試験にはPyxelが不要。CLIの検査後にだけ読み込む。
    import pyxel as p
    headless = args.headless_smoke or args.demo_checkpoint
    app = App(getattr(args, 'save_book', None))
    sequence = DEMO_KEYS if args.demo_checkpoint else ()
    limit = max(12, 2*len(sequence)+4)
    keys = {name:getattr(p,'KEY_'+name) for name in CONTROL_KEYS}
    p.init(WIDTH, HEIGHT, title='098 Storybook - Stage '+str(STAGE), fps=30,
           display_scale=1, headless=headless)
    finished = False
    def update():
        if headless:
            index = p.frame_count // 2
            app.feed({sequence[index]} if p.frame_count % 2 == 0 and index < len(sequence) else set())
        else:
            app.feed({name for name, key in keys.items() if p.btn(key)})
    def draw():
        nonlocal finished
        app.draw(p)
        if headless and p.frame_count >= limit and not finished:
            finished = True
            if args.screenshot:
                target = Path(args.screenshot).expanduser().absolute()
                target.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(target), scale=1)
            if args.report:
                target = Path(args.report).expanduser().absolute()
                target.parent.mkdir(parents=True, exist_ok=True)
                data = {'stage':STAGE,'kind':'actual Pyxel headless automated render','keys':sequence,
                        'state':asdict(app.s),'history_length':len(app.history),'notice':app.notice,
                        'os_input_tested':False,'human_play_tested':False,'audio_tested':False}
                target.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n',encoding='utf-8')
            p.quit()
    p.run(update, draw)
    return app

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

一手をUndoし、途中の本も安全に保存・読込する

状態のスナップショット(その時点の写し)、変わったときだけ履歴に追加、JSON の版と由来、検証、途中で壊れない置き換え(原子的な置換)。

意味のある操作で State が変わったときだけ、前の State を history(履歴)に追加します。移動、交換、待機、章の移動、章のやり直しを、X で1手ずつ戻せます。選ぶ操作、カーソルの移動、失敗した移動、S と L では、履歴は増えません。履歴は元のゲームと同じく最大1000手で、それより古いものは捨てます。S と L を使うには、起動するときに --save-book で JSON ファイルを指定しておく必要があります。保存の形式はこの教材だけのもので、ゲーム番号098、ABC、版、元のゲームのファイルの SHA-256(ファイルから作る短い値)、3つの章の状態、完成した絵、履歴、variant を持ちます。まだ完成していない本でも保存できます。次に同じパスで起動して L を押せば、続きから遊べます。保存するときは、まず一時ファイルに UTF-8 で書いてから、置き換えます。すでにあるファイルは、同じ形式の正常な本のときだけ上書きします。知らない版、関係のない JSON、壊れた保存、シンボリックリンク(別のファイルを指す特別なファイル)は受け付けません。親フォルダーは自動では作りません。元のゲームの保存形式から変えているので、元のゲームの save_098.json は読みません。

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

05_book.py / 注目する部分
def save_book(path, s, history):
    """未完成も保存可能。同形式だけ上書きし、成功前は既存ファイルを保持。"""
    import os
    import tempfile
    target = book_path(path)
    data = pack(s, history)
    validate_book(data)
    raw = (json.dumps(data, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
    if len(raw) > MAX_BYTES:
        raise ValueError('book too large')
    if target.exists():
        load_book(target)  # 無関係なJSONや破損ファイルを黙って消さない。
    temporary = None
    try:
        with tempfile.NamedTemporaryFile(mode='wb', dir=target.parent, prefix='.storybook-', suffix='.tmp', delete=False) as stream:
            temporary = stream.name
            stream.write(raw)
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(temporary, target)
        temporary = None
    finally:
        if temporary is not None:
            try:
                os.unlink(temporary)
            except FileNotFoundError:
                pass
    return target

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_book.py
macOS / Linux
.venv/bin/python 05_book.py
画面で見ること札を交換し、右で舟に乗ってから X を押すと、交換したあと・乗る前の状態に戻ります。完成した本で Q→X なら元の章に、R→Enter→X なら元の完成した絵まで戻ります。保存先を指定しただけでは、ファイルは作られません。S で saved と出ます。別の操作をしてから L を押すと loaded と出て、保存したときの3つの章と Undo の履歴が戻ります。S と L そのものでは、TICK は増えません。デモでは、ファイルを自動では保存せず、3つの章すべての完成と HISTORY 22 を表示します。
  • State が変わった意味のある操作だけを、履歴に入れる
  • 履歴は最大1000。選んでいる途中の状態は入れない
  • 指定したパスに保存する。途中でも保存できる。同じ形式のファイルだけ上書きする
  • 読み込みに失敗したら、今の状態と履歴をそのまま残す
  • 元のゲームにある範囲の検証と、新しく加えた外部ファイルの保護を区別する
つまずきやすいところ選んでいる途中の pair までゲームの履歴に積むと、X が「世界への1つの操作を戻す」という意味でなくなります。保存に成功しても、3つの章を達成したことにはなりません。ハッシュ(データを短い値にまとめたもの)は、どこから来たか(由来)を照らし合わせるためのものです。署名・認証・改ざん防止のためのものではありません。元のゲームから来た unpack は、形と範囲を確かめるだけです。そのため、実際には起こりえない complete=True などを、すべて見抜けるわけではありません。
少し変えてみよう

05_book.py --save-book "途中の本.json" で起動し、まだ渡らずに S を押してみましょう。札を交換してからもう一度 S を押すと、同じ形式の同じ名前のファイルが置き換えられます。一度終了して同じコマンドで起動し、L を押すと、交換したあとから続けられます。X で、保存してあった直前の世界の操作を戻せます。関係のない別の名前の JSON を保存先にすると save_failed になり、その中身はそのまま残ります。このことを確かめるテストも読んでみましょう。なくなると困る本物のファイルでは試さず、一時フォルダーを使います。

05_book.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(545行)
05_book.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""098 一冊の動く絵本・日本語教材 5/5。"""
from dataclasses import dataclass, replace, asdict
import json

WIDTH, HEIGHT = 800, 560
TITLES = ('渡し舟のページ', '橋のページ', '風のページ')
NAMES = (('舟', '石'), ('橋', '旗'), ('葉舟', '看板'))
LAND = ((0, 1, 5, 6), (0, 1, 5, 6), (0, 1, 3, 4, 6))
TAGS = (('重い', '軽い'), ('短い', '長い'), ('軽い', '重い'))
EN_NAMES = (('BOAT', 'STONE'), ('BRIDGE', 'FLAG'), ('LEAF BOAT', 'SIGN'))
EN_TAGS = {'重い':'HEAVY', '軽い':'LIGHT', '短い':'SHORT', '長い':'LONG'}
EN_SCENES = {'浮く舟、右岸の旅人':'FLOAT BOAT / TRAVELER ON RIGHT', '浮く石、右岸の旅人':'FLOAT STONE / TRAVELER ON RIGHT', '長い橋と短い旗':'LONG BRIDGE / SHORT FLAG', '短い橋と長い旗':'SHORT BRIDGE / LONG FLAG', '軽い葉舟の空':'LIGHT LEAF BOAT SKY', '軽い看板の空':'LIGHT SIGN SKY', '葉舟で風の丘':'LEAF BOAT WIND HILL', '看板で風の丘':'SIGN WIND HILL'}

STAGE = 5
PAGE_COUNT = 3
TITLE = 'UNDO / SAVE / LOAD A BOOK'

@dataclass(frozen=True)
class Page:
    hero: int
    objects: tuple
    tags: tuple
    riding: int = -1
    last_land: int = 0
    raised: bool = False
    complete: bool = False
    ticks: int = 0
    cause: str = ''

@dataclass(frozen=True)
class State:
    page: int
    pages: tuple
    memories: tuple
    variant: str = 'causes'
    events: tuple = ()

def fresh(i):
    return Page((1, 0, 0)[i], ((2, 4), (1, 6), (1, 4))[i], TAGS[i], last_land=(1, 0, 0)[i])

def new(variant='causes'):
    return State(0, tuple((fresh(i) for i in range(PAGE_COUNT))), tuple('' for _ in range(PAGE_COUNT)), variant)

def scene(i, p):
    if i == 0:
        return '浮く' + NAMES[i][p.tags.index('軽い')] + '、右岸の旅人'
    if i == 1:
        return '長い橋と短い旗' if p.tags[0] == '長い' else '短い橋と長い旗'
    return ('葉舟で風の丘' if p.riding == 0 else '看板で風の丘') if p.riding != -1 else '軽い葉舟の空' if p.tags[0] == '軽い' else '軽い看板の空'

def finish(s, p, cause):
    i = s.page
    complete = p.complete or (p.hero >= 5 if i == 0 else p.hero == 6 and p.raised if i == 1 else p.hero == 6 and p.riding == -1)
    p = replace(p, complete=complete, ticks=p.ticks + 1, cause=cause)
    pages = list(s.pages)
    pages[i] = p
    mem = list(s.memories)
    if complete:
        mem[i] = scene(i, p)
    return replace(s, pages=tuple(pages), memories=tuple(mem), events=(cause,))

def swap(s):
    i = s.page
    p = s.pages[i]
    tags = tuple(reversed(p.tags))
    p = replace(p, tags=tags)
    cause = ' / '.join((NAMES[i][j] + 'が' + tags[j] for j in (0, 1)))
    if i == 0:
        cause += ' → 浮く物と沈む物が交代'
        if p.riding >= 0 and tags[p.riding] == '重い':
            p = replace(p, hero=p.last_land, riding=-1)
            cause += '。旅人は泡で陸へ'
    if i == 1:
        cause += ' → 長さが同時に変わる'
        if p.hero in (3, 4) and tags[0] == '短い':
            p = replace(p, hero=p.last_land)
            cause += '。旅人はしおりで陸へ'
    if i == 2:
        cause += ' → 風に動く物が交代'
    return finish(s, p, cause)

def walk(s, d):
    i = s.page
    p = s.pages[i]
    target = p.hero + d
    if d not in (-1, 1) or not 0 <= target <= 6:
        return s
    objects = list(p.objects)
    if i == 0:
        if p.riding >= 0:
            if target in LAND[i]:
                return finish(s, replace(p, hero=target, riding=-1, last_land=target), '舟から陸へ降りた' if p.riding == 0 else '石から陸へ降りた')
            if target in (2, 3, 4) and (not any((j != p.riding and objects[j] == target and (p.tags[j] == '軽い') for j in (0, 1)))):
                objects[p.riding] = target
                return finish(s, replace(p, hero=target, objects=tuple(objects)), '軽い' + NAMES[i][p.riding] + 'と水上を進んだ')
            return s
        if target in LAND[i]:
            return finish(s, replace(p, hero=target, last_land=target), '陸を歩いた')
        riders = [j for j in (0, 1) if objects[j] == target and p.tags[j] == '軽い']
        if riders:
            return finish(s, replace(p, hero=target, riding=riders[0]), '軽い' + NAMES[i][riders[0]] + 'へ乗った')
        return s
    if i == 1:
        if target not in LAND[i] and (not (target == 2 or p.tags[0] == '長い')):
            return s
        return finish(s, replace(p, hero=target, last_land=target if target in LAND[i] else p.last_land), '橋を歩いた' if target not in LAND[i] else '陸へ歩いた')
    riders = sorted((j for j in (0, 1) if objects[j] == target), key=lambda j: p.tags[j] != '軽い')
    if riders:
        return finish(s, replace(p, hero=target, riding=riders[0]), NAMES[i][riders[0]] + 'へ乗った')
    if target in LAND[i]:
        return finish(s, replace(p, hero=target, riding=-1, last_land=target), '陸へ歩いた')
    return s

def interact(s):
    i = s.page
    p = s.pages[i]
    if i == 0 and p.riding == -1 and (p.hero in LAND[0]):
        candidates = [j for j in (0, 1) if p.tags[j] == '軽い' and 1 < abs(p.objects[j] - p.hero) <= 2]
        if candidates:
            j = candidates[0]
            o = list(p.objects)
            o[j] += 1 if p.hero > o[j] else -1
            return finish(s, replace(p, objects=tuple(o)), '軽い' + NAMES[i][j] + 'を1セル手前へ寄せた')
    if i == 1 and p.hero == 6 and (not p.raised):
        return finish(s, replace(p, raised=True), '旗が上がった。長さの違う結末も残せる')
    if i == 2 and p.riding >= 0 and (p.hero in LAND[2]):
        return finish(s, replace(p, riding=-1, last_land=p.hero), '乗り物から陸へ降りた')
    return s

def wind(s):
    p = s.pages[s.page]
    if s.page != 2:
        return finish(s, p, '一段階待った。景色はそのまま')
    o = list(p.objects)
    j = p.tags.index('軽い')
    target = min(6, o[j] + 1)
    o[j] = target
    hero = o[j] if p.riding == j else p.hero
    return finish(s, replace(p, objects=tuple(o), hero=hero), '軽い' + NAMES[2][j] + 'を風が1セル運んだ' if target != p.objects[j] else '丘の端で風の乗り物が止まった')

def step(s, a):
    if a == 'swap':
        return swap(s)
    if a == 'left':
        return walk(s, -1)
    if a == 'right':
        return walk(s, 1)
    if a == 'interact':
        return interact(s)
    if a == 'wait':
        return wind(s)
    if a == 'next' and s.page < PAGE_COUNT - 1 and s.pages[s.page].complete:
        return replace(s, page=s.page + 1, events=('次のページへ。前の絵は残っています',))
    if a == 'prev' and s.page > 0:
        return replace(s, page=s.page - 1, events=('前のページへ。状態も完成絵も保持',))
    if a == 'restart':
        p = list(s.pages)
        p[s.page] = fresh(s.page)
        m = list(s.memories)
        m[s.page] = ''
        return replace(s, pages=tuple(p), memories=tuple(m), events=('このページだけ、はじめから',))
    return s

SOURCE_SHA256 = {'model.py': '45aa000fcf2b0ad33612aeb3e7cbb45bfe21070a8b2cf5c9f8e6935c92df3ae4', 'controller.py': '3aeea8f4b3c494d2cb1ab20643cb0dbb3414aba8e8d27478b8a31fae0b048809', 'README.txt': 'dfd0a1632771b17d01d5a6227e7247691733211bd46b30f782a2f73226baa3ba', 'LICENSE.txt': '0d4e281c22d9d459060541d1cf266dc75b501cf78d45351152f0346bc91fdab3', 'experiments.py': '6ed1d651d6d04f523912165016f729429843525f8d88e7419781656e3d573f7b', 'test_model.py': '2aca3e34108468239c62ee4af01b35b817d824dde0de0a9ae63e0780002a866b', 'test_ui.py': '930d7d565a15f1877d360238a6991137c399a648135918bd125d836e3f5b7809', 'app.py': '6f24542c2f456d5259b2f147f5b07e8b26f48141946661df9c50a22fbb1b1990', 'assets/PYXEL-LICENSE.txt': 'd9f301d8f64dfbba5e7c3301daad34a1b66eb5240ad42e3ddc8e1d0a17b3d0db', 'assets/NOTO-LICENSE.txt': '849f4ea9c214fa4ac3593b770c699f387534b11ce671264c1b10d85bdcb5997b'}

def unpack(d):
    if not isinstance(d, dict) or set(d) != set(asdict(new())):
        raise ValueError('保存形式')
    if type(d['page']) is not int or not 0 <= d['page'] <= 2 or d['variant'] not in ('causes', 'instant'):
        raise ValueError('章または版')
    if not isinstance(d['pages'], list) or len(d['pages']) != 3:
        raise ValueError('ページ')
    pp = []
    for i, t in enumerate(d['pages']):
        if not isinstance(t, dict) or set(t) != set(asdict(fresh(i))):
            raise ValueError('章形式')
        for k in ('hero', 'last_land'):
            if type(t[k]) is not int or not 0 <= t[k] <= 6:
                raise ValueError('位置')
        if t['last_land'] not in LAND[i] or type(t['riding']) is not int or t['riding'] not in (-1, 0, 1):
            raise ValueError('足場')
        if not isinstance(t['objects'], list) or len(t['objects']) != 2 or any((type(x) is not int or not 0 <= x <= 6 for x in t['objects'])):
            raise ValueError('物位置')
        if not isinstance(t['tags'], list) or sorted(t['tags']) != sorted(TAGS[i]):
            raise ValueError('札')
        if type(t['raised']) is not bool or type(t['complete']) is not bool or type(t['ticks']) is not int or (not 0 <= t['ticks'] <= 1000000) or (not isinstance(t['cause'], str)) or (len(t['cause']) > 150):
            raise ValueError('値')
        if t['riding'] >= 0 and t['hero'] != t['objects'][t['riding']]:
            raise ValueError('追従')
        t = dict(t)
        t['objects'] = tuple(t['objects'])
        t['tags'] = tuple(t['tags'])
        pp.append(Page(**t))
    if not isinstance(d['memories'], list) or len(d['memories']) != 3 or any((not isinstance(x, str) or len(x) > 100 for x in d['memories'])):
        raise ValueError('完成絵')
    if not isinstance(d['events'], list) or any((not isinstance(x, str) or len(x) > 150 for x in d['events'])):
        raise ValueError('記録')
    return State(d['page'], tuple(pp), tuple(d['memories']), d['variant'], tuple(d['events']))

# 保存形式は教材専用。原作のsave_098.jsonとは互換にしない。
FORMAT = 'pyxel-tutorial-098'
VERSION = 1
MAX_BYTES = 2_000_000

def pack(s, history):
    data = {'format': FORMAT, 'version': VERSION, 'game': '098', 'case': 'ABC',
            'source_sha256': SOURCE_SHA256, 'state': asdict(s),
            'history': [asdict(t) for t in history], 'settings': {'variant': s.variant}}
    return json.loads(json.dumps(data, ensure_ascii=False))

def validate_book(data):
    if not isinstance(data, dict) or set(data) != {'format','version','game','case','source_sha256','state','history','settings'}:
        raise ValueError('book keys')
    if data['format'] != FORMAT or type(data['version']) is not int or data['version'] != VERSION or data['game'] != '098' or data['case'] != 'ABC' or data['source_sha256'] != SOURCE_SHA256:
        raise ValueError('book provenance/version')
    history = data['history']
    if not isinstance(history, list) or len(history) > 1000:
        raise ValueError('history length')
    s = unpack(data['state'])
    h = tuple(unpack(t) for t in history)
    if data['settings'] != {'variant': s.variant} or any(t.variant != s.variant for t in h):
        raise ValueError('settings mismatch')
    if any(len(t.events) > 1000 for t in (s,) + h):
        raise ValueError('event length')
    return s, h

def book_path(path):
    from pathlib import Path
    target = Path(path).expanduser().absolute()
    if target.suffix.lower() != '.json' or target.is_symlink():
        raise ValueError('use a regular .json file')
    return target

def load_book(path):
    target = book_path(path)
    with target.open('rb') as stream:
        raw = stream.read(MAX_BYTES + 1)
    if len(raw) > MAX_BYTES:
        raise ValueError('book too large')
    return validate_book(json.loads(raw.decode('utf-8')))

def save_book(path, s, history):
    """未完成も保存可能。同形式だけ上書きし、成功前は既存ファイルを保持。"""
    import os
    import tempfile
    target = book_path(path)
    data = pack(s, history)
    validate_book(data)
    raw = (json.dumps(data, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
    if len(raw) > MAX_BYTES:
        raise ValueError('book too large')
    if target.exists():
        load_book(target)  # 無関係なJSONや破損ファイルを黙って消さない。
    temporary = None
    try:
        with tempfile.NamedTemporaryFile(mode='wb', dir=target.parent, prefix='.storybook-', suffix='.tmp', delete=False) as stream:
            temporary = stream.name
            stream.write(raw)
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(temporary, target)
        temporary = None
    finally:
        if temporary is not None:
            try:
                os.unlink(temporary)
            except FileNotFoundError:
                pass
    return target

class App:
    def __init__(self, save_path=None):
        self.s = new()
        self.history = []
        self.mode = 'words' if STAGE == 1 else 'walk'
        self.cursor = 0
        self.pair = []
        self.previous = set()
        self.gate = False
        self.notice = 'ready'
        self.save_path = save_path
        self.last_save = None
        self.dialog = ''

    def apply(self, action):
        if action == 'undo':
            if STAGE < 5 or not self.history:
                self.notice = 'no_undo'
                return self.notice
            self.s = self.history.pop()
            self.pair = []
            self.notice = 'undo'
            return self.notice
        old = self.s
        self.s = step(old, action)
        if self.s == old:
            self.notice = 'blocked:' + action
            return self.notice
        if STAGE >= 5:
            self.history.append(old)
            self.history = self.history[-1000:]
        if action == 'swap':
            self.pair = []
        if action in ('next', 'prev', 'restart'):
            self.mode = 'words' if STAGE == 1 else 'walk'
            self.pair = []
        self.notice = action
        return action

    def feed(self, down):
        # 選択だけでは状態/時間を変えない。原作と同じ全解放ゲート。
        down = set(down) & set(CONTROL_KEYS)
        fresh = down - self.previous
        self.previous = down
        if not down:
            self.gate = False
        if self.gate or not fresh:
            return None
        self.gate = True
        if len(fresh) > 1:
            self.notice = 'ignored:multiple'
            return self.notice
        key = next(iter(fresh))
        if self.dialog:
            if key == 'X':
                self.dialog = ''
                self.notice = 'cancel'
            elif key == 'RETURN':
                self.dialog = ''
                self.apply('restart')
            return self.notice
        if key == 'R':
            self.dialog = 'restart'
            self.notice = 'confirm:restart'
            return self.notice
        if key == 'X':
            return self.apply('undo')
        if key in ('S', 'L') and STAGE >= 5:
            if not self.save_path:
                self.notice = 'use_--save-book'
                return self.notice
            try:
                if key == 'S':
                    self.last_save = str(save_book(self.save_path, self.s, self.history))
                    self.notice = 'saved'
                else:
                    state, history = load_book(self.save_path)
                    self.s, self.history = state, list(history)
                    self.mode, self.pair = 'walk', []
                    self.notice = 'loaded'
            except (OSError, ValueError, KeyError, TypeError, RecursionError):
                self.notice = 'save_failed' if key == 'S' else 'load_failed'
            return self.notice
        if key == 'N':
            return self.apply('next')
        if key == 'Q':
            return self.apply('prev')
        if key == 'TAB' and STAGE >= 2:
            self.mode = 'words' if self.mode == 'walk' else 'walk'
            self.pair = []
            self.notice = 'mode'
            return self.notice
        if key == 'SPACE':
            return self.apply('wait')
        if self.mode == 'words':
            if key in ('LEFT','RIGHT','UP','DOWN'):
                self.cursor = 1 - self.cursor
                self.notice = 'select:object'
            elif key == 'Z':
                if self.cursor in self.pair:
                    self.pair.remove(self.cursor)
                else:
                    self.pair.append(self.cursor)
                self.notice = 'select:pair'
            elif key == 'RETURN':
                if len(self.pair) == 2:
                    return self.apply('swap')
                self.notice = 'select_two_targets'
        else:
            if key in ('LEFT','RIGHT'):
                return self.apply(key.lower())
            if key == 'Z':
                return self.apply('interact')
        return self.notice

    def draw(self, p):
        s = self.s
        i = s.page
        page = s.pages[i]
        p.cls(1)
        p.rect(0, 0, WIDTH, 47, 2)
        p.text(16, 10, 'LIVING STORYBOOK / STAGE ' + str(STAGE), 10)
        p.text(16, 28, TITLE, 7)
        p.text(540, 10, 'CHAPTER '+str(i+1)+' / '+str(PAGE_COUNT), 6)
        p.text(540, 28, 'TICK '+str(page.ticks)+' / HISTORY '+str(len(self.history)), 6)
        p.text(16, 59, ('WORDS: CHOOSE TWO TARGETS' if self.mode == 'words' else 'WALK: MOVE / INTERACT'), 10)
        p.text(16, 75, 'SELECTING DOES NOT ADVANCE PHYSICS OR TIME.', 6)
        for j in range(2):
            x = 16 + j * 390
            p.rect(x, 98, 376, 76, 2)
            p.rectb(x, 98, 376, 76, 10 if self.cursor == j and self.mode == 'words' else 5)
            p.text(x+12, 110, ('[X] ' if j in self.pair else '[ ] ') + EN_NAMES[i][j], 11 if j in self.pair else 7)
            p.text(x+12, 130, 'WORD: '+EN_TAGS[page.tags[j]], 10)
            effect = ('FLOATS' if page.tags[j] == '軽い' else 'SINKS') if i == 0 else ('SIZE 3' if page.tags[j] == '長い' else 'SIZE 1') if i == 1 else ('WIND +1 CELL' if page.tags[j] == '軽い' else 'STAYS HERE')
            p.text(x+12, 150, 'EFFECT: '+effect, 6)
        p.text(16, 190, ('FERRY: REACH LAND CELL 5' if i == 0 else 'BRIDGE: REACH 6, THEN Z TO RAISE FLAG' if i == 1 else 'WIND: REACH 6 RIDING, THEN Z TO GET OFF'), 7)
        if STAGE == 1:
            p.text(16, 206, 'STAGE 1: OBSERVE ONLY. WALK / GOAL START IN STAGE 2.', 9)
        for cell in range(7):
            x = 64 + cell * 100
            p.text(x+26, 230, str(cell), 6)
            p.rect(x, 304, 88, 37, 4 if cell in LAND[i] else 12 if i == 0 else 0)
            p.rectb(x, 304, 88, 37, 5)
            p.text(x+18, 326, 'LAND' if cell in LAND[i] else 'WATER' if i == 0 else 'GAP', 7)
        if i == 1:
            p.rect(258, 294, 288 if page.tags[0] == '長い' else 88, 9, 9)
            p.rect(718, 254, 3, 51, 6)
            flag_y = 253 if page.raised else 285
            p.rect(721, flag_y, 42 if page.tags[1] == '長い' else 14, 12, 8)
        else:
            for j, cell in enumerate(page.objects):
                x = 80 + cell * 100
                y = (281 if page.tags[j] == '軽い' else 349) if i == 0 else (263 if page.tags[j] == '軽い' else 286)
                if j == 0:
                    p.rect(x, y, 57, 13, 9)
                    p.line(x, y+13, x+57, y+13, 4)
                else:
                    p.rect(x+13, y-4, 27, 18, 13 if i == 0 else 4)
                p.text(x+16, y+22, 'A' if j == 0 else 'B', 7)
        hx = 108 + page.hero * 100
        hy = 243 if i == 2 and page.riding >= 0 and page.tags[page.riding] == '軽い' else 272
        p.circ(hx, hy, 5, 10)
        p.line(hx, hy+5, hx, hy+19, 7)
        p.line(hx-6, hy+12, hx+6, hy+12, 7)
        p.text(16, 387, 'HERO '+str(page.hero)+' / RIDING '+('-' if page.riding < 0 else EN_NAMES[i][page.riding])+' / LAST LAND '+str(page.last_land), 7)
        p.text(16, 404, 'OBSERVATION: WORDS -> FLOAT / SINK. NO GOAL YET.' if STAGE == 1 else 'PAGE GOAL: '+('COMPLETE' if page.complete else 'OPEN')+' / WORDS CAN STILL CHANGE THE ENDING.', 11 if page.complete else 6)
        p.rect(14, 423, 772, 61, 0)
        p.text(22, 429, 'REMEMBERED CHAPTER OUTCOMES (IN THIS BOOK)', 6)
        for n, item in enumerate(s.pages):
            memory = EN_SCENES.get(s.memories[n], '-')
            p.text(22, 441+n*12, str(n+1)+': '+memory, 11 if s.memories[n] else 5)
        p.text(16, 494, 'STATUS: '+self.notice+(' / ENTER RESTARTS PAGE, X CANCELS' if self.dialog else ''), 10)
        p.text(16, 511, 'ARROWS TARGET / Z SELECT / ENTER SWAP | R RETRY / ENTER CONFIRM / X CANCEL' if STAGE == 1 else 'WORDS: ARROWS TARGET / Z SELECT / ENTER SWAP | TAB WORDS-WALK', 7)
        p.text(16, 527, 'NO WALK / WAIT / CHAPTERS / UNDO / SAVE YET | ESC QUIT' if STAGE == 1 else 'WALK: LEFT-RIGHT / Z PULL | SPACE WAIT | R RETRY | ESC QUIT' if STAGE == 2 else 'WALK: LEFT-RIGHT / Z USE | SPACE WAIT | N NEXT / Q PREV | R RETRY', 6)
        p.text(16, 543, 'X UNDO / S SAVE / L LOAD (--save-book) | ESC QUIT' if STAGE == 5 else 'MEMORY IS IN RAM. STAGE 5 ADDS UNDO AND SAVE.', 6)

CONTROL_KEYS = ('LEFT','RIGHT','UP','DOWN','TAB','Z','RETURN','SPACE','N','Q','R','X','S','L')
SWAP_KEYS = ('TAB','Z','RIGHT','Z','RETURN','TAB')

DEMO_KEYS = SWAP_KEYS + ('RIGHT',)*4 + ('N',) + SWAP_KEYS + ('RIGHT',)*6 + ('Z','N','RIGHT') + ('SPACE',)*5 + ('Z',)

def parse_args(argv=None):
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument('--headless-smoke', action='store_true')
    mode.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    if STAGE >= 5:
        parser.add_argument('--save-book', help='S/L use this explicit .json; valid tutorial book may be replaced')
    args = parser.parse_args(argv)
    if (args.screenshot or args.report) and not (args.headless_smoke or args.demo_checkpoint):
        parser.error('outputs require a headless mode')
    outputs = []
    for name, suffix in (('screenshot','.png'),('report','.json'),('save_book','.json')):
        value = getattr(args, name, None)
        if value:
            path = Path(value).expanduser().absolute()
            if path.suffix.lower() != suffix or path.is_symlink():
                parser.error(name+' needs a regular '+suffix+' path')
            outputs.append(path.resolve())
    if len(set(outputs)) != len(outputs):
        parser.error('output paths must be distinct')
    if Path(__file__).resolve() in outputs:
        parser.error('cannot overwrite this source')
    return args

def main(argv=None):
    from pathlib import Path
    args = parse_args(argv)
    # モデルのimportと試験にはPyxelが不要。CLIの検査後にだけ読み込む。
    import pyxel as p
    headless = args.headless_smoke or args.demo_checkpoint
    app = App(getattr(args, 'save_book', None))
    sequence = DEMO_KEYS if args.demo_checkpoint else ()
    limit = max(12, 2*len(sequence)+4)
    keys = {name:getattr(p,'KEY_'+name) for name in CONTROL_KEYS}
    p.init(WIDTH, HEIGHT, title='098 Storybook - Stage '+str(STAGE), fps=30,
           display_scale=1, headless=headless)
    finished = False
    def update():
        if headless:
            index = p.frame_count // 2
            app.feed({sequence[index]} if p.frame_count % 2 == 0 and index < len(sequence) else set())
        else:
            app.feed({name for name, key in keys.items() if p.btn(key)})
    def draw():
        nonlocal finished
        app.draw(p)
        if headless and p.frame_count >= limit and not finished:
            finished = True
            if args.screenshot:
                target = Path(args.screenshot).expanduser().absolute()
                target.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(target), scale=1)
            if args.report:
                target = Path(args.report).expanduser().absolute()
                target.parent.mkdir(parents=True, exist_ok=True)
                data = {'stage':STAGE,'kind':'actual Pyxel headless automated render','keys':sequence,
                        'state':asdict(app.s),'history_length':len(app.history),'notice':app.notice,
                        'os_input_tested':False,'human_play_tested':False,'audio_tested':False}
                target.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n',encoding='utf-8')
            p.quit()
    p.run(update, draw)
    return app

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

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

098 元のゲームの、ABCの3つの章です。2つのものの札を同時に交換します。「軽い」かどうかから浮き沈みと風での動きが、「長い」かどうかから橋の足場と旗の大きさが決まります。世界は7つのセルで、目標は3つです。舟で右岸に着く、旗を上げる、風の丘で降りる、の3つです。完成したあとも結末を変えられ、章の状態・完成した絵・Undo を保ちます。

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

比べる点5ステップの教材元の完成版
段階01は交換を観察するだけ、02は渡し舟、03は橋を含む2つの章、04と05はABCの3つの章の、元のゲームのモデル。05で履歴と保存を追加。最初から、ABCの3つの章と、履歴・保存・比較がある。
入力と表示表紙なし。800×560の、英字(ASCII)と図形の表示。キーを全部離してから次を受け付ける仕組みと、2つの対象を選ぶ仕組みは残す。18フレームの演出の間止まる処理は省き、交換はすぐに起こる。R は確認あり、Escape はすぐに終了。日本語の文字、表紙、ヘルプ、終了の確認、18フレームの原因(cause)の演出。比較用の instant 版も、動き(物理)は同じ。
保存05だけ、--save-book を指定したときに S と L が使える。教材だけの形式と、元のゲームのハッシュ。すでにある、同じ形式の正常なファイルだけ置き換える。知らないもの・壊れたもの・別の用途の JSON と、シンボリックリンク(symlink)は受け付けない。親フォルダーは自動では作らない。決まった保存先は save_098.json。game・version・case・state・history・settings を保存。すでにある同じ名前のファイルを、一時ファイルを通して置き換える。
検証の範囲元のゲームの unpack による形と範囲の検証に、2,000,000バイトの上限、イベント1000件の上限、ファイルの保護を加えた。実際に起こりうる状態かどうか(到達可能性)、認証、暗号の署名は作っていない。履歴1000、ticks 0〜1,000,000、cause 150文字、memory 100文字など。履歴や完成した絵が意味の上で合っているかを、すべて計算し直すわけではない。
記録と省略使っても使わなくてもよい --report は、描画の確認(QA)用の JSON で、保存用の book とは別。B の比較画面(UI)、M・H・P、F12、GIF の録画、音は省いた。表紙で B を押す causes と instant の比較、ヘルプ、メニュー、本物の Pyxel の画像と録画がある。元のゲーム自体にも音はない。
権利と実証日本語の文字(字形)は使っていない。元のゲームの MIT ライセンスの全文をそれぞれのソースに入れ、参照している Noto と Pyxel の権利の文書もすべて同梱。本物の Pyxel を画面なし(headless)で動かし、合成した入力でテストした。コードは MIT、日本語の文字は Noto をもとにした SIL OFL。人による試しプレイや、実際の機械での検査とは区別する。

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

  1. 元のゲームの原因の文と18フレームの演出を加え、演出中に押されたキーと、キーを離すまで待つ仕組み(解放ゲート)をテストする。
  2. 3つの章の完成した絵を、文章だけでなく小さな図として並べ、同じ入力で結末を比べる。
  3. 保存した状態が本当に起こりうるか(到達可能性)を確かめたいなら、最初の条件と意味入力の列を保存して計算し直し、形式の版を上げる。
  4. 元のゲームの instant の比較画面(UI)を加え、表示の設定のほかは状態が同じになる、というテストを保つ。
自分のゲームに変えるなら

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

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