LESSON 100パズルルール切替パズル原作022

ことばの橋の作り方

完成版の遊び方浮く石と沈む石のルールを、札で川全体いっせいに入れかえます。次にルールを入れかえられる陸を通りながら、荷物を届けます。

次にルールを入れかえられる陸を、先に見つけよう。
集合で川の地形を表し、2枚の札の入れかえ・予告・取り消し・Undo を5つのステップで作ります。完成版の3つの川と、それだけで動くステップごとのコードをダウンロードできます。

元の完成版Cで島から二札の交換を予告する自動描画画面
元の完成版Cで、途中の島から交換を予告している場面です。自動操作による実Pyxel描画です。教材は地形を調べる実験から始め、05で三つの川を扱います。
PLAY / 試験公開

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

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

完成版の主な操作:矢印移動、陸でSpace予告→再押下で文交換、Z取消/戻す、R再開、P停止、Q表紙

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

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

うまく表示されないとき

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

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

陸の上で通り方のルールを入れかえ、予告と確定を分ける。

ルールを1つずつ足していきます。5本の Python ファイルは、どれもそれだけで実行できます。最初のステップは地形を調べる実験、最後のステップでは元のゲームの3つの配置・予告・Undo を扱います。

必要な環境
Python3.11以上 / Pyxel2.9.9
知っておくとよいこと
Python の変数・if・for・関数・tuple・dict / 集合、dataclass(frozen=True)、replace は 01 で説明します / 集合の差、list の append・pop、property は少しずつ読んでいきます
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:一歩 / Space:予告・確定 / Z:取消・Undo / R:最初へ / P:停止 / Q:表紙 / 成功後N:次の川
難易度の目安
初級集合、不変Stateと入力の順番を基準にした編集上の目安です。難易度の読み方
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方

教材ZIPを全展開し、はじめる準備で環境を用意します。まず01_terrain.pyをそのまま起動します。抜粋と全文は同じ配布ソースです。

コードを変更したら

コードを書きかえたら、UTF-8 で保存し、Esc でゲームを終了してから、同じ Python ファイルをもう一度起動します。ゲームの中で R を押しても状態が戻るだけで、書きかえたコードは読みこまれません。

集合や class で迷ったらPythonの状態、1回押しと長押しのちがいは入力・更新・描画、座標は画面とマップの座標で確かめられます。わからない言葉は用語集で調べられます。画面は、日本語フォントを用意しなくても動くように、英語(ASCII)で表示しています。

STEP 01 / 05

集合で地形を調べ、二つの文を結ぶ

座標・変えられない集合(frozenset)・表と裏の関係にあるルール。

使うのは、Python の変数・if・for・関数・tuple と辞書です。最初はプレイヤーを歩かせず、白い調査カーソルで「地形」と「通れるかどうか」を調べる実験室を作ります。盤は左上が (0,0)、右下が (6,4) の 7×5 です。座標は (x,y) の順で、右へ行くと x、下へ行くと y が増えます。Level は地形の記録、frozenset はあとから変えられない集合です。dry は陸、light は軽い石 L、heavy は重い石 H で、どれにも入っていない座標が水です。A では、陸が (0,2) と (6,2)、L が (1,2) から (5,2)、H が (1,1),(2,1),(3,1) にあります。

terrain は「その座標がどの集合に入っているか」を調べます。walkable は「陸か、または今浮いている種類の石か」を調べる、別の関数です。L の石が沈んでも、地形は LIGHT のままで、通れるかどうかだけが変わります。range(1,6) の6はふくまれないので、L の列は1〜5です。画面の S は出発点、G は到着点、L/H は石の種類です。白い枠は調べている場所で、赤いプレイヤーはこの段階にはいません。

State は、floating(今浮いている種類)という1つの値だけを持ちます。L なら軽い石が FLOAT(浮く)で重い石が SINK(沈む)、H ならその逆です。2つの True/False を別々に保存せず、1つの値から rules で両方の文を作るので、「両方浮く」「両方沈む」というありえない状態が生まれません。dataclass(frozen=True) は記録への直接の代入を防ぎ、replace は指定した値だけを変えた、別の記録を返します。

表紙で Space を押して離すと始まります。矢印で枠を1マスずつ動かし、盤の外へは出ずに、はしで止まります。水や沈んだ石の上も調べられます。Space で実験用の floating を L/H に切りかえ、CURRENT(今)と PROBE(調べている場所)を見比べます。この段階だけは、陸でなくても実験の条件を変えられます。歩く人を安全に保つルールは 03 で加えます。手数・入れかえの回数・ゴールの判定はまだありません。

下のコードは 01_terrain.py の一部です。ステップの最後にある全文とあわせて読んでください。

01_terrain.py / 注目する部分
def walkable(level, position, floating):
    return position in level.dry or position in (level.light if floating == 'L' else level.heavy)

実行して確かめる

教材のZIPを展開したフォルダーで、はじめる準備で用意した環境を使って、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 01_terrain.py
macOS / Linux
.venv/bin/python 01_terrain.py
画面で見ること始めた直後は PROBE (0, 2) / DRY / WALKABLE、CURRENT は L FLOAT / H SINK です。右を1回押して離すと、(1,2) の LIGHT / WALKABLE。Space を押して離すと CURRENT は L SINK / H FLOAT になり、同じ LIGHT が BLOCKED(通れない)になります。上を押して離すと、(1,1) の HEAVY / WALKABLE です。
  • 地形の種類と、通れるかどうかを区別する
  • 1つの floating から、FLOAT と SINK を1枚ずつ作る
  • 調査カーソルには、まだプレイヤーを守るルールがない
つまずきやすいところ調査カーソルが水に入れることを、「プレイヤーが動ける」とまちがえないようにしましょう。terrain の LIGHT は石の種類、walkable の True/False は、そのときに通れるかどうかです。floating の文字と、画面の2つの文を、別々に書きかえないでください。
少し変えてみよう

PROBE_START = (0, 2) を PROBE_START = (1, 2) に変えます。UTF-8 で保存 → Esc で終了 → python 01_terrain.py でもう一度実行します。表紙で Space を押して全部離すと、最初から (1,2) の LIGHT / WALKABLE です。もう一度 Space を押して離すと、同じ座標が LIGHT / BLOCKED に変わります。python exercises/01_edit_control.py は、同じ変更を一時的なコピーに当てて、入力と値を確かめるプログラムです。最後に (0, 2) に戻し、保存して終了・もう一度実行してください。R は状態を戻すだけで、コードは読み直しません。

01_terrain.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(337行)
01_terrain.py / 全文
# -*- coding: utf-8 -*-

# LICENSE.txt (unchanged text):
# 022 Word Bridge / ことばの橋
# Original code and pixel shapes: provided for the user's game project.
# Bundled Japanese bitmap glyphs were rendered from Noto Sans CJK Regular.
# Noto font data is licensed under SIL Open Font License 1.1.
# See assets/NOTO-LICENSE.txt for copyright and the complete license.
# Glyph generation: tools/build_font.py. Pillow is a development-only dependency.
# Runtime engine: Pyxel 2.9.9 (MIT license, installed separately by pip).
# No downloaded sprites, music, sound recordings, network services or LLM models.

# DISTRIBUTION_PERMISSION.txt (unchanged text):
# 022 ことばの橋:公開・教材配布の補足
# 記録日:2026年10月6日
# 
# この作品のコードと自作図形、および本講座用の段階教材は、プロジェクト所有者の承認に基づき、
# 「Pyxel入門 Pythonゲームの作り方」で学習・改造用のソース付き教材として配布しています。
# 教材をダウンロードし、内容を読んで学び、ご自身の制作のために変更して試すことができます。
# 
# 原納品物の LICENSE.txt は変更せずに同梱しています。
# この補足は今回の公開・教材配布と学習利用の許諾を記録するものであり、原LICENSEをMIT本文へ
# 置き換えたり、元からMITの記載があったものとして扱ったりするものではありません。
# 本作品と原作の権利表示を、ほかの作品の権利表示で上書きしないでください。
# 
# コード・図形と外部ライブラリー・字形データの条件は区別します。
# ・ゲームの図形は、Pyxelの矩形・線・円・三角形などで描いたものです。
# ・元の完成版の日本語字形はNoto Sans CJK Regular由来です。
#   SIL Open Font License 1.1の原文を assets/NOTO-LICENSE.txt に保持しています。
# ・Pyxelは別途インストールする外部ライブラリーです。Pyxel自身のライセンスが適用されます。
# ・段階教材の画面は標準ASCII描画を使い、Noto字形データの追加導入を必要としません。
# 
# 元コード・図形の提供に関する記載、外部由来の字形の著作権とライセンスは、原本のまま残しています。

"""022 Word Bridge: a standalone, incrementally runnable teaching stage.
Original notice: LICENSE.txt. Teaching distribution: DISTRIBUTION_PERMISSION.txt.
No image/font file is needed. Pyxel is imported only when the window starts.
"""
from dataclasses import dataclass, replace, asdict
from pathlib import Path
import json

