完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:矢印移動、陸でSpace予告→再押下で文交換、Z取消/戻す、R再開、P停止、Q表紙
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
陸の上で通り方のルールを入れかえ、予告と確定を分ける。
ルールを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)で表示しています。
集合で地形を調べ、二つの文を結ぶ
座標・変えられない集合(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 の一部です。ステップの最後にある全文とあわせて読んでください。
def walkable(level, position, floating):
return position in level.dry or position in (level.light if floating == 'L' else level.heavy)実行して確かめる
教材のZIPを展開したフォルダーで、はじめる準備で用意した環境を使って、次のコマンドを実行します。
.venv\Scripts\python.exe 01_terrain.py.venv/bin/python 01_terrain.py(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 は状態を戻すだけで、コードは読み直しません。
このステップのコード全文(337行)
# -*- 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)通れる一歩だけを、新しい状態にする
書きかえない 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 の一部です。ステップの最後にある全文とあわせて読んでください。
if not walkable(level, destination, state.floating):
event = 'sunk' if destination in level.light | level.heavy else 'water'
return Result(state, event)実行して確かめる
教材のZIPを展開したフォルダーで、はじめる準備で用意した環境を使って、次のコマンドを実行します。
.venv\Scripts\python.exe 02_movement.py.venv/bin/python 02_movement.py(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 だけでは、コードの変更は反映されません。
このステップのコード全文(365行)
# -*- 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)陸の上で、二行をまとめて交換する
まとめて一度に変える更新・いつも守るルール・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 の一部です。ステップの最後にある全文とあわせて読んでください。
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を展開したフォルダーで、はじめる準備で用意した環境を使って、次のコマンドを実行します。
.venv\Scripts\python.exe 03_atomic_swap.py.venv/bin/python 03_atomic_swap.py(1,2) に進み MOVES 1。そこで Space を押して離すと bank で受け付けられず、(1,2)・L・MOVES 1・CHANGES 1 のままです。右をさらに5回、毎回離せばゴールです。- 陸かどうかを調べてから、
floatingとchangesを更新する - 1つの
floatingで、川全体をいっせいに切りかえる - おかしな札・同じ札・石の上での入れかえを区別する
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 ではコードは読み直されません。
このステップのコード全文(381行)
# -*- 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)現在を保ったまま、交換後を予告する
ゲームの中身と画面・予告と確定・キーを離すまで待つ。
使うのは、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 の一部です。ステップの最後にある全文とあわせて読んでください。
if 'SPACE' in edges:
self.editing = True
self.proposed = opposite(self.state.floating)
self.gate = True
return実行して確かめる
教材のZIPを展開したフォルダーで、はじめる準備で用意した環境を使って、次のコマンドを実行します。
.venv\Scripts\python.exe 04_preview.py.venv/bin/python 04_preview.py(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 ではコードは読み直されません。
このステップのコード全文(400行)
# -*- 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)一手を戻し、三つの川で陸を探す
状態の履歴・元のゲームの中身・配置のちがい。
使うのは、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 の一部です。ステップの最後にある全文とあわせて読んでください。
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を展開したフォルダーで、はじめる準備で用意した環境を使って、次のコマンドを実行します。
.venv\Scripts\python.exe 05_word_bridge.py.venv/bin/python 05_word_bridge.py(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 では書きかえたコードは読み直されません。
このステップのコード全文(368行)
# -*- 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)仕組みがわかったら、元の完成版を読んでみよう。
軽い石と重い石に、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)を新しく出力します。保存と読みこみの画面、動画や自動の記録はありません。 | 記録の機能と、デモ・比較・動画用の補助ツールがあります。 |
完成版のコードで、次に読むところ
- model.py: Level.__post_init__の地形検査
- model.py: walkable/transitionの拒否順と不変State
- model.py: apply_rulesの二通り検査とsame
- model.py: invariant/state_hash/semantic
- game.py: Game.act/undoの確定行動履歴
- game.py: Game.updateのgateと入力優先順
- app.py: 日本語字形と現在/予告の描き分け
- 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、物理キー、人の学習試験を確認したものではありません。ブラウザー版の確認範囲は確認状況をご覧ください。
