推理・観察LESSON 59音距離の観測から経路を絞る推理パズル原作062

足音の探偵の作り方

同じ床でも、聞く場所によって音の強さがちがう。
空想の「3歩の足取り」を、床の材質と音の強弱の並びから推理します。壁もふくめた距離を計算し、観測に合う候補を作ります。

完成版の遊び方壁で音の届き方が変わる3歩を、別の部屋で聞き直します。音と同じ情報の字幕から、4つの経路を比べます。

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

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

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

完成版の主な操作:上下欄/左右室・歩・経路、Z聞く/停止/提出、X戻す、R再開、P停止、M消音

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

画面に「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・関数 / 文字列・タプル・辞書 / dataclass・heapq・replace は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右:観測位置A〜F / 上下:02以降の候補P〜S / Z:03以降の観測/再生中なら停止 / V:03以降の同じ観測の再表示 / P:03以降の視覚時計・操作の一時停止 / Space:04以降の提出 / X:05の観測/提出Undo / R:初期化 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_distance.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

部屋から部屋までの音距離を数える

重みのあるグラフの準備と、いちばん短い距離。

6つの部屋 A〜F を点、つながりを線で表します。画面の黄色い枠が「聞いている部屋」、DIST はそこから各部屋までの、音の届く距離です。左右で聞く場所を選べます。この段階では、どの線の費用も1にして、距離だけを調べます。本当の足取りを当てる操作は、まだありません。

ROOMS は部屋の名前を並べた文字列、EDGES は "AB" のような2部屋の組です。文字列に in を使って、その部屋につながる線を見つけます。線の長さをピクセルで測るのではなく、通った線の費用を足していきます。たとえば A から E へは、A→B→E でも A→D→E でも2です。

辞書 dist に、各部屋までの「今わかっているいちばん短い距離」を置き、聞いている部屋だけ0、ほかはこの小さな地図では十分大きい99にします。heapq は、距離がいちばん小さい組を先に取り出せる「優先度つきの列」です。もっと短い道を見つけたら更新し、古い距離で入っていた組はとばします。この「ダイクストラ法」は、次の段階の壁の費用にも、そのまま使えます。

Level は、床・壁・候補の経路をまとめた、書きかえないデータです。最初は床の名前を表示に使うだけで、paths と truth は、あとの段階のための面のデータです。draw は距離を読むだけです。本当の時間が進んでも、聞く場所やゲームの証拠は変わりません。

画面は、1つのファイルで動く文字(ASCII)の表示です。WOOD=木、TILE=タイル、STONE=石です。日本語の説明を読みながら見比べられます。この教材は、空想の録音と部屋を使う論理パズルで、現実の人を特定するものではありません。

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

01_distance.py / 注目する部分
            if new_distance < dist[neighbor]:
                dist[neighbor] = new_distance
                heapq.heappush(queue, (new_distance, neighbor))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_distance.py
macOS / Linux
.venv/bin/python 01_distance.py
画面で見ること起動すると A で、距離は A0/B1/C2/D1/E2/F3 です。右を1回押して離すと、聞く部屋が B になり、距離は A1/B0/C1/D2/E1/F2 です。待つだけでは変わりません。
  • 線の費用を足して距離を求める
  • 短くなったときだけ更新する
  • 表示や待つことでは、ゲームの状態は変わらない
つまずきやすいところdist を更新しただけで、列に追加し忘れると、その先の部屋に短い道が伝わりません。また、最初に見つけた道がいちばん短いとは限らないので、「まだ行っていないか」だけで打ち切らないようにしましょう。99は一般的な「無限大」ではなく、この決まった地図のための値です。
少し変えてみよう

START_ROOM = "A" を "F" に変えて、もう一度起動してみましょう。黄色い枠は F になり、距離は A3/B2/C1/D2/E1/F0 になります。test_first_exercise_start_F で、同じ変更を一時的に当てて確かめています。確かめたら A に戻します。

01_distance.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(191行)
01_distance.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
import heapq

ROOMS = "ABCDEF"
EDGES = ("AB", "BC", "DE", "EF", "AD", "BE", "CF")
PATH_IDS = "PQRS"
START_ROOM = "A"
WALL_COST = 1

@dataclass(frozen=True)
class Level:
    floors: tuple
    walls: tuple
    paths: tuple
    truth: int

LEVEL = Level(("wood", "tile", "stone", "wood", "tile", "stone"),
              (), ("ABE", "DEB", "CFC", "FCF"), 2)


def distances(level, listener, wall_cost=WALL_COST):
    """Sum edge costs, not geometric distance. Dijkstra's priority queue."""
    dist = {room: 99 for room in ROOMS}
    dist[listener] = 0
    queue = [(0, listener)]
    while queue:
        distance, room = heapq.heappop(queue)
        if distance != dist[room]:
            continue
        for edge in EDGES:
            if room not in edge:
                continue
            neighbor = edge[1] if edge[0] == room else edge[0]
            cost = wall_cost if edge in level.walls else 1
            new_distance = distance + cost
            if new_distance < dist[neighbor]:
                dist[neighbor] = new_distance
                heapq.heappush(queue, (new_distance, neighbor))
    return dist


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

    def reset(self):
        self.room = START_ROOM
        self.candidate = 0
        self.gated = False
        self.message = "READY"

    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 in ("LEFT", "RIGHT"):
            delta = 1 if key == "RIGHT" else -1
            self.room = ROOMS[(ROOMS.index(self.room)+delta) % len(ROOMS)]
            self.message = "LISTENER "+self.room

    def tick(self):
        pass

    def draw(self, p):
        p.cls(1)
        p.text(10, 8, "FOOTSTEP LAB / STAGE 1", 10)
        p.text(10, 23, "FICTIONAL PUZZLE / VISUAL ONLY - NO AUDIO", 6)
        ds = distances(LEVEL, self.room)
        positions = {"A":(45,90), "B":(145,90), "C":(245,90),
                     "D":(45,175), "E":(145,175), "F":(245,175)}
        for edge in EDGES:
            x,y = positions[edge[0]]
            u,v = positions[edge[1]]
            wall = edge in LEVEL.walls
            p.line(x, y, u, v, 9 if wall else 6)
            p.text((x+u)//2+4, (y+v)//2-8, str(WALL_COST if wall else 1), 9 if wall else 6)
        for room in ROOMS:
            x,y = positions[room]
            p.rect(x-30, y-23, 61, 46, 3 if room == self.room else 2)
            p.rectb(x-30, y-23, 61, 46, 10 if room == self.room else 5)
            p.text(x-25, y-16, room+"  DIST "+str(ds[room]), 10)
            p.text(x-25, y, LEVEL.floors[ROOMS.index(room)].upper(), 7)
            if room == self.room:
                p.text(x-25, y+10, "LISTENER", 11)
        p.text(10, 213, "DOOR 1 / WALL "+str(WALL_COST)+" / SHORTEST SOUND COST", 6)
        p.text(305, 75, "1. PICK A LISTENER", 10)
        p.text(305, 101, "2. SUM EDGE COSTS", 7)
        p.text(305, 127, "3. KEEP THE MINIMUM", 7)
        p.text(10, 249, "ALL EDGES COST 1 IN THIS FIRST LESSON", 7)
        p.text(10, 277, "WAITING DOES NOT MOVE THE LISTENER", 7)

        p.text(10, 350, self.message.upper(), 10)
        p.text(10, 369, "LEFT/RIGHT LISTENER  R RESET  ESC QUIT", 7)
        p.text(10, 386, "DISTANCE IS A GRAPH COST, NOT PIXELS", 6)

DEMO_EVENTS = {2: 'RIGHT'}
KEYS = ('LEFT', 'RIGHT', 'R')
CHECKPOINT_FRAME = 16


def main(args):
    from pathlib import Path
    import pyxel as p
    destination = Path(args.screenshot).resolve() if args.screenshot else None
    if destination:
        destination.parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(560, 408, title="Footstep Lab", fps=30, display_scale=1, headless=args.headless_smoke)
    key_ids = {key: getattr(p, "KEY_"+key) for key in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= args.frames:
            p.quit()
            return
        if args.demo_checkpoint:
            key = DEMO_EVENTS.get(p.frame_count)
            pressed = {key} if key else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in key_ids.items() if p.btnp(v)},
                        {k for k,v in key_ids.items() if p.btn(v)})
        app.tick()
    def draw():
        app.draw(p)
        if destination and p.frame_count == args.frames-1:
            p.screenshot(str(destination), scale=1)
    p.run(update, draw)