STAGE = 1
STAGE_TITLE = 'TERRAIN AND COMPLEMENTARY RULES'
CONTROLS = ['ARROWS PROBE / SPACE LAB ASSIGNMENT', 'R RESET / Q TITLE / ESC QUIT / RELEASE EVERY KEY', 'CURSOR CAN INSPECT WATER; IT DOES NOT MOVE A PLAYER']
KEYS = {'UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'R', 'Q'}

DIRECTIONS = {'UP': (0, -1), 'DOWN': (0, 1), 'LEFT': (-1, 0), 'RIGHT': (1, 0)}
MOVEMENT_PRIORITY = ('UP', 'DOWN', 'LEFT', 'RIGHT')
LIGHT_X = range(1, 6)

@dataclass(frozen=True)
class Level:
    width: int = 7
    height: int = 5
    dry: frozenset = frozenset(((0, 2), (6, 2)))
    light: frozenset = frozenset((x, 2) for x in LIGHT_X)
    heavy: frozenset = frozenset((x, 1) for x in (1, 2, 3))
    start: tuple = (0, 2)
    goal: tuple = (6, 2)

LEVELS = (Level(),)

def opposite(floating):
    return 'H' if floating == 'L' else 'L'

def rules(state):
    # The two complete assignments always contain one FLOAT and one SINK.
    return {'L': 'FLOAT' if state.floating == 'L' else 'SINK',
            'H': 'FLOAT' if state.floating == 'H' else 'SINK'}

def terrain(level, position):
    if position in level.dry:
        return 'DRY'
    if position in level.light:
        return 'LIGHT'
    if position in level.heavy:
        return 'HEAVY'
    return 'WATER'

def walkable(level, position, floating):
    return position in level.dry or position in (level.light if floating == 'L' else level.heavy)

INITIAL_FLOATING = 'L'
PROBE_START = (0, 2)

@dataclass(frozen=True)
class State:
    floating: str = INITIAL_FLOATING

class Game:
    """A terrain laboratory: the cursor is not the player."""
    def __init__(self):
        self.level = 0
        self.title = True
        self.reset(False)

    @property
    def layout(self):
        return LEVELS[self.level]

    def reset(self, gate=True):
        self.state = State()
        self.probe = PROBE_START
        self.gate = gate
        self.event = 'ready'

    def update(self, held, edges):
        if self.gate:
            if not (held | edges) & KEYS:
                self.gate = False
            return
        if self.title:
            if 'SPACE' in edges:
                self.title = False
                self.gate = True
            return
        if 'Q' in edges:
            self.title = True
            self.gate = True
            return
        if 'R' in edges:
            self.reset()
            return
        if 'SPACE' in edges:
            self.state = replace(self.state, floating=opposite(self.state.floating))
            self.event = 'assignment'
            return
        for key in MOVEMENT_PRIORITY:
            if key in edges:
                dx, dy = DIRECTIONS[key]
                x, y = self.probe
                self.probe = (max(0, min(self.layout.width - 1, x + dx)),
                              max(0, min(self.layout.height - 1, y + dy)))
                self.event = 'probe'
                break

class App:
    """Turn sampled keys into new edges; drawing cannot change the model."""
    def __init__(self):
        self.game = Game()
        self.previous = set()

    def feed(self, held):
        held = set(held)
        edges = held - self.previous
        self.previous = held
        self.game.update(held, edges)

    def draw(self, p):
        g = self.game
        level, state = g.layout, g.state
        editing = getattr(g, 'editing', False)
        paused = getattr(g, 'paused', False)
        p.cls(1)
        p.text(16, 12, '022 WORD BRIDGE / STAGE ' + str(STAGE), 10)
        p.text(16, 27, 'LAYOUT ' + 'ABC'[g.level] + ' / ' + STAGE_TITLE, 7)
        p.text(16, 45, 'L = LIGHT / H = HEAVY / S = START / G = GOAL', 6)
        cell, ox, oy = 36, (512 - level.width * 36) // 2, 70
        for y in range(level.height):
            for x in range(level.width):
                pos = (x, y)
                xx, yy = ox + x * cell, oy + y * cell
                p.rectb(xx, yy, cell, cell, 5)
                p.line(xx + 7, yy + 27, xx + 15, yy + 27, 5)
                if pos in level.dry:
                    p.rect(xx + 2, yy + 2, cell - 4, cell - 4, 4)
                    p.rectb(xx + 2, yy + 2, cell - 4, cell - 4, 9)
                    p.text(xx + 4, yy + 5, 'S' if pos == level.start else 'G' if pos == level.goal else 'ISLE', 10)
                elif pos in level.light | level.heavy:
                    category = 'L' if pos in level.light else 'H'
                    active = category == state.floating
                    color = 11 if category == 'L' else 15
                    if active:
                        p.rect(xx + 5, yy + 5, cell - 10, cell - 10, color)
                        p.text(xx + 15, yy + 14, category, 1)
                    else:
                        p.rectb(xx + 6, yy + 11, cell - 12, cell - 15, 5)
                        p.text(xx + 15, yy + 16, category, 6)
                    if editing:
                        if category == g.proposed:
                            for a in range(1, cell - 1, 4):
                                p.pset(xx + a, yy + 1, 10)
                                p.pset(xx + a, yy + cell - 2, 10)
                                p.pset(xx + 1, yy + a, 10)
                                p.pset(xx + cell - 2, yy + a, 10)
                        else:
                            p.text(xx + 26, yy + 3, 'v', 8)
        if STAGE == 1:
            x, y = g.probe
            xx, yy = ox + x * cell, oy + y * cell
            p.rectb(xx, yy, cell, cell, 7)
            p.rectb(xx + 1, yy + 1, cell - 2, cell - 2, 7)
            label = terrain(level, g.probe)
            allowed = walkable(level, g.probe, state.floating)
            p.text(16, 300, 'PROBE ' + str(g.probe) + ' / ' + label + ' / ' + ('WALKABLE' if allowed else 'BLOCKED'), 10)
        else:
            x, y = state.position
            xx, yy = ox + x * cell, oy + y * cell
            p.circ(xx + 18, yy + 17, 7, 8)
            p.pset(xx + 20, yy + 15, 7)
            p.rect(xx + 24, yy + 19, 6, 7, 10)
            p.text(16, 300, 'POS ' + str(state.position) + ' / MOVES ' + str(state.moves) + ' / CHANGES ' + str(state.changes), 7)
            if STAGE == 5:
                p.text(332, 300, 'HISTORY ' + str(len(g.history)), 6)
        current = rules(state)
        p.rectb(12, 319, 488, 62, 5)
        p.text(22, 329, 'CURRENT / L ' + current['L'] + ' / H ' + current['H'], 7)
        if editing:
            proposed = {'L': 'FLOAT' if g.proposed == 'L' else 'SINK',
                        'H': 'FLOAT' if g.proposed == 'H' else 'SINK'}
            p.text(22, 345, 'PROPOSED / L ' + proposed['L'] + ' / H ' + proposed['H'], 10)
            allowed = state.position in level.dry
            p.text(22, 363, 'DRY: CONFIRM ALLOWED' if allowed else 'ON STONE: CONFIRM WILL BE REJECTED', 11 if allowed else 8)
        else:
            p.text(22, 350, 'ONE FLOAT + ONE SINK / BOTH ROWS CHANGE TOGETHER', 6)
            p.text(22, 365, 'NO TIMER / NO LIVES / NO LANGUAGE MODEL', 6)
        status = 'PREVIEW ONLY: DOTS = WILL FLOAT / v = WILL SINK' if editing else EVENTS.get(g.event, g.event.upper())
        p.text(16, 395, status, 8 if g.event in ('bank', 'water', 'sunk') and not editing else 10)
        for i, line in enumerate(CONTROLS):
            p.text(16, 417 + i * 15, line, 7 if i == 0 else 6)
        if getattr(state, 'status', '') == 'success':
            p.rect(90, 130, 332, 74, 1)
            p.rectb(90, 130, 332, 74, 10)
            p.text(160, 148, 'DELIVERY COMPLETE', 10)
            p.text(108, 174, 'N NEXT / Z UNDO / R RETRY' if STAGE == 5 else 'R RETRY / Q TITLE / ESC QUIT', 7)
        if g.title:
            p.rect(40, 82, 432, 200, 1)
            p.rectb(40, 82, 432, 200, 10)
            lines = ['WORD BRIDGE / STAGE ' + str(STAGE), STAGE_TITLE,
                     'ARROWS INSPECT TILES; CURSOR IS NOT A PLAYER' if STAGE == 1 else 'WALK ONLY ON DRY LAND OR FLOATING STONES',
                     'TWO COMPLEMENTARY RULES: ONE FLOAT, ONE SINK',
                     'SPACE START / RELEASE BEFORE THE NEXT ACTION',
                     'LEFT RIGHT: CHOOSE A / B / C' if STAGE == 5 else 'THIS STAGE USES ONLY LAYOUT A',
                     'SAVE CODE, QUIT AND RERUN AFTER AN EDIT',
                     'R RESETS THE STATE; IT DOES NOT RELOAD CODE']
            for i, line in enumerate(lines):
                p.text(57, 102 + i * 22, line, 10 if i in (0, 4) else 7)
        if paused:
            p.rect(68, 144, 376, 75, 1)
            p.rectb(68, 144, 376, 75, 10)
            message = 'PAUSED STATE KEPT / SPACE START, THEN P RESUME' if g.title else 'PAUSED / RELEASE, PRESS P, RELEASE TO RESUME'
            p.text(88, 165, message, 10)
            p.text(88, 190, 'STATE AND HISTORY STAY UNCHANGED', 7)

def tap(app, key):
    app.feed({key})
    app.feed(set())

def snapshot(app):
    g = app.game
    result = {'stage': STAGE, 'level': g.level, 'state': asdict(g.state),
              'title': g.title, 'event': g.event, 'gate': g.gate,
              'scope': 'actual Pyxel Linux headless rendering with synthetic input'}
    for field in ('probe', 'editing', 'proposed', 'paused'):
        if hasattr(g, field):
            result[field] = getattr(g, field)
    if hasattr(g, 'history'):
        result['history'] = [asdict(state) for state in g.history]
    return result

def demo(app):
    tap(app, 'SPACE')
    if STAGE == 1:
        tap(app, 'RIGHT')
    elif STAGE == 2:
        tap(app, 'RIGHT')
        tap(app, 'RIGHT')
    elif STAGE == 3:
        tap(app, 'SPACE')
        tap(app, 'RIGHT')
        tap(app, 'SPACE')
    elif STAGE == 4:
        tap(app, 'SPACE')
    else:
        tap(app, 'SPACE')
        tap(app, 'SPACE')
        tap(app, 'RIGHT')
        tap(app, 'Z')

EVENTS = {'ready': 'READY', 'probe': 'INSPECTION ONLY', 'assignment': 'LAB ASSIGNMENT CHANGED',
          'move': 'MOVED ONE TILE', 'water': 'WATER OR OUTSIDE: STATE UNCHANGED',
          'sunk': 'SUNK STONE: STATE UNCHANGED', 'bank': 'SWAP REJECTED: STAND ON DRY LAND',
          'swap': 'BOTH RULES COMMITTED TOGETHER', 'success': 'DELIVERY COMPLETE',
          'invalid': 'INVALID ACTION OR RULE PAIR', 'terminal': 'ALREADY DELIVERED',
          'cancel': 'PREVIEW CANCELLED: STATE UNCHANGED', 'same': 'SAME ASSIGNMENT: STATE UNCHANGED',
          'undo': 'ONE ACCEPTED ACTION UNDONE'}

def main(headless=False, screenshot=None, state_json=None, checkpoint=False, case=None):
    import pyxel as p
    app = App()
    if case is not None:
        app.game.level = case
        app.game.reset(False)
    if checkpoint:
        demo(app)
    p.init(512, 480, title='022 Word Bridge / Stage ' + str(STAGE), fps=30,
           display_scale=1, headless=headless)
    keymap = {key: getattr(p, 'KEY_' + key) for key in KEYS}
    def update():
        if headless and p.frame_count >= 4:
            app.draw(p)
            if screenshot:
                p.screen.save(screenshot, 1)
            if state_json:
                with Path(state_json).open('x', encoding='utf-8') as stream:
                    json.dump(snapshot(app), stream, ensure_ascii=False, indent=2)
                    stream.write('\n')
            p.quit()
            return
        app.feed(set() if headless else {key for key, code in keymap.items() if p.btn(code)})
    p.run(update, lambda: app.draw(p))

if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description='Standalone Word Bridge teaching stage')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    parser.add_argument('--case', choices=('A', 'B', 'C'))
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot is not None or args.state_json is not None or args.case) and not args.headless_smoke:
        parser.error('diagnostic options require --headless-smoke')
    if args.case and STAGE != 5:
        parser.error('--case is available only in stage 05')
    for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
        value = getattr(args, field)
        if value is not None:
            if not value.strip():
                parser.error('output path must not be empty')
            target = Path(value).absolute()
            if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists() or target.is_symlink():
                parser.error('output must be a new ' + suffix + ' in an existing directory')
            setattr(args, field, str(target))
    main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint,
         'ABC'.index(args.case) if args.case else None)
STEP 02 / 05

通れる一歩だけを、新しい状態にする

書きかえない State・入力から次の状態を計算する関数・受け付けない理由と手数。

使うのは、01 の内容と dataclass、replace、集合を合わせる | です。調査カーソルの代わりに、赤いプレイヤーを置きます。State に position(位置)、moves(手数)、changes(入れかえ回数)、status(状態)を加え、Result は「結果の State」「表示する event」「本当に変わったか(changed)」をまとめます。changes は、あとのステップと画面をそろえるために0で持っていますが、02 にはルールを入れかえる操作はありません。元のゲームの A は最初が H ですが、ここでは移動だけを学べるように、最初を L にしています。

transition は、入力のたびに次の状態を計算する関数です。まず、もう成功していれば terminal として同じ状態を返します。方向がおかしければ invalid です。方向の差を今の位置に足し、行き先を walkable で調べます。沈んだ石なら sunk、集合にない水や盤の外なら water として、同じ状態を返します。通れるときだけ replace で position を変え、moves を1増やします。G の座標に着いた1歩で、status が success になります。

この順番なら、行けない方向を試したときに、位置や手数を元に戻す処理はいりません。最初から書きかえていないからです。「画面の event だけが変わること」と「ゲームの State が変わること」を分けて読みましょう。水にぶつかっても、残機や時間は減らず、ゲームが終わることもありません。荷物の四角は配達の目的を示す絵で、重さや速さの計算には使いません。

App.feed は、今押されているキー held から、前回のキー previous を引いて、新しく押されたキー edges を求めます。押しっぱなしなら次の edges は空なので、何フレーム待っても1歩だけです。矢印を同時に押したときは、上 → 下 → 左 → 右 の順で最初の1つを処理します。始めた直後ややり直した直後は、gate で全部のキーを離すまで待ちます。画面は毎フレーム描き直されますが、描く関数だけでは動きません。

下のコードは 02_movement.py の一部です。ステップの最後にある全文とあわせて読んでください。

02_movement.py / 注目する部分
    if not walkable(level, destination, state.floating):
        event = 'sunk' if destination in level.light | level.heavy else 'water'
        return Result(state, event)

実行して確かめる

教材のZIPを展開したフォルダーで、はじめる準備で用意した環境を使って、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 02_movement.py
macOS / Linux
.venv/bin/python 02_movement.py
画面で見ることSpace で始めて全部離し、右を1回ずつ押して離す操作を6回すると、POS (6, 2)、MOVES 6、CHANGES 0、DELIVERY COMPLETE(配達完了)です。途中の (1,2) で上を押すと、H の石は沈んでいるので SUNK STONE と出ますが、位置と MOVES は変わりません。
  • 通れるかの確認を通った1歩だけ、moves を増やす
  • water と sunk、受け付けない理由を区別する
  • 押しっぱなしでは1歩しか進まない
つまずきやすいところ判定の前に position や moves を書きかえないようにしましょう。画面の理由の表示が変わっても、State まで変わったとは限りません。Space は表紙で始めるときだけ使い、02 の川ではルールを入れかえません。
少し変えてみよう

LIGHT_X = range(1, 6) を LIGHT_X = (1, 2, 4, 5) に変えて、(3,2) の石を1つ取り除きます。UTF-8 で保存 → Esc で終了 → python 02_movement.py でもう一度実行します。Space を押して離し、右を2回、毎回全部離すと POS (2, 2) / MOVES 2 です。さらに右を押して離すと WATER OR OUTSIDE と表示され、POS も MOVES も変わりません。「軽い石なのに沈んだ」のではなく、light から外れて水になったのだと説明してみましょう。python exercises/02_edit_control.py で同じ変更と入力を確かめたら、range(1, 6) に戻して、保存・終了・もう一度実行します。R だけでは、コードの変更は反映されません。

02_movement.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(365行)
02_movement.py / 全文
# -*- coding: utf-8 -*-

# LICENSE.txt (unchanged text):
# 022 Word Bridge / ことばの橋
# Original code and pixel shapes: provided for the user's game project.
# Bundled Japanese bitmap glyphs were rendered from Noto Sans CJK Regular.
# Noto font data is licensed under SIL Open Font License 1.1.
# See assets/NOTO-LICENSE.txt for copyright and the complete license.
# Glyph generation: tools/build_font.py. Pillow is a development-only dependency.
# Runtime engine: Pyxel 2.9.9 (MIT license, installed separately by pip).
# No downloaded sprites, music, sound recordings, network services or LLM models.

# DISTRIBUTION_PERMISSION.txt (unchanged text):
# 022 ことばの橋:公開・教材配布の補足
# 記録日:2026年10月6日
# 
# この作品のコードと自作図形、および本講座用の段階教材は、プロジェクト所有者の承認に基づき、
# 「Pyxel入門 Pythonゲームの作り方」で学習・改造用のソース付き教材として配布しています。
# 教材をダウンロードし、内容を読んで学び、ご自身の制作のために変更して試すことができます。
# 
# 原納品物の LICENSE.txt は変更せずに同梱しています。
# この補足は今回の公開・教材配布と学習利用の許諾を記録するものであり、原LICENSEをMIT本文へ
# 置き換えたり、元からMITの記載があったものとして扱ったりするものではありません。
# 本作品と原作の権利表示を、ほかの作品の権利表示で上書きしないでください。
# 
# コード・図形と外部ライブラリー・字形データの条件は区別します。
# ・ゲームの図形は、Pyxelの矩形・線・円・三角形などで描いたものです。
# ・元の完成版の日本語字形はNoto Sans CJK Regular由来です。
#   SIL Open Font License 1.1の原文を assets/NOTO-LICENSE.txt に保持しています。
# ・Pyxelは別途インストールする外部ライブラリーです。Pyxel自身のライセンスが適用されます。
# ・段階教材の画面は標準ASCII描画を使い、Noto字形データの追加導入を必要としません。
# 
# 元コード・図形の提供に関する記載、外部由来の字形の著作権とライセンスは、原本のまま残しています。

