パズルLESSON 36支持・荷重の離散パズル原作013

棚からぼたもちの作り方

支えを抜くと、重さの行き先が変わる。
ふれ合っている形から支えを探し、重さの分かれ方と、板がいっせいに割れるようすを計算する、空想の落下パズルを作ります。

完成版の遊び方どこで支えられていて、どれだけ重さがかかっているかを読み、残す板と抜く板を選んで、おもちを皿に落とします。

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

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

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

完成版の主な操作:左右板選択、Space引抜き、Z戻す、R再開、P停止、N次棚

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

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

うまく表示されないとき

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

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

同じ状態から、かかる重さと割れる板を決める。

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

1. 書きかえないデータ・はしをふくまない範囲・となり合い・描くことと判定を分ける
2. 向きのある支えの関係・Fraction・上からの合計・上にのる重さと自分の重さ
3. ある瞬間の状態を写しとる・「より大きい」の比べ方・集合を決めてから更新する
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.10以上、ターミナルと .venv / 変数・if・for・関数・tuple・dict / dataclass・replace・Fraction(分数)は本文とコードで説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
['01: 左右=全物体の参照', '02/03: 1/2/3=観察状態、左右=板参照。03はSpace=破断', '04: 左右=板選択、Space=除去、X=再計算1回', '05: 左右=板選択、Space=除去と解決、Z=Undo', '全段階: R=初期化、Escape=終了。一入力ごとに全操作キーを離す']
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_contacts.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

実際の格子から接触を見つける

書きかえないデータ・はしをふくまない範囲・となり合い・描くことと判定を分ける。

元のゲームの状況 C「荷重の引越し」を、16列×12行のマス目で表します。x は右、y は下へ増え、どの物体も高さは1行です。横幅 w の物体は、x から x+w−1 までのマスをしめ、right=x+w は範囲のすぐ外側です。contacts は、下の物体の y が上の物体の y+1 とぴったり同じで、横に1マス以上重なっているときだけ「ふれている」とします。名前ごとに手で書いた支えの表は作りません。Body は frozen な dataclass なので、位置を変えるときは、あとのステップで replace を使います。この段階の左右は物体を調べるだけで、板は抜けません。

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

01_contacts.py / 注目する部分
def overlap(a,b): return min(a.right,b.right)-max(a.x,b.x) >= 1

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_contacts.py
macOS / Linux
.venv/bin/python 01_contacts.py
画面で見ること最初は A1 を黄色い枠で調べていて、ふれている欄は a1L,a1R です。右 → 右 で D を調べると、x=3, y=7, w=10、ふれているのは E,F です。M1 の顔はマスより小さく、皿はうすく描かれていますが、どちらも高さ1マスの Body です。
  • right は範囲のすぐ外側
  • ふれているのは「y+1」で「横に重なる」とき
  • 固定ブロックに、見えない柱を足さない
  • 判定に使うのはマスの枠で、絵は飾り
つまずきやすいところ絵の透明なすきまや、皿の厚さを当たり判定に使わないでください。right をふくむ範囲にすると、横にとなり合っているだけの物体も重なっていることになってしまいます。固定ブロックはそのマスにだけあり、下まで続く柱ではありません。
少し変えてみよう

右 → 右 で D を調べます。contacts(initial())["D"] を調べて、結果が ("E", "F") になることを確かめてください。上と下の y の差を2にした実験の配置なら、横幅が重なっていてもふれていない理由を説明してみましょう。

01_contacts.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(311行)
01_contacts.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材画面はASCIIと図形のみ。字形データは使いません。

"""棚からぼたもちの独立教材。日本語の説明はLESSON.mdを参照。"""
from dataclasses import dataclass, replace, asdict
from fractions import Fraction as F
import hashlib
import json

FLOOR = 12

@dataclass(frozen=True)
class Body:
    id: str
    kind: str
    x: int
    y: int
    w: int = 1
    mass: F = F(0)
    capacity: F | None = None
    @property
    def right(self): return self.x + self.w
    @property
    def fixed(self): return self.kind in ('anchor','plate')
    @property
    def removable(self): return self.kind == 'board'

@dataclass(frozen=True)
class State:
    level: int
    bodies: tuple[Body, ...]
    removed: tuple[str, ...] = ()
    broken: tuple[str, ...] = ()
    delivered: tuple[str, ...] = ()
    status: str = 'ready'
    reason: str = 'ready'
    danger: tuple[str, ...] = ()
    moves: int = 0
    def __post_init__(self):
        object.__setattr__(self,'bodies',tuple(sorted(self.bodies,key=lambda b:b.id)))
        for name in ('removed','broken','delivered','danger'):
            object.__setattr__(self,name,tuple(sorted(getattr(self,name))))

@dataclass(frozen=True)
class Frame:
    state: State
    event: str
    ids: tuple[str, ...] = ()

@dataclass(frozen=True)
class Result:
    state: State
    frames: tuple[Frame, ...]

def board(id,x,y,w,capacity=4): return Body(id,'board',x,y,w,F(1),None if capacity is None else F(capacity))

def anchor(id,x,y): return Body(id,'anchor',x,y)

def mochi(id,x,y,mass=1): return Body(id,'mochi',x,y,1,F(mass))

def plate(id,x,y): return Body(id,'plate',x,y)

# 原作Cの固定配置そのもの。level=2は元作品の0始まりの番号。
def initial():
    return State(2, (
        mochi('M1',5,2), mochi('M2',10,2),
        board('A1',4,3,3), board('A2',9,3,3),
        anchor('a1L',4,4), anchor('a1R',6,4),
        anchor('a2L',9,4), anchor('a2R',11,4),
        board('D',3,7,10,None), board('E',2,8,2,F(5,4)),
        board('F',12,8,2,F(5,4)), anchor('eL',2,9),
        anchor('eR',3,9), anchor('fL',12,9), anchor('fR',13,9),
        plate('P1',5,9), plate('P2',10,9)))

def overlap(a,b): return min(a.right,b.right)-max(a.x,b.x) >= 1

def contacts(state):
    """Actual adjacent cell contacts, never authored graph shortcuts."""
    return {b.id:tuple(c.id for c in state.bodies if c.id != b.id and c.y == b.y+1 and overlap(b,c))
            for b in state.bodies if not b.fixed}

def validate_geometry(state):
    for b in state.bodies:
        assert 0<=b.x<b.right<=16 and 0<=b.y<FLOOR,(b,'bounds')
    for i,b in enumerate(state.bodies):
        for c in state.bodies[i+1:]:assert not (b.y==c.y and overlap(b,c)),('overlap',b,c)
    return True

def state_dict(state):
    data=asdict(state)
    for b in data['bodies']:
        for k in ('mass','capacity'):
            if b[k] is not None:b[k]=str(b[k])
    return data

def state_hash(state):
    return hashlib.sha256(json.dumps(state_dict(state),sort_keys=True,ensure_ascii=True,separators=(',',':')).encode('utf-8')).hexdigest()

STAGE = 1
STAGE_TITLE = 'CONTACTS'
GUIDE = 'LEFT/RIGHT: inspect body (no movement yet)'


# 合成入力も実キーボード入力も、この同じ入口へ渡す。
CONTROLS = ('LEFT','RIGHT','SPACE','X','Z','R','1','2','3')
REPLAY = {'L':'LEFT','T':'RIGHT','S':'SPACE','X':'X','Z':'Z','R':'R','1':'1','2':'2','3':'3'}