def parse_args(argv=None):
    # Reject unsupported combinations and unbounded runs before importing Pyxel.
    import argparse
    parser = argparse.ArgumentParser(description="Standalone visual footstep lesson")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int)
    parser.add_argument("--screenshot")
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke:
        parser.error("--demo-checkpoint, --frames and --screenshot require --headless-smoke")
    if args.frames is None:
        args.frames = CHECKPOINT_FRAME if args.demo_checkpoint else 12
    if not 2 <= args.frames <= 900:
        parser.error("--frames must be between 2 and 900")
    if args.demo_checkpoint and args.frames < CHECKPOINT_FRAME:
        parser.error("--frames is too short for this checkpoint")
    if args.screenshot and not args.screenshot.lower().endswith(".png"):
        parser.error("--screenshot must end in .png")
    return args


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

壁と床を三歩の署名へ変換する

距離を段階に分けることと、情報をかくす表し方。

ここで、元のゲームの状況 B の壁 BC・EF を加えます。白い扉の音の距離は1、だいだい色の壁は3です。音は壁を通りぬけられますが、人が歩く候補の経路は、扉だけを通ります。「音が通る道」と「歩く道」のルールを分けることが大切です。地図に描いた線の長さや床の名前は、移動の費用そのものではありません。

聞いている部屋から、足音が出た部屋までのいちばん短い距離が0〜1なら強さ2、2〜3なら強さ1、4以上なら聞こえない0にします。近ければ必ず強さ2なので、同じ部屋の0も、となりの1も、強い音です。signature は、経路の3文字を順に読み、3つの「(材質, 強さ)」をタプルにまとめます。

大事なのは、聞こえない音を ("stone",0) にしないことです。距離4以上では材質も分からないので、必ず ("none",0) にします。「石だけど無音」としてしまうと、本当は聞こえない床の情報が、答えをもらしてしまいます。NONE 0 も、記録しそこねたのではなく、1歩分の正式な観測の値です。

上下で、4つの経路 P〜S を選べます。ここでは、選んだ経路の予想だけを表示していて、実際の観測・候補を外すこと・提出は、まだありません。右で B、下を2回で R(C→F→C)を選ぶと、STONE 1 / NONE 0 / STONE 1 です。順番がちがう S(F→C→F)は NONE 0 / STONE 1 / NONE 0 になり、材質の種類だけでなく、順番にも情報があります。

音を出す機能は省いています。色・床の英語の名前・数字・3歩の順番を同時に見られるので、音を聞かなくても、同じ情報で進められます。耳で強弱を聞き分けられるかは、確かめていません。

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

02_signature.py / 注目する部分
    return tuple(
        ("none", 0) if ds[room] >= 4 else
        (level.floors[ROOMS.index(room)], 2 if ds[room] <= 1 else 1)
        for room in path
    )

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_signature.py
macOS / Linux
.venv/bin/python 02_signature.py
画面で見ることA からは C の距離4、F の距離5 です。B に移ると、C は3、F は4 です。B で R を選ぶと「弱い石・無音・弱い石」、S なら「無音・弱い石・無音」になります。
  • 壁の費用と、歩けるかどうかを分ける
  • 無音のときは、材質もかくす
  • 3歩の順番を守る
つまずきやすいところマス目の縦横の距離や、経路の歩数を、音の距離として使わないようにしましょう。壁もふくめたいちばん短い距離を、先に計算します。距離4を弱い音に入れず、無音のときは材質まで none に置きかえます。
少し変えてみよう

WALL_COST = 3 を1に変えて、もう一度起動してみましょう。B で R を予想すると、C が距離1、F が距離2 なので、STONE 2 / STONE 1 / STONE 2 になります。test_second_exercise_wall_one で確かめています。値を変えると、すでに集めた証拠の意味も変わるので、必ず起動し直して比べ、最後は3に戻します。

02_signature.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(213行)
02_signature.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
import heapq

ROOMS = "ABCDEF"
EDGES = ("AB", "BC", "DE", "EF", "AD", "BE", "CF")
PATH_IDS = "PQRS"
START_ROOM = "A"
WALL_COST = 3

@dataclass(frozen=True)
class Level:
    floors: tuple
    walls: tuple
    paths: tuple
    truth: int

LEVEL = Level(("wood", "tile", "stone", "wood", "tile", "stone"),
              ("BC", "EF"), ("ABE", "DEB", "CFC", "FCF"), 2)


def distances(level, listener, wall_cost=WALL_COST):
    """Sum edge costs, not geometric distance. Dijkstra's priority queue."""
    dist = {room: 99 for room in ROOMS}
    dist[listener] = 0
    queue = [(0, listener)]
    while queue:
        distance, room = heapq.heappop(queue)
        if distance != dist[room]:
            continue
        for edge in EDGES:
            if room not in edge:
                continue
            neighbor = edge[1] if edge[0] == room else edge[0]
            cost = wall_cost if edge in level.walls else 1
            new_distance = distance + cost
            if new_distance < dist[neighbor]:
                dist[neighbor] = new_distance
                heapq.heappush(queue, (new_distance, neighbor))
    return dist


def signature(level, listener, path, wall_cost=WALL_COST):
    ds = distances(level, listener, wall_cost)
    return tuple(
        ("none", 0) if ds[room] >= 4 else
        (level.floors[ROOMS.index(room)], 2 if ds[room] <= 1 else 1)
        for room in path
    )


def sound_text(sound):
    material, strength = sound
    return "NONE 0" if strength == 0 else material.upper()+" "+str(strength)


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

    def reset(self):
        self.room = START_ROOM
        self.candidate = 0
        self.gated = False
        self.message = "READY"

    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 in ("LEFT", "RIGHT"):
            delta = 1 if key == "RIGHT" else -1
            self.room = ROOMS[(ROOMS.index(self.room)+delta) % len(ROOMS)]
            self.message = "LISTENER "+self.room
        elif key in ("UP", "DOWN"):
            self.candidate = (self.candidate+(1 if key == "DOWN" else -1)) % 4
            self.message = "PATH "+PATH_IDS[self.candidate]

    def tick(self):
        pass

    def draw(self, p):
        p.cls(1)
        p.text(10, 8, "FOOTSTEP LAB / STAGE 2", 10)
        p.text(10, 23, "FICTIONAL PUZZLE / VISUAL ONLY - NO AUDIO", 6)
        ds = distances(LEVEL, self.room)
        positions = {"A":(45,90), "B":(145,90), "C":(245,90),
                     "D":(45,175), "E":(145,175), "F":(245,175)}
        for edge in EDGES:
            x,y = positions[edge[0]]
            u,v = positions[edge[1]]
            wall = edge in LEVEL.walls
            p.line(x, y, u, v, 9 if wall else 6)
            p.text((x+u)//2+4, (y+v)//2-8, str(WALL_COST if wall else 1), 9 if wall else 6)
        for room in ROOMS:
            x,y = positions[room]
            p.rect(x-30, y-23, 61, 46, 3 if room == self.room else 2)
            p.rectb(x-30, y-23, 61, 46, 10 if room == self.room else 5)
            p.text(x-25, y-16, room+"  DIST "+str(ds[room]), 10)
            p.text(x-25, y, LEVEL.floors[ROOMS.index(room)].upper(), 7)
            if room == self.room:
                p.text(x-25, y+10, "LISTENER", 11)
        p.text(10, 213, "DOOR 1 / WALL "+str(WALL_COST)+" / SHORTEST SOUND COST", 6)
        for i,path in enumerate(LEVEL.paths):
            y = 58+i*35
            p.rect(305, y, 242, 30, 3 if i == self.candidate else 2)
            p.text(312, y+5, PATH_IDS[i]+" "+" > ".join(path), 10 if i == self.candidate else 7)
            p.text(312, y+18, "PREDICTION ONLY", 6)
        p.text(10, 232, "DIST 0..1 STRONG 2 / 2..3 WEAK 1 / 4+ NONE 0", 13)
        p.text(10, 258, "PREDICTED AT "+self.room+" FOR PATH "+PATH_IDS[self.candidate], 7)
        for beat, sound in enumerate(signature(LEVEL, self.room, LEVEL.paths[self.candidate])):
            p.text(10+beat*175, 283, str(beat+1)+": "+sound_text(sound), 10)
        p.text(10, 315, "NONE HIDES MATERIAL TOO. NO OBSERVATION YET.", 6)

        p.text(10, 350, self.message.upper(), 10)
        p.text(10, 369, "LEFT/RIGHT LISTENER  UP/DOWN PATH  R RESET  ESC QUIT", 7)
        p.text(10, 386, "DISTANCE IS A GRAPH COST, NOT PIXELS", 6)

DEMO_EVENTS = {2: 'RIGHT', 6: 'DOWN', 10: 'DOWN'}
KEYS = ('LEFT', 'RIGHT', 'R', 'UP', 'DOWN')
CHECKPOINT_FRAME = 16


def main(args):
    from pathlib import Path
    import pyxel as p
    destination = Path(args.screenshot).resolve() if args.screenshot else None
    if destination:
        destination.parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(560, 408, title="Footstep Lab", fps=30, display_scale=1, headless=args.headless_smoke)
    key_ids = {key: getattr(p, "KEY_"+key) for key in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= args.frames:
            p.quit()
            return
        if args.demo_checkpoint:
            key = DEMO_EVENTS.get(p.frame_count)
            pressed = {key} if key else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in key_ids.items() if p.btnp(v)},
                        {k for k,v in key_ids.items() if p.btn(v)})
        app.tick()
    def draw():
        app.draw(p)
        if destination and p.frame_count == args.frames-1:
            p.screenshot(str(destination), scale=1)
    p.run(update, draw)