"""022 Word Bridge: a standalone, incrementally runnable teaching stage.
Original notice: LICENSE.txt. Teaching distribution: DISTRIBUTION_PERMISSION.txt.
No image/font file is needed. Pyxel is imported only when the window starts.
"""
from dataclasses import dataclass, replace, asdict
from pathlib import Path
import json

STAGE = 2
STAGE_TITLE = 'SAFE GRID MOVEMENT'
CONTROLS = ['ARROWS MOVE ONE TILE / NO RULE SWAP IN STAGE 02', 'R RESET / Q TITLE / ESC QUIT / RELEASE EVERY KEY', 'STARTS WITH LIGHT FLOATING AS A MOVEMENT EXERCISE']
KEYS = {'UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'R', 'Q'}

DIRECTIONS = {'UP': (0, -1), 'DOWN': (0, 1), 'LEFT': (-1, 0), 'RIGHT': (1, 0)}
MOVEMENT_PRIORITY = ('UP', 'DOWN', 'LEFT', 'RIGHT')
LIGHT_X = range(1, 6)

@dataclass(frozen=True)
class Level:
    width: int = 7
    height: int = 5
    dry: frozenset = frozenset(((0, 2), (6, 2)))
    light: frozenset = frozenset((x, 2) for x in LIGHT_X)
    heavy: frozenset = frozenset((x, 1) for x in (1, 2, 3))
    start: tuple = (0, 2)
    goal: tuple = (6, 2)

LEVELS = (Level(),)

def opposite(floating):
    return 'H' if floating == 'L' else 'L'

def rules(state):
    # The two complete assignments always contain one FLOAT and one SINK.
    return {'L': 'FLOAT' if state.floating == 'L' else 'SINK',
            'H': 'FLOAT' if state.floating == 'H' else 'SINK'}

def terrain(level, position):
    if position in level.dry:
        return 'DRY'
    if position in level.light:
        return 'LIGHT'
    if position in level.heavy:
        return 'HEAVY'
    return 'WATER'

def walkable(level, position, floating):
    return position in level.dry or position in (level.light if floating == 'L' else level.heavy)

INITIAL_FLOATING = 'L'

@dataclass(frozen=True)
class State:
    position: tuple
    floating: str
    moves: int = 0
    changes: int = 0
    status: str = 'playing'

@dataclass(frozen=True)
class Result:
    state: State
    event: str
    changed: bool = False

def initial(level):
    return State(level.start, INITIAL_FLOATING)

def transition(level, state, action):
    if state.status == 'success':
        return Result(state, 'terminal')
    if action not in DIRECTIONS:
        return Result(state, 'invalid')
    dx, dy = DIRECTIONS[action]
    destination = (state.position[0] + dx, state.position[1] + dy)
    if not walkable(level, destination, state.floating):
        event = 'sunk' if destination in level.light | level.heavy else 'water'
        return Result(state, event)
    end = destination == level.goal
    return Result(replace(state, position=destination, moves=state.moves + 1,
                          status='success' if end else 'playing'),
                  'success' if end else 'move', True)

class Game:
    def __init__(self):
        self.level = 0
        self.title = True
        self.reset(False)

    @property
    def layout(self):
        return LEVELS[self.level]

    def reset(self, gate=True):
        self.state = initial(self.layout)
        self.gate = gate
        self.event = 'ready'

    def act(self, action):
        result = transition(self.layout, self.state, action)
        self.event = result.event
        self.state = result.state
        if self.state.status == 'success':
            self.gate = True
        return result

    def update(self, held, edges):
        # Mode changes require an entirely neutral frame, including new edges.
        if self.gate:
            if not (held | edges) & KEYS:
                self.gate = False
            return
        if self.title:
            if 'SPACE' in edges:
                self.title = False
                self.gate = True
            return
        if 'Q' in edges:
            self.title = True
            self.gate = True
            return
        if 'R' in edges:
            self.reset()
            return
        if self.state.status == 'success':
            return
        for key in MOVEMENT_PRIORITY:
            if key in edges:
                self.act(key)
                break

class App:
    """Turn sampled keys into new edges; drawing cannot change the model."""
    def __init__(self):
        self.game = Game()
        self.previous = set()

    def feed(self, held):
        held = set(held)
        edges = held - self.previous
        self.previous = held
        self.game.update(held, edges)

    def draw(self, p):
        g = self.game
        level, state = g.layout, g.state
        editing = getattr(g, 'editing', False)
        paused = getattr(g, 'paused', False)
        p.cls(1)
        p.text(16, 12, '022 WORD BRIDGE / STAGE ' + str(STAGE), 10)
        p.text(16, 27, 'LAYOUT ' + 'ABC'[g.level] + ' / ' + STAGE_TITLE, 7)
        p.text(16, 45, 'L = LIGHT / H = HEAVY / S = START / G = GOAL', 6)
        cell, ox, oy = 36, (512 - level.width * 36) // 2, 70
        for y in range(level.height):
            for x in range(level.width):
                pos = (x, y)
                xx, yy = ox + x * cell, oy + y * cell
                p.rectb(xx, yy, cell, cell, 5)
                p.line(xx + 7, yy + 27, xx + 15, yy + 27, 5)
                if pos in level.dry:
                    p.rect(xx + 2, yy + 2, cell - 4, cell - 4, 4)
                    p.rectb(xx + 2, yy + 2, cell - 4, cell - 4, 9)
                    p.text(xx + 4, yy + 5, 'S' if pos == level.start else 'G' if pos == level.goal else 'ISLE', 10)
                elif pos in level.light | level.heavy:
                    category = 'L' if pos in level.light else 'H'
                    active = category == state.floating
                    color = 11 if category == 'L' else 15
                    if active:
                        p.rect(xx + 5, yy + 5, cell - 10, cell - 10, color)
                        p.text(xx + 15, yy + 14, category, 1)
                    else:
                        p.rectb(xx + 6, yy + 11, cell - 12, cell - 15, 5)
                        p.text(xx + 15, yy + 16, category, 6)
                    if editing:
                        if category == g.proposed:
                            for a in range(1, cell - 1, 4):
                                p.pset(xx + a, yy + 1, 10)
                                p.pset(xx + a, yy + cell - 2, 10)
                                p.pset(xx + 1, yy + a, 10)
                                p.pset(xx + cell - 2, yy + a, 10)
                        else:
                            p.text(xx + 26, yy + 3, 'v', 8)
        if STAGE == 1:
            x, y = g.probe
            xx, yy = ox + x * cell, oy + y * cell
            p.rectb(xx, yy, cell, cell, 7)
            p.rectb(xx + 1, yy + 1, cell - 2, cell - 2, 7)
            label = terrain(level, g.probe)
            allowed = walkable(level, g.probe, state.floating)
            p.text(16, 300, 'PROBE ' + str(g.probe) + ' / ' + label + ' / ' + ('WALKABLE' if allowed else 'BLOCKED'), 10)
        else:
            x, y = state.position
            xx, yy = ox + x * cell, oy + y * cell
            p.circ(xx + 18, yy + 17, 7, 8)
            p.pset(xx + 20, yy + 15, 7)
            p.rect(xx + 24, yy + 19, 6, 7, 10)
            p.text(16, 300, 'POS ' + str(state.position) + ' / MOVES ' + str(state.moves) + ' / CHANGES ' + str(state.changes), 7)
            if STAGE == 5:
                p.text(332, 300, 'HISTORY ' + str(len(g.history)), 6)
        current = rules(state)
        p.rectb(12, 319, 488, 62, 5)
        p.text(22, 329, 'CURRENT / L ' + current['L'] + ' / H ' + current['H'], 7)
        if editing:
            proposed = {'L': 'FLOAT' if g.proposed == 'L' else 'SINK',
                        'H': 'FLOAT' if g.proposed == 'H' else 'SINK'}
            p.text(22, 345, 'PROPOSED / L ' + proposed['L'] + ' / H ' + proposed['H'], 10)
            allowed = state.position in level.dry
            p.text(22, 363, 'DRY: CONFIRM ALLOWED' if allowed else 'ON STONE: CONFIRM WILL BE REJECTED', 11 if allowed else 8)
        else:
            p.text(22, 350, 'ONE FLOAT + ONE SINK / BOTH ROWS CHANGE TOGETHER', 6)
            p.text(22, 365, 'NO TIMER / NO LIVES / NO LANGUAGE MODEL', 6)
        status = 'PREVIEW ONLY: DOTS = WILL FLOAT / v = WILL SINK' if editing else EVENTS.get(g.event, g.event.upper())
        p.text(16, 395, status, 8 if g.event in ('bank', 'water', 'sunk') and not editing else 10)
        for i, line in enumerate(CONTROLS):
            p.text(16, 417 + i * 15, line, 7 if i == 0 else 6)
        if getattr(state, 'status', '') == 'success':
            p.rect(90, 130, 332, 74, 1)
            p.rectb(90, 130, 332, 74, 10)
            p.text(160, 148, 'DELIVERY COMPLETE', 10)
            p.text(108, 174, 'N NEXT / Z UNDO / R RETRY' if STAGE == 5 else 'R RETRY / Q TITLE / ESC QUIT', 7)
        if g.title:
            p.rect(40, 82, 432, 200, 1)
            p.rectb(40, 82, 432, 200, 10)
            lines = ['WORD BRIDGE / STAGE ' + str(STAGE), STAGE_TITLE,
                     'ARROWS INSPECT TILES; CURSOR IS NOT A PLAYER' if STAGE == 1 else 'WALK ONLY ON DRY LAND OR FLOATING STONES',
                     'TWO COMPLEMENTARY RULES: ONE FLOAT, ONE SINK',
                     'SPACE START / RELEASE BEFORE THE NEXT ACTION',
                     'LEFT RIGHT: CHOOSE A / B / C' if STAGE == 5 else 'THIS STAGE USES ONLY LAYOUT A',
                     'SAVE CODE, QUIT AND RERUN AFTER AN EDIT',
                     'R RESETS THE STATE; IT DOES NOT RELOAD CODE']
            for i, line in enumerate(lines):
                p.text(57, 102 + i * 22, line, 10 if i in (0, 4) else 7)
        if paused:
            p.rect(68, 144, 376, 75, 1)
            p.rectb(68, 144, 376, 75, 10)
            message = 'PAUSED STATE KEPT / SPACE START, THEN P RESUME' if g.title else 'PAUSED / RELEASE, PRESS P, RELEASE TO RESUME'
            p.text(88, 165, message, 10)
            p.text(88, 190, 'STATE AND HISTORY STAY UNCHANGED', 7)

def tap(app, key):
    app.feed({key})
    app.feed(set())

def snapshot(app):
    g = app.game
    result = {'stage': STAGE, 'level': g.level, 'state': asdict(g.state),
              'title': g.title, 'event': g.event, 'gate': g.gate,
              'scope': 'actual Pyxel Linux headless rendering with synthetic input'}
    for field in ('probe', 'editing', 'proposed', 'paused'):
        if hasattr(g, field):
            result[field] = getattr(g, field)
    if hasattr(g, 'history'):
        result['history'] = [asdict(state) for state in g.history]
    return result

def demo(app):
    tap(app, 'SPACE')
    if STAGE == 1:
        tap(app, 'RIGHT')
    elif STAGE == 2:
        tap(app, 'RIGHT')
        tap(app, 'RIGHT')
    elif STAGE == 3:
        tap(app, 'SPACE')
        tap(app, 'RIGHT')
        tap(app, 'SPACE')
    elif STAGE == 4:
        tap(app, 'SPACE')
    else:
        tap(app, 'SPACE')
        tap(app, 'SPACE')
        tap(app, 'RIGHT')
        tap(app, 'Z')

EVENTS = {'ready': 'READY', 'probe': 'INSPECTION ONLY', 'assignment': 'LAB ASSIGNMENT CHANGED',
          'move': 'MOVED ONE TILE', 'water': 'WATER OR OUTSIDE: STATE UNCHANGED',
          'sunk': 'SUNK STONE: STATE UNCHANGED', 'bank': 'SWAP REJECTED: STAND ON DRY LAND',
          'swap': 'BOTH RULES COMMITTED TOGETHER', 'success': 'DELIVERY COMPLETE',
          'invalid': 'INVALID ACTION OR RULE PAIR', 'terminal': 'ALREADY DELIVERED',
          'cancel': 'PREVIEW CANCELLED: STATE UNCHANGED', 'same': 'SAME ASSIGNMENT: STATE UNCHANGED',
          'undo': 'ONE ACCEPTED ACTION UNDONE'}

def main(headless=False, screenshot=None, state_json=None, checkpoint=False, case=None):
    import pyxel as p
    app = App()
    if case is not None:
        app.game.level = case
        app.game.reset(False)
    if checkpoint:
        demo(app)
    p.init(512, 480, title='022 Word Bridge / Stage ' + str(STAGE), fps=30,
           display_scale=1, headless=headless)
    keymap = {key: getattr(p, 'KEY_' + key) for key in KEYS}
    def update():
        if headless and p.frame_count >= 4:
            app.draw(p)
            if screenshot:
                p.screen.save(screenshot, 1)
            if state_json:
                with Path(state_json).open('x', encoding='utf-8') as stream:
                    json.dump(snapshot(app), stream, ensure_ascii=False, indent=2)
                    stream.write('\n')
            p.quit()
            return
        app.feed(set() if headless else {key for key, code in keymap.items() if p.btn(code)})
    p.run(update, lambda: app.draw(p))

if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description='Standalone Word Bridge teaching stage')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    parser.add_argument('--case', choices=('A', 'B', 'C'))
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot is not None or args.state_json is not None or args.case) and not args.headless_smoke:
        parser.error('diagnostic options require --headless-smoke')
    if args.case and STAGE != 5:
        parser.error('--case is available only in stage 05')
    for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
        value = getattr(args, field)
        if value is not None:
            if not value.strip():
                parser.error('output path must not be empty')
            target = Path(value).absolute()
            if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists() or target.is_symlink():
                parser.error('output must be a new ' + suffix + ' in an existing directory')
            setattr(args, field, str(target))
    main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint,
         'ABC'.index(args.case) if args.case else None)
STEP 03 / 05

陸の上で、二行をまとめて交換する

まとめて一度に変える更新・いつも守るルール・2枚の札のチェック。