class Controller:
    def __init__(self):
        self.gate = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.selected = 'A1'
        self.reason = 'ready'
        self.event_ids = ()
        self.pending = False
        self.history = []
        self.last_frames = ()
        # Rでもgateは消さない。全操作キーを離してから再入力。

    def select(self,direction):
        options = self.selectable()
        if options:
            index = options.index(self.selected) if self.selected in options else 0
            self.selected = options[(index+direction)%len(options)]

    def selectable(self):
        return [b.id for b in self.state.bodies ]

    def tick(self,pressed=(),held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return
        if not pressed and len(held) <= 1:
            return
        if len(pressed) != 1 or pressed != held:
            self.gate = True
            self.reason = 'chord_rejected'
            return
        self.gate = True
        key = next(iter(pressed))
        if key == 'R':
            self.reset()
            return
        if key in ('LEFT','RIGHT'):
            self.select(-1 if key == 'LEFT' else 1)
            self.reason = 'select'
        else:
            self.reason = 'unused' 

    def snapshot(self):
        return {'state':state_dict(self.state),'hash':state_hash(self.state),
                'selected':self.selected,'event':self.reason,'event_ids':self.event_ids,
                'pending':self.pending,'history_depth':len(self.history),
                'contacts':contacts(self.state),
                
                'last_frames':[{'event':f.event,'ids':f.ids,'hash':state_hash(f.state)}
                               for f in self.last_frames]}


def draw_board(p,c):
    """描画だけ。state・履歴・入力ゲートへ書き込まない。"""
    p.cls(1)
    p.text(12,10,'SHELF MOCHI / STEP '+str(STAGE)+' / '+STAGE_TITLE,10)
    p.text(12,25,'Original case C / exact grid / no rotation or sliding',6)
    ox,oy,cell = 24,52,24
    for y in range(13):
        p.text(9,oy+y*cell+8,str(y),13)
        p.line(ox,oy+y*cell,ox+16*cell,oy+y*cell,5)
    for x in range(17):
        p.line(ox+x*cell,oy,ox+x*cell,oy+12*cell,5)
        if x < 16:
            p.text(ox+x*cell+8,oy-12,str(x),13)
    state = c.state
    by = {b.id:b for b in state.bodies}
    for b in sorted(state.bodies,key=lambda b:(-b.y,b.id)):
        x,y,w = ox+b.x*cell,oy+b.y*cell,b.w*cell
        # 点線枠が占有する格子。図形の余白や皿の薄さは接触判定を変えない。
        p.rectb(x,y,w,cell,13)
        if b.kind == 'anchor':
            p.rect(x+2,y+2,w-4,cell-4,5)
            p.line(x+3,y+3,x+w-4,y+cell-4,6)
            p.text(x+3,y+8,b.id,7)
        elif b.kind == 'board':
            p.rect(x+1,y+2,w-2,cell-4,9 if b.id not in ('E','F') else 8)
            p.line(x+3,y+5,x+w-4,y+5,10)
            p.text(x+w//2-len(b.id)*2,y+10,b.id,1)
        elif b.kind == 'plate':
            p.rect(x+2,y+1,w-4,4,7)
            p.text(x+7,y+10,b.id,6)
        elif b.kind == 'mochi':
            p.rect(x+4,y+3,w-8,cell-6,15)
            p.pset(x+8,y+8,1);p.pset(x+15,y+8,1)
            p.text(x+7,y+13,b.id,1)
        else:
            p.rect(x+2,y+2,w-4,cell-4,13)
            p.text(x+8,y+8,b.id,1)
    selected = by.get(c.selected)
    if selected:
        p.rectb(ox+selected.x*cell-1,oy+selected.y*cell-1,selected.w*cell+2,cell+2,10)
    p.rect(417,40,211,319,0)
    p.text(429,51,'INSPECT '+c.selected,10)
    if selected:
        p.text(429,68,f'x={selected.x} y={selected.y} w={selected.w}',7)
        p.text(429,85,'kind: '+selected.kind,7)
        p.text(429,102,'CONTACTS:',6)
        contact = contacts(state).get(selected.id,())
        p.text(429,115,','.join(contact) or '(none / fixed)',7)
        p.text(429,141,'Only y+1 and x overlap',6)
        p.text(429,159,'make a contact.',6)
    p.text(429,241,'REMOVED: '+','.join(state.removed),6)
    p.text(429,257,'BROKEN: '+','.join(state.broken),8)
    p.text(429,273,'DELIVERED: '+','.join(state.delivered),11)
    p.text(429,291,'MOVES: '+str(state.moves),7)
    p.text(429,307,'STATUS: '+state.status.upper(),10)
    p.text(429,324,'HISTORY: '+str(len(c.history)),7)
    p.text(429,340,'PENDING: '+('YES - USE X' if c.pending else 'NO'),10 if c.pending else 13)
    p.text(12,372,'EVENT: '+c.reason.upper()+' '+','.join(c.event_ids),10)
    p.text(12,389,GUIDE,7)
    p.text(12,406,'R: reset  ESC: quit / release ALL keys before next input',6)
    p.text(12,424,'Fictional non-rotating model. NOT a real structural safety calculation.',13)
    p.text(12,440,'Cell outlines = collision cells; artwork is only decoration. Silent.',13)


def main(headless=False,sequence='',screenshot=None,report=None):
    from pathlib import Path
    import time
    import pyxel  # import時には不要。実画面を開く時だけ読み込む。
    controller = Controller()
    keycodes = {name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    frame = 0
    events = []
    last_frame = max(12,len(sequence)*2+2)
    start = time.perf_counter()
    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame-1)//2
            if frame % 2 and index < len(sequence):
                key = REPLAY[sequence[index]]
                controller.tick((key,),(key,))
                events.append({'input':sequence[index],'snapshot':controller.snapshot()})
            else:
                controller.tick()
        else:
            controller.tick([n for n,k in keycodes.items() if pyxel.btnp(k)],
                            [n for n,k in keycodes.items() if pyxel.btn(k)])
    def draw():
        draw_board(pyxel,controller)
        if headless and frame >= last_frame:
            if screenshot:
                pyxel.screenshot(str(Path(screenshot).resolve()),scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label':'actual Pyxel headless / synthetic controller input / silent',
                    'stage':STAGE,'frames':frame,'logical_seconds':frame/30,
                    'program_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
                    'wall_seconds_before_exit':round(time.perf_counter()-start,6),
                    'sequence':sequence,'events':events,'snapshot':controller.snapshot()
                },ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(640,460,title='Shelf Mochi - Step '+str(STAGE),fps=30,headless=headless)
    pyxel.run(update,draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--headless-replay',help='Synthetic keys: '+str(REPLAY))
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Outputs require headless mode')
    sequence = args.headless_replay or ''
    if any(char not in REPLAY for char in sequence):
        parser.error('Unsupported replay character')
    main(headless,sequence,args.screenshot,args.report)
STEP 02 / 05

上から受ける重さを配る

向きのある支えの関係・Fraction・上からの合計・上にのる重さと自分の重さ。

support_edges は、今の位置でふれている物体から、重さの分け方を作ります。板の両はしが支えられていれば片側ずつ1/2、片方のはしだけなら、そこに全部です。同じ支えが両はしにあれば、分けた量を足して1にします。はしに支えがなく、まん中だけでふれているときは、ふれている相手に同じだけ分けます。これは板が回転しない、空想の決まりです。loads は上から順に、受け取った重さ(payload)に自分の重さ(mass)を足して、下に渡します。板が耐えられる重さと比べるのは payload だけで、自分の重さは入れません。Fraction(分数)を使うので、5/4 や 3/2 を四捨五入しません。

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

02_loads.py / 注目する部分
def loads(state):
    """Return received payload; add own mass only when forwarding downward."""
    edges=support_edges(state); payload={b.id:F(0) for b in state.bodies}
    for b in sorted(state.bodies,key=lambda b:(b.y,b.id)):
        for target,share in edges.get(b.id,{}).items(): payload[target]+=(payload[b.id]+b.mass)*share
    return payload

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_loads.py
macOS / Linux
.venv/bin/python 02_loads.py
画面で見ること1/2/3 キーで、D の上におもちが0個・1個・2個のっている、観察用の位置を読みこみます。最初の E/F にかかる重さはそれぞれ 1/2、1個のときは1、2個のときは 3/2 です。D の自分の重さ1が加わるからです。3 → 右 で E を調べると、PAYLOAD 3/2、SELF MASS 1、CAPACITY 5/4 と表示されます。まだ割れたり落ちたりはしません。
  • 耐えられる重さと比べるのは、上から受け取った重さだけ
  • 下に渡すときは、自分の重さを足す
  • 片方のはしだけなら、重さを全部そこに渡す
  • 上の行から合計し、分数のまま計算する
つまずきやすいところ耐えられる重さと比べるときに、板の自分の重さを足すと、別のゲームになってしまいます。ただし、下に渡すときは自分の重さも足します。1/2/3 は、おもちを瞬間移動させるゲームの操作ではなく、元のゲームの途中の状態を観察するための、教材用の設定です。
少し変えてみよう

1 → 右、2 → 右、3 → 右 で、E にかかる重さを比べてみましょう。tools/run_examples.py では ["1/2", "1", "3/2"] と出ます。2個のとき、E がさらに固定ブロック eL へ渡す量は (3/2+1)/2=5/4 です。この 5/4 が「何の量」なのかを区別してください。

02_loads.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(364行)
02_loads.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材画面はASCIIと図形のみ。字形データは使いません。

"""棚からぼたもちの独立教材。日本語の説明はLESSON.mdを参照。"""
from dataclasses import dataclass, replace, asdict
from fractions import Fraction as F
import hashlib
import json

FLOOR = 12

@dataclass(frozen=True)
class Body:
    id: str
    kind: str
    x: int
    y: int
    w: int = 1
    mass: F = F(0)
    capacity: F | None = None
    @property
    def right(self): return self.x + self.w
    @property
    def fixed(self): return self.kind in ('anchor','plate')
    @property
    def removable(self): return self.kind == 'board'

@dataclass(frozen=True)
class State:
    level: int
    bodies: tuple[Body, ...]
    removed: tuple[str, ...] = ()
    broken: tuple[str, ...] = ()
    delivered: tuple[str, ...] = ()
    status: str = 'ready'
    reason: str = 'ready'
    danger: tuple[str, ...] = ()
    moves: int = 0
    def __post_init__(self):
        object.__setattr__(self,'bodies',tuple(sorted(self.bodies,key=lambda b:b.id)))
        for name in ('removed','broken','delivered','danger'):
            object.__setattr__(self,name,tuple(sorted(getattr(self,name))))

@dataclass(frozen=True)
class Frame:
    state: State
    event: str
    ids: tuple[str, ...] = ()

@dataclass(frozen=True)
class Result:
    state: State
    frames: tuple[Frame, ...]

def board(id,x,y,w,capacity=4): return Body(id,'board',x,y,w,F(1),None if capacity is None else F(capacity))

def anchor(id,x,y): return Body(id,'anchor',x,y)

def mochi(id,x,y,mass=1): return Body(id,'mochi',x,y,1,F(mass))

def plate(id,x,y): return Body(id,'plate',x,y)

# 原作Cの固定配置そのもの。level=2は元作品の0始まりの番号。
def initial():
    return State(2, (
        mochi('M1',5,2), mochi('M2',10,2),
        board('A1',4,3,3), board('A2',9,3,3),
        anchor('a1L',4,4), anchor('a1R',6,4),
        anchor('a2L',9,4), anchor('a2R',11,4),
        board('D',3,7,10,None), board('E',2,8,2,F(5,4)),
        board('F',12,8,2,F(5,4)), anchor('eL',2,9),
        anchor('eR',3,9), anchor('fL',12,9), anchor('fR',13,9),
        plate('P1',5,9), plate('P2',10,9)))

def overlap(a,b): return min(a.right,b.right)-max(a.x,b.x) >= 1

def contacts(state):
    """Actual adjacent cell contacts, never authored graph shortcuts."""
    return {b.id:tuple(c.id for c in state.bodies if c.id != b.id and c.y == b.y+1 and overlap(b,c))
            for b in state.bodies if not b.fixed}

def validate_geometry(state):
    for b in state.bodies:
        assert 0<=b.x<b.right<=16 and 0<=b.y<FLOOR,(b,'bounds')
    for i,b in enumerate(state.bodies):
        for c in state.bodies[i+1:]:assert not (b.y==c.y and overlap(b,c)),('overlap',b,c)
    return True

def state_dict(state):
    data=asdict(state)
    for b in data['bodies']:
        for k in ('mass','capacity'):
            if b[k] is not None:b[k]=str(b[k])
    return data

def state_hash(state):
    return hashlib.sha256(json.dumps(state_dict(state),sort_keys=True,ensure_ascii=True,separators=(',',':')).encode('utf-8')).hexdigest()


def support_edges(state):
    by={b.id:b for b in state.bodies}; contact=contacts(state); out={}
    for b in state.bodies:
        if b.fixed: continue
        below=[by[i] for i in contact[b.id]]
        if b.kind=='board':
            left=[c for c in below if c.x<=b.x<c.right]
            right=[c for c in below if c.x<=b.right-1<c.right]
            shares={}
            sides=[side for side in (left,right) if side]
            if sides:
                for side in sides:
                    for c in side: shares[c.id]=shares.get(c.id,F(0))+F(1,len(sides)*len(side))
            elif below:
                shares={c.id:F(1,len(below)) for c in below}
            out[b.id]=shares
        else:
            under=[c for c in below if c.x<=b.x<c.right]
            out[b.id]={c.id:F(1,len(under)) for c in under}
    return out

def loads(state):
    """Return received payload; add own mass only when forwarding downward."""
    edges=support_edges(state); payload={b.id:F(0) for b in state.bodies}
    for b in sorted(state.bodies,key=lambda b:(b.y,b.id)):
        for target,share in edges.get(b.id,{}).items(): payload[target]+=(payload[b.id]+b.mass)*share
    return payload

def example_state(count):
    """原作の落下フレームから取り出した、D上のもち0/1/2個の実座標。"""
    if count not in (0,1,2):
        raise ValueError('count must be 0, 1 or 2')
    removed = ('A1','A2')[:count]
    moved = ('M1','M2')[:count]
    return replace(initial(),
        bodies=tuple(replace(b,y=6) if b.id in moved else b
                     for b in initial().bodies if b.id not in removed),
        removed=removed, moves=count)

STAGE = 2
STAGE_TITLE = 'CARRIED LOAD'
GUIDE = '1/2/3: mochi on D = 0/1/2 / LEFT/RIGHT: inspect boards'


# 合成入力も実キーボード入力も、この同じ入口へ渡す。
CONTROLS = ('LEFT','RIGHT','SPACE','X','Z','R','1','2','3')
REPLAY = {'L':'LEFT','T':'RIGHT','S':'SPACE','X':'X','Z':'Z','R':'R','1':'1','2':'2','3':'3'}

class Controller:
    def __init__(self):
        self.gate = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.selected = 'A1'
        self.reason = 'ready'
        self.event_ids = ()
        self.pending = False
        self.history = []
        self.last_frames = ()
        # Rでもgateは消さない。全操作キーを離してから再入力。

    def select(self,direction):
        options = self.selectable()
        if options:
            index = options.index(self.selected) if self.selected in options else 0
            self.selected = options[(index+direction)%len(options)]

    def selectable(self):
        return [b.id for b in self.state.bodies if b.removable]

    def tick(self,pressed=(),held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return
        if not pressed and len(held) <= 1:
            return
        if len(pressed) != 1 or pressed != held:
            self.gate = True
            self.reason = 'chord_rejected'
            return
        self.gate = True
        key = next(iter(pressed))
        if key == 'R':
            self.reset()
            return
        if key in ('1','2','3'):
            self.state = example_state(int(key)-1)
            self.selected = 'D'
            self.reason = 'snapshot_'+key
            self.event_ids = ()
            self.last_frames = ()
        elif key in ('LEFT','RIGHT'):
            self.select(-1 if key == 'LEFT' else 1)
            self.reason = 'select'
        
        else:
            self.reason = 'unused' 

    def snapshot(self):
        return {'state':state_dict(self.state),'hash':state_hash(self.state),
                'selected':self.selected,'event':self.reason,'event_ids':self.event_ids,
                'pending':self.pending,'history_depth':len(self.history),
                'contacts':contacts(self.state),
                'loads':{k:str(v) for k,v in loads(self.state).items()},
                'shares':{k:{a:str(b) for a,b in v.items()} for k,v in support_edges(self.state).items()},
                'last_frames':[{'event':f.event,'ids':f.ids,'hash':state_hash(f.state)}
                               for f in self.last_frames]}


def draw_board(p,c):
    """描画だけ。state・履歴・入力ゲートへ書き込まない。"""
    p.cls(1)
    p.text(12,10,'SHELF MOCHI / STEP '+str(STAGE)+' / '+STAGE_TITLE,10)
    p.text(12,25,'Original case C / exact grid / no rotation or sliding',6)
    ox,oy,cell = 24,52,24
    for y in range(13):
        p.text(9,oy+y*cell+8,str(y),13)
        p.line(ox,oy+y*cell,ox+16*cell,oy+y*cell,5)
    for x in range(17):
        p.line(ox+x*cell,oy,ox+x*cell,oy+12*cell,5)
        if x < 16:
            p.text(ox+x*cell+8,oy-12,str(x),13)
    state = c.state
    by = {b.id:b for b in state.bodies}
    for b in sorted(state.bodies,key=lambda b:(-b.y,b.id)):
        x,y,w = ox+b.x*cell,oy+b.y*cell,b.w*cell
        # 点線枠が占有する格子。図形の余白や皿の薄さは接触判定を変えない。
        p.rectb(x,y,w,cell,13)
        if b.kind == 'anchor':
            p.rect(x+2,y+2,w-4,cell-4,5)
            p.line(x+3,y+3,x+w-4,y+cell-4,6)
            p.text(x+3,y+8,b.id,7)
        elif b.kind == 'board':
            p.rect(x+1,y+2,w-2,cell-4,9 if b.id not in ('E','F') else 8)
            p.line(x+3,y+5,x+w-4,y+5,10)
            p.text(x+w//2-len(b.id)*2,y+10,b.id,1)
        elif b.kind == 'plate':
            p.rect(x+2,y+1,w-4,4,7)
            p.text(x+7,y+10,b.id,6)
        elif b.kind == 'mochi':
            p.rect(x+4,y+3,w-8,cell-6,15)
            p.pset(x+8,y+8,1);p.pset(x+15,y+8,1)
            p.text(x+7,y+13,b.id,1)
        else:
            p.rect(x+2,y+2,w-4,cell-4,13)
            p.text(x+8,y+8,b.id,1)
    selected = by.get(c.selected)
    if selected:
        p.rectb(ox+selected.x*cell-1,oy+selected.y*cell-1,selected.w*cell+2,cell+2,10)
    p.rect(417,40,211,319,0)
    p.text(429,51,'INSPECT '+c.selected,10)
    if selected:
        p.text(429,68,f'x={selected.x} y={selected.y} w={selected.w}',7)
        p.text(429,85,'kind: '+selected.kind,7)
        p.text(429,102,'CONTACTS:',6)
        contact = contacts(state).get(selected.id,())
        p.text(429,115,','.join(contact) or '(none / fixed)',7)
        payload = loads(state)
        p.text(429,134,'PAYLOAD: '+str(payload[selected.id]),10)
        p.text(429,150,'SELF MASS: '+str(selected.mass),7)
        p.text(429,166,'CAPACITY: '+('UNLIMITED' if selected.capacity is None else str(selected.capacity)),7)
        p.text(429,184,'OUTGOING SHARE:',6)
        for row,(target,share) in enumerate(support_edges(state).get(selected.id,{}).items()):
            p.text(429,199+row*12,target+': '+str(share),7)
    p.text(429,241,'REMOVED: '+','.join(state.removed),6)
    p.text(429,257,'BROKEN: '+','.join(state.broken),8)
    p.text(429,273,'DELIVERED: '+','.join(state.delivered),11)
    p.text(429,291,'MOVES: '+str(state.moves),7)
    p.text(429,307,'STATUS: '+state.status.upper(),10)
    p.text(429,324,'HISTORY: '+str(len(c.history)),7)
    p.text(429,340,'PENDING: '+('YES - USE X' if c.pending else 'NO'),10 if c.pending else 13)
    p.text(12,372,'EVENT: '+c.reason.upper()+' '+','.join(c.event_ids),10)
    p.text(12,389,GUIDE,7)
    p.text(12,406,'R: reset  ESC: quit / release ALL keys before next input',6)
    p.text(12,424,'Fictional non-rotating model. NOT a real structural safety calculation.',13)
    p.text(12,440,'Cell outlines = collision cells; artwork is only decoration. Silent.',13)


def main(headless=False,sequence='',screenshot=None,report=None):
    from pathlib import Path
    import time
    import pyxel  # import時には不要。実画面を開く時だけ読み込む。
    controller = Controller()
    keycodes = {name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    frame = 0
    events = []
    last_frame = max(12,len(sequence)*2+2)
    start = time.perf_counter()
    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame-1)//2
            if frame % 2 and index < len(sequence):
                key = REPLAY[sequence[index]]
                controller.tick((key,),(key,))
                events.append({'input':sequence[index],'snapshot':controller.snapshot()})
            else:
                controller.tick()
        else:
            controller.tick([n for n,k in keycodes.items() if pyxel.btnp(k)],
                            [n for n,k in keycodes.items() if pyxel.btn(k)])
    def draw():
        draw_board(pyxel,controller)
        if headless and frame >= last_frame:
            if screenshot:
                pyxel.screenshot(str(Path(screenshot).resolve()),scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label':'actual Pyxel headless / synthetic controller input / silent',
                    'stage':STAGE,'frames':frame,'logical_seconds':frame/30,
                    'program_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
                    'wall_seconds_before_exit':round(time.perf_counter()-start,6),
                    'sequence':sequence,'events':events,'snapshot':controller.snapshot()
                },ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(640,460,title='Shelf Mochi - Step '+str(STAGE),fps=30,headless=headless)
    pyxel.run(update,draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--headless-replay',help='Synthetic keys: '+str(REPLAY))
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Outputs require headless mode')
    sequence = args.headless_replay or ''
    if any(char not in REPLAY for char in sequence):
        parser.error('Unsupported replay character')
    main(headless,sequence,args.screenshot,args.report)
STEP 03 / 05

同じ旧状態から板を同時に割る

ある瞬間の状態を写しとる・「より大きい」の比べ方・集合を決めてから更新する。

overloaded で重さを1回だけ計算し、payload>capacity(耐えられる重さより大きい)板の ID を全部集めます。そのあとで break_once が、それらをまとめて取り去ります。先に E を消してから F の重さを計算し直すやり方では、同じ瞬間の判定になりません。E/F が耐えられる重さは 5/4 なので、おもちが2個で、それぞれ 3/2 になり、両方とも割れます。ちょうど 5/4 なら割れません。この段階は割れるところだけの実験で、支えを失った D を落とす処理はまだありません。

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

03_breakage.py / 注目する部分
def overloaded(state):
    # 必ず同じ旧状態の荷重で全候補を調べる。等しい場合は割れない。
    payload = loads(state)
    return tuple(sorted(b.id for b in state.bodies
        if b.kind == 'board' and b.capacity is not None
        and payload[b.id] > b.capacity))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_breakage.py
macOS / Linux
.venv/bin/python 03_breakage.py
画面で見ること3 でおもち2個が D にのった状態を読みこみ、Space で E/F がいっぺんに消えます。BROKEN E,F、REMOVED A1,A2 と表示され、D は y=7 のままです。1 か 2 → Space では NO_BREAK(割れない)です。割れた板と、プレイヤーが抜いた板は、別々に記録されます。
  • 重さは、前の状態から1回だけ計算する
  • 「より大きい(>)」なら割れ、「同じ(=)」なら耐える
  • 割れる板の ID を決めてから、まとめて更新する
  • D は消さず、次の段階の「落ちる」につなげる
つまずきやすいところfor の中で物体のリストから消したり、1枚消すごとに loads を計算し直したりしないでください。また、>= で比べると、「ちょうどなら耐える」という元のゲームのルールが変わってしまいます。E/F が消えても、D を自動で消すのはまちがいです。
少し変えてみよう

3 → Space で、いっせいに割れることを確かめ、R → 2 → Space で割れないことを確かめます。コードで E/F の capacity を 3/2 に変えた実験では、おもちが2個でも overloaded が空になることを試しましょう。test_stages.py の equal_capacity も、同じ境目を確かめています。

03_breakage.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(385行)
03_breakage.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材画面はASCIIと図形のみ。字形データは使いません。

"""棚からぼたもちの独立教材。日本語の説明はLESSON.mdを参照。"""
from dataclasses import dataclass, replace, asdict
from fractions import Fraction as F
import hashlib
import json

FLOOR = 12

@dataclass(frozen=True)
class Body:
    id: str
    kind: str
    x: int
    y: int
    w: int = 1
    mass: F = F(0)
    capacity: F | None = None
    @property
    def right(self): return self.x + self.w
    @property
    def fixed(self): return self.kind in ('anchor','plate')
    @property
    def removable(self): return self.kind == 'board'

@dataclass(frozen=True)
class State:
    level: int
    bodies: tuple[Body, ...]
    removed: tuple[str, ...] = ()
    broken: tuple[str, ...] = ()
    delivered: tuple[str, ...] = ()
    status: str = 'ready'
    reason: str = 'ready'
    danger: tuple[str, ...] = ()
    moves: int = 0
    def __post_init__(self):
        object.__setattr__(self,'bodies',tuple(sorted(self.bodies,key=lambda b:b.id)))
        for name in ('removed','broken','delivered','danger'):
            object.__setattr__(self,name,tuple(sorted(getattr(self,name))))

@dataclass(frozen=True)
class Frame:
    state: State
    event: str
    ids: tuple[str, ...] = ()

@dataclass(frozen=True)
class Result:
    state: State
    frames: tuple[Frame, ...]

def board(id,x,y,w,capacity=4): return Body(id,'board',x,y,w,F(1),None if capacity is None else F(capacity))

def anchor(id,x,y): return Body(id,'anchor',x,y)

def mochi(id,x,y,mass=1): return Body(id,'mochi',x,y,1,F(mass))

def plate(id,x,y): return Body(id,'plate',x,y)

# 原作Cの固定配置そのもの。level=2は元作品の0始まりの番号。
def initial():
    return State(2, (
        mochi('M1',5,2), mochi('M2',10,2),
        board('A1',4,3,3), board('A2',9,3,3),
        anchor('a1L',4,4), anchor('a1R',6,4),
        anchor('a2L',9,4), anchor('a2R',11,4),
        board('D',3,7,10,None), board('E',2,8,2,F(5,4)),
        board('F',12,8,2,F(5,4)), anchor('eL',2,9),
        anchor('eR',3,9), anchor('fL',12,9), anchor('fR',13,9),
        plate('P1',5,9), plate('P2',10,9)))

def overlap(a,b): return min(a.right,b.right)-max(a.x,b.x) >= 1

def contacts(state):
    """Actual adjacent cell contacts, never authored graph shortcuts."""
    return {b.id:tuple(c.id for c in state.bodies if c.id != b.id and c.y == b.y+1 and overlap(b,c))
            for b in state.bodies if not b.fixed}

def validate_geometry(state):
    for b in state.bodies:
        assert 0<=b.x<b.right<=16 and 0<=b.y<FLOOR,(b,'bounds')
    for i,b in enumerate(state.bodies):
        for c in state.bodies[i+1:]:assert not (b.y==c.y and overlap(b,c)),('overlap',b,c)
    return True

def state_dict(state):
    data=asdict(state)
    for b in data['bodies']:
        for k in ('mass','capacity'):
            if b[k] is not None:b[k]=str(b[k])
    return data

def state_hash(state):
    return hashlib.sha256(json.dumps(state_dict(state),sort_keys=True,ensure_ascii=True,separators=(',',':')).encode('utf-8')).hexdigest()


def support_edges(state):
    by={b.id:b for b in state.bodies}; contact=contacts(state); out={}
    for b in state.bodies:
        if b.fixed: continue
        below=[by[i] for i in contact[b.id]]
        if b.kind=='board':
            left=[c for c in below if c.x<=b.x<c.right]
            right=[c for c in below if c.x<=b.right-1<c.right]
            shares={}
            sides=[side for side in (left,right) if side]
            if sides:
                for side in sides:
                    for c in side: shares[c.id]=shares.get(c.id,F(0))+F(1,len(sides)*len(side))
            elif below:
                shares={c.id:F(1,len(below)) for c in below}
            out[b.id]=shares
        else:
            under=[c for c in below if c.x<=b.x<c.right]
            out[b.id]={c.id:F(1,len(under)) for c in under}
    return out

def loads(state):
    """Return received payload; add own mass only when forwarding downward."""
    edges=support_edges(state); payload={b.id:F(0) for b in state.bodies}
    for b in sorted(state.bodies,key=lambda b:(b.y,b.id)):
        for target,share in edges.get(b.id,{}).items(): payload[target]+=(payload[b.id]+b.mass)*share
    return payload

def example_state(count):
    """原作の落下フレームから取り出した、D上のもち0/1/2個の実座標。"""
    if count not in (0,1,2):
        raise ValueError('count must be 0, 1 or 2')
    removed = ('A1','A2')[:count]
    moved = ('M1','M2')[:count]
    return replace(initial(),
        bodies=tuple(replace(b,y=6) if b.id in moved else b
                     for b in initial().bodies if b.id not in removed),
        removed=removed, moves=count)


def overloaded(state):
    # 必ず同じ旧状態の荷重で全候補を調べる。等しい場合は割れない。
    payload = loads(state)
    return tuple(sorted(b.id for b in state.bodies
        if b.kind == 'board' and b.capacity is not None
        and payload[b.id] > b.capacity))


def break_once(state):
    ids = overloaded(state)
    if not ids:
        return Frame(state,'no_break')
    new = replace(state,
        bodies=tuple(b for b in state.bodies if b.id not in ids),
        broken=tuple(sorted(state.broken+ids)))
    return Frame(new,'break',ids)

STAGE = 3
STAGE_TITLE = 'ONE SNAPSHOT BREAK'
GUIDE = '1/2/3: snapshots / SPACE: simultaneous break / arrows: inspect'


# 合成入力も実キーボード入力も、この同じ入口へ渡す。
CONTROLS = ('LEFT','RIGHT','SPACE','X','Z','R','1','2','3')
REPLAY = {'L':'LEFT','T':'RIGHT','S':'SPACE','X':'X','Z':'Z','R':'R','1':'1','2':'2','3':'3'}

class Controller:
    def __init__(self):
        self.gate = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.selected = 'A1'
        self.reason = 'ready'
        self.event_ids = ()
        self.pending = False
        self.history = []
        self.last_frames = ()
        # Rでもgateは消さない。全操作キーを離してから再入力。

    def select(self,direction):
        options = self.selectable()
        if options:
            index = options.index(self.selected) if self.selected in options else 0
            self.selected = options[(index+direction)%len(options)]

    def selectable(self):
        return [b.id for b in self.state.bodies if b.removable]

    def tick(self,pressed=(),held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return
        if not pressed and len(held) <= 1:
            return
        if len(pressed) != 1 or pressed != held:
            self.gate = True
            self.reason = 'chord_rejected'
            return
        self.gate = True
        key = next(iter(pressed))
        if key == 'R':
            self.reset()
            return
        if key in ('1','2','3'):
            self.state = example_state(int(key)-1)
            self.selected = 'D'
            self.reason = 'snapshot_'+key
            self.event_ids = ()
            self.last_frames = ()
        elif key in ('LEFT','RIGHT'):
            self.select(-1 if key == 'LEFT' else 1)
            self.reason = 'select'
        elif key == 'SPACE':
            frame = break_once(self.state)
            self.state,self.reason,self.event_ids = frame.state,frame.event,frame.ids
            self.last_frames = (frame,)
        else:
            self.reason = 'unused' 

    def snapshot(self):
        return {'state':state_dict(self.state),'hash':state_hash(self.state),
                'selected':self.selected,'event':self.reason,'event_ids':self.event_ids,
                'pending':self.pending,'history_depth':len(self.history),
                'contacts':contacts(self.state),
                'loads':{k:str(v) for k,v in loads(self.state).items()},
                'shares':{k:{a:str(b) for a,b in v.items()} for k,v in support_edges(self.state).items()},
                'last_frames':[{'event':f.event,'ids':f.ids,'hash':state_hash(f.state)}
                               for f in self.last_frames]}


def draw_board(p,c):
    """描画だけ。state・履歴・入力ゲートへ書き込まない。"""
    p.cls(1)
    p.text(12,10,'SHELF MOCHI / STEP '+str(STAGE)+' / '+STAGE_TITLE,10)
    p.text(12,25,'Original case C / exact grid / no rotation or sliding',6)
    ox,oy,cell = 24,52,24
    for y in range(13):
        p.text(9,oy+y*cell+8,str(y),13)
        p.line(ox,oy+y*cell,ox+16*cell,oy+y*cell,5)
    for x in range(17):
        p.line(ox+x*cell,oy,ox+x*cell,oy+12*cell,5)
        if x < 16:
            p.text(ox+x*cell+8,oy-12,str(x),13)
    state = c.state
    by = {b.id:b for b in state.bodies}
    for b in sorted(state.bodies,key=lambda b:(-b.y,b.id)):
        x,y,w = ox+b.x*cell,oy+b.y*cell,b.w*cell
        # 点線枠が占有する格子。図形の余白や皿の薄さは接触判定を変えない。
        p.rectb(x,y,w,cell,13)
        if b.kind == 'anchor':
            p.rect(x+2,y+2,w-4,cell-4,5)
            p.line(x+3,y+3,x+w-4,y+cell-4,6)
            p.text(x+3,y+8,b.id,7)
        elif b.kind == 'board':
            p.rect(x+1,y+2,w-2,cell-4,9 if b.id not in ('E','F') else 8)
            p.line(x+3,y+5,x+w-4,y+5,10)
            p.text(x+w//2-len(b.id)*2,y+10,b.id,1)
        elif b.kind == 'plate':
            p.rect(x+2,y+1,w-4,4,7)
            p.text(x+7,y+10,b.id,6)
        elif b.kind == 'mochi':
            p.rect(x+4,y+3,w-8,cell-6,15)
            p.pset(x+8,y+8,1);p.pset(x+15,y+8,1)
            p.text(x+7,y+13,b.id,1)
        else:
            p.rect(x+2,y+2,w-4,cell-4,13)
            p.text(x+8,y+8,b.id,1)
    selected = by.get(c.selected)
    if selected:
        p.rectb(ox+selected.x*cell-1,oy+selected.y*cell-1,selected.w*cell+2,cell+2,10)
    p.rect(417,40,211,319,0)
    p.text(429,51,'INSPECT '+c.selected,10)
    if selected:
        p.text(429,68,f'x={selected.x} y={selected.y} w={selected.w}',7)
        p.text(429,85,'kind: '+selected.kind,7)
        p.text(429,102,'CONTACTS:',6)
        contact = contacts(state).get(selected.id,())
        p.text(429,115,','.join(contact) or '(none / fixed)',7)
        payload = loads(state)
        p.text(429,134,'PAYLOAD: '+str(payload[selected.id]),10)
        p.text(429,150,'SELF MASS: '+str(selected.mass),7)
        p.text(429,166,'CAPACITY: '+('UNLIMITED' if selected.capacity is None else str(selected.capacity)),7)
        p.text(429,184,'OUTGOING SHARE:',6)
        for row,(target,share) in enumerate(support_edges(state).get(selected.id,{}).items()):
            p.text(429,199+row*12,target+': '+str(share),7)
    p.text(429,241,'REMOVED: '+','.join(state.removed),6)
    p.text(429,257,'BROKEN: '+','.join(state.broken),8)
    p.text(429,273,'DELIVERED: '+','.join(state.delivered),11)
    p.text(429,291,'MOVES: '+str(state.moves),7)
    p.text(429,307,'STATUS: '+state.status.upper(),10)
    p.text(429,324,'HISTORY: '+str(len(c.history)),7)
    p.text(429,340,'PENDING: '+('YES - USE X' if c.pending else 'NO'),10 if c.pending else 13)
    p.text(12,372,'EVENT: '+c.reason.upper()+' '+','.join(c.event_ids),10)
    p.text(12,389,GUIDE,7)
    p.text(12,406,'R: reset  ESC: quit / release ALL keys before next input',6)
    p.text(12,424,'Fictional non-rotating model. NOT a real structural safety calculation.',13)
    p.text(12,440,'Cell outlines = collision cells; artwork is only decoration. Silent.',13)


def main(headless=False,sequence='',screenshot=None,report=None):
    from pathlib import Path
    import time
    import pyxel  # import時には不要。実画面を開く時だけ読み込む。
    controller = Controller()
    keycodes = {name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    frame = 0
    events = []
    last_frame = max(12,len(sequence)*2+2)
    start = time.perf_counter()
    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame-1)//2
            if frame % 2 and index < len(sequence):
                key = REPLAY[sequence[index]]
                controller.tick((key,),(key,))
                events.append({'input':sequence[index],'snapshot':controller.snapshot()})
            else:
                controller.tick()
        else:
            controller.tick([n for n,k in keycodes.items() if pyxel.btnp(k)],
                            [n for n,k in keycodes.items() if pyxel.btn(k)])
    def draw():
        draw_board(pyxel,controller)
        if headless and frame >= last_frame:
            if screenshot:
                pyxel.screenshot(str(Path(screenshot).resolve()),scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label':'actual Pyxel headless / synthetic controller input / silent',
                    'stage':STAGE,'frames':frame,'logical_seconds':frame/30,
                    'program_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
                    'wall_seconds_before_exit':round(time.perf_counter()-start,6),
                    'sequence':sequence,'events':events,'snapshot':controller.snapshot()
                },ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(640,460,title='Shelf Mochi - Step '+str(STAGE),fps=30,headless=headless)
    pyxel.run(update,draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--headless-replay',help='Synthetic keys: '+str(REPLAY))
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Outputs require headless mode')
    sequence = args.headless_replay or ''
    if any(char not in REPLAY for char in sequence):
        parser.error('Unsupported replay character')
    main(headless,sequence,args.screenshot,args.report)
STEP 04 / 05

落ちる一群を1格子ずつ進める

下からの安定の判定・いっせいに動かす・1回ずつ進む時間・処理の優先順位。

Space で板を抜いたら、X を押すたびに settle_once を1回だけ実行します。1回の中身は「危ないものにふれたか → いっせいに割れる → 安定しているかを判定して1マス落とす → 止まる」の順番です。安定の判定は固定されたものから始め、下の行から調べます。重さを渡す相手のどれかが安定していれば、その物体も安定とします。片側だけの支えやまん中だけの支えでも、水平のまま止まる模型です。落ちる ID を前の状態から全部集め終えてから、全部をいっせいに y+1 に動かします。だから、落ちる板の上のおもちも同じ1マスだけ動き、途中で重なることはありません。

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

04_falling.py / 注目する部分
def remove_board(state, action):
    if state.status != 'ready':
        return None
    if not any(b.id == action and b.removable for b in state.bodies):
        return None
    new = replace(state,bodies=tuple(b for b in state.bodies if b.id != action),
                  removed=tuple(sorted(state.removed+(action,))),moves=state.moves+1)
    return Frame(new,'removed',(action,))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_falling.py
macOS / Linux
.venv/bin/python 04_falling.py
画面で見ること最初に A1 を Space で抜くと PENDING(処理中)になります。X を1回押すと M1.y=3、さらに3回で y=6 になり、D にふれます。5回目の X で READY(操作できる)になり、次の板 A2 を調べます。この段階には、まだ皿に着いたときの成功の判定も Undo もありません。PENDING の間は Space や左右を受け付けず、X か R だけで進めます。
  • 重さは上から、安定は下から調べる
  • 落ちるものを計算している間に、位置を変えない
  • 危ないものにふれたかを、成功より先に調べる
  • 途中の入力を止めて、1回の更新を観察する
つまずきやすいところ上から順番に座標を書きかえると、上の物体が、まだ動いていない下の物体を支えだとかんちがいしやすくなります。下から調べる安定の判定と、上から調べる重さの合計は、目的がちがいます。これは、速さや加速を計算する物理エンジンではありません。
少し変えてみよう

Space → X で1マスだけ動くことを確かめ、X を合計5回押して止めます。そのまま Space で A2 を抜き、X を5回押すと E/F がいっせいに割れ、6回目で D が y=8 に落ち、7回目で皿にふれて失敗です。失敗したら R で戻ります。

04_falling.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(453行)
04_falling.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材画面はASCIIと図形のみ。字形データは使いません。

"""棚からぼたもちの独立教材。日本語の説明はLESSON.mdを参照。"""
from dataclasses import dataclass, replace, asdict
from fractions import Fraction as F
import hashlib
import json

FLOOR = 12

@dataclass(frozen=True)
class Body:
    id: str
    kind: str
    x: int
    y: int
    w: int = 1
    mass: F = F(0)
    capacity: F | None = None
    @property
    def right(self): return self.x + self.w
    @property
    def fixed(self): return self.kind in ('anchor','plate')
    @property
    def removable(self): return self.kind == 'board'

@dataclass(frozen=True)
class State:
    level: int
    bodies: tuple[Body, ...]
    removed: tuple[str, ...] = ()
    broken: tuple[str, ...] = ()
    delivered: tuple[str, ...] = ()
    status: str = 'ready'
    reason: str = 'ready'
    danger: tuple[str, ...] = ()
    moves: int = 0
    def __post_init__(self):
        object.__setattr__(self,'bodies',tuple(sorted(self.bodies,key=lambda b:b.id)))
        for name in ('removed','broken','delivered','danger'):
            object.__setattr__(self,name,tuple(sorted(getattr(self,name))))

@dataclass(frozen=True)
class Frame:
    state: State
    event: str
    ids: tuple[str, ...] = ()

@dataclass(frozen=True)
class Result:
    state: State
    frames: tuple[Frame, ...]

def board(id,x,y,w,capacity=4): return Body(id,'board',x,y,w,F(1),None if capacity is None else F(capacity))

def anchor(id,x,y): return Body(id,'anchor',x,y)

def mochi(id,x,y,mass=1): return Body(id,'mochi',x,y,1,F(mass))

def plate(id,x,y): return Body(id,'plate',x,y)

# 原作Cの固定配置そのもの。level=2は元作品の0始まりの番号。
def initial():
    return State(2, (
        mochi('M1',5,2), mochi('M2',10,2),
        board('A1',4,3,3), board('A2',9,3,3),
        anchor('a1L',4,4), anchor('a1R',6,4),
        anchor('a2L',9,4), anchor('a2R',11,4),
        board('D',3,7,10,None), board('E',2,8,2,F(5,4)),
        board('F',12,8,2,F(5,4)), anchor('eL',2,9),
        anchor('eR',3,9), anchor('fL',12,9), anchor('fR',13,9),
        plate('P1',5,9), plate('P2',10,9)))

def overlap(a,b): return min(a.right,b.right)-max(a.x,b.x) >= 1

def contacts(state):
    """Actual adjacent cell contacts, never authored graph shortcuts."""
    return {b.id:tuple(c.id for c in state.bodies if c.id != b.id and c.y == b.y+1 and overlap(b,c))
            for b in state.bodies if not b.fixed}

def validate_geometry(state):
    for b in state.bodies:
        assert 0<=b.x<b.right<=16 and 0<=b.y<FLOOR,(b,'bounds')
    for i,b in enumerate(state.bodies):
        for c in state.bodies[i+1:]:assert not (b.y==c.y and overlap(b,c)),('overlap',b,c)
    return True

def state_dict(state):
    data=asdict(state)
    for b in data['bodies']:
        for k in ('mass','capacity'):
            if b[k] is not None:b[k]=str(b[k])
    return data

def state_hash(state):
    return hashlib.sha256(json.dumps(state_dict(state),sort_keys=True,ensure_ascii=True,separators=(',',':')).encode('utf-8')).hexdigest()


def support_edges(state):
    by={b.id:b for b in state.bodies}; contact=contacts(state); out={}
    for b in state.bodies:
        if b.fixed: continue
        below=[by[i] for i in contact[b.id]]
        if b.kind=='board':
            left=[c for c in below if c.x<=b.x<c.right]
            right=[c for c in below if c.x<=b.right-1<c.right]
            shares={}
            sides=[side for side in (left,right) if side]
            if sides:
                for side in sides:
                    for c in side: shares[c.id]=shares.get(c.id,F(0))+F(1,len(sides)*len(side))
            elif below:
                shares={c.id:F(1,len(below)) for c in below}
            out[b.id]=shares
        else:
            under=[c for c in below if c.x<=b.x<c.right]
            out[b.id]={c.id:F(1,len(under)) for c in under}
    return out

def loads(state):
    """Return received payload; add own mass only when forwarding downward."""
    edges=support_edges(state); payload={b.id:F(0) for b in state.bodies}
    for b in sorted(state.bodies,key=lambda b:(b.y,b.id)):
        for target,share in edges.get(b.id,{}).items(): payload[target]+=(payload[b.id]+b.mass)*share
    return payload

def example_state(count):
    """原作の落下フレームから取り出した、D上のもち0/1/2個の実座標。"""
    if count not in (0,1,2):
        raise ValueError('count must be 0, 1 or 2')
    removed = ('A1','A2')[:count]
    moved = ('M1','M2')[:count]
    return replace(initial(),
        bodies=tuple(replace(b,y=6) if b.id in moved else b
                     for b in initial().bodies if b.id not in removed),
        removed=removed, moves=count)


def overloaded(state):
    # 必ず同じ旧状態の荷重で全候補を調べる。等しい場合は割れない。
    payload = loads(state)
    return tuple(sorted(b.id for b in state.bodies
        if b.kind == 'board' and b.capacity is not None
        and payload[b.id] > b.capacity))


def break_once(state):
    ids = overloaded(state)
    if not ids:
        return Frame(state,'no_break')
    new = replace(state,
        bodies=tuple(b for b in state.bodies if b.id not in ids),
        broken=tuple(sorted(state.broken+ids)))
    return Frame(new,'break',ids)


def settle_once(state, goals=False):
    """1回の再計算。接触危険→同時破断→同時落下→静止/成功。"""
    by = {b.id:b for b in state.bodies}
    contact = contacts(state)
    # 原作と同じ下側優先。危険と到着が同時なら危険を先に返す。
    for b in sorted(state.bodies,key=lambda b:(-b.y,b.id)):
        if b.fixed:
            continue
        for cid in contact[b.id]:
            c = by[cid]
            reason = ('dish_hit' if c.kind == 'plate' else
                      'crushed' if c.kind == 'mochi' else None)
            if b.kind in ('board','stone') and reason:
                ids = tuple(sorted((b.id,c.id)))
                return Frame(replace(state,status='failure',reason=reason,danger=ids),reason,ids)
        if b.kind == 'mochi' and b.y+1 >= FLOOR:
            return Frame(replace(state,status='failure',reason='floor',danger=(b.id,)),'floor',(b.id,))
    broken = break_once(state)
    if broken.ids:
        return broken
    edges = support_edges(state)
    stable = {b.id for b in state.bodies if b.fixed}
    falling = []
    # 旧状態を下から調べる。リスト中のBodyをこのループで動かさない。
    for b in sorted(state.bodies,key=lambda b:(-b.y,b.id)):
        if b.fixed:
            continue
        if b.y+1 >= FLOOR and b.kind != 'mochi':
            stable.add(b.id)
        elif edges.get(b.id) and any(i in stable for i in edges[b.id]):
            stable.add(b.id)
        else:
            falling.append(b.id)
    if falling:
        new = replace(state,bodies=tuple(replace(b,y=b.y+1) if b.id in falling else b
                                         for b in state.bodies))
        validate_geometry(new)
        return Frame(new,'fall',tuple(sorted(falling)))
    delivered = tuple(sorted(b.id for b in state.bodies if b.kind == 'mochi'
        and any(by[i].kind == 'plate' for i in contact[b.id]))) if goals else ()
    won = goals and len(delivered) == sum(b.kind == 'mochi' for b in state.bodies)
    new = replace(state,delivered=delivered,status='success' if won else 'ready',
                  reason='success' if won else 'ready')
    return Frame(new,'plate' if delivered else 'ready',delivered)


def remove_board(state, action):
    if state.status != 'ready':
        return None
    if not any(b.id == action and b.removable for b in state.bodies):
        return None
    new = replace(state,bodies=tuple(b for b in state.bodies if b.id != action),
                  removed=tuple(sorted(state.removed+(action,))),moves=state.moves+1)
    return Frame(new,'removed',(action,))

STAGE = 4
STAGE_TITLE = 'DISCRETE FALL'
GUIDE = 'Arrows: board / SPACE: remove / X: ONE settle step (until READY)'


# 合成入力も実キーボード入力も、この同じ入口へ渡す。
CONTROLS = ('LEFT','RIGHT','SPACE','X','Z','R','1','2','3')
REPLAY = {'L':'LEFT','T':'RIGHT','S':'SPACE','X':'X','Z':'Z','R':'R','1':'1','2':'2','3':'3'}

class Controller:
    def __init__(self):
        self.gate = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.selected = 'A1'
        self.reason = 'ready'
        self.event_ids = ()
        self.pending = False
        self.history = []
        self.last_frames = ()
        # Rでもgateは消さない。全操作キーを離してから再入力。

    def select(self,direction):
        options = self.selectable()
        if options:
            index = options.index(self.selected) if self.selected in options else 0
            self.selected = options[(index+direction)%len(options)]

    def selectable(self):
        return [b.id for b in self.state.bodies if b.removable]

    def tick(self,pressed=(),held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return
        if not pressed and len(held) <= 1:
            return
        if len(pressed) != 1 or pressed != held:
            self.gate = True
            self.reason = 'chord_rejected'
            return
        self.gate = True
        key = next(iter(pressed))
        if key == 'R':
            self.reset()
            return
        if self.state.status != 'ready':
            self.reason = self.state.reason
            return
        if key == 'X' and self.pending:
            frame = settle_once(self.state)
            self.state,self.reason,self.event_ids = frame.state,frame.event,frame.ids
            self.last_frames += (frame,)
            self.pending = frame.event in ('break','fall')
            if not self.pending and self.selected not in self.selectable():
                options = self.selectable()
                if options:
                    self.selected = options[0]
        elif self.pending:
            self.reason = 'busy_use_X'
        elif key in ('LEFT','RIGHT'):
            self.select(-1 if key == 'LEFT' else 1)
            self.reason = 'select'
        elif key == 'SPACE':
            frame = remove_board(self.state,self.selected)
            if frame:
                self.state,self.reason,self.event_ids = frame.state,frame.event,frame.ids
                self.last_frames = (frame,)
                self.pending = True
            else:
                self.reason = 'unselected'
        else:
            self.reason = 'unused' 

    def snapshot(self):
        return {'state':state_dict(self.state),'hash':state_hash(self.state),
                'selected':self.selected,'event':self.reason,'event_ids':self.event_ids,
                'pending':self.pending,'history_depth':len(self.history),
                'contacts':contacts(self.state),
                'loads':{k:str(v) for k,v in loads(self.state).items()},
                'shares':{k:{a:str(b) for a,b in v.items()} for k,v in support_edges(self.state).items()},
                'last_frames':[{'event':f.event,'ids':f.ids,'hash':state_hash(f.state)}
                               for f in self.last_frames]}


def draw_board(p,c):
    """描画だけ。state・履歴・入力ゲートへ書き込まない。"""
    p.cls(1)
    p.text(12,10,'SHELF MOCHI / STEP '+str(STAGE)+' / '+STAGE_TITLE,10)
    p.text(12,25,'Original case C / exact grid / no rotation or sliding',6)
    ox,oy,cell = 24,52,24
    for y in range(13):
        p.text(9,oy+y*cell+8,str(y),13)
        p.line(ox,oy+y*cell,ox+16*cell,oy+y*cell,5)
    for x in range(17):
        p.line(ox+x*cell,oy,ox+x*cell,oy+12*cell,5)
        if x < 16:
            p.text(ox+x*cell+8,oy-12,str(x),13)
    state = c.state
    by = {b.id:b for b in state.bodies}
    for b in sorted(state.bodies,key=lambda b:(-b.y,b.id)):
        x,y,w = ox+b.x*cell,oy+b.y*cell,b.w*cell
        # 点線枠が占有する格子。図形の余白や皿の薄さは接触判定を変えない。
        p.rectb(x,y,w,cell,13)
        if b.kind == 'anchor':
            p.rect(x+2,y+2,w-4,cell-4,5)
            p.line(x+3,y+3,x+w-4,y+cell-4,6)
            p.text(x+3,y+8,b.id,7)
        elif b.kind == 'board':
            p.rect(x+1,y+2,w-2,cell-4,9 if b.id not in ('E','F') else 8)
            p.line(x+3,y+5,x+w-4,y+5,10)
            p.text(x+w//2-len(b.id)*2,y+10,b.id,1)
        elif b.kind == 'plate':
            p.rect(x+2,y+1,w-4,4,7)
            p.text(x+7,y+10,b.id,6)
        elif b.kind == 'mochi':
            p.rect(x+4,y+3,w-8,cell-6,15)
            p.pset(x+8,y+8,1);p.pset(x+15,y+8,1)
            p.text(x+7,y+13,b.id,1)
        else:
            p.rect(x+2,y+2,w-4,cell-4,13)
            p.text(x+8,y+8,b.id,1)
    selected = by.get(c.selected)
    if selected:
        p.rectb(ox+selected.x*cell-1,oy+selected.y*cell-1,selected.w*cell+2,cell+2,10)
    p.rect(417,40,211,319,0)
    p.text(429,51,'INSPECT '+c.selected,10)
    if selected:
        p.text(429,68,f'x={selected.x} y={selected.y} w={selected.w}',7)
        p.text(429,85,'kind: '+selected.kind,7)
        p.text(429,102,'CONTACTS:',6)
        contact = contacts(state).get(selected.id,())
        p.text(429,115,','.join(contact) or '(none / fixed)',7)
        payload = loads(state)
        p.text(429,134,'PAYLOAD: '+str(payload[selected.id]),10)
        p.text(429,150,'SELF MASS: '+str(selected.mass),7)
        p.text(429,166,'CAPACITY: '+('UNLIMITED' if selected.capacity is None else str(selected.capacity)),7)
        p.text(429,184,'OUTGOING SHARE:',6)
        for row,(target,share) in enumerate(support_edges(state).get(selected.id,{}).items()):
            p.text(429,199+row*12,target+': '+str(share),7)
    p.text(429,241,'REMOVED: '+','.join(state.removed),6)
    p.text(429,257,'BROKEN: '+','.join(state.broken),8)
    p.text(429,273,'DELIVERED: '+','.join(state.delivered),11)
    p.text(429,291,'MOVES: '+str(state.moves),7)
    p.text(429,307,'STATUS: '+state.status.upper(),10)
    p.text(429,324,'HISTORY: '+str(len(c.history)),7)
    p.text(429,340,'PENDING: '+('YES - USE X' if c.pending else 'NO'),10 if c.pending else 13)
    p.text(12,372,'EVENT: '+c.reason.upper()+' '+','.join(c.event_ids),10)
    p.text(12,389,GUIDE,7)
    p.text(12,406,'R: reset  ESC: quit / release ALL keys before next input',6)
    p.text(12,424,'Fictional non-rotating model. NOT a real structural safety calculation.',13)
    p.text(12,440,'Cell outlines = collision cells; artwork is only decoration. Silent.',13)


def main(headless=False,sequence='',screenshot=None,report=None):
    from pathlib import Path
    import time
    import pyxel  # import時には不要。実画面を開く時だけ読み込む。
    controller = Controller()
    keycodes = {name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    frame = 0
    events = []
    last_frame = max(12,len(sequence)*2+2)
    start = time.perf_counter()
    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame-1)//2
            if frame % 2 and index < len(sequence):
                key = REPLAY[sequence[index]]
                controller.tick((key,),(key,))
                events.append({'input':sequence[index],'snapshot':controller.snapshot()})
            else:
                controller.tick()
        else:
            controller.tick([n for n,k in keycodes.items() if pyxel.btnp(k)],
                            [n for n,k in keycodes.items() if pyxel.btn(k)])
    def draw():
        draw_board(pyxel,controller)
        if headless and frame >= last_frame:
            if screenshot:
                pyxel.screenshot(str(Path(screenshot).resolve()),scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label':'actual Pyxel headless / synthetic controller input / silent',
                    'stage':STAGE,'frames':frame,'logical_seconds':frame/30,
                    'program_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
                    'wall_seconds_before_exit':round(time.perf_counter()-start,6),
                    'sequence':sequence,'events':events,'snapshot':controller.snapshot()
                },ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(640,460,title='Shelf Mochi - Step '+str(STAGE),fps=30,headless=headless)
    pyxel.run(update,draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--headless-replay',help='Synthetic keys: '+str(REPLAY))
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Outputs require headless mode')
    sequence = args.headless_replay or ''
    if any(char not in REPLAY for char in sequence):
        parser.error('Unsupported replay character')
    main(headless,sequence,args.screenshot,args.report)
STEP 05 / 05

全てのもちを皿へ届けて一手戻す

回数に限りのあるくり返し・終わりの条件・状態を書きかえない変化・書きかえない履歴・元のゲームとの照合。

resolve は、止まる・成功・失敗のどれかになるまで settle_once をくり返し、128回をこえたら異常として見つけます。1手の途中の状態は Frame の並びとして残し、transition は新しい State と Frame の並びを返します。成功は、全部のおもちが皿に直接ふれたときです。先に危ないものにふれたかを調べるので、同じ状態で「着いた」と「危ない」が両方あれば、失敗を優先します。Controller は、受け付けた「板を抜く」の前の State と選んでいた板を履歴に保存し、Z で正確に戻します。描く関数は、state も履歴も変えません。

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

05_puzzle.py / 注目する部分
def transition(state, action):
    removed = remove_board(state,action)
    if removed is None:
        return Result(state,())
    result = resolve(removed.state)
    return Result(result.state,(removed,)+result.frames)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_puzzle.py
macOS / Linux
.venv/bin/python 05_puzzle.py
画面で見ることSpace → 右 → Space → Space で、A1 → D → A2 の順に抜くと成功です。moves=3、DELIVERED M1,M2、BROKEN は空です。最初に Space → Space と押すと A1 → A2 の順になり、E/F が割れて D が皿にぶつかり、失敗します。Z で失敗の1手前に戻り、右 → Space → Space で成功に直せます。
  • 成功は、全部のおもちが皿に直接ふれること
  • 危険を優先し、「ちょうどなら耐える」と「いっせいに割れる」を守る
  • 受け付けた「板を抜く」だけを履歴に保存する
  • 元のゲームと一致している範囲と、確かめていない範囲を区別する
つまずきやすいところ全部の板を抜く必要はありません。選ぶだけの操作、受け付けない入力、成功したあとの「抜く」を、履歴に入れないでください。画面のフレーム数や描く速さで、重さや勝ち負けが変わらないようにします。元のゲームの演出は省いているので、完成版の落下は1手ですぐ決まります。途中を目で追いたいときは 04 を使いましょう。
少し変えてみよう

安全な解き方を実行したあと、Z で ready・moves=2 に戻ることを確かめ、Space でもう一度成功させます。R で最初に戻して Space → Space で失敗し、Z で戻して別の板を選びます。R と Space を同時に押して受け付けられないこと、片方だけ離しても動かず、全部のキーを離してから操作できることも確かめましょう。

05_puzzle.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(480行)
05_puzzle.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材画面はASCIIと図形のみ。字形データは使いません。

"""棚からぼたもちの独立教材。日本語の説明はLESSON.mdを参照。"""
from dataclasses import dataclass, replace, asdict
from fractions import Fraction as F
import hashlib
import json

FLOOR = 12

@dataclass(frozen=True)
class Body:
    id: str
    kind: str
    x: int
    y: int
    w: int = 1
    mass: F = F(0)
    capacity: F | None = None
    @property
    def right(self): return self.x + self.w
    @property
    def fixed(self): return self.kind in ('anchor','plate')
    @property
    def removable(self): return self.kind == 'board'

@dataclass(frozen=True)
class State:
    level: int
    bodies: tuple[Body, ...]
    removed: tuple[str, ...] = ()
    broken: tuple[str, ...] = ()
    delivered: tuple[str, ...] = ()
    status: str = 'ready'
    reason: str = 'ready'
    danger: tuple[str, ...] = ()
    moves: int = 0
    def __post_init__(self):
        object.__setattr__(self,'bodies',tuple(sorted(self.bodies,key=lambda b:b.id)))
        for name in ('removed','broken','delivered','danger'):
            object.__setattr__(self,name,tuple(sorted(getattr(self,name))))

@dataclass(frozen=True)
class Frame:
    state: State
    event: str
    ids: tuple[str, ...] = ()

@dataclass(frozen=True)
class Result:
    state: State
    frames: tuple[Frame, ...]

def board(id,x,y,w,capacity=4): return Body(id,'board',x,y,w,F(1),None if capacity is None else F(capacity))

def anchor(id,x,y): return Body(id,'anchor',x,y)

def mochi(id,x,y,mass=1): return Body(id,'mochi',x,y,1,F(mass))

def plate(id,x,y): return Body(id,'plate',x,y)

# 原作Cの固定配置そのもの。level=2は元作品の0始まりの番号。
def initial():
    return State(2, (
        mochi('M1',5,2), mochi('M2',10,2),
        board('A1',4,3,3), board('A2',9,3,3),
        anchor('a1L',4,4), anchor('a1R',6,4),
        anchor('a2L',9,4), anchor('a2R',11,4),
        board('D',3,7,10,None), board('E',2,8,2,F(5,4)),
        board('F',12,8,2,F(5,4)), anchor('eL',2,9),
        anchor('eR',3,9), anchor('fL',12,9), anchor('fR',13,9),
        plate('P1',5,9), plate('P2',10,9)))

def overlap(a,b): return min(a.right,b.right)-max(a.x,b.x) >= 1

def contacts(state):
    """Actual adjacent cell contacts, never authored graph shortcuts."""
    return {b.id:tuple(c.id for c in state.bodies if c.id != b.id and c.y == b.y+1 and overlap(b,c))
            for b in state.bodies if not b.fixed}

def validate_geometry(state):
    for b in state.bodies:
        assert 0<=b.x<b.right<=16 and 0<=b.y<FLOOR,(b,'bounds')
    for i,b in enumerate(state.bodies):
        for c in state.bodies[i+1:]:assert not (b.y==c.y and overlap(b,c)),('overlap',b,c)
    return True

def state_dict(state):
    data=asdict(state)
    for b in data['bodies']:
        for k in ('mass','capacity'):
            if b[k] is not None:b[k]=str(b[k])
    return data

def state_hash(state):
    return hashlib.sha256(json.dumps(state_dict(state),sort_keys=True,ensure_ascii=True,separators=(',',':')).encode('utf-8')).hexdigest()


def support_edges(state):
    by={b.id:b for b in state.bodies}; contact=contacts(state); out={}
    for b in state.bodies:
        if b.fixed: continue
        below=[by[i] for i in contact[b.id]]
        if b.kind=='board':
            left=[c for c in below if c.x<=b.x<c.right]
            right=[c for c in below if c.x<=b.right-1<c.right]
            shares={}
            sides=[side for side in (left,right) if side]
            if sides:
                for side in sides:
                    for c in side: shares[c.id]=shares.get(c.id,F(0))+F(1,len(sides)*len(side))
            elif below:
                shares={c.id:F(1,len(below)) for c in below}
            out[b.id]=shares
        else:
            under=[c for c in below if c.x<=b.x<c.right]
            out[b.id]={c.id:F(1,len(under)) for c in under}
    return out

def loads(state):
    """Return received payload; add own mass only when forwarding downward."""
    edges=support_edges(state); payload={b.id:F(0) for b in state.bodies}
    for b in sorted(state.bodies,key=lambda b:(b.y,b.id)):
        for target,share in edges.get(b.id,{}).items(): payload[target]+=(payload[b.id]+b.mass)*share
    return payload

def example_state(count):
    """原作の落下フレームから取り出した、D上のもち0/1/2個の実座標。"""
    if count not in (0,1,2):
        raise ValueError('count must be 0, 1 or 2')
    removed = ('A1','A2')[:count]
    moved = ('M1','M2')[:count]
    return replace(initial(),
        bodies=tuple(replace(b,y=6) if b.id in moved else b
                     for b in initial().bodies if b.id not in removed),
        removed=removed, moves=count)


def overloaded(state):
    # 必ず同じ旧状態の荷重で全候補を調べる。等しい場合は割れない。
    payload = loads(state)
    return tuple(sorted(b.id for b in state.bodies
        if b.kind == 'board' and b.capacity is not None
        and payload[b.id] > b.capacity))


def break_once(state):
    ids = overloaded(state)
    if not ids:
        return Frame(state,'no_break')
    new = replace(state,
        bodies=tuple(b for b in state.bodies if b.id not in ids),
        broken=tuple(sorted(state.broken+ids)))
    return Frame(new,'break',ids)


def settle_once(state, goals=False):
    """1回の再計算。接触危険→同時破断→同時落下→静止/成功。"""
    by = {b.id:b for b in state.bodies}
    contact = contacts(state)
    # 原作と同じ下側優先。危険と到着が同時なら危険を先に返す。
    for b in sorted(state.bodies,key=lambda b:(-b.y,b.id)):
        if b.fixed:
            continue
        for cid in contact[b.id]:
            c = by[cid]
            reason = ('dish_hit' if c.kind == 'plate' else
                      'crushed' if c.kind == 'mochi' else None)
            if b.kind in ('board','stone') and reason:
                ids = tuple(sorted((b.id,c.id)))
                return Frame(replace(state,status='failure',reason=reason,danger=ids),reason,ids)
        if b.kind == 'mochi' and b.y+1 >= FLOOR:
            return Frame(replace(state,status='failure',reason='floor',danger=(b.id,)),'floor',(b.id,))
    broken = break_once(state)
    if broken.ids:
        return broken
    edges = support_edges(state)
    stable = {b.id for b in state.bodies if b.fixed}
    falling = []
    # 旧状態を下から調べる。リスト中のBodyをこのループで動かさない。
    for b in sorted(state.bodies,key=lambda b:(-b.y,b.id)):
        if b.fixed:
            continue
        if b.y+1 >= FLOOR and b.kind != 'mochi':
            stable.add(b.id)
        elif edges.get(b.id) and any(i in stable for i in edges[b.id]):
            stable.add(b.id)
        else:
            falling.append(b.id)
    if falling:
        new = replace(state,bodies=tuple(replace(b,y=b.y+1) if b.id in falling else b
                                         for b in state.bodies))
        validate_geometry(new)
        return Frame(new,'fall',tuple(sorted(falling)))
    delivered = tuple(sorted(b.id for b in state.bodies if b.kind == 'mochi'
        and any(by[i].kind == 'plate' for i in contact[b.id]))) if goals else ()
    won = goals and len(delivered) == sum(b.kind == 'mochi' for b in state.bodies)
    new = replace(state,delivered=delivered,status='success' if won else 'ready',
                  reason='success' if won else 'ready')
    return Frame(new,'plate' if delivered else 'ready',delivered)


def remove_board(state, action):
    if state.status != 'ready':
        return None
    if not any(b.id == action and b.removable for b in state.bodies):
        return None
    new = replace(state,bodies=tuple(b for b in state.bodies if b.id != action),
                  removed=tuple(sorted(state.removed+(action,))),moves=state.moves+1)
    return Frame(new,'removed',(action,))


def resolve(state):
    frames = []
    for _ in range(128):
        frame = settle_once(state,goals=True)
        frames.append(frame)
        state = frame.state
        if frame.event not in ('break','fall'):
            return Result(state,tuple(frames))
    state = replace(state,status='failure',reason='cycle',danger=())
    return Result(state,tuple(frames)+(Frame(state,'cycle'),))


def transition(state, action):
    removed = remove_board(state,action)
    if removed is None:
        return Result(state,())
    result = resolve(removed.state)
    return Result(result.state,(removed,)+result.frames)


def actions(state):
    return tuple(b.id for b in state.bodies if b.removable) if state.status == 'ready' else ()

STAGE = 5
STAGE_TITLE = 'COMPLETE PUZZLE'
GUIDE = 'Arrows: board / SPACE: remove + resolve / Z: undo'


# 合成入力も実キーボード入力も、この同じ入口へ渡す。
CONTROLS = ('LEFT','RIGHT','SPACE','X','Z','R','1','2','3')
REPLAY = {'L':'LEFT','T':'RIGHT','S':'SPACE','X':'X','Z':'Z','R':'R','1':'1','2':'2','3':'3'}

class Controller:
    def __init__(self):
        self.gate = False
        self.reset()

    def reset(self):
        self.state = initial()
        self.selected = 'A1'
        self.reason = 'ready'
        self.event_ids = ()
        self.pending = False
        self.history = []
        self.last_frames = ()
        # Rでもgateは消さない。全操作キーを離してから再入力。

    def select(self,direction):
        options = self.selectable()
        if options:
            index = options.index(self.selected) if self.selected in options else 0
            self.selected = options[(index+direction)%len(options)]

    def selectable(self):
        return [b.id for b in self.state.bodies if b.removable]

    def tick(self,pressed=(),held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return
        if not pressed and len(held) <= 1:
            return
        if len(pressed) != 1 or pressed != held:
            self.gate = True
            self.reason = 'chord_rejected'
            return
        self.gate = True
        key = next(iter(pressed))
        if key == 'R':
            self.reset()
            return
        if key == 'Z':
            if self.history:
                self.state,self.selected = self.history.pop()
                self.reason = 'undo'
                self.event_ids = ()
                self.last_frames = ()
            else:
                self.reason = 'no_history'
            return
        if self.state.status != 'ready':
            self.reason = self.state.reason
            return
        if key in ('LEFT','RIGHT'):
            self.select(-1 if key == 'LEFT' else 1)
            self.reason = 'select'
        elif key == 'SPACE':
            result = transition(self.state,self.selected)
            if result.frames:
                self.history.append((self.state,self.selected))
                self.state = result.state
                self.last_frames = result.frames
                self.reason = self.state.reason
                self.event_ids = ()
                options = self.selectable()
                if options and self.selected not in options:
                    self.selected = options[0]
            else:
                self.reason = 'unselected'
        else:
            self.reason = 'unused' 

    def snapshot(self):
        return {'state':state_dict(self.state),'hash':state_hash(self.state),
                'selected':self.selected,'event':self.reason,'event_ids':self.event_ids,
                'pending':self.pending,'history_depth':len(self.history),
                'contacts':contacts(self.state),
                'loads':{k:str(v) for k,v in loads(self.state).items()},
                'shares':{k:{a:str(b) for a,b in v.items()} for k,v in support_edges(self.state).items()},
                'last_frames':[{'event':f.event,'ids':f.ids,'hash':state_hash(f.state)}
                               for f in self.last_frames]}


def draw_board(p,c):
    """描画だけ。state・履歴・入力ゲートへ書き込まない。"""
    p.cls(1)
    p.text(12,10,'SHELF MOCHI / STEP '+str(STAGE)+' / '+STAGE_TITLE,10)
    p.text(12,25,'Original case C / exact grid / no rotation or sliding',6)
    ox,oy,cell = 24,52,24
    for y in range(13):
        p.text(9,oy+y*cell+8,str(y),13)
        p.line(ox,oy+y*cell,ox+16*cell,oy+y*cell,5)
    for x in range(17):
        p.line(ox+x*cell,oy,ox+x*cell,oy+12*cell,5)
        if x < 16:
            p.text(ox+x*cell+8,oy-12,str(x),13)
    state = c.state
    by = {b.id:b for b in state.bodies}
    for b in sorted(state.bodies,key=lambda b:(-b.y,b.id)):
        x,y,w = ox+b.x*cell,oy+b.y*cell,b.w*cell
        # 点線枠が占有する格子。図形の余白や皿の薄さは接触判定を変えない。
        p.rectb(x,y,w,cell,13)
        if b.kind == 'anchor':
            p.rect(x+2,y+2,w-4,cell-4,5)
            p.line(x+3,y+3,x+w-4,y+cell-4,6)
            p.text(x+3,y+8,b.id,7)
        elif b.kind == 'board':
            p.rect(x+1,y+2,w-2,cell-4,9 if b.id not in ('E','F') else 8)
            p.line(x+3,y+5,x+w-4,y+5,10)
            p.text(x+w//2-len(b.id)*2,y+10,b.id,1)
        elif b.kind == 'plate':
            p.rect(x+2,y+1,w-4,4,7)
            p.text(x+7,y+10,b.id,6)
        elif b.kind == 'mochi':
            p.rect(x+4,y+3,w-8,cell-6,15)
            p.pset(x+8,y+8,1);p.pset(x+15,y+8,1)
            p.text(x+7,y+13,b.id,1)
        else:
            p.rect(x+2,y+2,w-4,cell-4,13)
            p.text(x+8,y+8,b.id,1)
    selected = by.get(c.selected)
    if selected:
        p.rectb(ox+selected.x*cell-1,oy+selected.y*cell-1,selected.w*cell+2,cell+2,10)
    p.rect(417,40,211,319,0)
    p.text(429,51,'INSPECT '+c.selected,10)
    if selected:
        p.text(429,68,f'x={selected.x} y={selected.y} w={selected.w}',7)
        p.text(429,85,'kind: '+selected.kind,7)
        p.text(429,102,'CONTACTS:',6)
        contact = contacts(state).get(selected.id,())
        p.text(429,115,','.join(contact) or '(none / fixed)',7)
        payload = loads(state)
        p.text(429,134,'PAYLOAD: '+str(payload[selected.id]),10)
        p.text(429,150,'SELF MASS: '+str(selected.mass),7)
        p.text(429,166,'CAPACITY: '+('UNLIMITED' if selected.capacity is None else str(selected.capacity)),7)
        p.text(429,184,'OUTGOING SHARE:',6)
        for row,(target,share) in enumerate(support_edges(state).get(selected.id,{}).items()):
            p.text(429,199+row*12,target+': '+str(share),7)
    p.text(429,241,'REMOVED: '+','.join(state.removed),6)
    p.text(429,257,'BROKEN: '+','.join(state.broken),8)
    p.text(429,273,'DELIVERED: '+','.join(state.delivered),11)
    p.text(429,291,'MOVES: '+str(state.moves),7)
    p.text(429,307,'STATUS: '+state.status.upper(),10)
    p.text(429,324,'HISTORY: '+str(len(c.history)),7)
    p.text(429,340,'PENDING: '+('YES - USE X' if c.pending else 'NO'),10 if c.pending else 13)
    p.text(12,372,'EVENT: '+c.reason.upper()+' '+','.join(c.event_ids),10)
    p.text(12,389,GUIDE,7)
    p.text(12,406,'R: reset  ESC: quit / release ALL keys before next input',6)
    p.text(12,424,'Fictional non-rotating model. NOT a real structural safety calculation.',13)
    p.text(12,440,'Cell outlines = collision cells; artwork is only decoration. Silent.',13)


def main(headless=False,sequence='',screenshot=None,report=None):
    from pathlib import Path
    import time
    import pyxel  # import時には不要。実画面を開く時だけ読み込む。
    controller = Controller()
    keycodes = {name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    frame = 0
    events = []
    last_frame = max(12,len(sequence)*2+2)
    start = time.perf_counter()
    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame-1)//2
            if frame % 2 and index < len(sequence):
                key = REPLAY[sequence[index]]
                controller.tick((key,),(key,))
                events.append({'input':sequence[index],'snapshot':controller.snapshot()})
            else:
                controller.tick()
        else:
            controller.tick([n for n,k in keycodes.items() if pyxel.btnp(k)],
                            [n for n,k in keycodes.items() if pyxel.btn(k)])
    def draw():
        draw_board(pyxel,controller)
        if headless and frame >= last_frame:
            if screenshot:
                pyxel.screenshot(str(Path(screenshot).resolve()),scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label':'actual Pyxel headless / synthetic controller input / silent',
                    'stage':STAGE,'frames':frame,'logical_seconds':frame/30,
                    'program_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
                    'wall_seconds_before_exit':round(time.perf_counter()-start,6),
                    'sequence':sequence,'events':events,'snapshot':controller.snapshot()
                },ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(640,460,title='Shelf Mochi - Step '+str(STAGE),fps=30,headless=headless)
    pyxel.run(update,draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--headless-replay',help='Synthetic keys: '+str(REPLAY))
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Outputs require headless mode')
    sequence = args.headless_replay or ''
    if any(char not in REPLAY for char in sequence):
        parser.error('Unsupported replay character')
    main(headless,sequence,args.screenshot,args.report)
AFTER STEP 05

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

元のゲームの状況 C「荷重の引越し」(level=2)の座標・重さ・耐えられる重さをそのまま使います。E/F が耐えられる重さは 5/4、板の自分の重さは1です。板を抜いて、全部のおもちを皿に届けます。実際の座標による支え、分数での重さ、いっせいに割れる、いっせいに1マス落ちる、危険を優先する、というルールです。回転や横すべりのない空想の模型で、本物の建物の安全性を計算するものではありません。

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

比べる点5ステップの教材元の完成版
対象範囲状況 C だけの5段階です。01 はふれ合い、02 は重さ、03 は割れる、04 は手で進める落下、05 が完成版です。01〜04 は実験用です。A・B・C の3つの決まった配置と、耐えられる重さが無制限の比較モードがあります。
観察状態02/03 の 1/2/3 キーは、元のゲームの途中で D の上におもちが0・1・2個ある位置を読みこむものです。ゲームの操作としては使いません。板を抜いて落とすことでしか、その配置にはたどり着けません。
入力と見せ方全部のキーを離すまで、次の入力を受け付けません。R をふくむ同時押しは受け付けません。05 は1手をすぐに決め、Frame の並びを記録します。04 は手で1マスずつ更新します。アニメーション中の入力の制限、P での一時停止、タイトル、面の選択、N で次の面、F12 での撮影などがあります。
画面・音640×460、標準の文字(ASCII)と図形で、マス目の枠をはっきり表示します。日本語の文字・動画・音はありません。384×432、一緒に入っている日本語の文字、落下と割れる演出、記録用の画像・動画があります。音はありません。
権利元のゲームの MIT ライセンスの全文を、各 .py と LICENSE に残しています。参照している Noto の OFL の文書も、そのまま入れています。Noto の文字データは使っていません。Noto をもとにした日本語の文字を使っています。
検証の範囲元のゲームをもとにした24の決まった手順と、13の境目の配置で、状態と途中の出来事を照らし合わせています。実際の Pyxel で画面を出さないテスト、作った入力、各 .py を日本語とスペースが入ったフォルダーから単独で起動するテストもしています。すべての手順を証明したものではありません。元のゲームの30のテストとデモの確認は、別の記録です。教材のテストの数には足していません。

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

  1. 元のゲームの initial(level) の A/B を加えて、面ごとのデータを分けてみましょう。
  2. 元のゲームの app.py の queue と animate を読み、Frame の並びを時間差で描いてみましょう。
  3. 元のゲームの baseline の引数で E/F の耐えられる重さを無制限にして、重さの分かれ方と勝ち負けのちがいを比べてみましょう。
  4. 元のゲームの solver.py の状態の作り方を読み、板を抜く順番の探索を広げてみましょう。
  5. 日本語の文字を表示するときは、Noto の OFL と MIT の表示を残してください。
自分のゲームに変えるなら

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

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