def parse_args(argv=None):
    # Reject unsupported combinations and unbounded runs before importing Pyxel.
    import argparse
    parser = argparse.ArgumentParser(description="Standalone visual footstep lesson")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int)
    parser.add_argument("--screenshot")
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke:
        parser.error("--demo-checkpoint, --frames and --screenshot require --headless-smoke")
    if args.frames is None:
        args.frames = CHECKPOINT_FRAME if args.demo_checkpoint else 12
    if not 2 <= args.frames <= 900:
        parser.error("--frames must be between 2 and 900")
    if args.demo_checkpoint and args.frames < CHECKPOINT_FRAME:
        parser.error("--frames is too short for this checkpoint")
    if args.screenshot and not args.screenshot.lower().endswith(".png"):
        parser.error("--screenshot must end in .png")
    return args


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

観測だけで候補を減らす

仮説の共通部分と、表示のための時計。

Z で、その部屋から、同じ録音の3歩を観測します。元のゲームの状況 B と同じ空想の面で、本当の足取りから音の特徴を1つ作ります。次に、それぞれの候補の予想と、その観測の値を比べ、一致する候補だけを残します。候補の番号を、正解の番号と直接比べて外すことはしません。

State は、observations(観測の記録)・candidates(候補)・selected_room(聞く部屋)をまとめた、書きかえない箱です。新しい観測はタプルの最後に足し、残っている候補だけを、もう一度調べます。だから候補の集合が増えることはなく、何回分もの観測の条件が積み重なります。聞く場所を移動するだけでは、記録は増えません。

A の部屋で聞く3歩は、全部 NONE 0 で、R と S が残ります。A でもう一度観測しても同じ情報なので、2つのままです。一方、B で観測すると「弱い石・無音・弱い石」が聞こえ、R だけになります。無音を「情報がないから無視する」のではなく、3つの正式な値として比べることで、P と Q を外せています。

3歩の特徴は、観測した瞬間に全部決まります。Playback の frame と beat は、その特徴を順に強調して見せるための時計です。30 fps で1拍は18フレーム、0・18・36で拍が切りかわり、54回目の tick で止まります。ふつうの速さで全体1.8秒ですが、画面を出さないテストで本当の時間を保証するものではありません。アニメーションが終わるまで、候補の判定を待つ仕組みではありません。

再生中の Z は再生を止めて、字幕を残します。V は同じ観測を最初から見せ直し、記録の数は増やしません。P は表示の時計と操作を一時停止します。R は最初に戻します。キーを押したら、全部のキーを離してから次を押してください。止める・見せ直す・待つことで、新しい証拠が生まれない作りです。

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

03_observe.py / 注目する部分
        candidates = tuple(i for i in state.candidates
                           if signature(level, room, level.paths[i], wall_cost) == heard.sounds)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_observe.py
macOS / Linux
.venv/bin/python 03_observe.py
画面で見ることA で Z を押すと R/S が残り、OBS 1 です。3歩の表示が止まるのを待つか、Z で止めてから、右で B に移って Z を押します。OBS 2 で R だけが残ります。この段階には、提出と勝ち負けはありません。
  • 観測した特徴と、予想を比べる
  • 同じ部屋で何回聞いても、新しい情報は増えない
  • 見せ直しの時計と、証拠の状態を分ける
つまずきやすいところ見せ直すたびに apply("listen:...") を呼ぶと、同じ録音を見せ直しただけで観測の数が増えてしまいます。V は Playback.start だけを呼びます。また、新しい観測は、最初の全部の候補ではなく、今の candidates を絞りこみます。
少し変えてみよう

A で2回観測してみましょう。毎回、再生が終わるか Z で止めてから、次を押します。OBS 2 でも、R/S の2つの候補です。右を4回押して E に移って観測すると、OBS 3、NONE 0 / STONE 1 / NONE 0 で、R だけになります。test_third_exercise_A_repeat_then_E で、この結果を確かめています。

03_observe.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(304行)
03_observe.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
import heapq

ROOMS = "ABCDEF"
EDGES = ("AB", "BC", "DE", "EF", "AD", "BE", "CF")
PATH_IDS = "PQRS"
START_ROOM = "A"
WALL_COST = 3

@dataclass(frozen=True)
class Level:
    floors: tuple
    walls: tuple
    paths: tuple
    truth: int

LEVEL = Level(("wood", "tile", "stone", "wood", "tile", "stone"),
              ("BC", "EF"), ("ABE", "DEB", "CFC", "FCF"), 2)


def distances(level, listener, wall_cost=WALL_COST):
    """Sum edge costs, not geometric distance. Dijkstra's priority queue."""
    dist = {room: 99 for room in ROOMS}
    dist[listener] = 0
    queue = [(0, listener)]
    while queue:
        distance, room = heapq.heappop(queue)
        if distance != dist[room]:
            continue
        for edge in EDGES:
            if room not in edge:
                continue
            neighbor = edge[1] if edge[0] == room else edge[0]
            cost = wall_cost if edge in level.walls else 1
            new_distance = distance + cost
            if new_distance < dist[neighbor]:
                dist[neighbor] = new_distance
                heapq.heappush(queue, (new_distance, neighbor))
    return dist


def signature(level, listener, path, wall_cost=WALL_COST):
    ds = distances(level, listener, wall_cost)
    return tuple(
        ("none", 0) if ds[room] >= 4 else
        (level.floors[ROOMS.index(room)], 2 if ds[room] <= 1 else 1)
        for room in path
    )


def sound_text(sound):
    material, strength = sound
    return "NONE 0" if strength == 0 else material.upper()+" "+str(strength)


@dataclass(frozen=True)
class Observation:
    room: str
    sounds: tuple


@dataclass(frozen=True)
class State:
    observations: tuple = ()
    candidates: tuple = (0, 1, 2, 3)
    selected_room: str = START_ROOM


def initial():
    return State()


def apply(state, level, action, wall_cost=WALL_COST):
    if action.startswith("listen:"):
        room = action.partition(":")[2]
        if len(room) != 1 or room not in ROOMS:
            return state, "invalid_room"
        # Truth produces an observation. Filtering receives only that observation.
        heard = Observation(room, signature(level, room, level.paths[level.truth], wall_cost))
        candidates = tuple(i for i in state.candidates
                           if signature(level, room, level.paths[i], wall_cost) == heard.sounds)
        return replace(state, observations=state.observations+(heard,),
                       candidates=candidates, selected_room=room), "heard"
    return state, "unknown"


BEAT_FRAMES = 18