使うのは、02 の内容と、「1回の確定で、関係する値をまとめて更新する」という考え方です。最初の floating を元のゲームの A と同じ H に戻し、Space ですぐに SWAP(入れかえ)する操作を足します。始めた直後は、右どなりの L が沈んでいるので、そのままでは渡れません。出発点の陸で Space を1回押して離すと、L が浮いて H が沈みます。この段階には、予告・取り消し・Undo はまだありません。

transition は SWAP を受け取ると、まず今の位置が dry(陸)にあるかを調べます。石の上なら bank という理由で、同じ State を返します。陸なら floating を反対にして、changes を1増やした新しい State を返します。position と moves はそのままです。川全体の L/H は1つの floating を見ているので、同じ種類の石がいっせいに変わります。石を1つずつ変えていく途中の状態や、片方の文だけが書きかわった瞬間はありません。これが、ここでの「まとめて一度に変える更新」です。

陸はどちらのルールでも通れるので、陸の上から入れかえれば、入れかえたあとも足元は安全です。反対に、今乗っている浮いた石を沈めてしまう入れかえは受け付けません。石の上で押しても、changes・moves・position・floating は何も変えずに、SWAP REJECTED(入れかえ不可)と表示します。最初の岸だけでなく、dry に登録された途中の島でも入れかえられます。島がいくつもある配置は 05 で出てきます。

apply_rules は、2行の札を直接わたすための関数です。受け付ける組は (FLOAT,SINK) と (SINK,FLOAT) だけで、(FLOAT,FLOAT) などは invalid です。今と同じ組なら same で何もしません。ちがう組なら transition の SWAP に渡し、陸のチェックも同じものを使います。今と同じ札を確かめるだけなら石の上でも same ですが、反対にする場合は bank です。画面の Space はいつも反対への入れかえを求めるもので、この関数に好きな文字を入力する画面はありません。言葉を読み取る AI ではなく、2通りの決まったルールを扱っています。

下のコードは 03_atomic_swap.py の一部です。ステップの最後にある全文とあわせて読んでください。

03_atomic_swap.py / 注目する部分
    if action == 'SWAP':
        if state.position not in level.dry:
            return Result(state, 'bank')
        return Result(replace(state, floating=opposite(state.floating),
                              changes=state.changes + 1), 'swap', True)

実行して確かめる

教材のZIPを展開したフォルダーで、はじめる準備で用意した環境を使って、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_atomic_swap.py
macOS / Linux
.venv/bin/python 03_atomic_swap.py
画面で見ることSpace で始めて離します。右を押して離しても、最初は H なので POS (0, 2)、MOVES 0、CHANGES 0 のままです。Space を押して離すと CURRENT は L FLOAT / H SINK、CHANGES 1 になります。右で (1,2) に進み MOVES 1。そこで Space を押して離すと bank で受け付けられず、(1,2)・L・MOVES 1・CHANGES 1 のままです。右をさらに5回、毎回離せばゴールです。
  • 陸かどうかを調べてから、floating と changes を更新する
  • 1つの floating で、川全体をいっせいに切りかえる
  • おかしな札・同じ札・石の上での入れかえを区別する
つまずきやすいところ2行のルールを別々の変数で更新すると、片方しか変わっていない状態が入りこんでしまいます。また、受け付けないと分かってから changes を引き戻すのではなく、先に陸かどうかを調べてください。03 の Space はすぐ確定するので、04 の「2回押し」とまちがえないようにしましょう。
少し変えてみよう

INITIAL_FLOATING = 'H' を INITIAL_FLOATING = 'L' に変えます。UTF-8 で保存 → Esc で終了 → python 03_atomic_swap.py でもう一度実行します。Space で始めて離し、右を押して離すと、入れかえずに (1,2)、MOVES 1、CHANGES 0 です。そこで Space を押して離すと bank で受け付けられず、値は変わりません。左を押して離して (0,2) に戻り、Space を押して離すと H に入れかわり、MOVES 2、CHANGES 1 です。python exercises/03_edit_control.py で同じ入力を確かめたら、'H' に戻して、保存・終了・もう一度実行します。R ではコードは読み直されません。

03_atomic_swap.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(381行)
03_atomic_swap.py / 全文
# -*- coding: utf-8 -*-

# LICENSE.txt (unchanged text):
# 022 Word Bridge / ことばの橋
# Original code and pixel shapes: provided for the user's game project.
# Bundled Japanese bitmap glyphs were rendered from Noto Sans CJK Regular.
# Noto font data is licensed under SIL Open Font License 1.1.
# See assets/NOTO-LICENSE.txt for copyright and the complete license.
# Glyph generation: tools/build_font.py. Pillow is a development-only dependency.
# Runtime engine: Pyxel 2.9.9 (MIT license, installed separately by pip).
# No downloaded sprites, music, sound recordings, network services or LLM models.

# DISTRIBUTION_PERMISSION.txt (unchanged text):
# 022 ことばの橋:公開・教材配布の補足
# 記録日:2026年10月6日
# 
# この作品のコードと自作図形、および本講座用の段階教材は、プロジェクト所有者の承認に基づき、
# 「Pyxel入門 Pythonゲームの作り方」で学習・改造用のソース付き教材として配布しています。
# 教材をダウンロードし、内容を読んで学び、ご自身の制作のために変更して試すことができます。
# 
# 原納品物の LICENSE.txt は変更せずに同梱しています。
# この補足は今回の公開・教材配布と学習利用の許諾を記録するものであり、原LICENSEをMIT本文へ
# 置き換えたり、元からMITの記載があったものとして扱ったりするものではありません。
# 本作品と原作の権利表示を、ほかの作品の権利表示で上書きしないでください。
# 
# コード・図形と外部ライブラリー・字形データの条件は区別します。
# ・ゲームの図形は、Pyxelの矩形・線・円・三角形などで描いたものです。
# ・元の完成版の日本語字形はNoto Sans CJK Regular由来です。
#   SIL Open Font License 1.1の原文を assets/NOTO-LICENSE.txt に保持しています。
# ・Pyxelは別途インストールする外部ライブラリーです。Pyxel自身のライセンスが適用されます。
# ・段階教材の画面は標準ASCII描画を使い、Noto字形データの追加導入を必要としません。
# 
# 元コード・図形の提供に関する記載、外部由来の字形の著作権とライセンスは、原本のまま残しています。

"""022 Word Bridge: a standalone, incrementally runnable teaching stage.
Original notice: LICENSE.txt. Teaching distribution: DISTRIBUTION_PERMISSION.txt.
No image/font file is needed. Pyxel is imported only when the window starts.
"""
from dataclasses import dataclass, replace, asdict
from pathlib import Path
import json

STAGE = 3
STAGE_TITLE = 'ATOMIC SWAP ON DRY LAND'
CONTROLS = ['ARROWS MOVE / SPACE SWAP IMMEDIATELY ON DRY LAND', 'R RESET / Q TITLE / ESC QUIT / RELEASE EVERY KEY', 'ON A STONE, SPACE IS REJECTED WITH NO PENALTY']
KEYS = {'UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'R', 'Q'}

DIRECTIONS = {'UP': (0, -1), 'DOWN': (0, 1), 'LEFT': (-1, 0), 'RIGHT': (1, 0)}
MOVEMENT_PRIORITY = ('UP', 'DOWN', 'LEFT', 'RIGHT')
LIGHT_X = range(1, 6)

@dataclass(frozen=True)
class Level:
    width: int = 7
    height: int = 5
    dry: frozenset = frozenset(((0, 2), (6, 2)))
    light: frozenset = frozenset((x, 2) for x in LIGHT_X)
    heavy: frozenset = frozenset((x, 1) for x in (1, 2, 3))
    start: tuple = (0, 2)
    goal: tuple = (6, 2)

LEVELS = (Level(),)

def opposite(floating):
    return 'H' if floating == 'L' else 'L'

def rules(state):
    # The two complete assignments always contain one FLOAT and one SINK.
    return {'L': 'FLOAT' if state.floating == 'L' else 'SINK',
            'H': 'FLOAT' if state.floating == 'H' else 'SINK'}

def terrain(level, position):
    if position in level.dry:
        return 'DRY'
    if position in level.light:
        return 'LIGHT'
    if position in level.heavy:
        return 'HEAVY'
    return 'WATER'

def walkable(level, position, floating):
    return position in level.dry or position in (level.light if floating == 'L' else level.heavy)

INITIAL_FLOATING = 'H'

@dataclass(frozen=True)
class State:
    position: tuple
    floating: str
    moves: int = 0
    changes: int = 0
    status: str = 'playing'

@dataclass(frozen=True)
class Result:
    state: State
    event: str
    changed: bool = False

def initial(level):
    return State(level.start, INITIAL_FLOATING)

def transition(level, state, action):
    if state.status == 'success':
        return Result(state, 'terminal')
    if action == 'SWAP':
        if state.position not in level.dry:
            return Result(state, 'bank')
        return Result(replace(state, floating=opposite(state.floating),
                              changes=state.changes + 1), 'swap', True)
    if action not in DIRECTIONS:
        return Result(state, 'invalid')
    dx, dy = DIRECTIONS[action]
    destination = (state.position[0] + dx, state.position[1] + dy)
    if not walkable(level, destination, state.floating):
        event = 'sunk' if destination in level.light | level.heavy else 'water'
        return Result(state, event)
    end = destination == level.goal
    return Result(replace(state, position=destination, moves=state.moves + 1,
                          status='success' if end else 'playing'),
                  'success' if end else 'move', True)

def apply_rules(level, state, light, heavy):
    if (light, heavy) not in (('FLOAT', 'SINK'), ('SINK', 'FLOAT')):
        return Result(state, 'invalid')
    target = 'L' if light == 'FLOAT' else 'H'
    if target == state.floating:
        return Result(state, 'same')
    return transition(level, state, 'SWAP')

class Game:
    def __init__(self):
        self.level = 0
        self.title = True
        self.reset(False)

    @property
    def layout(self):
        return LEVELS[self.level]

    def reset(self, gate=True):
        self.state = initial(self.layout)
        self.gate = gate
        self.event = 'ready'

    def act(self, action):
        result = transition(self.layout, self.state, action)
        self.event = result.event
        self.state = result.state
        if self.state.status == 'success':
            self.gate = True
        return result

    def update(self, held, edges):
        # Mode changes require an entirely neutral frame, including new edges.
        if self.gate:
            if not (held | edges) & KEYS:
                self.gate = False
            return
        if self.title:
            if 'SPACE' in edges:
                self.title = False
                self.gate = True
            return
        if 'Q' in edges:
            self.title = True
            self.gate = True
            return
        if 'R' in edges:
            self.reset()
            return
        if self.state.status == 'success':
            return
        if 'SPACE' in edges:
            self.act('SWAP')
            return
        for key in MOVEMENT_PRIORITY:
            if key in edges:
                self.act(key)
                break

class App:
    """Turn sampled keys into new edges; drawing cannot change the model."""
    def __init__(self):
        self.game = Game()
        self.previous = set()

    def feed(self, held):
        held = set(held)
        edges = held - self.previous
        self.previous = held
        self.game.update(held, edges)

    def draw(self, p):
        g = self.game
        level, state = g.layout, g.state
        editing = getattr(g, 'editing', False)
        paused = getattr(g, 'paused', False)
        p.cls(1)
        p.text(16, 12, '022 WORD BRIDGE / STAGE ' + str(STAGE), 10)
        p.text(16, 27, 'LAYOUT ' + 'ABC'[g.level] + ' / ' + STAGE_TITLE, 7)
        p.text(16, 45, 'L = LIGHT / H = HEAVY / S = START / G = GOAL', 6)
        cell, ox, oy = 36, (512 - level.width * 36) // 2, 70
        for y in range(level.height):
            for x in range(level.width):
                pos = (x, y)
                xx, yy = ox + x * cell, oy + y * cell
                p.rectb(xx, yy, cell, cell, 5)
                p.line(xx + 7, yy + 27, xx + 15, yy + 27, 5)
                if pos in level.dry:
                    p.rect(xx + 2, yy + 2, cell - 4, cell - 4, 4)
                    p.rectb(xx + 2, yy + 2, cell - 4, cell - 4, 9)
                    p.text(xx + 4, yy + 5, 'S' if pos == level.start else 'G' if pos == level.goal else 'ISLE', 10)
                elif pos in level.light | level.heavy:
                    category = 'L' if pos in level.light else 'H'
                    active = category == state.floating
                    color = 11 if category == 'L' else 15
                    if active:
                        p.rect(xx + 5, yy + 5, cell - 10, cell - 10, color)
                        p.text(xx + 15, yy + 14, category, 1)
                    else:
                        p.rectb(xx + 6, yy + 11, cell - 12, cell - 15, 5)
                        p.text(xx + 15, yy + 16, category, 6)
                    if editing:
                        if category == g.proposed:
                            for a in range(1, cell - 1, 4):
                                p.pset(xx + a, yy + 1, 10)
                                p.pset(xx + a, yy + cell - 2, 10)
                                p.pset(xx + 1, yy + a, 10)
                                p.pset(xx + cell - 2, yy + a, 10)
                        else:
                            p.text(xx + 26, yy + 3, 'v', 8)
        if STAGE == 1:
            x, y = g.probe
            xx, yy = ox + x * cell, oy + y * cell
            p.rectb(xx, yy, cell, cell, 7)
            p.rectb(xx + 1, yy + 1, cell - 2, cell - 2, 7)
            label = terrain(level, g.probe)
            allowed = walkable(level, g.probe, state.floating)
            p.text(16, 300, 'PROBE ' + str(g.probe) + ' / ' + label + ' / ' + ('WALKABLE' if allowed else 'BLOCKED'), 10)
        else:
            x, y = state.position
            xx, yy = ox + x * cell, oy + y * cell
            p.circ(xx + 18, yy + 17, 7, 8)
            p.pset(xx + 20, yy + 15, 7)
            p.rect(xx + 24, yy + 19, 6, 7, 10)
            p.text(16, 300, 'POS ' + str(state.position) + ' / MOVES ' + str(state.moves) + ' / CHANGES ' + str(state.changes), 7)
            if STAGE == 5:
                p.text(332, 300, 'HISTORY ' + str(len(g.history)), 6)
        current = rules(state)
        p.rectb(12, 319, 488, 62, 5)
        p.text(22, 329, 'CURRENT / L ' + current['L'] + ' / H ' + current['H'], 7)
        if editing:
            proposed = {'L': 'FLOAT' if g.proposed == 'L' else 'SINK',
                        'H': 'FLOAT' if g.proposed == 'H' else 'SINK'}
            p.text(22, 345, 'PROPOSED / L ' + proposed['L'] + ' / H ' + proposed['H'], 10)
            allowed = state.position in level.dry
            p.text(22, 363, 'DRY: CONFIRM ALLOWED' if allowed else 'ON STONE: CONFIRM WILL BE REJECTED', 11 if allowed else 8)
        else:
            p.text(22, 350, 'ONE FLOAT + ONE SINK / BOTH ROWS CHANGE TOGETHER', 6)
            p.text(22, 365, 'NO TIMER / NO LIVES / NO LANGUAGE MODEL', 6)
        status = 'PREVIEW ONLY: DOTS = WILL FLOAT / v = WILL SINK' if editing else EVENTS.get(g.event, g.event.upper())
        p.text(16, 395, status, 8 if g.event in ('bank', 'water', 'sunk') and not editing else 10)
        for i, line in enumerate(CONTROLS):
            p.text(16, 417 + i * 15, line, 7 if i == 0 else 6)
        if getattr(state, 'status', '') == 'success':
            p.rect(90, 130, 332, 74, 1)
            p.rectb(90, 130, 332, 74, 10)
            p.text(160, 148, 'DELIVERY COMPLETE', 10)
            p.text(108, 174, 'N NEXT / Z UNDO / R RETRY' if STAGE == 5 else 'R RETRY / Q TITLE / ESC QUIT', 7)
        if g.title:
            p.rect(40, 82, 432, 200, 1)
            p.rectb(40, 82, 432, 200, 10)
            lines = ['WORD BRIDGE / STAGE ' + str(STAGE), STAGE_TITLE,
                     'ARROWS INSPECT TILES; CURSOR IS NOT A PLAYER' if STAGE == 1 else 'WALK ONLY ON DRY LAND OR FLOATING STONES',
                     'TWO COMPLEMENTARY RULES: ONE FLOAT, ONE SINK',
                     'SPACE START / RELEASE BEFORE THE NEXT ACTION',
                     'LEFT RIGHT: CHOOSE A / B / C' if STAGE == 5 else 'THIS STAGE USES ONLY LAYOUT A',
                     'SAVE CODE, QUIT AND RERUN AFTER AN EDIT',
                     'R RESETS THE STATE; IT DOES NOT RELOAD CODE']
            for i, line in enumerate(lines):
                p.text(57, 102 + i * 22, line, 10 if i in (0, 4) else 7)
        if paused:
            p.rect(68, 144, 376, 75, 1)
            p.rectb(68, 144, 376, 75, 10)
            message = 'PAUSED STATE KEPT / SPACE START, THEN P RESUME' if g.title else 'PAUSED / RELEASE, PRESS P, RELEASE TO RESUME'
            p.text(88, 165, message, 10)
            p.text(88, 190, 'STATE AND HISTORY STAY UNCHANGED', 7)

def tap(app, key):
    app.feed({key})
    app.feed(set())

def snapshot(app):
    g = app.game
    result = {'stage': STAGE, 'level': g.level, 'state': asdict(g.state),
              'title': g.title, 'event': g.event, 'gate': g.gate,
              'scope': 'actual Pyxel Linux headless rendering with synthetic input'}
    for field in ('probe', 'editing', 'proposed', 'paused'):
        if hasattr(g, field):
            result[field] = getattr(g, field)
    if hasattr(g, 'history'):
        result['history'] = [asdict(state) for state in g.history]
    return result

def demo(app):
    tap(app, 'SPACE')
    if STAGE == 1:
        tap(app, 'RIGHT')
    elif STAGE == 2:
        tap(app, 'RIGHT')
        tap(app, 'RIGHT')
    elif STAGE == 3:
        tap(app, 'SPACE')
        tap(app, 'RIGHT')
        tap(app, 'SPACE')
    elif STAGE == 4:
        tap(app, 'SPACE')
    else:
        tap(app, 'SPACE')
        tap(app, 'SPACE')
        tap(app, 'RIGHT')
        tap(app, 'Z')

EVENTS = {'ready': 'READY', 'probe': 'INSPECTION ONLY', 'assignment': 'LAB ASSIGNMENT CHANGED',
          'move': 'MOVED ONE TILE', 'water': 'WATER OR OUTSIDE: STATE UNCHANGED',
          'sunk': 'SUNK STONE: STATE UNCHANGED', 'bank': 'SWAP REJECTED: STAND ON DRY LAND',
          'swap': 'BOTH RULES COMMITTED TOGETHER', 'success': 'DELIVERY COMPLETE',
          'invalid': 'INVALID ACTION OR RULE PAIR', 'terminal': 'ALREADY DELIVERED',
          'cancel': 'PREVIEW CANCELLED: STATE UNCHANGED', 'same': 'SAME ASSIGNMENT: STATE UNCHANGED',
          'undo': 'ONE ACCEPTED ACTION UNDONE'}

def main(headless=False, screenshot=None, state_json=None, checkpoint=False, case=None):
    import pyxel as p
    app = App()
    if case is not None:
        app.game.level = case
        app.game.reset(False)
    if checkpoint:
        demo(app)
    p.init(512, 480, title='022 Word Bridge / Stage ' + str(STAGE), fps=30,
           display_scale=1, headless=headless)
    keymap = {key: getattr(p, 'KEY_' + key) for key in KEYS}
    def update():
        if headless and p.frame_count >= 4:
            app.draw(p)
            if screenshot:
                p.screen.save(screenshot, 1)
            if state_json:
                with Path(state_json).open('x', encoding='utf-8') as stream:
                    json.dump(snapshot(app), stream, ensure_ascii=False, indent=2)
                    stream.write('\n')
            p.quit()
            return
        app.feed(set() if headless else {key for key, code in keymap.items() if p.btn(code)})
    p.run(update, lambda: app.draw(p))

if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description='Standalone Word Bridge teaching stage')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    parser.add_argument('--case', choices=('A', 'B', 'C'))
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot is not None or args.state_json is not None or args.case) and not args.headless_smoke:
        parser.error('diagnostic options require --headless-smoke')
    if args.case and STAGE != 5:
        parser.error('--case is available only in stage 05')
    for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
        value = getattr(args, field)
        if value is not None:
            if not value.strip():
                parser.error('output path must not be empty')
            target = Path(value).absolute()
            if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists() or target.is_symlink():
                parser.error('output must be a new ' + suffix + ' in an existing directory')
            setattr(args, field, str(target))
    main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint,
         'ABC'.index(args.case) if args.case else None)
