シミュレーションLESSON 70板の配置と整数の振動を読む建築パズル原作073

橋を渡る楽団の作り方

同じ重さでも、足拍子で揺れが変わる。
板そのものの重さと人の重さに、揺れの「回転」「弱まり(減衰)」「着地の衝撃」を表す小さな整数の模型を加え、1刻(1回の時間の区切り)先の予告と Undo を作ります。

完成版の遊び方同じ足拍子で大きくなる揺れを読み、軽い板と筋交いの板を組み合わせて、全員が渡りきれる橋を組みます。

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

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

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

完成版の主な操作:左右桁/上下板、Z交換、Enter試運転、Space一刻、P連続/停止、X戻す

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

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

うまく表示されないとき

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

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

同時に進む歩行と、着いたときだけの衝撃を分けて扱う。

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

1. 入ってくる時刻と、進む間隔が違う移動
2. 編集中の状態と、橋全体の重さの制限
3. 揺れの回転・弱まり(減衰)・着いたときだけの衝撃
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / tuple と dataclass/replace / 整数の割り算(//)と余り(%)
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
Enter:編集から試運転を開始 / Space:一刻だけ進める / 左右:02以降、編集する桁を選ぶ / 上下:02以降、板の候補を選ぶ / Z:02以降、候補を確定 / X:05で一判断を戻す / R:初期配置からやり直す。新しい押下を優先 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_walkers.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

三人の歩行を、整数の時計へ

入ってくる時刻と、進む間隔が違う移動。

毎フレーム動かす代わりに、Space を1回押すと時間が1刻進むようにします。Enter で始めると、3人がそれぞれ1・5・9刻に左岸から橋に入ります。どの人も4刻ごとに次の桁(橋げた)へ進みます。この段階では、まだ板を選んだり揺れで壊れたりはしません。元のゲームの状況 A の時計を読む練習です。

位置0は左岸、1〜3が3本の桁、4が右岸です。画面の板の番号0〜2とは、1つずれています。人の通り道に両岸を含めているためです。時刻 t が入ってくる時刻 entry より前なら位置は0、そうでなければ 1+(t-entry)//beat です。整数の割り算なので、同じ板に4刻とどまります。min を使って、右岸より先へ行かないようにします。

3人の位置を、新しい時刻から一度にまとめて求め、そのあとで各桁に立っている人数を count で数えます。橋にかかる重さ(負荷)は、普通の板の重さ2+人数×2 です。表示を更新する draw では、時刻を進めません。キーを離してから次を押す仕組みなので、長押ししても1刻しか進みません。

3人が右岸に着いた21刻で done(終了)です。終わったあとに Space を押しても、同じ State を返します。まずはこの小さな時計だけを動かし、あとで揺れも同じ1刻の中に足していきましょう。

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

01_walkers.py / 注目する部分
    positions=tuple(0 if t<entry else min(n+1,1+(t-entry)//beat)
                    for entry,beat in zip(level.entries,level.beats))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_walkers.py
macOS / Linux
.venv/bin/python 01_walkers.py
画面で見ることEnter → Space を5回押します。TICK 5、人の位置 (2,1,0)、3つの桁の LOAD(負荷)は (4,4,2) です。Space を合計21回押すと、全員が右岸に着いて DONE になります。
  • 時刻を整数で1つずつ進める
  • 橋に入る前と、入ったあとを分ける
  • 人の通り道の位置と、板の番号を区別する
つまずきやすいところ人の位置1は、板の番号0です。その桁に立っている人数は、positions.count(i) ではなく count(i+1) で調べます。
少し変えてみよう

LEVELS の最初の A にある入ってくる時刻の表 (1, 5, 9) だけを (2, 5, 9) に変えてみましょう。Enter → Space 1回では3人とも左岸にいて、2回目で1人目が板に乗ります。C にも同じ表がありますが、この練習では A だけを変え、確認したら元に戻します。

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

"""橋を渡る楽団・教材1/5。整数の玩具で、現実の橋の安全計算ではありません。"""
from dataclasses import dataclass,replace,asdict
WEIGHTS = (2, 1, 3)
STRENGTHS = (11, 9, 16)

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    widths: tuple
    capacity: int
    entries: tuple
    beats: tuple
    limit: int = 32
LEVELS = (Level('A', 'そろい足の三人', (1, 2, 1), 6, (1, 5, 9), (4, 4, 4)), Level('B', 'ばらけた足拍子', (1, 2, 1), 6, (1, 4, 7), (3, 3, 3)), Level('C', '中央が二枚の橋', (1, 2, 2, 1), 8, (1, 5, 9), (4, 4, 4)))

@dataclass(frozen=True)
class State:
    boards: tuple
    t: int = 0
    positions: tuple = (0,0,0)
    loads: tuple = ()
    status: str = 'edit'

def initial(level):
    n=len(level.widths)
    return State((0,)*n,loads=(2,)*n)

def total(s):
    return sum((WEIGHTS[b] for b in s.boards))

def advance(level,s,impact=4):
    if s.status!='run':
        return s
    t=s.t+1
    n=len(s.boards)
    positions=tuple(0 if t<entry else min(n+1,1+(t-entry)//beat)
                    for entry,beat in zip(level.entries,level.beats))
    loads=tuple(WEIGHTS[b]+2*positions.count(i+1) for i,b in enumerate(s.boards))
    status='done' if all(p==n+1 for p in positions) else ('time' if t>=level.limit else 'run')
    return replace(s,t=t,positions=positions,loads=loads,status=status)

STAGE = 1
LEVEL = LEVELS[0]
BOARD_LABELS = ("PLAIN","LIGHT","BRACE")

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state=initial(LEVEL)
        self.cursor=0
        self.pending=0
        self.gated=False
        self.message='READY'

    def commit(self,action):
        before=self.state
        state=self.state
        if action=='start' and state.status=='edit' and total(state)<=LEVEL.capacity:
            self.state=replace(state,status='run')
        elif action=='tick':
            self.state=advance(LEVEL,state)
        elif action.startswith('board:') and state.status=='edit':
            _,index,board=action.split(':')
            index,board=int(index),int(board)
            if 0<=index<len(state.boards) and 0<=board<3:
                boards=list(state.boards);boards[index]=board
                self.state=replace(state,boards=tuple(boards),loads=tuple(WEIGHTS[b] for b in boards))
        self.message=action.upper() if self.state!=before else 'NO CHANGE / REFUSED'
        if action=='start' and total(self.state)>LEVEL.capacity:
            self.message='TOO HEAVY: LIGHTEN A BOARD'

    def control(self,pressed,held):
        if 'R' in pressed:
            self.reset();self.gated=True
            return
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.gated=True;self.message='RELEASE ALL KEYS'
            return
        if len(pressed)!=1:return
        key=next(iter(pressed));self.gated=True
        if key=='RETURN':self.commit('start')
        elif key=='SPACE':self.commit('tick')

    def draw(self,p):
        s=self.state;n=len(s.boards)
        p.cls(1)
        p.text(12,10,'BRIDGE ORCHESTRA / STAGE '+str(STAGE)+' / A',10)
        p.text(12,27,'TICK '+str(s.t)+' / STATUS '+s.status.upper(),7)
        p.text(12,44,'BOARD WEIGHT '+str(total(s))+' / CAP '+str(LEVEL.capacity),8 if total(s)>LEVEL.capacity else 11)
        p.text(12,62,'ENTRY 1,5,9 / WALK EVERY 4 TICKS / PEOPLE WEIGHT 2',6)
        for i,board in enumerate(s.boards):
            x=72+i*120
            p.rect(x,145,116,18,(5,6,9)[board]);p.rectb(x,145,116,18,10 if i==self.cursor else 7)
            p.text(x+8,175,str(i)+': '+BOARD_LABELS[board],7)
            p.text(x+8,192,'LOAD '+str(s.loads[i]),7)
            if hasattr(s,'vectors'):
                p.text(x+8,209,'V '+str(s.vectors[i]),14)
                p.text(x+8,226,'HITS '+str(s.strikes[i]),14)
            if STAGE>=4:
                p.text(x+8,243,'LIMIT '+str(limits(LEVEL,s)[i]),8 if i in s.faults else 11)
        p.rect(12,145,54,80,3);p.rect(438,145,54,80,3)
        for i,position in enumerate(s.positions):
            x=35 if position==0 else 465 if position==n+1 else 125+(position-1)*120
            y=130-i*14
            p.circ(x,y,5,(8,10,12)[i]);p.text(x-1,y-2,str(i+1),1)
        if STAGE>=2:p.text(12,275,'CANDIDATE '+str(self.cursor)+': '+BOARD_LABELS[self.pending]+' (Z TO COMMIT)',7)
        if STAGE>=4:
            q=forecast(LEVEL,s)
            p.text(12,294,'NEXT TICK LOADS '+str(q.loads) if q else 'NO NEXT-TICK FORECAST',6)
            p.text(12,311,'FAULT SEGMENTS '+str(s.faults)+' / PEAK '+str(s.peak),8 if s.faults else 6)
        elif STAGE==3:p.text(12,294,'PRACTICE: LOADS ONLY / STRENGTH NOT CHECKED YET',14)
        else:p.text(12,294,'PRACTICE: QUIET FOOTSTEPS / NO STRENGTH CHECK',14)
        p.text(12,350,self.message,10)
        p.text(12,372,'ENTER: START / SPACE: ONE TICK / R: RESET / ESC: QUIT',7)
        if STAGE>=2:p.text(12,389,'ARROWS: SEGMENT + CANDIDATE / Z: COMMIT IN EDIT',7)
        if STAGE==5:p.text(12,406,'X: UNDO (DECISION COUNT DOES NOT REWIND)',7)

def make_demo_keys():
    return ('RETURN',)+('SPACE',)*5

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(508,432,title='Bridge orchestra lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=make_demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set()
                app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 02 / 05

交換候補と、確定した板を分ける

編集中の状態と、橋全体の重さの制限。

板を、普通 PLAIN・軽い LIGHT・筋交い BRACE(斜めの補強が入った板)から選べるようにします。重さはそれぞれ 2・1・3 です。左右キーで桁を選び、上下キーで候補を変え、Z で確定します。候補の欄を変えただけでは、橋は変わりません。小さな選択画面でも、カーソル・候補と、実際の State を分けておくと、あとから取り消しや予告を加えやすくなります。

板の重さの合計は、WEIGHTS から板の種類ごとの重さを取り出して足した sum です。状況 A の上限は6です。真ん中を筋交いに変えただけだと(普通,筋交い,普通)で7になり、Enter を押しても始められません。橋が重すぎる状態は編集の途中なら許し、試運転を始めるところで検査する作りです。

右端を軽い板に変えれば(普通,筋交い,軽い)で6になります。Enter で run(試運転中)になり、そのあとは編集キーで橋を変えられません。同じ試運転の途中で、板だけが変わってしまうのを防ぐためです。R で最初の並びに戻して、別の橋を試しましょう。

この段階には、足の衝撃も、板の強さ(耐力)による破損もありません。LOAD は、板の重さと人の重さだけです。筋交いを選ぶ意味を、先に成功・失敗でごまかさず、次に揺れを、そのあとに耐力を足していきます。

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

02_boards.py / 注目する部分
        if action=='start' and state.status=='edit' and total(state)<=LEVEL.capacity:
            self.state=replace(state,status='run')

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_boards.py
macOS / Linux
.venv/bin/python 02_boards.py
画面で見ること右 → 上 → 上 → Z で、真ん中を BRACE にします。重さ7なので、Enter を押しても EDIT のままです。右 → 上 → Z で右端を LIGHT にすると、重さ6になります。Enter → Space を9回押すと、位置 (3,2,1)、LOAD (4,5,3) です。
  • 候補と、確定した板を分ける
  • 橋全体の板の重さを足し算する
  • 試運転の間は編集しない
つまずきやすいところ上下キーで候補を変えただけで、boards(確定した板)を書き換えないでください。また、上限を超えたときは「成功」にせず、始めるのを断ります。
少し変えてみよう

LEVELS の最初の A にある「(1, 2, 1), 6,」の6だけを7に変えてみましょう。(普通,筋交い,普通)の重さ7でも、Enter で始められます。これは橋全体で許される重さだけを変える練習で、人の重さは変わりません。確認したら6に戻します。

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

"""橋を渡る楽団・教材2/5。整数の玩具で、現実の橋の安全計算ではありません。"""
from dataclasses import dataclass,replace,asdict
WEIGHTS = (2, 1, 3)
STRENGTHS = (11, 9, 16)

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    widths: tuple
    capacity: int
    entries: tuple
    beats: tuple
    limit: int = 32
LEVELS = (Level('A', 'そろい足の三人', (1, 2, 1), 6, (1, 5, 9), (4, 4, 4)), Level('B', 'ばらけた足拍子', (1, 2, 1), 6, (1, 4, 7), (3, 3, 3)), Level('C', '中央が二枚の橋', (1, 2, 2, 1), 8, (1, 5, 9), (4, 4, 4)))

@dataclass(frozen=True)
class State:
    boards: tuple
    t: int = 0
    positions: tuple = (0,0,0)
    loads: tuple = ()
    status: str = 'edit'

def initial(level):
    n=len(level.widths)
    return State((0,)*n,loads=(2,)*n)

def total(s):
    return sum((WEIGHTS[b] for b in s.boards))

def advance(level,s,impact=4):
    if s.status!='run':
        return s
    t=s.t+1
    n=len(s.boards)
    positions=tuple(0 if t<entry else min(n+1,1+(t-entry)//beat)
                    for entry,beat in zip(level.entries,level.beats))
    loads=tuple(WEIGHTS[b]+2*positions.count(i+1) for i,b in enumerate(s.boards))
    status='done' if all(p==n+1 for p in positions) else ('time' if t>=level.limit else 'run')
    return replace(s,t=t,positions=positions,loads=loads,status=status)

STAGE = 2
LEVEL = LEVELS[0]
BOARD_LABELS = ("PLAIN","LIGHT","BRACE")

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state=initial(LEVEL)
        self.cursor=0
        self.pending=0
        self.gated=False
        self.message='READY'

    def commit(self,action):
        before=self.state
        state=self.state
        if action=='start' and state.status=='edit' and total(state)<=LEVEL.capacity:
            self.state=replace(state,status='run')
        elif action=='tick':
            self.state=advance(LEVEL,state)
        elif action.startswith('board:') and state.status=='edit':
            _,index,board=action.split(':')
            index,board=int(index),int(board)
            if 0<=index<len(state.boards) and 0<=board<3:
                boards=list(state.boards);boards[index]=board
                self.state=replace(state,boards=tuple(boards),loads=tuple(WEIGHTS[b] for b in boards))
        self.message=action.upper() if self.state!=before else 'NO CHANGE / REFUSED'
        if action=='start' and total(self.state)>LEVEL.capacity:
            self.message='TOO HEAVY: LIGHTEN A BOARD'

    def control(self,pressed,held):
        if 'R' in pressed:
            self.reset();self.gated=True
            return
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.gated=True;self.message='RELEASE ALL KEYS'
            return
        if len(pressed)!=1:return
        key=next(iter(pressed));self.gated=True
        if key=='RETURN':self.commit('start')
        elif key=='SPACE':self.commit('tick')
        elif self.state.status=='edit':
            if key in ('LEFT','RIGHT'):
                self.cursor=(self.cursor+(-1 if key=='LEFT' else 1))%len(self.state.boards)
                self.pending=self.state.boards[self.cursor]
            elif key in ('UP','DOWN'):
                self.pending=(self.pending+(1 if key=='UP' else -1))%3
            elif key=='Z':self.commit('board:'+str(self.cursor)+':'+str(self.pending))

    def draw(self,p):
        s=self.state;n=len(s.boards)
        p.cls(1)
        p.text(12,10,'BRIDGE ORCHESTRA / STAGE '+str(STAGE)+' / A',10)
        p.text(12,27,'TICK '+str(s.t)+' / STATUS '+s.status.upper(),7)
        p.text(12,44,'BOARD WEIGHT '+str(total(s))+' / CAP '+str(LEVEL.capacity),8 if total(s)>LEVEL.capacity else 11)
        p.text(12,62,'ENTRY 1,5,9 / WALK EVERY 4 TICKS / PEOPLE WEIGHT 2',6)
        for i,board in enumerate(s.boards):
            x=72+i*120
            p.rect(x,145,116,18,(5,6,9)[board]);p.rectb(x,145,116,18,10 if i==self.cursor else 7)
            p.text(x+8,175,str(i)+': '+BOARD_LABELS[board],7)
            p.text(x+8,192,'LOAD '+str(s.loads[i]),7)
            if hasattr(s,'vectors'):
                p.text(x+8,209,'V '+str(s.vectors[i]),14)
                p.text(x+8,226,'HITS '+str(s.strikes[i]),14)
            if STAGE>=4:
                p.text(x+8,243,'LIMIT '+str(limits(LEVEL,s)[i]),8 if i in s.faults else 11)
        p.rect(12,145,54,80,3);p.rect(438,145,54,80,3)
        for i,position in enumerate(s.positions):
            x=35 if position==0 else 465 if position==n+1 else 125+(position-1)*120
            y=130-i*14
            p.circ(x,y,5,(8,10,12)[i]);p.text(x-1,y-2,str(i+1),1)
        if STAGE>=2:p.text(12,275,'CANDIDATE '+str(self.cursor)+': '+BOARD_LABELS[self.pending]+' (Z TO COMMIT)',7)
        if STAGE>=4:
            q=forecast(LEVEL,s)
            p.text(12,294,'NEXT TICK LOADS '+str(q.loads) if q else 'NO NEXT-TICK FORECAST',6)
            p.text(12,311,'FAULT SEGMENTS '+str(s.faults)+' / PEAK '+str(s.peak),8 if s.faults else 6)
        elif STAGE==3:p.text(12,294,'PRACTICE: LOADS ONLY / STRENGTH NOT CHECKED YET',14)
        else:p.text(12,294,'PRACTICE: QUIET FOOTSTEPS / NO STRENGTH CHECK',14)
        p.text(12,350,self.message,10)
        p.text(12,372,'ENTER: START / SPACE: ONE TICK / R: RESET / ESC: QUIT',7)
        if STAGE>=2:p.text(12,389,'ARROWS: SEGMENT + CANDIDATE / Z: COMMIT IN EDIT',7)
        if STAGE==5:p.text(12,406,'X: UNDO (DECISION COUNT DOES NOT REWIND)',7)

def make_demo_keys():
    return ('RIGHT','UP','UP','Z','RIGHT','UP','Z','RETURN')+('SPACE',)*9

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(508,432,title='Bridge orchestra lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=make_demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set()
                app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 03 / 05

足拍子を、向きのある整数へ足す

揺れの回転・弱まり(減衰)・着いたときだけの衝撃。

各桁に2つの整数 (x,y) を持たせ、毎刻 (y,-x) に回します。これは本物の振動の方程式を解くものではなく、足拍子のタイミングで揺れが重なったり打ち消し合ったりする、元のゲームのおもちゃの模型です。現実の橋の設計や安全性を判断する式ではありません。

4の倍数の刻では、回したあとで、各値を0に1だけ近づけます(damping:揺れの弱まり)。damping(2)=1、damping(-2)=-1、damping(0)=0 です。毎回ただ1を引くと、マイナスの値はかえって大きくなってしまうので、プラスかマイナスか(符号)を見て近づけます。そのあとで、新しい足の衝撃を x に加えます。この順番を変えると、別の模型になってしまいます。

人がその桁に新しく着いたときだけ、strikes(足の衝撃の回数)を増やします。同じ板に4刻立っていても、毎刻足踏みしているわけではありません。人数の重さは毎刻加えますが、足の衝撃4は着いたときだけです。揺れの大きさは max(abs(x),abs(y)) で、これを板の重さ+人数×2 に加えます。

まだ板の強さ(耐力)の判定はありません。全部普通の板でも壊れず、元のゲームでは壊れてしまう13刻の LOAD を読める練習です。画面にも STRENGTH NOT CHECKED YET(強さはまだ調べていない)と表示します。見た目の揺れではなく、V(揺れ)・HITS(衝撃)・LOAD の数字を確かめてから次へ進みましょう。

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

03_oscillation.py / 注目する部分
    vectors=tuple((x+impact*k,y) for (x,y),k in zip((rotate(v,t) for v in s.vectors),strikes))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_oscillation.py
macOS / Linux
.venv/bin/python 03_oscillation.py
画面で見ること02 と同じ(普通,筋交い,軽い)にして、Enter → Space を9回押します。V は ((10,0),(7,0),(4,0))、HITS は (1,1,1)、LOAD (14,12,7)、PEAK 10 です。
  • 回転 → 弱まり → 衝撃の順に計算する
  • 新しく着いた人だけを数える
  • 止まった重さと、揺れを分ける
つまずきやすいところ人がそこに「いる」ことと、この刻に「着いた」ことを混同しないでください。新しい positions と古い s.positions を比べて、衝撃が1回だけになるようにします。
少し変えてみよう

advance の引数「impact=4」だけを「impact=0」に変えて、同じ9刻を試してみましょう。V は全部 (0,0)、PEAK 0、LOAD (4,5,3) になります。人の重さと、入ってくる時刻の表はそのままです。確認したら4に戻します。

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

"""橋を渡る楽団・教材3/5。整数の玩具で、現実の橋の安全計算ではありません。"""
from dataclasses import dataclass,replace,asdict
WEIGHTS = (2, 1, 3)
STRENGTHS = (11, 9, 16)

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    widths: tuple
    capacity: int
    entries: tuple
    beats: tuple
    limit: int = 32
LEVELS = (Level('A', 'そろい足の三人', (1, 2, 1), 6, (1, 5, 9), (4, 4, 4)), Level('B', 'ばらけた足拍子', (1, 2, 1), 6, (1, 4, 7), (3, 3, 3)), Level('C', '中央が二枚の橋', (1, 2, 2, 1), 8, (1, 5, 9), (4, 4, 4)))

@dataclass(frozen=True)
class State:
    boards: tuple
    t: int = 0
    positions: tuple = (0,0,0)
    loads: tuple = ()
    status: str = 'edit'
    vectors: tuple = ()
    strikes: tuple = ()
    peak: int = 0

def initial(level):
    n=len(level.widths)
    return State((0,)*n,loads=(2,)*n,vectors=((0,0),)*n,strikes=(0,)*n)

def total(s):
    return sum((WEIGHTS[b] for b in s.boards))

def damping(v):
    return v - (v > 0) + (v < 0)

def rotate(pair, t):
    x, y = pair
    y, x = (-x, y)
    return (damping(x), damping(y)) if t % 4 == 0 else (x, y)

def advance(level,s,impact=4):
    if s.status!='run':
        return s
    t=s.t+1
    n=len(s.boards)
    positions=tuple(0 if t<entry else min(n+1,1+(t-entry)//beat)
                    for entry,beat in zip(level.entries,level.beats))
    strikes=tuple(sum(p!=old and p==i+1 for p,old in zip(positions,s.positions)) for i in range(n))
    vectors=tuple((x+impact*k,y) for (x,y),k in zip((rotate(v,t) for v in s.vectors),strikes))
    amplitudes=tuple(max(abs(x),abs(y)) for x,y in vectors)
    loads=tuple(WEIGHTS[b]+2*positions.count(i+1)+amplitudes[i] for i,b in enumerate(s.boards))
    status='done' if all(p==n+1 for p in positions) else ('time' if t>=level.limit else 'run')
    return replace(s,t=t,positions=positions,loads=loads,status=status,vectors=vectors,strikes=strikes,peak=max(s.peak,*amplitudes))

STAGE = 3
LEVEL = LEVELS[0]
BOARD_LABELS = ("PLAIN","LIGHT","BRACE")

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state=initial(LEVEL)
        self.cursor=0
        self.pending=0
        self.gated=False
        self.message='READY'

    def commit(self,action):
        before=self.state
        state=self.state
        if action=='start' and state.status=='edit' and total(state)<=LEVEL.capacity:
            self.state=replace(state,status='run')
        elif action=='tick':
            self.state=advance(LEVEL,state)
        elif action.startswith('board:') and state.status=='edit':
            _,index,board=action.split(':')
            index,board=int(index),int(board)
            if 0<=index<len(state.boards) and 0<=board<3:
                boards=list(state.boards);boards[index]=board
                self.state=replace(state,boards=tuple(boards),loads=tuple(WEIGHTS[b] for b in boards))
        self.message=action.upper() if self.state!=before else 'NO CHANGE / REFUSED'
        if action=='start' and total(self.state)>LEVEL.capacity:
            self.message='TOO HEAVY: LIGHTEN A BOARD'

    def control(self,pressed,held):
        if 'R' in pressed:
            self.reset();self.gated=True
            return
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.gated=True;self.message='RELEASE ALL KEYS'
            return
        if len(pressed)!=1:return
        key=next(iter(pressed));self.gated=True
        if key=='RETURN':self.commit('start')
        elif key=='SPACE':self.commit('tick')
        elif self.state.status=='edit':
            if key in ('LEFT','RIGHT'):
                self.cursor=(self.cursor+(-1 if key=='LEFT' else 1))%len(self.state.boards)
                self.pending=self.state.boards[self.cursor]
            elif key in ('UP','DOWN'):
                self.pending=(self.pending+(1 if key=='UP' else -1))%3
            elif key=='Z':self.commit('board:'+str(self.cursor)+':'+str(self.pending))

    def draw(self,p):
        s=self.state;n=len(s.boards)
        p.cls(1)
        p.text(12,10,'BRIDGE ORCHESTRA / STAGE '+str(STAGE)+' / A',10)
        p.text(12,27,'TICK '+str(s.t)+' / STATUS '+s.status.upper(),7)
        p.text(12,44,'BOARD WEIGHT '+str(total(s))+' / CAP '+str(LEVEL.capacity),8 if total(s)>LEVEL.capacity else 11)
        p.text(12,62,'ENTRY 1,5,9 / WALK EVERY 4 TICKS / PEOPLE WEIGHT 2',6)
        for i,board in enumerate(s.boards):
            x=72+i*120
            p.rect(x,145,116,18,(5,6,9)[board]);p.rectb(x,145,116,18,10 if i==self.cursor else 7)
            p.text(x+8,175,str(i)+': '+BOARD_LABELS[board],7)
            p.text(x+8,192,'LOAD '+str(s.loads[i]),7)
            if hasattr(s,'vectors'):
                p.text(x+8,209,'V '+str(s.vectors[i]),14)
                p.text(x+8,226,'HITS '+str(s.strikes[i]),14)
            if STAGE>=4:
                p.text(x+8,243,'LIMIT '+str(limits(LEVEL,s)[i]),8 if i in s.faults else 11)
        p.rect(12,145,54,80,3);p.rect(438,145,54,80,3)
        for i,position in enumerate(s.positions):
            x=35 if position==0 else 465 if position==n+1 else 125+(position-1)*120
            y=130-i*14
            p.circ(x,y,5,(8,10,12)[i]);p.text(x-1,y-2,str(i+1),1)
        if STAGE>=2:p.text(12,275,'CANDIDATE '+str(self.cursor)+': '+BOARD_LABELS[self.pending]+' (Z TO COMMIT)',7)
        if STAGE>=4:
            q=forecast(LEVEL,s)
            p.text(12,294,'NEXT TICK LOADS '+str(q.loads) if q else 'NO NEXT-TICK FORECAST',6)
            p.text(12,311,'FAULT SEGMENTS '+str(s.faults)+' / PEAK '+str(s.peak),8 if s.faults else 6)
        elif STAGE==3:p.text(12,294,'PRACTICE: LOADS ONLY / STRENGTH NOT CHECKED YET',14)
        else:p.text(12,294,'PRACTICE: QUIET FOOTSTEPS / NO STRENGTH CHECK',14)
        p.text(12,350,self.message,10)
        p.text(12,372,'ENTER: START / SPACE: ONE TICK / R: RESET / ESC: QUIT',7)
        if STAGE>=2:p.text(12,389,'ARROWS: SEGMENT + CANDIDATE / Z: COMMIT IN EDIT',7)
        if STAGE==5:p.text(12,406,'X: UNDO (DECISION COUNT DOES NOT REWIND)',7)

def make_demo_keys():
    return ('RIGHT','UP','UP','Z','RIGHT','UP','Z','RETURN')+('SPACE',)*9

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(508,432,title='Bridge orchestra lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=make_demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set()
                app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 04 / 05

全ての桁を検査して、結果を確定する

ぴったりの境目の扱いと、1刻先の予告。

ここで、元のゲームの状況 A の試運転のルールを完成させます。板の強さ(耐力)は、普通の板11、軽い板9、筋交いの板16 です。支える長さ width が1の両端の桁は +4 され、状況 A の3つの桁の支える長さは (1,2,1) です。負荷と耐力は同じ整数の単位です。負荷が耐力とちょうど同じならセーフで、超えたときだけ、その桁を faults(壊れた桁)に入れます。

すべての桁を、同じ新しい時刻で検査します。最初の1か所を見つけた時点でやめず、超えたすべての桁をまとめます。判定の順番は、①破損、②全員が右岸に着いたか、③32刻の期限、です。今の State が run でなければ進めません。最初の全部普通の板だと、13刻に真ん中の桁が負荷14・耐力11になって止まります。

NEXT TICK LOADS は、今確定している板のまま1刻進めたときの予告です。この関数は新しい State を返すだけで、今の状態を書き換えません。EDIT のときは「始めた直後」から1刻、RUN のときは今から1刻を予告します。候補の欄で選んでいる、まだ確定していない板の予言ではありません。重さの上限を超えて始められないときや、結果の画面では、予告を出しません。

真ん中を筋交い、右端を軽い板にすると、重さ6で21刻を渡りきれます。左端も軽くすると、重さ5でも渡りきれます。一番軽い組み合わせだけを成功とする隠れた採点はありません。04/05 のモデルは元のゲームの状況 A/B/C を持っていますが、この教材の画面は A だけです。B/C や、静かな足との比較は、元のゲームを読むときの発展の項目です。

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

04_strength.py / 注目する部分
    faults = tuple((i for i, (v, lim) in enumerate(zip(loads, limits(level, s))) if v > lim))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_strength.py
macOS / Linux
.venv/bin/python 04_strength.py
画面で見ること右 → 上 → 上 → Z → 右 → 上 → Z → Enter と押し、そのあと Space を21回押します。DONE、TICK 21、LOAD (9,11,10)、FAULT SEGMENTS ()、PEAK 10 です。全部普通の板で始めると、13刻に FAULT になります。
  • 耐力とちょうど同じなら、セーフにする
  • 壊れた桁は全部まとめる
  • 予告しても、今の状態は変えない
つまずきやすいところ「負荷 >= 耐力」で壊すと、ちょうどの境目の結果が変わってしまいます。元のゲームは「負荷 > 耐力」のときだけ壊れます。また、着いたことだけを先に確定せず、その刻の負荷を先に検査します。
少し変えてみよう

R から、左端を 上 → Z で LIGHT にし、真ん中の BRACE と右端の LIGHT も確定します。重さ5で、同じ21刻の DONE になることを確かめましょう。元の重さ6での成功も正しいクリアで、軽くするのは自由に選べる工夫です。

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

"""橋を渡る楽団・教材4/5。整数の玩具で、現実の橋の安全計算ではありません。"""
from dataclasses import dataclass, replace, asdict

import hashlib, json

VERSION = '073-v1.1'

BOARD_NAMES = ('普通板', '軽い板', '筋交い板')

WEIGHTS = (2, 1, 3)

STRENGTHS = (11, 9, 16)

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    widths: tuple
    capacity: int
    entries: tuple
    beats: tuple
    limit: int = 32

LEVELS = (Level('A', 'そろい足の三人', (1, 2, 1), 6, (1, 5, 9), (4, 4, 4)), Level('B', 'ばらけた足拍子', (1, 2, 1), 6, (1, 4, 7), (3, 3, 3)), Level('C', '中央が二枚の橋', (1, 2, 2, 1), 8, (1, 5, 9), (4, 4, 4)))

@dataclass(frozen=True)
class State:
    boards: tuple
    t: int = 0
    vectors: tuple = ()
    positions: tuple = (0, 0, 0)
    loads: tuple = ()
    strikes: tuple = ()
    faults: tuple = ()
    peak: int = 0
    status: str = 'edit'
    reason: str = '板を選んで、同じ楽団を試そう。'

MAX_DECISIONS = 256

def initial(level):
    n = len(level.widths)
    return State((0,) * n, vectors=((0, 0),) * n, loads=(2,) * n, strikes=(0,) * n)

def total(s):
    return sum((WEIGHTS[b] for b in s.boards))

def limits(level, s):
    return tuple((STRENGTHS[b] + (4 if w == 1 else 0) for b, w in zip(s.boards, level.widths)))

def damping(v):
    return v - (v > 0) + (v < 0)

def rotate(pair, t):
    x, y = pair
    y, x = (-x, y)
    return (damping(x), damping(y)) if t % 4 == 0 else (x, y)

def advance(level, s, impact=4):
    if s.status != 'run':
        return s
    t = s.t + 1
    n = len(s.boards)
    vec = [rotate(p, t) for p in s.vectors]
    pos = []
    strikes = [0] * n
    for e, b, old in zip(level.entries, level.beats, s.positions):
        p = 0 if t < e else min(n + 1, 1 + (t - e) // b)
        pos.append(p)
        if p != old and 1 <= p <= n:
            strikes[p - 1] += 1
    vec = tuple(((x + impact * k, y) for (x, y), k in zip(vec, strikes)))
    amps = [max(abs(x), abs(y)) for x, y in vec]
    loads = tuple((WEIGHTS[s.boards[i]] + 2 * pos.count(i + 1) + amps[i] for i in range(n)))
    faults = tuple((i for i, (v, lim) in enumerate(zip(loads, limits(level, s))) if v > lim))
    status = 'fault' if faults else 'done' if all((p == n + 1 for p in pos)) else 'time' if t >= level.limit else 'run'
    reason = '桁 ' + ','.join((str(i + 1) for i in faults)) + ' が耐力を超えました。Xで戻して原因を確認。' if faults else '全員が右岸へ。あなたの橋を渡り切りました。' if status == 'done' else '32刻で試運転終了。' if status == 'time' else '足拍子と揺れの向きを見比べよう。' if impact else '静かな足。揺れはなく、板と人の重さで比べます。'
    return replace(s, t=t, vectors=vec, positions=tuple(pos), loads=loads, strikes=tuple(strikes), faults=faults, peak=max(s.peak, *amps), status=status, reason=reason)

def simulate(level, s, impact=4):
    if total(s) > level.capacity:
        return replace(s, status='weight', reason='岸の支えには重い。試運転できません。')
    s = replace(s, status='run') if s.status == 'edit' else s
    while s.status == 'run':
        s = advance(level, s, impact)
    return s

def forecast(level, s, impact=4):
    if total(s) > level.capacity:
        return None
    if s.status == 'edit':
        s = replace(s, status='run')
    return advance(level, s, impact) if s.status == 'run' else None

def digest(x):
    return hashlib.sha256(json.dumps(asdict(x) if hasattr(x, '__dataclass_fields__') else x, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest()

STAGE = 4
LEVEL = LEVELS[0]
BOARD_LABELS = ("PLAIN","LIGHT","BRACE")

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.state=initial(LEVEL)
        self.cursor=0
        self.pending=0
        self.gated=False
        self.message='READY'

    def commit(self,action):
        before=self.state
        state=self.state
        if action=='start' and state.status=='edit' and total(state)<=LEVEL.capacity:
            self.state=replace(state,status='run')
        elif action=='tick':
            self.state=advance(LEVEL,state)
        elif action.startswith('board:') and state.status=='edit':
            _,index,board=action.split(':')
            index,board=int(index),int(board)
            if 0<=index<len(state.boards) and 0<=board<3:
                boards=list(state.boards);boards[index]=board
                self.state=replace(state,boards=tuple(boards),loads=tuple(WEIGHTS[b] for b in boards))
        self.message=action.upper() if self.state!=before else 'NO CHANGE / REFUSED'
        if action=='start' and total(self.state)>LEVEL.capacity:
            self.message='TOO HEAVY: LIGHTEN A BOARD'

    def control(self,pressed,held):
        if 'R' in pressed:
            self.reset();self.gated=True
            return
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.gated=True;self.message='RELEASE ALL KEYS'
            return
        if len(pressed)!=1:return
        key=next(iter(pressed));self.gated=True
        if key=='RETURN':self.commit('start')
        elif key=='SPACE':self.commit('tick')
        elif self.state.status=='edit':
            if key in ('LEFT','RIGHT'):
                self.cursor=(self.cursor+(-1 if key=='LEFT' else 1))%len(self.state.boards)
                self.pending=self.state.boards[self.cursor]
            elif key in ('UP','DOWN'):
                self.pending=(self.pending+(1 if key=='UP' else -1))%3
            elif key=='Z':self.commit('board:'+str(self.cursor)+':'+str(self.pending))

    def draw(self,p):
        s=self.state;n=len(s.boards)
        p.cls(1)
        p.text(12,10,'BRIDGE ORCHESTRA / STAGE '+str(STAGE)+' / A',10)
        p.text(12,27,'TICK '+str(s.t)+' / STATUS '+s.status.upper(),7)
        p.text(12,44,'BOARD WEIGHT '+str(total(s))+' / CAP '+str(LEVEL.capacity),8 if total(s)>LEVEL.capacity else 11)
        p.text(12,62,'ENTRY 1,5,9 / WALK EVERY 4 TICKS / PEOPLE WEIGHT 2',6)
        for i,board in enumerate(s.boards):
            x=72+i*120
            p.rect(x,145,116,18,(5,6,9)[board]);p.rectb(x,145,116,18,10 if i==self.cursor else 7)
            p.text(x+8,175,str(i)+': '+BOARD_LABELS[board],7)
            p.text(x+8,192,'LOAD '+str(s.loads[i]),7)
            if hasattr(s,'vectors'):
                p.text(x+8,209,'V '+str(s.vectors[i]),14)
                p.text(x+8,226,'HITS '+str(s.strikes[i]),14)
            if STAGE>=4:
                p.text(x+8,243,'LIMIT '+str(limits(LEVEL,s)[i]),8 if i in s.faults else 11)
        p.rect(12,145,54,80,3);p.rect(438,145,54,80,3)
        for i,position in enumerate(s.positions):
            x=35 if position==0 else 465 if position==n+1 else 125+(position-1)*120
            y=130-i*14
            p.circ(x,y,5,(8,10,12)[i]);p.text(x-1,y-2,str(i+1),1)
        if STAGE>=2:p.text(12,275,'CANDIDATE '+str(self.cursor)+': '+BOARD_LABELS[self.pending]+' (Z TO COMMIT)',7)
        if STAGE>=4:
            q=forecast(LEVEL,s)
            p.text(12,294,'NEXT TICK LOADS '+str(q.loads) if q else 'NO NEXT-TICK FORECAST',6)
            p.text(12,311,'FAULT SEGMENTS '+str(s.faults)+' / PEAK '+str(s.peak),8 if s.faults else 6)
        elif STAGE==3:p.text(12,294,'PRACTICE: LOADS ONLY / STRENGTH NOT CHECKED YET',14)
        else:p.text(12,294,'PRACTICE: QUIET FOOTSTEPS / NO STRENGTH CHECK',14)
        p.text(12,350,self.message,10)
        p.text(12,372,'ENTER: START / SPACE: ONE TICK / R: RESET / ESC: QUIT',7)
        if STAGE>=2:p.text(12,389,'ARROWS: SEGMENT + CANDIDATE / Z: COMMIT IN EDIT',7)
        if STAGE==5:p.text(12,406,'X: UNDO (DECISION COUNT DOES NOT REWIND)',7)

def make_demo_keys():
    return ('RIGHT','UP','UP','Z','RIGHT','UP','Z','RETURN')+('SPACE',)*21

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(508,432,title='Bridge orchestra lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=make_demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set()
                app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 05 / 05

始めたことも、一刻も戻せる履歴へ

状態の記録と、判断の合計回数の、別々の役割。

Timeline は、今の State、過去の State を並べた past、判断の合計回数 decisions を持ちます。確定した板の変更、開始、1刻進めることで State が変わったときだけ、変わる前の状態を past に1件追加します。候補の移動、同じ板の確定し直し、断られた開始、結果のあとの Space では増えません。

X は、最後に保存した State をそのまま戻します。位置だけでなく、板・時刻・揺れ・負荷・着いたときの衝撃・最大の揺れ・結果・説明まで元に戻します。開始を取り消せば EDIT に戻り、同じ橋を直せます。Undo のあと、候補の欄を今選んでいる桁の確定した板に合わせるのは、教材用の画面の作りです。

元のゲームには、1回の挑戦で256回の判断という上限があります。decisions は Undo しても減りません。取り消しの X は上限の検査より先に処理するので、上限に達しても履歴は戻せますが、そのあと新しい判断を確定することはできません。R は履歴と判断の回数を0にし、最初の全部普通の板から、新しい挑戦を始めます。ファイルへの保存や、取り消しのやり直し(Redo)は、ここでは作りません。

04 の成功を 05 で行うと、板の変更2回+開始1回+21刻で、24回の判断です。X を1回押すと20刻の RUN に戻り、HISTORY 23、DECISIONS 24 です。Space で、同じ21刻の DONE をもう一度出せます。これは、決まった動きをする模型を再生できるかの確認で、人がおもしろいと感じたことの証拠ではありません。

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

05_undo.py / 注目する部分
    if action == 'undo':
        return Timeline(tl.past[-1], tl.past[:-1], tl.decisions) if tl.past else tl

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること04 と同じ手順で21刻の DONE にしてから、X を押します。TICK 20、RUN、HISTORY 23、DECISIONS 24、V ((0,7),(0,8),(0,9))、位置 (4,4,3)、LOAD (9,11,12) です。Space で同じ DONE になります。
  • 状態が変わった判断だけを記録する
  • State 全体を丸ごと元に戻す
  • 判断の合計回数は、巻き戻さない
つまずきやすいところpast を戻したときに、decisions まで減らさないでください。この値は残り時間でも履歴の長さでもなく、1回の挑戦で行った判断の合計回数です。
少し変えてみよう

R → Enter → Space で1刻進め、X で RUN の0刻に、もう一度 X で EDIT の0刻に戻ります。DECISIONS は2のままです。そこで真ん中を BRACE に変えて Z を押すと、新しい判断になって3になります。前の板の並びや結果が残っていないことを確かめましょう。

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

"""橋を渡る楽団・教材5/5。整数の玩具で、現実の橋の安全計算ではありません。"""
from dataclasses import dataclass, replace, asdict

import hashlib, json

VERSION = '073-v1.1'

BOARD_NAMES = ('普通板', '軽い板', '筋交い板')

WEIGHTS = (2, 1, 3)

STRENGTHS = (11, 9, 16)

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    widths: tuple
    capacity: int
    entries: tuple
    beats: tuple
    limit: int = 32

LEVELS = (Level('A', 'そろい足の三人', (1, 2, 1), 6, (1, 5, 9), (4, 4, 4)), Level('B', 'ばらけた足拍子', (1, 2, 1), 6, (1, 4, 7), (3, 3, 3)), Level('C', '中央が二枚の橋', (1, 2, 2, 1), 8, (1, 5, 9), (4, 4, 4)))

@dataclass(frozen=True)
class State:
    boards: tuple
    t: int = 0
    vectors: tuple = ()
    positions: tuple = (0, 0, 0)
    loads: tuple = ()
    strikes: tuple = ()
    faults: tuple = ()
    peak: int = 0
    status: str = 'edit'
    reason: str = '板を選んで、同じ楽団を試そう。'

@dataclass(frozen=True)
class Timeline:
    state: State
    past: tuple = ()
    decisions: int = 0

MAX_DECISIONS = 256

def initial(level):
    n = len(level.widths)
    return State((0,) * n, vectors=((0, 0),) * n, loads=(2,) * n, strikes=(0,) * n)

def new(level):
    return Timeline(initial(level))

def total(s):
    return sum((WEIGHTS[b] for b in s.boards))

def limits(level, s):
    return tuple((STRENGTHS[b] + (4 if w == 1 else 0) for b, w in zip(s.boards, level.widths)))

def damping(v):
    return v - (v > 0) + (v < 0)

def rotate(pair, t):
    x, y = pair
    y, x = (-x, y)
    return (damping(x), damping(y)) if t % 4 == 0 else (x, y)

def advance(level, s, impact=4):
    if s.status != 'run':
        return s
    t = s.t + 1
    n = len(s.boards)
    vec = [rotate(p, t) for p in s.vectors]
    pos = []
    strikes = [0] * n
    for e, b, old in zip(level.entries, level.beats, s.positions):
        p = 0 if t < e else min(n + 1, 1 + (t - e) // b)
        pos.append(p)
        if p != old and 1 <= p <= n:
            strikes[p - 1] += 1
    vec = tuple(((x + impact * k, y) for (x, y), k in zip(vec, strikes)))
    amps = [max(abs(x), abs(y)) for x, y in vec]
    loads = tuple((WEIGHTS[s.boards[i]] + 2 * pos.count(i + 1) + amps[i] for i in range(n)))
    faults = tuple((i for i, (v, lim) in enumerate(zip(loads, limits(level, s))) if v > lim))
    status = 'fault' if faults else 'done' if all((p == n + 1 for p in pos)) else 'time' if t >= level.limit else 'run'
    reason = '桁 ' + ','.join((str(i + 1) for i in faults)) + ' が耐力を超えました。Xで戻して原因を確認。' if faults else '全員が右岸へ。あなたの橋を渡り切りました。' if status == 'done' else '32刻で試運転終了。' if status == 'time' else '足拍子と揺れの向きを見比べよう。' if impact else '静かな足。揺れはなく、板と人の重さで比べます。'
    return replace(s, t=t, vectors=vec, positions=tuple(pos), loads=loads, strikes=tuple(strikes), faults=faults, peak=max(s.peak, *amps), status=status, reason=reason)

def act(level, tl, action, impact=4):
    if action == 'undo':
        return Timeline(tl.past[-1], tl.past[:-1], tl.decisions) if tl.past else tl
    if tl.decisions >= MAX_DECISIONS:
        return tl
    s = tl.state
    n = s
    if action == 'start' and s.status == 'edit':
        if total(s) > level.capacity:
            return tl
        n = replace(s, status='run', reason='Spaceで1刻。Pで連続運転・停止。')
    elif action == 'tick':
        n = advance(level, s, impact)
    elif action.startswith('board:') and s.status == 'edit':
        try:
            _, i, b = action.split(':')
            i = int(i)
            b = int(b)
        except ValueError:
            return tl
        if 0 <= i < len(s.boards) and 0 <= b < 3:
            boards = list(s.boards)
            boards[i] = b
            n = replace(s, boards=tuple(boards), loads=tuple((WEIGHTS[v] for v in boards)))
    if n == s:
        return tl
    return Timeline(n, tl.past + (s,), tl.decisions + 1)

def simulate(level, s, impact=4):
    if total(s) > level.capacity:
        return replace(s, status='weight', reason='岸の支えには重い。試運転できません。')
    s = replace(s, status='run') if s.status == 'edit' else s
    while s.status == 'run':
        s = advance(level, s, impact)
    return s

def forecast(level, s, impact=4):
    if total(s) > level.capacity:
        return None
    if s.status == 'edit':
        s = replace(s, status='run')
    return advance(level, s, impact) if s.status == 'run' else None

def digest(x):
    return hashlib.sha256(json.dumps(asdict(x) if hasattr(x, '__dataclass_fields__') else x, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest()

def replay(level, actions, impact=4):
    tl = new(level)
    records = []
    for a in actions:
        tl = act(level, tl, a, impact)
        records.append({'action': a, 't': tl.state.t, 'sha256': digest(tl.state)})
    return (tl, records)

STAGE = 5
LEVEL = LEVELS[0]
BOARD_LABELS = ("PLAIN","LIGHT","BRACE")

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.timeline=new(LEVEL)
        self.cursor=0
        self.pending=0
        self.gated=False
        self.message='READY'

    @property
    def state(self):
        return self.timeline.state

    def commit(self,action):
        before=self.state
        self.timeline=act(LEVEL,self.timeline,action)
        self.message=action.upper() if self.state!=before else 'NO CHANGE / REFUSED'
        if action=='start' and total(self.state)>LEVEL.capacity:
            self.message='TOO HEAVY: LIGHTEN A BOARD'

    def control(self,pressed,held):
        if 'R' in pressed:
            self.reset();self.gated=True
            return
        if self.gated:
            if not pressed and not held:self.gated=False
            return
        if len(pressed|held)>1:
            self.gated=True;self.message='RELEASE ALL KEYS'
            return
        if len(pressed)!=1:return
        key=next(iter(pressed));self.gated=True
        if key=='X':
            self.commit('undo');self.pending=self.state.boards[self.cursor]
            return
        if key=='RETURN':self.commit('start')
        elif key=='SPACE':self.commit('tick')
        elif self.state.status=='edit':
            if key in ('LEFT','RIGHT'):
                self.cursor=(self.cursor+(-1 if key=='LEFT' else 1))%len(self.state.boards)
                self.pending=self.state.boards[self.cursor]
            elif key in ('UP','DOWN'):
                self.pending=(self.pending+(1 if key=='UP' else -1))%3
            elif key=='Z':self.commit('board:'+str(self.cursor)+':'+str(self.pending))

    def draw(self,p):
        s=self.state;n=len(s.boards)
        p.cls(1)
        p.text(12,10,'BRIDGE ORCHESTRA / STAGE '+str(STAGE)+' / A',10)
        p.text(12,27,'TICK '+str(s.t)+' / STATUS '+s.status.upper(),7)
        p.text(12,44,'BOARD WEIGHT '+str(total(s))+' / CAP '+str(LEVEL.capacity),8 if total(s)>LEVEL.capacity else 11)
        p.text(12,62,'ENTRY 1,5,9 / WALK EVERY 4 TICKS / PEOPLE WEIGHT 2',6)
        for i,board in enumerate(s.boards):
            x=72+i*120
            p.rect(x,145,116,18,(5,6,9)[board]);p.rectb(x,145,116,18,10 if i==self.cursor else 7)
            p.text(x+8,175,str(i)+': '+BOARD_LABELS[board],7)
            p.text(x+8,192,'LOAD '+str(s.loads[i]),7)
            if hasattr(s,'vectors'):
                p.text(x+8,209,'V '+str(s.vectors[i]),14)
                p.text(x+8,226,'HITS '+str(s.strikes[i]),14)
            if STAGE>=4:
                p.text(x+8,243,'LIMIT '+str(limits(LEVEL,s)[i]),8 if i in s.faults else 11)
        p.rect(12,145,54,80,3);p.rect(438,145,54,80,3)
        for i,position in enumerate(s.positions):
            x=35 if position==0 else 465 if position==n+1 else 125+(position-1)*120
            y=130-i*14
            p.circ(x,y,5,(8,10,12)[i]);p.text(x-1,y-2,str(i+1),1)
        if STAGE>=2:p.text(12,275,'CANDIDATE '+str(self.cursor)+': '+BOARD_LABELS[self.pending]+' (Z TO COMMIT)',7)
        if STAGE>=4:
            q=forecast(LEVEL,s)
            p.text(12,294,'NEXT TICK LOADS '+str(q.loads) if q else 'NO NEXT-TICK FORECAST',6)
            p.text(12,311,'FAULT SEGMENTS '+str(s.faults)+' / PEAK '+str(s.peak),8 if s.faults else 6)
        elif STAGE==3:p.text(12,294,'PRACTICE: LOADS ONLY / STRENGTH NOT CHECKED YET',14)
        else:p.text(12,294,'PRACTICE: QUIET FOOTSTEPS / NO STRENGTH CHECK',14)
        p.text(12,329,'HISTORY '+str(len(self.timeline.past))+' / DECISIONS '+str(self.timeline.decisions),6)
        p.text(12,350,self.message,10)
        p.text(12,372,'ENTER: START / SPACE: ONE TICK / R: RESET / ESC: QUIT',7)
        if STAGE>=2:p.text(12,389,'ARROWS: SEGMENT + CANDIDATE / Z: COMMIT IN EDIT',7)
        if STAGE==5:p.text(12,406,'X: UNDO (DECISION COUNT DOES NOT REWIND)',7)

def make_demo_keys():
    return ('RIGHT','UP','UP','Z','RIGHT','UP','Z','RETURN')+('SPACE',)*21+('X',)

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(508,432,title='Bridge orchestra lesson '+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App();sequence=make_demo_keys() if demo else ()
    keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R}
    limit=max(20,2*len(sequence)+8)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={sequence[index]} if p.frame_count%2==0 and index<len(sequence) else set()
                app.control(pressed,pressed)
        else:app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
AFTER STEP 05

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

ルールのまとめ:元のゲームの状況 A の、決まった入る時刻と、4刻ごとの歩行。板の重さと人数の重さに、整数で表した揺れの回転・弱まり・着いたときの衝撃から求めた揺れを加えます。橋全体の重さは6まで。すべての桁が耐力を超えずに、3人が右岸に着けば成功。現実の建物の安全計算ではありません。

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

比べる点5ステップの教材元の完成版
段階01 は歩行と止まった重さ、02 は板の編集と重さの上限、03 は揺れの数字だけで破損なし。04/05 で元のゲームの試運転のルール。最初から、板の編集・揺れ・耐力・Undo を使います。
画面と操作英数字の表示で、状況 A だけ。R/Esc はすぐ実行、続けて自動で進める機能や保存はなし。盤面の板の番号は0〜2、人の通り道は両岸を含めて0〜4。日本語の画面、3つの状況、静かな足との比較、P で続けて自動で進める機能、保存、表紙と説明。
比較と権利04/05 のモデルは、状況 A/B/C の数値と元のルールを持っていて、全135通りの板の並び×2種類の衝撃でテストしています。日本語の文字や音は使っていません。Noto フォントと Pyxel の、それぞれのライセンスを残しています。自動での記録は、人が実際に遊んで試したこととは別です。

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

  1. model.py の rotate / advance:揺れの回転・弱まり・着いたときの衝撃
  2. model.py の limits:耐力と、結果を決める順番
  3. model.py の act / Timeline:履歴と、判断の回数の上限
  4. controller.py の automatic_tick:続けて自動で進める機能と、その止め方
  5. experiments.py:全135通りの並びと、静かな足との比較
自分のゲームに変えるなら

最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。

教材をダウンロードする

無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について

検証について

どこまで動作を確認したかを見る

2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。

この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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