class Playback:
    """Visual clock only. No audio output and no access to evidence state."""
    def __init__(self):
        self.frame = None
        self.beat = 0
        self.paused = False
        self.events = []

    def start(self):
        self.frame = 0
        self.beat = 0

    def stop(self):
        self.frame = None

    def tick(self):
        if self.paused or self.frame is None:
            return
        if self.frame % BEAT_FRAMES == 0:
            self.beat = self.frame // BEAT_FRAMES
            self.events.append((self.frame, self.beat))
        self.frame += 1
        if self.frame >= BEAT_FRAMES * 3:
            self.frame = None


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

    def reset(self):
        self.room = START_ROOM
        self.candidate = 0
        self.gated = False
        self.message = "READY"
        self.state = initial()
        self.playback = Playback()

    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 == "P":
            self.playback.paused = not self.playback.paused
            self.message = "PAUSED" if self.playback.paused else "RESUMED"
            return
        if self.playback.paused:
            return
        if key in ("LEFT", "RIGHT"):
            delta = 1 if key == "RIGHT" else -1
            self.room = ROOMS[(ROOMS.index(self.room)+delta) % len(ROOMS)]
            self.message = "LISTENER "+self.room
        elif key in ("UP", "DOWN"):
            self.candidate = (self.candidate+(1 if key == "DOWN" else -1)) % 4
            self.message = "PATH "+PATH_IDS[self.candidate]
        elif key == "Z":
            if self.playback.frame is not None:
                self.playback.stop()
                self.message = "STOPPED / SIGNATURE RETAINED"
            else:
                self.action("listen:"+self.room)
        elif key == "V" and self.state.observations:
            self.playback.start()
            self.message = "REPLAY / NO NEW OBSERVATION"

    def tick(self):
        self.playback.tick()

    def action(self, action):
        self.state, self.message = apply(self.state, LEVEL, action)
        if self.message == "heard":
            self.playback.start()
        elif action == "undo" or action.startswith("submit:"):
            self.playback.stop()
        if action == "undo" and self.message == "undo":
            self.room = self.state.selected_room

    def draw(self, p):
        p.cls(1)
        p.text(10, 8, "FOOTSTEP LAB / STAGE 3", 10)
        p.text(10, 23, "FICTIONAL PUZZLE / VISUAL ONLY - NO AUDIO", 6)
        ds = distances(LEVEL, self.room)
        positions = {"A":(45,90), "B":(145,90), "C":(245,90),
                     "D":(45,175), "E":(145,175), "F":(245,175)}
        for edge in EDGES:
            x,y = positions[edge[0]]
            u,v = positions[edge[1]]
            wall = edge in LEVEL.walls
            p.line(x, y, u, v, 9 if wall else 6)
            p.text((x+u)//2+4, (y+v)//2-8, str(WALL_COST if wall else 1), 9 if wall else 6)
        for room in ROOMS:
            x,y = positions[room]
            p.rect(x-30, y-23, 61, 46, 3 if room == self.room else 2)
            p.rectb(x-30, y-23, 61, 46, 10 if room == self.room else 5)
            p.text(x-25, y-16, room+"  DIST "+str(ds[room]), 10)
            p.text(x-25, y, LEVEL.floors[ROOMS.index(room)].upper(), 7)
            if room == self.room:
                p.text(x-25, y+10, "LISTENER", 11)
        p.text(10, 213, "DOOR 1 / WALL "+str(WALL_COST)+" / SHORTEST SOUND COST", 6)
        for i,path in enumerate(LEVEL.paths):
            y = 58+i*35
            p.rect(305, y, 242, 30, 3 if i == self.candidate else 2)
            p.text(312, y+5, PATH_IDS[i]+" "+" > ".join(path), 10 if i == self.candidate else 7)
            p.text(312, y+18, "REMAINS" if i in self.state.candidates else "EXCLUDED BY OBSERVATION", 11 if i in self.state.candidates else 13)
        p.text(10, 232, "DIST 0..1 STRONG 2 / 2..3 WEAK 1 / 4+ NONE 0", 13)
        p.rect(10, 253, 537, 50, 2)
        if self.state.observations:
            observation = self.state.observations[-1]
            p.text(16, 260, "OBS "+str(len(self.state.observations))+" / ROOM "+observation.room+" / COMPLETE THREE-STEP SIGNATURE", 7)
            for beat, sound in enumerate(observation.sounds):
                p.text(16+beat*175, 283, str(beat+1)+": "+sound_text(sound), 10 if beat == self.playback.beat else 7)
        else:
            p.text(16, 273, "Z: OBSERVE THE SAME THREE STEPS", 7)
        clock = "PAUSED" if self.playback.paused else "STOPPED" if self.playback.frame is None else "PLAYING"
        p.text(10, 315, "VISUAL CLOCK: "+clock+" / EVIDENCE DOES NOT CHANGE WITH TIME", 6)

        p.text(10, 350, self.message.upper(), 10)
        p.text(10, 369, "LEFT/RIGHT LISTENER  UP/DOWN PATH  R RESET  ESC QUIT", 7)
        p.text(10, 386, "Z OBSERVE/STOP  V REPLAY  P PAUSE", 6)

DEMO_EVENTS = {2: 'Z', 6: 'Z', 10: 'RIGHT', 14: 'Z'}
KEYS = ('LEFT', 'RIGHT', 'R', 'UP', 'DOWN', 'Z', 'V', 'P')
CHECKPOINT_FRAME = 80


def main(args):
    from pathlib import Path
    import pyxel as p
    destination = Path(args.screenshot).resolve() if args.screenshot else None
    if destination:
        destination.parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(560, 408, title="Footstep Lab", fps=30, display_scale=1, headless=args.headless_smoke)
    key_ids = {key: getattr(p, "KEY_"+key) for key in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= args.frames:
            p.quit()
            return
        if args.demo_checkpoint:
            key = DEMO_EVENTS.get(p.frame_count)
            pressed = {key} if key else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in key_ids.items() if p.btnp(v)},
                        {k for k,v in key_ids.items() if p.btn(v)})
        app.tick()
    def draw():
        app.draw(p)
        if destination and p.frame_count == args.frames-1:
            p.screenshot(str(destination), scale=1)
    p.run(update, draw)


def parse_args(argv=None):
    # Reject unsupported combinations and unbounded runs before importing Pyxel.
    import argparse
    parser = argparse.ArgumentParser(description="Standalone visual footstep lesson")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int)
    parser.add_argument("--screenshot")
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke:
        parser.error("--demo-checkpoint, --frames and --screenshot require --headless-smoke")
    if args.frames is None:
        args.frames = CHECKPOINT_FRAME if args.demo_checkpoint else 12
    if not 2 <= args.frames <= 900:
        parser.error("--frames must be between 2 and 900")
    if args.demo_checkpoint and args.frames < CHECKPOINT_FRAME:
        parser.error("--frames is too short for this checkpoint")
    if args.screenshot and not args.screenshot.lower().endswith(".png"):
        parser.error("--screenshot must end in .png")
    return args


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

次の観測場所を予測して根拠を示す

情報の分け方・矛盾の証拠・根拠のある提出。

候補を選んで、Space で提出できるようにします。正しい経路でも、候補が複数あるうちは INSUFFICIENT(根拠が足りない)です。まちがった経路でも、まだ証拠と矛盾していなければ NOT_YET_PROVEN(まだ証明されていない)、すでに観測で外された経路は CONTRADICTED(矛盾している)です。正しい経路が1つに絞れたときだけ、SUPPORTED(裏づけあり)と WON(勝ち)になります。早く提出したり、まちがえたりしても、観測は続けられます。

「次にどの部屋へ行けば、候補が分かれるか」を、forecast で調べます。それぞれの候補の予想の特徴を辞書のキーにして、同じものをまとめます。GROUPS はちがう特徴の種類の数、WORST LEFT はいちばん大きいまとまりの数です。今の候補から考えられるどの観測が来ても、多くていくつの候補が残るかを表します。これが「いちばんいい選び方」を決める、ただ1つの物差しという意味ではありません。

forecast は level.truth(本当の答え)を読みません。答えを見て「ここがいい」とすすめると、推理の手助けにならないからです。A で1回聞いて R/S の2つの候補が残っているとき、A は同じ無音なので GROUPS 1、WORST LEFT 2 です。B にカーソルを移すだけで、GROUPS 2、WORST LEFT 1 に変わります。予想を表示しただけでは、観測の数も候補も変わりません。