STEP 04 / 05

現在を保ったまま、交換後を予告する

ゲームの中身と画面・予告と確定・キーを離すまで待つ。

使うのは、03 の内容と集合の差、それに画面側だけが持つ一時的な値です。Game に editing(予告中か)と proposed(予告の内容)を加えます。最初の Space では State を変えずに editing=True にして、反対の floating を proposed に入れます。CURRENT は今の川、PROPOSED は確定したらどうなるかの2行です。点線は次に浮く種類、v は次に沈む種類を表します。ふつうの石のぬりは CURRENT をもとにしているので、予告中の見た目と、実際に通れるかどうかを区別できます。

「始める Space を離す → 予告の Space を離す → 確定の Space を離す」の順に操作します。予告を開いたとき・閉じたとき・取り消したときは gate=True になり、held と edges の両方からキーがなくなるまで、次の入力を止めます。Space を押し続けても確定にはなりません。Space を離す瞬間に別のキーを押していて、何も押していないフレームがなかったときも、gate は開きません。課題では毎回、キーを全部離してください。

予告中の矢印では移動せず、Z は予告だけを取り消します。取り消すと proposed は今の値に戻り、State の position・moves・changes・floating はそのままです。確定の Space では、proposed から2枚の札を作り、03 の apply_rules に渡します。中のチェックに通って、はじめて新しい State になります。予告を描いた回数や、待った時間は手数に入りません。

石の上でも予告は開けます。「ON STONE: CONFIRM WILL BE REJECTED」(石の上なので確定できません)と出て、Space で確定しようとすると bank になり、予告は閉じます。今の状態は変わりません。「予告できる」と「実行できる」は別の条件です。04 の Z は取り消し専用で、ふつうの画面の Z は何もしません。確定した移動や入れかえを戻す履歴は、次の 05 で加えます。

下のコードは 04_preview.py の一部です。ステップの最後にある全文とあわせて読んでください。

04_preview.py / 注目する部分
        if 'SPACE' in edges:
            self.editing = True
            self.proposed = opposite(self.state.floating)
            self.gate = True
            return

実行して確かめる

教材のZIPを展開したフォルダーで、はじめる準備で用意した環境を使って、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 04_preview.py
macOS / Linux
.venv/bin/python 04_preview.py
画面で見ること最初の A で Space を押して離して始めます。もう一度 Space を押して離すと、CURRENT は H が FLOAT、PROPOSED は L が FLOAT で、MOVES 0、CHANGES 0 のままです。右を押して離しても、予告中なので (0,2) のままです。Z を押して離すと予告が消え、CURRENT も回数も変わりません。あらためて Space・全部離す・Space・全部離す で L に確定し、CHANGES 1 です。右で (1,2) へ行き、Space を2回(毎回離す)押すと、bank で受け付けられません。
  • CURRENT と PROPOSED の役割を説明できる
  • 予告や取り消しでは State は変わらない
  • 石の上でも予告は見られるが、確定は受け付けられない
  • 確かめる間に、キーを全部離す
つまずきやすいところ予告を作るときに self.state に代入すると、その時点で入れかわってしまいます。proposed に代入し、確定するときだけゲームの中身に渡します。Z は予告の取り消しで、04 では確定した1手は戻せません。
少し変えてみよう

Game.update の、予告を開く部分にある self.proposed = opposite(self.state.floating) を self.proposed = self.state.floating に変えます。reset や取り消しにも似た代入があるので、反対にする関数 opposite を呼んでいる1行だけを変えてください。UTF-8 で保存 → Esc で終了 → python 04_preview.py でもう一度実行します。Space で始めて離し、Space で予告して離すと、CURRENT も PROPOSED も H が FLOAT です。もう一度 Space を押して離すと SAME ASSIGNMENT(同じ組み合わせ)と出て、MOVES 0、CHANGES 0、floating=H のままです。python exercises/04_edit_control.py で同じ変更を確かめます。最後に opposite(self.state.floating) に戻して、保存・終了・もう一度実行します。元の版で予告 → Z で取り消しを試し、値が変わらないことも確かめてください。R ではコードは読み直されません。

04_preview.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(400行)
04_preview.py / 全文
# -*- coding: utf-8 -*-

# LICENSE.txt (unchanged text):
# 022 Word Bridge / ことばの橋
# Original code and pixel shapes: provided for the user's game project.
# Bundled Japanese bitmap glyphs were rendered from Noto Sans CJK Regular.
# Noto font data is licensed under SIL Open Font License 1.1.
# See assets/NOTO-LICENSE.txt for copyright and the complete license.
# Glyph generation: tools/build_font.py. Pillow is a development-only dependency.
# Runtime engine: Pyxel 2.9.9 (MIT license, installed separately by pip).
# No downloaded sprites, music, sound recordings, network services or LLM models.

# DISTRIBUTION_PERMISSION.txt (unchanged text):
# 022 ことばの橋:公開・教材配布の補足
# 記録日:2026年10月6日
# 
# この作品のコードと自作図形、および本講座用の段階教材は、プロジェクト所有者の承認に基づき、
# 「Pyxel入門 Pythonゲームの作り方」で学習・改造用のソース付き教材として配布しています。
# 教材をダウンロードし、内容を読んで学び、ご自身の制作のために変更して試すことができます。
# 
# 原納品物の LICENSE.txt は変更せずに同梱しています。
# この補足は今回の公開・教材配布と学習利用の許諾を記録するものであり、原LICENSEをMIT本文へ
# 置き換えたり、元からMITの記載があったものとして扱ったりするものではありません。
# 本作品と原作の権利表示を、ほかの作品の権利表示で上書きしないでください。
# 
# コード・図形と外部ライブラリー・字形データの条件は区別します。
# ・ゲームの図形は、Pyxelの矩形・線・円・三角形などで描いたものです。
# ・元の完成版の日本語字形はNoto Sans CJK Regular由来です。
#   SIL Open Font License 1.1の原文を assets/NOTO-LICENSE.txt に保持しています。
# ・Pyxelは別途インストールする外部ライブラリーです。Pyxel自身のライセンスが適用されます。
# ・段階教材の画面は標準ASCII描画を使い、Noto字形データの追加導入を必要としません。
# 
# 元コード・図形の提供に関する記載、外部由来の字形の著作権とライセンスは、原本のまま残しています。

"""022 Word Bridge: a standalone, incrementally runnable teaching stage.
Original notice: LICENSE.txt. Teaching distribution: DISTRIBUTION_PERMISSION.txt.
No image/font file is needed. Pyxel is imported only when the window starts.
"""
from dataclasses import dataclass, replace, asdict
from pathlib import Path
import json

STAGE = 4
STAGE_TITLE = 'PREVIEW / CONFIRM / CANCEL'
CONTROLS = ['ARROWS MOVE / SPACE PREVIEW, RELEASE, SPACE CONFIRM', 'Z CANCEL PREVIEW / R RESET / Q TITLE / ESC QUIT', 'RELEASE ALL KEYS AT EVERY MODE CHANGE']
KEYS = {'UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'R', 'Q', 'Z'}

DIRECTIONS = {'UP': (0, -1), 'DOWN': (0, 1), 'LEFT': (-1, 0), 'RIGHT': (1, 0)}
MOVEMENT_PRIORITY = ('UP', 'DOWN', 'LEFT', 'RIGHT')
LIGHT_X = range(1, 6)

@dataclass(frozen=True)
class Level:
    width: int = 7
    height: int = 5
    dry: frozenset = frozenset(((0, 2), (6, 2)))
    light: frozenset = frozenset((x, 2) for x in LIGHT_X)
    heavy: frozenset = frozenset((x, 1) for x in (1, 2, 3))
    start: tuple = (0, 2)
    goal: tuple = (6, 2)

LEVELS = (Level(),)

def opposite(floating):
    return 'H' if floating == 'L' else 'L'

def rules(state):
    # The two complete assignments always contain one FLOAT and one SINK.
    return {'L': 'FLOAT' if state.floating == 'L' else 'SINK',
            'H': 'FLOAT' if state.floating == 'H' else 'SINK'}

def terrain(level, position):
    if position in level.dry:
        return 'DRY'
    if position in level.light:
        return 'LIGHT'
    if position in level.heavy:
        return 'HEAVY'
    return 'WATER'

def walkable(level, position, floating):
    return position in level.dry or position in (level.light if floating == 'L' else level.heavy)

INITIAL_FLOATING = 'H'

@dataclass(frozen=True)
class State:
    position: tuple
    floating: str
    moves: int = 0
    changes: int = 0
    status: str = 'playing'

@dataclass(frozen=True)
class Result:
    state: State
    event: str
    changed: bool = False

def initial(level):
    return State(level.start, INITIAL_FLOATING)

def transition(level, state, action):
    if state.status == 'success':
        return Result(state, 'terminal')
    if action == 'SWAP':
        if state.position not in level.dry:
            return Result(state, 'bank')
        return Result(replace(state, floating=opposite(state.floating),
                              changes=state.changes + 1), 'swap', True)
    if action not in DIRECTIONS:
        return Result(state, 'invalid')
    dx, dy = DIRECTIONS[action]
    destination = (state.position[0] + dx, state.position[1] + dy)
    if not walkable(level, destination, state.floating):
        event = 'sunk' if destination in level.light | level.heavy else 'water'
        return Result(state, event)
    end = destination == level.goal
    return Result(replace(state, position=destination, moves=state.moves + 1,
                          status='success' if end else 'playing'),
                  'success' if end else 'move', True)

def apply_rules(level, state, light, heavy):
    if (light, heavy) not in (('FLOAT', 'SINK'), ('SINK', 'FLOAT')):
        return Result(state, 'invalid')
    target = 'L' if light == 'FLOAT' else 'H'
    if target == state.floating:
        return Result(state, 'same')
    return transition(level, state, 'SWAP')

class Game:
    def __init__(self):
        self.level = 0
        self.title = True
        self.reset(False)

    @property
    def layout(self):
        return LEVELS[self.level]

    def reset(self, gate=True):
        self.state = initial(self.layout)
        self.gate = gate
        self.event = 'ready'
        self.editing = False
        self.proposed = self.state.floating

    def act(self, action):
        result = transition(self.layout, self.state, action)
        self.event = result.event
        self.state = result.state
        if self.state.status == 'success':
            self.gate = True
        return result

    def update(self, held, edges):
        # Mode changes require an entirely neutral frame, including new edges.
        if self.gate:
            if not (held | edges) & KEYS:
                self.gate = False
            return
        if self.title:
            if 'SPACE' in edges:
                self.title = False
                self.gate = True
            return
        if 'Q' in edges:
            self.title = True
            self.gate = True
            self.editing = False
            return
        if 'R' in edges:
            self.reset()
            return
        if self.state.status == 'success':
            return
        if self.editing:
            if 'Z' in edges:
                self.editing = False
                self.proposed = self.state.floating
                self.event = 'cancel'
                self.gate = True
            elif 'SPACE' in edges:
                cards = rules(State(self.state.position, self.proposed))
                result = apply_rules(self.layout, self.state, cards['L'], cards['H'])
                self.state = result.state
                self.event = result.event
                self.editing = False
                self.gate = True
            return
        if 'SPACE' in edges:
            self.editing = True
            self.proposed = opposite(self.state.floating)
            self.gate = True
            return
        for key in MOVEMENT_PRIORITY:
            if key in edges:
                self.act(key)
                break

class App:
    """Turn sampled keys into new edges; drawing cannot change the model."""
    def __init__(self):
        self.game = Game()
        self.previous = set()

    def feed(self, held):
        held = set(held)
        edges = held - self.previous
        self.previous = held
        self.game.update(held, edges)

    def draw(self, p):
        g = self.game
        level, state = g.layout, g.state
        editing = getattr(g, 'editing', False)
        paused = getattr(g, 'paused', False)
        p.cls(1)
        p.text(16, 12, '022 WORD BRIDGE / STAGE ' + str(STAGE), 10)
        p.text(16, 27, 'LAYOUT ' + 'ABC'[g.level] + ' / ' + STAGE_TITLE, 7)
        p.text(16, 45, 'L = LIGHT / H = HEAVY / S = START / G = GOAL', 6)
        cell, ox, oy = 36, (512 - level.width * 36) // 2, 70
        for y in range(level.height):
            for x in range(level.width):
                pos = (x, y)
                xx, yy = ox + x * cell, oy + y * cell
                p.rectb(xx, yy, cell, cell, 5)
                p.line(xx + 7, yy + 27, xx + 15, yy + 27, 5)
                if pos in level.dry:
                    p.rect(xx + 2, yy + 2, cell - 4, cell - 4, 4)
                    p.rectb(xx + 2, yy + 2, cell - 4, cell - 4, 9)
                    p.text(xx + 4, yy + 5, 'S' if pos == level.start else 'G' if pos == level.goal else 'ISLE', 10)
                elif pos in level.light | level.heavy:
                    category = 'L' if pos in level.light else 'H'
                    active = category == state.floating
                    color = 11 if category == 'L' else 15
                    if active:
                        p.rect(xx + 5, yy + 5, cell - 10, cell - 10, color)
                        p.text(xx + 15, yy + 14, category, 1)
                    else:
                        p.rectb(xx + 6, yy + 11, cell - 12, cell - 15, 5)
                        p.text(xx + 15, yy + 16, category, 6)
                    if editing:
                        if category == g.proposed:
                            for a in range(1, cell - 1, 4):
                                p.pset(xx + a, yy + 1, 10)
                                p.pset(xx + a, yy + cell - 2, 10)
                                p.pset(xx + 1, yy + a, 10)
                                p.pset(xx + cell - 2, yy + a, 10)
                        else:
                            p.text(xx + 26, yy + 3, 'v', 8)
        if STAGE == 1:
            x, y = g.probe
            xx, yy = ox + x * cell, oy + y * cell
            p.rectb(xx, yy, cell, cell, 7)
            p.rectb(xx + 1, yy + 1, cell - 2, cell - 2, 7)
            label = terrain(level, g.probe)
            allowed = walkable(level, g.probe, state.floating)
            p.text(16, 300, 'PROBE ' + str(g.probe) + ' / ' + label + ' / ' + ('WALKABLE' if allowed else 'BLOCKED'), 10)
        else:
            x, y = state.position
            xx, yy = ox + x * cell, oy + y * cell
            p.circ(xx + 18, yy + 17, 7, 8)
            p.pset(xx + 20, yy + 15, 7)
            p.rect(xx + 24, yy + 19, 6, 7, 10)
            p.text(16, 300, 'POS ' + str(state.position) + ' / MOVES ' + str(state.moves) + ' / CHANGES ' + str(state.changes), 7)
            if STAGE == 5:
                p.text(332, 300, 'HISTORY ' + str(len(g.history)), 6)
        current = rules(state)
        p.rectb(12, 319, 488, 62, 5)
        p.text(22, 329, 'CURRENT / L ' + current['L'] + ' / H ' + current['H'], 7)
        if editing:
            proposed = {'L': 'FLOAT' if g.proposed == 'L' else 'SINK',
                        'H': 'FLOAT' if g.proposed == 'H' else 'SINK'}
            p.text(22, 345, 'PROPOSED / L ' + proposed['L'] + ' / H ' + proposed['H'], 10)
            allowed = state.position in level.dry
            p.text(22, 363, 'DRY: CONFIRM ALLOWED' if allowed else 'ON STONE: CONFIRM WILL BE REJECTED', 11 if allowed else 8)
        else:
            p.text(22, 350, 'ONE FLOAT + ONE SINK / BOTH ROWS CHANGE TOGETHER', 6)
            p.text(22, 365, 'NO TIMER / NO LIVES / NO LANGUAGE MODEL', 6)
        status = 'PREVIEW ONLY: DOTS = WILL FLOAT / v = WILL SINK' if editing else EVENTS.get(g.event, g.event.upper())
        p.text(16, 395, status, 8 if g.event in ('bank', 'water', 'sunk') and not editing else 10)
        for i, line in enumerate(CONTROLS):
            p.text(16, 417 + i * 15, line, 7 if i == 0 else 6)
        if getattr(state, 'status', '') == 'success':
            p.rect(90, 130, 332, 74, 1)
            p.rectb(90, 130, 332, 74, 10)
            p.text(160, 148, 'DELIVERY COMPLETE', 10)
            p.text(108, 174, 'N NEXT / Z UNDO / R RETRY' if STAGE == 5 else 'R RETRY / Q TITLE / ESC QUIT', 7)
        if g.title:
            p.rect(40, 82, 432, 200, 1)
            p.rectb(40, 82, 432, 200, 10)
            lines = ['WORD BRIDGE / STAGE ' + str(STAGE), STAGE_TITLE,
                     'ARROWS INSPECT TILES; CURSOR IS NOT A PLAYER' if STAGE == 1 else 'WALK ONLY ON DRY LAND OR FLOATING STONES',
                     'TWO COMPLEMENTARY RULES: ONE FLOAT, ONE SINK',
                     'SPACE START / RELEASE BEFORE THE NEXT ACTION',
                     'LEFT RIGHT: CHOOSE A / B / C' if STAGE == 5 else 'THIS STAGE USES ONLY LAYOUT A',
                     'SAVE CODE, QUIT AND RERUN AFTER AN EDIT',
                     'R RESETS THE STATE; IT DOES NOT RELOAD CODE']
            for i, line in enumerate(lines):
                p.text(57, 102 + i * 22, line, 10 if i in (0, 4) else 7)
        if paused:
            p.rect(68, 144, 376, 75, 1)
            p.rectb(68, 144, 376, 75, 10)
            message = 'PAUSED STATE KEPT / SPACE START, THEN P RESUME' if g.title else 'PAUSED / RELEASE, PRESS P, RELEASE TO RESUME'
            p.text(88, 165, message, 10)
            p.text(88, 190, 'STATE AND HISTORY STAY UNCHANGED', 7)

def tap(app, key):
    app.feed({key})
    app.feed(set())

def snapshot(app):
    g = app.game
    result = {'stage': STAGE, 'level': g.level, 'state': asdict(g.state),
              'title': g.title, 'event': g.event, 'gate': g.gate,
              'scope': 'actual Pyxel Linux headless rendering with synthetic input'}
    for field in ('probe', 'editing', 'proposed', 'paused'):
        if hasattr(g, field):
            result[field] = getattr(g, field)
    if hasattr(g, 'history'):
        result['history'] = [asdict(state) for state in g.history]
    return result

def demo(app):
    tap(app, 'SPACE')
    if STAGE == 1:
        tap(app, 'RIGHT')
    elif STAGE == 2:
        tap(app, 'RIGHT')
        tap(app, 'RIGHT')
    elif STAGE == 3:
        tap(app, 'SPACE')
        tap(app, 'RIGHT')
        tap(app, 'SPACE')
    elif STAGE == 4:
        tap(app, 'SPACE')
    else:
        tap(app, 'SPACE')
        tap(app, 'SPACE')
        tap(app, 'RIGHT')
        tap(app, 'Z')

EVENTS = {'ready': 'READY', 'probe': 'INSPECTION ONLY', 'assignment': 'LAB ASSIGNMENT CHANGED',
          'move': 'MOVED ONE TILE', 'water': 'WATER OR OUTSIDE: STATE UNCHANGED',
          'sunk': 'SUNK STONE: STATE UNCHANGED', 'bank': 'SWAP REJECTED: STAND ON DRY LAND',
          'swap': 'BOTH RULES COMMITTED TOGETHER', 'success': 'DELIVERY COMPLETE',
          'invalid': 'INVALID ACTION OR RULE PAIR', 'terminal': 'ALREADY DELIVERED',
          'cancel': 'PREVIEW CANCELLED: STATE UNCHANGED', 'same': 'SAME ASSIGNMENT: STATE UNCHANGED',
          'undo': 'ONE ACCEPTED ACTION UNDONE'}

def main(headless=False, screenshot=None, state_json=None, checkpoint=False, case=None):
    import pyxel as p
    app = App()
    if case is not None:
        app.game.level = case
        app.game.reset(False)
    if checkpoint:
        demo(app)
    p.init(512, 480, title='022 Word Bridge / Stage ' + str(STAGE), fps=30,
           display_scale=1, headless=headless)
    keymap = {key: getattr(p, 'KEY_' + key) for key in KEYS}
    def update():
        if headless and p.frame_count >= 4:
            app.draw(p)
            if screenshot:
                p.screen.save(screenshot, 1)
            if state_json:
                with Path(state_json).open('x', encoding='utf-8') as stream:
                    json.dump(snapshot(app), stream, ensure_ascii=False, indent=2)
                    stream.write('\n')
            p.quit()
            return
        app.feed(set() if headless else {key for key, code in keymap.items() if p.btn(code)})
    p.run(update, lambda: app.draw(p))

if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description='Standalone Word Bridge teaching stage')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    parser.add_argument('--case', choices=('A', 'B', 'C'))
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot is not None or args.state_json is not None or args.case) and not args.headless_smoke:
        parser.error('diagnostic options require --headless-smoke')
    if args.case and STAGE != 5:
        parser.error('--case is available only in stage 05')
    for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
        value = getattr(args, field)
        if value is not None:
            if not value.strip():
                parser.error('output path must not be empty')
            target = Path(value).absolute()
            if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists() or target.is_symlink():
                parser.error('output must be a new ' + suffix + ' in an existing directory')
            setattr(args, field, str(target))
    main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint,
         'ABC'.index(args.case) if args.case else None)