evidence は、選んだ候補の予想と、実際に保存したすべての観測を、1歩ずつ比べます。一致しなかった部屋・何歩目か・予想・聞いた値・予想した距離を返します。画面には最初の矛盾を1件だけ表示しますが、関数が返す値には全部が入っています。B で観測したあとに S を選ぶと、1歩目の予想は NONE 0、観測は STONE 1 で、なぜ外せたのかを確かめられます。

提出も State に記録されます。元のゲームと同じく、WON のあとにもう一度観測すると、outcome は RUNNING(続行中)に戻り、提出し直せます。完全に終わって操作を止めるゲームにはしていません。候補を外すのは特徴を比べることだけ、提出の採点は面の正解と証拠の数を使う、という2つの役割を分けています。

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

04_evidence.py / 注目する部分
        for i in state.candidates:
            predicted = signature(level, room, level.paths[i], wall_cost)
            groups.setdefault(predicted, []).append(i)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_evidence.py
macOS / Linux
.venv/bin/python 04_evidence.py
画面で見ることR で最初に戻す → 下を2回で R の経路を選ぶ → Space で INSUFFICIENT です。A で観測、B で観測してから R を選んで Space を押すと、SUPPORTED/WON です。B の1回の観測だけでも、R に絞れる面です。
  • 予想は、正解を見ずに作る
  • 予想だけでは、証拠は増えない
  • 根拠不足・矛盾・達成を分ける
つまずきやすいところ予想の中で truth を見ないようにしましょう。今残っている仮説を、すべて同じ方法で分けます。また、「矛盾がない」ことと、「1つに証明された」ことはちがいます。候補が2つあるうちは、正解を選んでも達成にはしません。
少し変えてみよう

A で観測したあと、再生を止めて、A の GROUPS 1 / WORST LEFT 2 を確かめましょう。右で B にカーソルを移すだけで、2 / 1 になります。このとき OBS は1、候補は R/S のままです。B で Z を押したあとにだけ、R の1つになります。test_fourth_exercise_information_A_vs_B と、描いても変わらないことのテストで確かめています。

04_evidence.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(363行)
04_evidence.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
import heapq

ROOMS = "ABCDEF"
EDGES = ("AB", "BC", "DE", "EF", "AD", "BE", "CF")
PATH_IDS = "PQRS"
START_ROOM = "A"
WALL_COST = 3

@dataclass(frozen=True)
class Level:
    floors: tuple
    walls: tuple
    paths: tuple
    truth: int

LEVEL = Level(("wood", "tile", "stone", "wood", "tile", "stone"),
              ("BC", "EF"), ("ABE", "DEB", "CFC", "FCF"), 2)


def distances(level, listener, wall_cost=WALL_COST):
    """Sum edge costs, not geometric distance. Dijkstra's priority queue."""
    dist = {room: 99 for room in ROOMS}
    dist[listener] = 0
    queue = [(0, listener)]
    while queue:
        distance, room = heapq.heappop(queue)
        if distance != dist[room]:
            continue
        for edge in EDGES:
            if room not in edge:
                continue
            neighbor = edge[1] if edge[0] == room else edge[0]
            cost = wall_cost if edge in level.walls else 1
            new_distance = distance + cost
            if new_distance < dist[neighbor]:
                dist[neighbor] = new_distance
                heapq.heappush(queue, (new_distance, neighbor))
    return dist


def signature(level, listener, path, wall_cost=WALL_COST):
    ds = distances(level, listener, wall_cost)
    return tuple(
        ("none", 0) if ds[room] >= 4 else
        (level.floors[ROOMS.index(room)], 2 if ds[room] <= 1 else 1)
        for room in path
    )


def sound_text(sound):
    material, strength = sound
    return "NONE 0" if strength == 0 else material.upper()+" "+str(strength)


@dataclass(frozen=True)
class Observation:
    room: str
    sounds: tuple


@dataclass(frozen=True)
class Submission:
    candidate: int
    verdict: str


@dataclass(frozen=True)
class State:
    observations: tuple = ()
    candidates: tuple = (0, 1, 2, 3)
    selected_room: str = START_ROOM
    submissions: tuple = ()
    outcome: str = "running"


def initial():
    return State()


def apply(state, level, action, wall_cost=WALL_COST):
    if action.startswith("listen:"):
        room = action.partition(":")[2]
        if len(room) != 1 or room not in ROOMS:
            return state, "invalid_room"
        # Truth produces an observation. Filtering receives only that observation.
        heard = Observation(room, signature(level, room, level.paths[level.truth], wall_cost))
        candidates = tuple(i for i in state.candidates
                           if signature(level, room, level.paths[i], wall_cost) == heard.sounds)
        return replace(state, observations=state.observations+(heard,),
                       candidates=candidates, selected_room=room, outcome="running"), "heard"
    if action.startswith("submit:"):
        try:
            candidate = int(action.partition(":")[2])
        except ValueError:
            return state, "invalid_candidate"
        if candidate not in range(len(level.paths)):
            return state, "invalid_candidate"
        if candidate == level.truth:
            verdict = "supported" if len(state.candidates) == 1 else "insufficient"
        else:
            verdict = "contradicted" if candidate not in state.candidates else "not_yet_proven"
        return replace(state,
                       submissions=state.submissions+(Submission(candidate, verdict),),
                       outcome="won" if verdict == "supported" else "running"), verdict
    return state, "unknown"


def forecast(state, level, wall_cost=WALL_COST):
    """Group possible signatures; never read level.truth."""
    rows = []
    for room in ROOMS:
        groups = {}
        for i in state.candidates:
            predicted = signature(level, room, level.paths[i], wall_cost)
            groups.setdefault(predicted, []).append(i)
        sizes = tuple(sorted(len(group) for group in groups.values()))
        rows.append({"room": room, "groups": len(groups),
                     "worst_remaining": max(sizes, default=0), "sizes": sizes})
    return tuple(rows)


def evidence(state, level, candidate, wall_cost=WALL_COST):
    rows = []
    for index, observation in enumerate(state.observations):
        predicted = signature(level, observation.room, level.paths[candidate], wall_cost)
        for beat, (guess, heard) in enumerate(zip(predicted, observation.sounds)):
            if guess != heard:
                rows.append({"observation": index, "room": observation.room, "beat": beat,
                             "predicted": guess, "heard": heard,
                             "distance": distances(level, observation.room, wall_cost)[level.paths[candidate][beat]]})
    return tuple(rows)


BEAT_FRAMES = 18

class Playback:
    """Visual clock only. No audio output and no access to evidence state."""
    def __init__(self):
        self.frame = None
        self.beat = 0
        self.paused = False
        self.events = []

    def start(self):
        self.frame = 0
        self.beat = 0

    def stop(self):
        self.frame = None

    def tick(self):
        if self.paused or self.frame is None:
            return
        if self.frame % BEAT_FRAMES == 0:
            self.beat = self.frame // BEAT_FRAMES
            self.events.append((self.frame, self.beat))
        self.frame += 1
        if self.frame >= BEAT_FRAMES * 3:
            self.frame = None


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

    def reset(self):
        self.room = START_ROOM
        self.candidate = 0
        self.gated = False
        self.message = "READY"
        self.state = initial()
        self.playback = Playback()

    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 == "P":
            self.playback.paused = not self.playback.paused
            self.message = "PAUSED" if self.playback.paused else "RESUMED"
            return
        if self.playback.paused:
            return
        if key in ("LEFT", "RIGHT"):
            delta = 1 if key == "RIGHT" else -1
            self.room = ROOMS[(ROOMS.index(self.room)+delta) % len(ROOMS)]
            self.message = "LISTENER "+self.room
        elif key in ("UP", "DOWN"):
            self.candidate = (self.candidate+(1 if key == "DOWN" else -1)) % 4
            self.message = "PATH "+PATH_IDS[self.candidate]
        elif key == "Z":
            if self.playback.frame is not None:
                self.playback.stop()
                self.message = "STOPPED / SIGNATURE RETAINED"
            else:
                self.action("listen:"+self.room)
        elif key == "V" and self.state.observations:
            self.playback.start()
            self.message = "REPLAY / NO NEW OBSERVATION"
        elif key == "SPACE":
            self.action("submit:"+str(self.candidate))

    def tick(self):
        self.playback.tick()

    def action(self, action):
        self.state, self.message = apply(self.state, LEVEL, action)
        if self.message == "heard":
            self.playback.start()
        elif action == "undo" or action.startswith("submit:"):
            self.playback.stop()
        if action == "undo" and self.message == "undo":
            self.room = self.state.selected_room

    def draw(self, p):
        p.cls(1)
        p.text(10, 8, "FOOTSTEP LAB / STAGE 4", 10)
        p.text(10, 23, "FICTIONAL PUZZLE / VISUAL ONLY - NO AUDIO", 6)
        ds = distances(LEVEL, self.room)
        positions = {"A":(45,90), "B":(145,90), "C":(245,90),
                     "D":(45,175), "E":(145,175), "F":(245,175)}
        for edge in EDGES:
            x,y = positions[edge[0]]
            u,v = positions[edge[1]]
            wall = edge in LEVEL.walls
            p.line(x, y, u, v, 9 if wall else 6)
            p.text((x+u)//2+4, (y+v)//2-8, str(WALL_COST if wall else 1), 9 if wall else 6)
        for room in ROOMS:
            x,y = positions[room]
            p.rect(x-30, y-23, 61, 46, 3 if room == self.room else 2)
            p.rectb(x-30, y-23, 61, 46, 10 if room == self.room else 5)
            p.text(x-25, y-16, room+"  DIST "+str(ds[room]), 10)
            p.text(x-25, y, LEVEL.floors[ROOMS.index(room)].upper(), 7)
            if room == self.room:
                p.text(x-25, y+10, "LISTENER", 11)
        p.text(10, 213, "DOOR 1 / WALL "+str(WALL_COST)+" / SHORTEST SOUND COST", 6)
        for i,path in enumerate(LEVEL.paths):
            y = 58+i*35
            p.rect(305, y, 242, 30, 3 if i == self.candidate else 2)
            p.text(312, y+5, PATH_IDS[i]+" "+" > ".join(path), 10 if i == self.candidate else 7)
            p.text(312, y+18, "REMAINS" if i in self.state.candidates else "EXCLUDED BY OBSERVATION", 11 if i in self.state.candidates else 13)
        p.text(10, 232, "DIST 0..1 STRONG 2 / 2..3 WEAK 1 / 4+ NONE 0", 13)
        p.rect(10, 253, 537, 50, 2)
        if self.state.observations:
            observation = self.state.observations[-1]
            p.text(16, 260, "OBS "+str(len(self.state.observations))+" / ROOM "+observation.room+" / COMPLETE THREE-STEP SIGNATURE", 7)
            for beat, sound in enumerate(observation.sounds):
                p.text(16+beat*175, 283, str(beat+1)+": "+sound_text(sound), 10 if beat == self.playback.beat else 7)
        else:
            p.text(16, 273, "Z: OBSERVE THE SAME THREE STEPS", 7)
        clock = "PAUSED" if self.playback.paused else "STOPPED" if self.playback.frame is None else "PLAYING"
        p.text(10, 315, "VISUAL CLOCK: "+clock+" / EVIDENCE DOES NOT CHANGE WITH TIME", 6)
        row = next(row for row in forecast(self.state, LEVEL) if row["room"] == self.room)
        p.text(305, 207, "PREDICT AT "+self.room+": "+str(row["groups"])+" GROUPS", 11)
        p.text(305, 221, "WORST LEFT "+str(row["worst_remaining"]), 11)
        mismatches = evidence(self.state, LEVEL, self.candidate)
        if mismatches:
            item = mismatches[0]
            p.text(10, 333, "CONTRADICTION AT "+item["room"]+" BEAT "+str(item["beat"]+1)+": EXPECT "+sound_text(item["predicted"])+" / HEARD "+sound_text(item["heard"]), 9)
        else:
            p.text(10, 333, "OUTCOME: "+self.state.outcome.upper()+" / NO RECORDED CONTRADICTION FOR SELECTED PATH", 11)

        p.text(10, 350, self.message.upper(), 10)
        p.text(10, 369, "LEFT/RIGHT LISTENER  UP/DOWN PATH  R RESET  ESC QUIT", 7)
        p.text(10, 386, "Z OBSERVE/STOP  V REPLAY  P PAUSE  SPACE SUBMIT", 6)

DEMO_EVENTS = {2: 'Z', 6: 'Z', 10: 'RIGHT', 14: 'Z', 72: 'DOWN', 76: 'DOWN', 80: 'SPACE'}
KEYS = ('LEFT', 'RIGHT', 'R', 'UP', 'DOWN', 'Z', 'V', 'P', 'SPACE')
CHECKPOINT_FRAME = 88


def main(args):
    from pathlib import Path
    import pyxel as p
    destination = Path(args.screenshot).resolve() if args.screenshot else None
    if destination:
        destination.parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(560, 408, title="Footstep Lab", fps=30, display_scale=1, headless=args.headless_smoke)
    key_ids = {key: getattr(p, "KEY_"+key) for key in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= args.frames:
            p.quit()
            return
        if args.demo_checkpoint:
            key = DEMO_EVENTS.get(p.frame_count)
            pressed = {key} if key else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in key_ids.items() if p.btnp(v)},
                        {k for k,v in key_ids.items() if p.btn(v)})
        app.tick()
    def draw():
        app.draw(p)
        if destination and p.frame_count == args.frames-1:
            p.screenshot(str(destination), scale=1)
    p.run(update, draw)


def parse_args(argv=None):
    # Reject unsupported combinations and unbounded runs before importing Pyxel.
    import argparse
    parser = argparse.ArgumentParser(description="Standalone visual footstep lesson")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int)
    parser.add_argument("--screenshot")
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke:
        parser.error("--demo-checkpoint, --frames and --screenshot require --headless-smoke")
    if args.frames is None:
        args.frames = CHECKPOINT_FRAME if args.demo_checkpoint else 12
    if not 2 <= args.frames <= 900:
        parser.error("--frames must be between 2 and 900")
    if args.demo_checkpoint and args.frames < CHECKPOINT_FRAME:
        parser.error("--frames is too short for this checkpoint")
    if args.screenshot and not args.screenshot.lower().endswith(".png"):
        parser.error("--screenshot must end in .png")
    return args


if __name__ == "__main__":
    main(parse_args())
STEP 05 / 05

観測と提出をまとめて戻す

書きかえない状態の履歴と、推理のやり直し。

Timeline に、今の State と履歴のタプルを持たせ、X で1つ前の観測か提出を取り消します。観測の数だけを1つ減らすのではありません。観測の一覧・候補の集合・最後に観測した部屋・提出の一覧・結果を、全部いっしょに戻します。

step は、apply がちがう State を返したときだけ、変わる前の State を履歴に積みます。おかしな部屋の名前や、ない候補の番号では、履歴は使いません。一方、根拠不足の提出でも Submission(提出の記録)が増えるので、1つの判断として記録されます。同じ部屋でもう一度観測しても OBS が増えるので、戻せます。

カーソルの移動・表示の時計が進むこと・V での見せ直し・P での一時停止は、証拠の変化ではないので、Undo の対象外です。X を受け付けたときは再生を止め、聞く場所のカーソルを、戻した State の selected_room に合わせます。候補のカーソルはそのままです。音の途中の時刻まで巻き戻す仕組みではありません。

A で観測 → B で観測 → R を提出して WON にしたあと、X を1回押すと提出の前に、もう1回で B の観測の前に戻ります。候補は R/S に戻り、最後の観測は A の3つの無音です。そこから E に移って新しく観測すれば、別の証拠で同じ R を確定できます。履歴は、分かれる前の未来を使い回しません。

元のゲームとの照らし合わせは、状況 B・壁の費用1と3・4通りの本当の答えで行います。ふつうの apply と予想・矛盾、それに Undo をふくむ並びを比べています。ゲームの中身の値は元のゲームと合わせていますが、表紙・3つの面の切りかえ・日本語の文字・録音の書き出し・実際の音の出力は省いています。元のゲームの画面全体や、人の学びの効果を再現したという意味ではありません。

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