STEP 05 / 05

一手を戻し、三つの川で陸を探す

状態の履歴・元のゲームの中身・配置のちがい。

使うのは、04 の内容と list の append・pop、property、dataclass の作成後のチェックです。ここで、元のゲームの model.py の全部と、モジュールの import をのぞいた元のゲームの Game を組みこみます。前のステップで完成版をかくしていたわけではなく、05 ではじめて3つの配置・履歴・一時停止・次の川を入れます。Level.__post_init__ は、地形の集合の重なり・範囲の外・陸ではない出発点や到着点などを受け付けません。invariant は「今いる場所が通れる」「到着点と成功の状態が合っている」を調べる関数です。

Game.act は、Result.changed が True のときだけ、変わる前の State を history に加えます。1回の移動も、陸での1回の入れかえも、1手です。受け付けなかった移動や入れかえ・予告・取り消し・一時停止は、履歴に入りません。ふつうの画面の Z は、history.pop で1手前の State をまるごと戻します。位置だけでなく、floating・moves・changes・status も戻すので、成功の直前にも戻れます。予告中の Z は、04 と同じく取り消しを優先し、履歴は消しません。履歴が空なら State は変わりません。

表紙で左右を押して A・B・C を選び、Space で始めます。A「短い文」は、最初の岸で H から L に入れかえる配置です。B「中ほどの句点」は、L で右へ3歩進み、(3,2) の陸で H に入れかえ、さらに右へ3歩です。C「近く見える継ぎ目」は、右へ3歩の (3,2) から先の H へ渡れそうに見えますが、足元は L の石なので入れかえられません。先に (2,4) の島へ向かいます。最初から 右2・下2・島で入れかえ・右4・上2・右1 でゴールです。A と B は MOVES 6・CHANGES 1、C は MOVES 11・CHANGES 1 で着きます。ここでの入れかえは、毎回 Space → 全部離す → Space → 全部離す です。

P で一時停止と再開をし、そのたびに全部離します。一時停止中は、歩く・予告・Undo は処理しませんが、R のやり直しや Q の表紙へ戻ることはできます。Q は今のゲームを消さずに表紙へ戻ります。同じ配置のまま Space を押せば続きから再開し、表紙で左右を押して配置を変えると最初からになります。一時停止中に Q で表紙へ戻ったときは、一時停止の状態も残るので、Space で川に戻ったあと、全部離して P で一時停止を解除します。R は同じ配置の最初に戻し、履歴を空にします。成功したあとの N は次の配置へ進み、C の次は A です。Z を押せば、成功した最後の1歩を戻せます。保存と読みこみ、タイマー、残機、点数の減点はありません。

表示は 512×480 の文字(ASCII)の教材版です。元のゲームの日本語の文字、川の景色や配達の演出、画像や動画の自動記録は、移していません。state_hash(状態の照合)と semantic(限られた状態の探索)は、補助の関数として残していますが、学習用の画面に、保存や動画撮影のキーは加えていません。川のルールと、元のゲームの Game の入力の優先順・キーを離すまで待つ処理は、そのまま残しています。

下のコードは 05_word_bridge.py の一部です。ステップの最後にある全文とあわせて読んでください。

05_word_bridge.py / 注目する部分
    def act(self,action):
        r=transition(self.layout,self.state,action);self.event=r.event
        if r.changed:self.history.append(self.state);self.state=r.state
        if self.state.status=='success':self.gate=True
        return r

実行して確かめる

教材のZIPを展開したフォルダーで、はじめる準備で用意した環境を使って、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 05_word_bridge.py
macOS / Linux
.venv/bin/python 05_word_bridge.py
画面で見ることA で Space で開始 → 全部離す → Space で予告 → 全部離す → Space で確定 → 全部離す と操作します。POS (0, 2)、MOVES 0、CHANGES 1、HISTORY 1、L が FLOAT です。右を押して離すと (1,2)、MOVES 1、HISTORY 2 です。Z を押して離すと、入れかえた直後の岸に戻り、MOVES 0、CHANGES 1、HISTORY 1 です。もう一度 Z を押して離すと、最初の H、CHANGES 0、HISTORY 0 です。
  • 移動や入れかえが成功したときだけ、その直前の State を履歴に入れる
  • Z が「予告の取り消し」か「Undo」かを、画面の状態で区別する
  • C の島 (2,4) で安全に入れかえて渡る
  • Q の再開・R のやり直し・N の次の配置を使い分ける
つまずきやすいところ受け付けなかった入力や、画面を描いた回数まで history に入れないようにしましょう。Undo で moves を1減らすだけでは、入れかえや成功の状態を戻せません。C では見た目の近さだけで進まず、「ルールを入れかえたあとも安全な陸」を先に探してください。Q の表紙へ戻る操作は、R のやり直しとはちがいます。
少し変えてみよう

Game.__init__ の self.level=0;self.title=True;self.reset(False) を self.level=2;self.title=True;self.reset(False) に変えます。UTF-8 で保存 → Esc で終了 → python 05_word_bridge.py でもう一度実行します。表紙が C になったことを確かめて Space を押し、全部離します。右を3回(毎回離す)押すと (3,2)、MOVES 3 です。もう一度右を押すと sunk で受け付けられません。Space を2回(毎回離す)押しても、石の上なので bank になり、MOVES 3・CHANGES 0・HISTORY 3 のままです。Z を押して離すと、最後に成功した右への移動が戻り、(2,2)、MOVES 2、HISTORY 2 です。下2で (2,4) の島へ行き、Space を2回(毎回離す)押して H に入れかえ、右4 → 上2 → 右1(毎回キーを全部離す)で成功です。MOVES 11、CHANGES 1、HISTORY 12 を確かめます。python exercises/05_edit_control.py で同じ変更と入力を確かめたら、self.level=0 に戻して、保存・終了・もう一度実行してください。R では書きかえたコードは読み直されません。

05_word_bridge.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(368行)
05_word_bridge.py / 全文
# -*- coding: utf-8 -*-

# LICENSE.txt (unchanged text):
# 022 Word Bridge / ことばの橋
# Original code and pixel shapes: provided for the user's game project.
# Bundled Japanese bitmap glyphs were rendered from Noto Sans CJK Regular.
# Noto font data is licensed under SIL Open Font License 1.1.
# See assets/NOTO-LICENSE.txt for copyright and the complete license.
# Glyph generation: tools/build_font.py. Pillow is a development-only dependency.
# Runtime engine: Pyxel 2.9.9 (MIT license, installed separately by pip).
# No downloaded sprites, music, sound recordings, network services or LLM models.

# DISTRIBUTION_PERMISSION.txt (unchanged text):
# 022 ことばの橋:公開・教材配布の補足
# 記録日:2026年10月6日
# 
# この作品のコードと自作図形、および本講座用の段階教材は、プロジェクト所有者の承認に基づき、
# 「Pyxel入門 Pythonゲームの作り方」で学習・改造用のソース付き教材として配布しています。
# 教材をダウンロードし、内容を読んで学び、ご自身の制作のために変更して試すことができます。
# 
# 原納品物の LICENSE.txt は変更せずに同梱しています。
# この補足は今回の公開・教材配布と学習利用の許諾を記録するものであり、原LICENSEをMIT本文へ
# 置き換えたり、元からMITの記載があったものとして扱ったりするものではありません。
# 本作品と原作の権利表示を、ほかの作品の権利表示で上書きしないでください。
# 
# コード・図形と外部ライブラリー・字形データの条件は区別します。
# ・ゲームの図形は、Pyxelの矩形・線・円・三角形などで描いたものです。
# ・元の完成版の日本語字形はNoto Sans CJK Regular由来です。
#   SIL Open Font License 1.1の原文を assets/NOTO-LICENSE.txt に保持しています。
# ・Pyxelは別途インストールする外部ライブラリーです。Pyxel自身のライセンスが適用されます。
# ・段階教材の画面は標準ASCII描画を使い、Noto字形データの追加導入を必要としません。
# 
# 元コード・図形の提供に関する記載、外部由来の字形の著作権とライセンスは、原本のまま残しています。

"""022 Word Bridge: a standalone, incrementally runnable teaching stage.
Original notice: LICENSE.txt. Teaching distribution: DISTRIBUTION_PERMISSION.txt.
No image/font file is needed. Pyxel is imported only when the window starts.
"""
from dataclasses import dataclass, replace, asdict
from pathlib import Path
import json

STAGE = 5
STAGE_TITLE = 'UNDO AND THREE ORIGINAL LAYOUTS'
CONTROLS = ['ARROWS MOVE / SPACE PREVIEW, RELEASE, SPACE CONFIRM', 'Z CANCEL OR UNDO / R RESET / P PAUSE / Q TITLE', 'AFTER SUCCESS N NEXT / ESC QUIT / RELEASE ALL KEYS']

# Original pure model, retained verbatim below.
"""Finite, immutable river model. No renderer, clock, randomness, or network."""
from dataclasses import dataclass, replace, asdict
import hashlib, json

DIRECTIONS={'UP':(0,-1),'DOWN':(0,1),'LEFT':(-1,0),'RIGHT':(1,0)}
ACTIONS=tuple(DIRECTIONS)+('SWAP',)
REASONS={'ready':'荷物を旗まで届けよう','move':'浮いている石だけ歩ける',
 'water':'水には進めません','sunk':'その石は沈んでいます','bank':'陸で文を替えよう',
 'swap':'同じ印の石が、全部入れ替わった','success':'配達できた!','invalid':'その文は作れません',
 'terminal':'配達済みです','cancel':'文を替えずに戻りました','same':'文はそのまま','undo':'一手戻しました'}
@dataclass(frozen=True)
class Level:
    title:str
    width:int
    height:int
    dry:frozenset
    light:frozenset
    heavy:frozenset
    start:tuple
    goal:tuple
    floating:str
    def __post_init__(self):
        if self.floating not in ('L','H'):raise ValueError('unknown category')
        if self.dry&self.light or self.dry&self.heavy or self.light&self.heavy:raise ValueError('overlapping terrain')
        if self.start not in self.dry or self.goal not in self.dry:raise ValueError('endpoints must be dry')
        if self.start==self.goal:raise ValueError('endpoints must differ')
        if any(not(0<=x<self.width and 0<=y<self.height) for x,y in self.dry|self.light|self.heavy):raise ValueError('out of bounds')
@dataclass(frozen=True)
class State:
    position:tuple
    floating:str
    moves:int=0
    changes:int=0
    status:str='playing'
@dataclass(frozen=True)
class Result:
    state:State
    event:str
    changed:bool=False

LEVELS=(
 Level('短い文',7,5,frozenset(((0,2),(6,2))),frozenset((x,2) for x in range(1,6)),frozenset((x,1) for x in (1,2,3)),(0,2),(6,2),'H'),
 Level('中ほどの句点',7,5,frozenset(((0,2),(3,2),(6,2))),frozenset(((1,2),(2,2))),frozenset(((4,2),(5,2))),(0,2),(6,2),'L'),
 Level('近く見える継ぎ目',8,6,frozenset(((0,2),(2,4),(7,2))),frozenset(((1,2),(2,2),(3,2),(2,3))),frozenset(((4,2),(5,2),(6,2),(3,4),(4,4),(5,4),(6,4),(6,3))),(0,2),(7,2),'L'))

def initial(level):return State(level.start,level.floating)
def rules(state):return {'L':'FLOAT' if state.floating=='L' else 'SINK','H':'FLOAT' if state.floating=='H' else 'SINK'}
def walkable(level,position,floating):return position in level.dry or position in (level.light if floating=='L' else level.heavy)
def invariant(level,state):
    return state.floating in ('L','H') and walkable(level,state.position,state.floating) and state.status==('success' if state.position==level.goal else 'playing')
def transition(level,state,action):
    if state.status=='success':return Result(state,'terminal')
    if action=='SWAP':
        if state.position not in level.dry:return Result(state,'bank')
        return Result(replace(state,floating='H' if state.floating=='L' else 'L',changes=state.changes+1),'swap',True)
    if action not in DIRECTIONS:return Result(state,'invalid')
    dx,dy=DIRECTIONS[action];p=(state.position[0]+dx,state.position[1]+dy)
    if not walkable(level,p,state.floating):return Result(state,'sunk' if p in level.light|level.heavy else 'water')
    end=p==level.goal
    return Result(replace(state,position=p,moves=state.moves+1,status='success' if end else 'playing'),'success' if end else 'move',True)
def apply_rules(level,state,light,heavy):
    """Only two complete complementary assignments exist; reject malformed cards atomically."""
    if (light,heavy) not in (('FLOAT','SINK'),('SINK','FLOAT')):return Result(state,'invalid')
    target='L' if light=='FLOAT' else 'H'
    if target==state.floating:return Result(state,'same')
    return transition(level,state,'SWAP')
def state_hash(state):return hashlib.sha256(json.dumps(asdict(state),sort_keys=True,separators=(',',':')).encode('utf-8')).hexdigest()
def semantic(state):return state.position,state.floating,state.status