05_undo.py / 注目する部分
    state, reason = apply(timeline.state, level, action, wall_cost)
    if state == timeline.state:
        return timeline, reason
    return Timeline(state, timeline.history+(timeline.state,)), reason

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ることA → B で観測して R を提出したあと、X で RUNNING・候補 R・提出0、さらに X で OBS 1・候補 R/S・聞く部屋 A に戻ります。E で観測して R を提出すると、OBS 2・WON です。R では履歴も全部最初に戻ります。
  • 候補と提出もふくめて Undo する
  • 受け付けない操作では履歴は増えない
  • 見せ直しと観測を区別する
つまずきやすいところ候補の集合を戻さないと、証拠を消しても候補が1つしか残らず、根拠がこわれてしまいます。State 全体を保存します。V の見せ直しを履歴に積んだり、X で時計だけを戻したりしないようにしましょう。
少し変えてみよう

A で観測 → B で観測 → R を提出 → X → X → E で観測 → R を提出、と実行してみましょう。最後の観測した部屋は [A,E]、候補は R、提出は1回、履歴は3件です。test_fifth_exercise_branch_E_after_undo と、実際の Pyxel のチェックポイントで確かめています。X を3回押すと最初の状態になり、もう1回押すと NO_UNDO(戻せるものがない)です。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(386行)
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
import heapq

ROOMS = "ABCDEF"
EDGES = ("AB", "BC", "DE", "EF", "AD", "BE", "CF")
PATH_IDS = "PQRS"
START_ROOM = "A"
WALL_COST = 3

@dataclass(frozen=True)
class Level:
    floors: tuple
    walls: tuple
    paths: tuple
    truth: int

LEVEL = Level(("wood", "tile", "stone", "wood", "tile", "stone"),
              ("BC", "EF"), ("ABE", "DEB", "CFC", "FCF"), 2)


def distances(level, listener, wall_cost=WALL_COST):
    """Sum edge costs, not geometric distance. Dijkstra's priority queue."""
    dist = {room: 99 for room in ROOMS}
    dist[listener] = 0
    queue = [(0, listener)]
    while queue:
        distance, room = heapq.heappop(queue)
        if distance != dist[room]:
            continue
        for edge in EDGES:
            if room not in edge:
                continue
            neighbor = edge[1] if edge[0] == room else edge[0]
            cost = wall_cost if edge in level.walls else 1
            new_distance = distance + cost
            if new_distance < dist[neighbor]:
                dist[neighbor] = new_distance
                heapq.heappush(queue, (new_distance, neighbor))
    return dist


def signature(level, listener, path, wall_cost=WALL_COST):
    ds = distances(level, listener, wall_cost)
    return tuple(
        ("none", 0) if ds[room] >= 4 else
        (level.floors[ROOMS.index(room)], 2 if ds[room] <= 1 else 1)
        for room in path
    )


def sound_text(sound):
    material, strength = sound
    return "NONE 0" if strength == 0 else material.upper()+" "+str(strength)


@dataclass(frozen=True)
class Observation:
    room: str
    sounds: tuple


@dataclass(frozen=True)
class Submission:
    candidate: int
    verdict: str


@dataclass(frozen=True)
class State:
    observations: tuple = ()
    candidates: tuple = (0, 1, 2, 3)
    selected_room: str = START_ROOM
    submissions: tuple = ()
    outcome: str = "running"


def initial():
    return State()


def apply(state, level, action, wall_cost=WALL_COST):
    if action.startswith("listen:"):
        room = action.partition(":")[2]
        if len(room) != 1 or room not in ROOMS:
            return state, "invalid_room"
        # Truth produces an observation. Filtering receives only that observation.
        heard = Observation(room, signature(level, room, level.paths[level.truth], wall_cost))
        candidates = tuple(i for i in state.candidates
                           if signature(level, room, level.paths[i], wall_cost) == heard.sounds)
        return replace(state, observations=state.observations+(heard,),
                       candidates=candidates, selected_room=room, outcome="running"), "heard"
    if action.startswith("submit:"):
        try:
            candidate = int(action.partition(":")[2])
        except ValueError:
            return state, "invalid_candidate"
        if candidate not in range(len(level.paths)):
            return state, "invalid_candidate"
        if candidate == level.truth:
            verdict = "supported" if len(state.candidates) == 1 else "insufficient"
        else:
            verdict = "contradicted" if candidate not in state.candidates else "not_yet_proven"
        return replace(state,
                       submissions=state.submissions+(Submission(candidate, verdict),),
                       outcome="won" if verdict == "supported" else "running"), verdict
    return state, "unknown"


def forecast(state, level, wall_cost=WALL_COST):
    """Group possible signatures; never read level.truth."""
    rows = []
    for room in ROOMS:
        groups = {}
        for i in state.candidates:
            predicted = signature(level, room, level.paths[i], wall_cost)
            groups.setdefault(predicted, []).append(i)
        sizes = tuple(sorted(len(group) for group in groups.values()))
        rows.append({"room": room, "groups": len(groups),
                     "worst_remaining": max(sizes, default=0), "sizes": sizes})
    return tuple(rows)


def evidence(state, level, candidate, wall_cost=WALL_COST):
    rows = []
    for index, observation in enumerate(state.observations):
        predicted = signature(level, observation.room, level.paths[candidate], wall_cost)
        for beat, (guess, heard) in enumerate(zip(predicted, observation.sounds)):
            if guess != heard:
                rows.append({"observation": index, "room": observation.room, "beat": beat,
                             "predicted": guess, "heard": heard,
                             "distance": distances(level, observation.room, wall_cost)[level.paths[candidate][beat]]})
    return tuple(rows)


@dataclass(frozen=True)
class Timeline:
    state: State
    history: tuple = ()


def step(timeline, level, action, wall_cost=WALL_COST):
    if action == "undo":
        if not timeline.history:
            return timeline, "no_undo"
        return Timeline(timeline.history[-1], timeline.history[:-1]), "undo"
    state, reason = apply(timeline.state, level, action, wall_cost)
    if state == timeline.state:
        return timeline, reason
    return Timeline(state, timeline.history+(timeline.state,)), reason


BEAT_FRAMES = 18