# Original controller, with only its module import removed.
"""Edge-only input controller. All mode changes gate actions until a neutral frame."""
KEYS={'UP','DOWN','LEFT','RIGHT','SPACE','Z','R','P','N','Q'}
class Game:
    def __init__(self):
        self.level=0;self.title=True;self.reset(False)
    @property
    def layout(self):return LEVELS[self.level]
    def reset(self,gate=True):
        self.state=initial(self.layout);self.history=[];self.editing=False;self.paused=False
        self.row=0;self.proposed=self.state.floating;self.gate=gate;self.event='ready'
    def act(self,action):
        r=transition(self.layout,self.state,action);self.event=r.event
        if r.changed:self.history.append(self.state);self.state=r.state
        if self.state.status=='success':self.gate=True
        return r
    def undo(self):
        if self.history:self.state=self.history.pop();self.event='undo'
        self.editing=False;self.proposed=self.state.floating;self.gate=True
    def update(self,held,edges):
        # Include both held and new edges: this catches adjacent-frame combinations.
        if self.gate:
            if not (held|edges)&KEYS:self.gate=False
            return
        if self.title:
            if 'LEFT' in edges:self.level=(self.level-1)%3;self.reset(False)
            elif 'RIGHT' in edges:self.level=(self.level+1)%3;self.reset(False)
            elif 'SPACE' in edges:self.title=False;self.gate=True
            return
        if 'Q' in edges:self.title=True;self.editing=False;self.gate=True;return
        if 'R' in edges:self.reset();return
        if 'P' in edges:self.paused=not self.paused;self.gate=True;return
        if self.paused:return
        if 'Z' in edges:
            if self.editing:self.editing=False;self.proposed=self.state.floating;self.gate=True;self.event='cancel'
            else:self.undo()
            return
        if self.state.status=='success':
            if 'N' in edges:self.level=(self.level+1)%3;self.reset()
            return
        if self.editing:
            if 'SPACE' in edges:
                if self.proposed!=self.state.floating:self.act('SWAP')
                else:self.event='same'
                self.editing=False;self.gate=True
            return
        if 'SPACE' in edges:
            self.editing=True;self.proposed='H' if self.state.floating=='L' else 'L';self.gate=True;return
        # At most one move per press. Simultaneous arrows have explicit priority.
        for key in ('UP','DOWN','LEFT','RIGHT'):
            if key in edges:self.act(key);break

class App:
    """Turn sampled keys into new edges; drawing cannot change the model."""
    def __init__(self):
        self.game = Game()
        self.previous = set()

    def feed(self, held):
        held = set(held)
        edges = held - self.previous
        self.previous = held
        self.game.update(held, edges)

    def draw(self, p):
        g = self.game
        level, state = g.layout, g.state
        editing = getattr(g, 'editing', False)
        paused = getattr(g, 'paused', False)
        p.cls(1)
        p.text(16, 12, '022 WORD BRIDGE / STAGE ' + str(STAGE), 10)
        p.text(16, 27, 'LAYOUT ' + 'ABC'[g.level] + ' / ' + STAGE_TITLE, 7)
        p.text(16, 45, 'L = LIGHT / H = HEAVY / S = START / G = GOAL', 6)
        cell, ox, oy = 36, (512 - level.width * 36) // 2, 70
        for y in range(level.height):
            for x in range(level.width):
                pos = (x, y)
                xx, yy = ox + x * cell, oy + y * cell
                p.rectb(xx, yy, cell, cell, 5)
                p.line(xx + 7, yy + 27, xx + 15, yy + 27, 5)
                if pos in level.dry:
                    p.rect(xx + 2, yy + 2, cell - 4, cell - 4, 4)
                    p.rectb(xx + 2, yy + 2, cell - 4, cell - 4, 9)
                    p.text(xx + 4, yy + 5, 'S' if pos == level.start else 'G' if pos == level.goal else 'ISLE', 10)
                elif pos in level.light | level.heavy:
                    category = 'L' if pos in level.light else 'H'
                    active = category == state.floating
                    color = 11 if category == 'L' else 15
                    if active:
                        p.rect(xx + 5, yy + 5, cell - 10, cell - 10, color)
                        p.text(xx + 15, yy + 14, category, 1)
                    else:
                        p.rectb(xx + 6, yy + 11, cell - 12, cell - 15, 5)
                        p.text(xx + 15, yy + 16, category, 6)
                    if editing:
                        if category == g.proposed:
                            for a in range(1, cell - 1, 4):
                                p.pset(xx + a, yy + 1, 10)
                                p.pset(xx + a, yy + cell - 2, 10)
                                p.pset(xx + 1, yy + a, 10)
                                p.pset(xx + cell - 2, yy + a, 10)
                        else:
                            p.text(xx + 26, yy + 3, 'v', 8)
        if STAGE == 1:
            x, y = g.probe
            xx, yy = ox + x * cell, oy + y * cell
            p.rectb(xx, yy, cell, cell, 7)
            p.rectb(xx + 1, yy + 1, cell - 2, cell - 2, 7)
            label = terrain(level, g.probe)
            allowed = walkable(level, g.probe, state.floating)
            p.text(16, 300, 'PROBE ' + str(g.probe) + ' / ' + label + ' / ' + ('WALKABLE' if allowed else 'BLOCKED'), 10)
        else:
            x, y = state.position
            xx, yy = ox + x * cell, oy + y * cell
            p.circ(xx + 18, yy + 17, 7, 8)
            p.pset(xx + 20, yy + 15, 7)
            p.rect(xx + 24, yy + 19, 6, 7, 10)
            p.text(16, 300, 'POS ' + str(state.position) + ' / MOVES ' + str(state.moves) + ' / CHANGES ' + str(state.changes), 7)
            if STAGE == 5:
                p.text(332, 300, 'HISTORY ' + str(len(g.history)), 6)
        current = rules(state)
        p.rectb(12, 319, 488, 62, 5)
        p.text(22, 329, 'CURRENT / L ' + current['L'] + ' / H ' + current['H'], 7)
        if editing:
            proposed = {'L': 'FLOAT' if g.proposed == 'L' else 'SINK',
                        'H': 'FLOAT' if g.proposed == 'H' else 'SINK'}
            p.text(22, 345, 'PROPOSED / L ' + proposed['L'] + ' / H ' + proposed['H'], 10)
            allowed = state.position in level.dry
            p.text(22, 363, 'DRY: CONFIRM ALLOWED' if allowed else 'ON STONE: CONFIRM WILL BE REJECTED', 11 if allowed else 8)
        else:
            p.text(22, 350, 'ONE FLOAT + ONE SINK / BOTH ROWS CHANGE TOGETHER', 6)
            p.text(22, 365, 'NO TIMER / NO LIVES / NO LANGUAGE MODEL', 6)
        status = 'PREVIEW ONLY: DOTS = WILL FLOAT / v = WILL SINK' if editing else EVENTS.get(g.event, g.event.upper())
        p.text(16, 395, status, 8 if g.event in ('bank', 'water', 'sunk') and not editing else 10)
        for i, line in enumerate(CONTROLS):
            p.text(16, 417 + i * 15, line, 7 if i == 0 else 6)
        if getattr(state, 'status', '') == 'success':
            p.rect(90, 130, 332, 74, 1)
            p.rectb(90, 130, 332, 74, 10)
            p.text(160, 148, 'DELIVERY COMPLETE', 10)
            p.text(108, 174, 'N NEXT / Z UNDO / R RETRY' if STAGE == 5 else 'R RETRY / Q TITLE / ESC QUIT', 7)
        if g.title:
            p.rect(40, 82, 432, 200, 1)
            p.rectb(40, 82, 432, 200, 10)
            lines = ['WORD BRIDGE / STAGE ' + str(STAGE), STAGE_TITLE,
                     'ARROWS INSPECT TILES; CURSOR IS NOT A PLAYER' if STAGE == 1 else 'WALK ONLY ON DRY LAND OR FLOATING STONES',
                     'TWO COMPLEMENTARY RULES: ONE FLOAT, ONE SINK',
                     'SPACE START / RELEASE BEFORE THE NEXT ACTION',
                     'LEFT RIGHT: CHOOSE A / B / C' if STAGE == 5 else 'THIS STAGE USES ONLY LAYOUT A',
                     'SAVE CODE, QUIT AND RERUN AFTER AN EDIT',
                     'R RESETS THE STATE; IT DOES NOT RELOAD CODE']
            for i, line in enumerate(lines):
                p.text(57, 102 + i * 22, line, 10 if i in (0, 4) else 7)
        if paused:
            p.rect(68, 144, 376, 75, 1)
            p.rectb(68, 144, 376, 75, 10)
            message = 'PAUSED STATE KEPT / SPACE START, THEN P RESUME' if g.title else 'PAUSED / RELEASE, PRESS P, RELEASE TO RESUME'
            p.text(88, 165, message, 10)
            p.text(88, 190, 'STATE AND HISTORY STAY UNCHANGED', 7)

def tap(app, key):
    app.feed({key})
    app.feed(set())

def snapshot(app):
    g = app.game
    result = {'stage': STAGE, 'level': g.level, 'state': asdict(g.state),
              'title': g.title, 'event': g.event, 'gate': g.gate,
              'scope': 'actual Pyxel Linux headless rendering with synthetic input'}
    for field in ('probe', 'editing', 'proposed', 'paused'):
        if hasattr(g, field):
            result[field] = getattr(g, field)
    if hasattr(g, 'history'):
        result['history'] = [asdict(state) for state in g.history]
    return result

def demo(app):
    tap(app, 'SPACE')
    if STAGE == 1:
        tap(app, 'RIGHT')
    elif STAGE == 2:
        tap(app, 'RIGHT')
        tap(app, 'RIGHT')
    elif STAGE == 3:
        tap(app, 'SPACE')
        tap(app, 'RIGHT')
        tap(app, 'SPACE')
    elif STAGE == 4:
        tap(app, 'SPACE')
    else:
        tap(app, 'SPACE')
        tap(app, 'SPACE')
        tap(app, 'RIGHT')
        tap(app, 'Z')

EVENTS = {'ready': 'READY', 'probe': 'INSPECTION ONLY', 'assignment': 'LAB ASSIGNMENT CHANGED',
          'move': 'MOVED ONE TILE', 'water': 'WATER OR OUTSIDE: STATE UNCHANGED',
          'sunk': 'SUNK STONE: STATE UNCHANGED', 'bank': 'SWAP REJECTED: STAND ON DRY LAND',
          'swap': 'BOTH RULES COMMITTED TOGETHER', 'success': 'DELIVERY COMPLETE',
          'invalid': 'INVALID ACTION OR RULE PAIR', 'terminal': 'ALREADY DELIVERED',
          'cancel': 'PREVIEW CANCELLED: STATE UNCHANGED', 'same': 'SAME ASSIGNMENT: STATE UNCHANGED',
          'undo': 'ONE ACCEPTED ACTION UNDONE'}

def main(headless=False, screenshot=None, state_json=None, checkpoint=False, case=None):
    import pyxel as p
    app = App()
    if case is not None:
        app.game.level = case
        app.game.reset(False)
    if checkpoint:
        demo(app)
    p.init(512, 480, title='022 Word Bridge / Stage ' + str(STAGE), fps=30,
           display_scale=1, headless=headless)
    keymap = {key: getattr(p, 'KEY_' + key) for key in KEYS}
    def update():
        if headless and p.frame_count >= 4:
            app.draw(p)
            if screenshot:
                p.screen.save(screenshot, 1)
            if state_json:
                with Path(state_json).open('x', encoding='utf-8') as stream:
                    json.dump(snapshot(app), stream, ensure_ascii=False, indent=2)
                    stream.write('\n')
            p.quit()
            return
        app.feed(set() if headless else {key for key, code in keymap.items() if p.btn(code)})
    p.run(update, lambda: app.draw(p))

if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description='Standalone Word Bridge teaching stage')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    parser.add_argument('--case', choices=('A', 'B', 'C'))
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot is not None or args.state_json is not None or args.case) and not args.headless_smoke:
        parser.error('diagnostic options require --headless-smoke')
    if args.case and STAGE != 5:
        parser.error('--case is available only in stage 05')
    for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
        value = getattr(args, field)
        if value is not None:
            if not value.strip():
                parser.error('output path must not be empty')
            target = Path(value).absolute()
            if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists() or target.is_symlink():
                parser.error('output must be a new ' + suffix + ' in an existing directory')
            setattr(args, field, str(target))
    main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint,
         'ABC'.index(args.case) if args.case else None)
AFTER STEP05

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

軽い石と重い石に、FLOAT と SINK を1枚ずつ割り当てます。2枚の札をいっせいに入れかえられるのは陸の上だけです。水や沈んだ石への移動は受け付けず、旗に着けば配達成功です。時間制限・残機・点数の減点はありません。

教材は、元のゲームの仕組みを取り出した簡略版です。元のゲームの39ファイルは、別のZIPに何も変えずに保存しています。

比べる点5ステップの教材元の完成版
段階01 は歩く人のいない地形の実験、02 は A の最初を L にした移動の練習、03 は元のゲームの A(最初は H)で、すぐ入れかえる版、04 は A での予告と取り消し、05 で元のゲームの中身すべてと Game を使います。最初から3つの配置・予告・Undo・一時停止・次の配置があります。A の最初は H です。
表示512×480 の文字(ASCII)で、座標・CURRENT/PROPOSED・手数・入れかえの回数・履歴の数を表示します。日本語の文字、川の景色、荷物の配達の演出がある表示です。
記録画面を出さないテストのときだけ、画像(PNG)と状態(JSON)を新しく出力します。保存と読みこみの画面、動画や自動の記録はありません。記録の機能と、デモ・比較・動画用の補助ツールがあります。

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

  1. model.py: Level.__post_init__の地形検査
  2. model.py: walkable/transitionの拒否順と不変State
  3. model.py: apply_rulesの二通り検査とsame
  4. model.py: invariant/state_hash/semantic
  5. game.py: Game.act/undoの確定行動履歴
  6. game.py: Game.updateのgateと入力優先順
  7. app.py: 日本語字形と現在/予告の描き分け
  8. solver.py: 有限な意味状態の探索

教材をダウンロードする

購入やメルマガ登録は不要です。ZIPは必ずすべて展開してから使い、元の完成版は同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

022 は、運営者の承認を受けて、ソース付きの教材として配っています。元の LICENSE.txt と DISTRIBUTION_PERMISSION.txt をあわせて読んでください。Noto をもとにした文字は、別に SIL OFL 1.1 です。出典・権利について

検証について

2026年10月6日、Python3.12.14 / Pyxel2.9.9のLinux環境で43テスト、具体的な5編集課題、原作モデル・入力の対応、全5段階の実Pyxel起動と描画、文字コードと移設を確認しました。完成版の自動デモも別に確認しています。

自動入力とヘッドレス描画による検査です。Windows/macOS実機GUI、物理キー、人の学習試験を確認したものではありません。ブラウザー版の確認範囲は確認状況をご覧ください。

起動やコードのエラーで困ったとき / 再現メモを作る