class Playback:
    """Visual clock only. No audio output and no access to evidence state."""
    def __init__(self):
        self.frame = None
        self.beat = 0
        self.paused = False
        self.events = []

    def start(self):
        self.frame = 0
        self.beat = 0

    def stop(self):
        self.frame = None

    def tick(self):
        if self.paused or self.frame is None:
            return
        if self.frame % BEAT_FRAMES == 0:
            self.beat = self.frame // BEAT_FRAMES
            self.events.append((self.frame, self.beat))
        self.frame += 1
        if self.frame >= BEAT_FRAMES * 3:
            self.frame = None


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

    def reset(self):
        self.room = START_ROOM
        self.candidate = 0
        self.gated = False
        self.message = "READY"
        self.timeline = Timeline(initial())
        self.playback = Playback()

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

    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 == "P":
            self.playback.paused = not self.playback.paused
            self.message = "PAUSED" if self.playback.paused else "RESUMED"
            return
        if self.playback.paused:
            return
        if key in ("LEFT", "RIGHT"):
            delta = 1 if key == "RIGHT" else -1
            self.room = ROOMS[(ROOMS.index(self.room)+delta) % len(ROOMS)]
            self.message = "LISTENER "+self.room
        elif key in ("UP", "DOWN"):
            self.candidate = (self.candidate+(1 if key == "DOWN" else -1)) % 4
            self.message = "PATH "+PATH_IDS[self.candidate]
        elif key == "Z":
            if self.playback.frame is not None:
                self.playback.stop()
                self.message = "STOPPED / SIGNATURE RETAINED"
            else:
                self.action("listen:"+self.room)
        elif key == "V" and self.state.observations:
            self.playback.start()
            self.message = "REPLAY / NO NEW OBSERVATION"
        elif key == "SPACE":
            self.action("submit:"+str(self.candidate))
        elif key == "X":
            self.action("undo")

    def tick(self):
        self.playback.tick()

    def action(self, action):
        self.timeline, self.message = step(self.timeline, LEVEL, action)
        if self.message == "heard":
            self.playback.start()
        elif action == "undo" or action.startswith("submit:"):
            self.playback.stop()
        if action == "undo" and self.message == "undo":
            self.room = self.state.selected_room

    def draw(self, p):
        p.cls(1)
        p.text(10, 8, "FOOTSTEP LAB / STAGE 5", 10)
        p.text(10, 23, "FICTIONAL PUZZLE / VISUAL ONLY - NO AUDIO", 6)
        ds = distances(LEVEL, self.room)
        positions = {"A":(45,90), "B":(145,90), "C":(245,90),
                     "D":(45,175), "E":(145,175), "F":(245,175)}
        for edge in EDGES:
            x,y = positions[edge[0]]
            u,v = positions[edge[1]]
            wall = edge in LEVEL.walls
            p.line(x, y, u, v, 9 if wall else 6)
            p.text((x+u)//2+4, (y+v)//2-8, str(WALL_COST if wall else 1), 9 if wall else 6)
        for room in ROOMS:
            x,y = positions[room]
            p.rect(x-30, y-23, 61, 46, 3 if room == self.room else 2)
            p.rectb(x-30, y-23, 61, 46, 10 if room == self.room else 5)
            p.text(x-25, y-16, room+"  DIST "+str(ds[room]), 10)
            p.text(x-25, y, LEVEL.floors[ROOMS.index(room)].upper(), 7)
            if room == self.room:
                p.text(x-25, y+10, "LISTENER", 11)
        p.text(10, 213, "DOOR 1 / WALL "+str(WALL_COST)+" / SHORTEST SOUND COST", 6)
        for i,path in enumerate(LEVEL.paths):
            y = 58+i*35
            p.rect(305, y, 242, 30, 3 if i == self.candidate else 2)
            p.text(312, y+5, PATH_IDS[i]+" "+" > ".join(path), 10 if i == self.candidate else 7)
            p.text(312, y+18, "REMAINS" if i in self.state.candidates else "EXCLUDED BY OBSERVATION", 11 if i in self.state.candidates else 13)
        p.text(10, 232, "DIST 0..1 STRONG 2 / 2..3 WEAK 1 / 4+ NONE 0", 13)
        p.rect(10, 253, 537, 50, 2)
        if self.state.observations:
            observation = self.state.observations[-1]
            p.text(16, 260, "OBS "+str(len(self.state.observations))+" / ROOM "+observation.room+" / COMPLETE THREE-STEP SIGNATURE", 7)
            for beat, sound in enumerate(observation.sounds):
                p.text(16+beat*175, 283, str(beat+1)+": "+sound_text(sound), 10 if beat == self.playback.beat else 7)
        else:
            p.text(16, 273, "Z: OBSERVE THE SAME THREE STEPS", 7)
        clock = "PAUSED" if self.playback.paused else "STOPPED" if self.playback.frame is None else "PLAYING"
        p.text(10, 315, "VISUAL CLOCK: "+clock+" / EVIDENCE DOES NOT CHANGE WITH TIME", 6)
        row = next(row for row in forecast(self.state, LEVEL) if row["room"] == self.room)
        p.text(305, 207, "PREDICT AT "+self.room+": "+str(row["groups"])+" GROUPS", 11)
        p.text(305, 221, "WORST LEFT "+str(row["worst_remaining"]), 11)
        mismatches = evidence(self.state, LEVEL, self.candidate)
        if mismatches:
            item = mismatches[0]
            p.text(10, 333, "CONTRADICTION AT "+item["room"]+" BEAT "+str(item["beat"]+1)+": EXPECT "+sound_text(item["predicted"])+" / HEARD "+sound_text(item["heard"]), 9)
        else:
            p.text(10, 333, "OUTCOME: "+self.state.outcome.upper()+" / NO RECORDED CONTRADICTION FOR SELECTED PATH", 11)

        p.text(10, 350, self.message.upper(), 10)
        p.text(10, 369, "LEFT/RIGHT LISTENER  UP/DOWN PATH  R RESET  ESC QUIT", 7)
        p.text(10, 386, "Z OBSERVE/STOP  V REPLAY  P PAUSE  SPACE SUBMIT  X UNDO", 6)

DEMO_EVENTS = {2: 'Z', 6: 'Z', 10: 'RIGHT', 14: 'Z', 72: 'DOWN', 76: 'DOWN', 80: 'SPACE', 84: 'X', 88: 'X', 92: 'RIGHT', 96: 'RIGHT', 100: 'RIGHT', 104: 'RIGHT', 108: 'Z', 164: 'SPACE'}
KEYS = ('LEFT', 'RIGHT', 'R', 'UP', 'DOWN', 'Z', 'V', 'P', 'SPACE', 'X')
CHECKPOINT_FRAME = 172


def main(args):
    from pathlib import Path
    import pyxel as p
    destination = Path(args.screenshot).resolve() if args.screenshot else None
    if destination:
        destination.parent.mkdir(parents=True, exist_ok=True)
    app = App()
    p.init(560, 408, title="Footstep Lab", fps=30, display_scale=1, headless=args.headless_smoke)
    key_ids = {key: getattr(p, "KEY_"+key) for key in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= args.frames:
            p.quit()
            return
        if args.demo_checkpoint:
            key = DEMO_EVENTS.get(p.frame_count)
            pressed = {key} if key else set()
            app.control(pressed, pressed)
        else:
            app.control({k for k,v in key_ids.items() if p.btnp(v)},
                        {k for k,v in key_ids.items() if p.btn(v)})
        app.tick()
    def draw():
        app.draw(p)
        if destination and p.frame_count == args.frames-1:
            p.screenshot(str(destination), scale=1)
    p.run(update, draw)


def parse_args(argv=None):
    # Reject unsupported combinations and unbounded runs before importing Pyxel.
    import argparse
    parser = argparse.ArgumentParser(description="Standalone visual footstep lesson")
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int)
    parser.add_argument("--screenshot")
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke:
        parser.error("--demo-checkpoint, --frames and --screenshot require --headless-smoke")
    if args.frames is None:
        args.frames = CHECKPOINT_FRAME if args.demo_checkpoint else 12
    if not 2 <= args.frames <= 900:
        parser.error("--frames must be between 2 and 900")
    if args.demo_checkpoint and args.frames < CHECKPOINT_FRAME:
        parser.error("--frames is too short for this checkpoint")
    if args.screenshot and not args.screenshot.lower().endswith(".png"):
        parser.error("--screenshot must end in .png")
    return args


if __name__ == "__main__":
    main(parse_args())
AFTER STEP 05

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

元のゲームの状況 B です。床は、木・タイル・石が2列に並びます。壁 BC/EF の音の距離は3、扉は1。距離0〜1は強い音、2〜3は弱い音、4以上は材質も分からない無音です。3歩の特徴だけで候補を絞ります。正解でも、根拠が足りなければ達成になりません。

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

比べる点5ステップの教材元の完成版
段階01 は壁なしの距離、02 は壁と予想だけ、03 は観測、04 は予想の手助け・矛盾・提出、05 は Undo です。A・B・C の3つの面と、壁の費用1/3を、動かしながら比べられます。
音と表示音は出ません。文字(ASCII)の材質・強さ・無音と、拍の強調を表示します。日本語の文字、Pyxel の音で材質と強弱を鳴らす機能、音を消す設定と字幕があります。
入力と再生左右で聞く部屋、上下で候補、Space で提出、V で見せ直しです。全部のキーを離すまで待ちます。P を解除したフレームの更新から時計が動き出します。上下で欄を切りかえ、左右で欄ごとに選び、Z でそれぞれの操作をします。表紙と、独自の入力の制御があります。
Undo と記録証拠の State は元のゲームの状況 B と同じです。Undo で画面の聞く場所も戻し、再生を止めます。ファイルに保存するのは、テスト用の画像だけです。Undo しても、画面の部屋のカーソルは別に管理します。入力の意味の再現の記録と、音のイベントの書き出しがあります。
空集合forecast に空の候補が来たとき、worst_remaining を0にする、念のための追加をしています。ふつうにたどりつく状態では、空にはなりません。max(sizes) を使っているので、おかしな空の候補を直接渡すとエラーになります。

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

  1. model.py: distances / signature の距離から情報への変換
  2. model.py: apply / forecast / evidence の役割分離
  3. model.py: step / record のUndoと再現記録
  4. app.py: animate と audio_defs.py: BEAT_FRAMES の音声実装
自分のゲームに変えるなら

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

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