探索・アドベンチャーLESSON 15ターン制ステルス × 情報と記憶原作031

静かな足音の作り方

聞こえた場所へ、見張りが向かう。
「見える」「聞こえる」「覚えている」「見回る」を分けて作り、決まったルールで動く見張りがいる、小さなステルスゲームを作ります。

完成版の遊び方床の種類で変わる足音や、わざと踏み鳴らす音を使って見張りを誘導し、帳面を盗んで出口まで持ち帰ります。

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

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

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

完成版の主な操作:矢印キーで1歩、Spaceで音を立てずに待つ、Zで踏み鳴らす、Xで1手戻す、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
知っておくとよいこと
Python 3.11以上の実行と仮想環境 / if・for・関数・タプル・辞書の基本 / クラスと dataclass の初歩(frozen と replace はこの教材で説明します)
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:1歩 / Space:音を立てずに待つ / Z:踏み鳴らす(03から) / X:1手戻す(05だけ) / R:最初に戻す / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_grid.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

マスを動き、帳面を出口へ運ぶ

座標・通れるかの判定・目的の状態を分ける。

プレイヤーの位置を (x, y) の組で持ち、矢印キーを1回押すと1マス動くようにします。floor で外側の壁と棚を通れないようにし、通れない入力ではターン(手番)を使いません。book(帳面を持っているか)は、一度取ったら True のままです。出口に行くだけでは成功せず、「帳面を持っている」と「出口に着いた」の両方を調べます。この段階には、見張りも音の判定もありません。Level にある見張りや床の情報は、あとの段階で使います。

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

01_grid.py / 注目する部分
    book = s.book or p == b.book
    status = 'won' if book and p == b.exit else 'play'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_grid.py
macOS / Linux
.venv/bin/python 01_grid.py
画面で見ることスタート地点は (1,3) です。右を2回押すと (3,3)、さらに右は棚にさえぎられて TURN は増えません。R で戻し、下下右右右右上右 で帳面を取り、下左左左左左 で出口へ運ぶと WON(成功)になります。
  • 矢印キー1回で1マス進む
  • 壁に向かう入力ではターンを使わない
  • 帳面を取ってから出口に戻る
つまずきやすいところ画面のピクセルの座標をゲームの座標に混ぜると、1歩が1マスになりません。State はマス目の座標、center は画面の座標に直す役、と役割を分けます。
少し変えてみよう

出口だけを (2,5) に移して攻略の道すじを考え、帳面を持っていないと WON にならないことを確かめましょう。次に、壁に向かう入力でも turns を増やした場合、操作した感じがどう変わるか比べてみましょう。

01_grid.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(219行)
01_grid.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.
# 教材はNoto字形を同梱せず、ASCIIと図形のみを使います。

"""静かな足音・教材 1/5。原作Aの盤面を段階的に作る独立ファイル。"""
from dataclasses import dataclass, replace, asdict

DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0))  # 上・下・左・右の同距離優先順
ACTIONS = {'U': (0, -1), 'D': (0, 1), 'L': (-1, 0), 'R': (1, 0)}

@dataclass(frozen=True)
class Level:
    title: str
    width: int
    height: int
    walls: tuple
    start: tuple
    book: tuple
    exit: tuple
    guard: tuple
    facing: tuple
    patrol: tuple
    metal: tuple
    boards: tuple

LEVEL = Level(
    '棚の角', 9, 7, ((4, 2), (4, 3), (4, 4)),
    (1, 3), (6, 4), (1, 5), (6, 2), (0, 1),
    ((6, 2), (6, 4)), ((3, 2),), ((3, 1), (4, 1)),
)

def floor(b, p):
    return 0 < p[0] < b.width - 1 and 0 < p[1] < b.height - 1 and (p not in b.walls)

@dataclass(frozen=True)
class State:
    player: tuple
    book: bool = False
    turns: int = 0
    status: str = 'play'
    reason: str = 'ready'


def initial():
    return State(LEVEL.start)


def step(b, s, action):
    if s.status != 'play' or action not in (*ACTIONS, 'W'):
        return s
    p = s.player
    if action in ACTIONS:
        dx, dy = ACTIONS[action]
        p = (p[0] + dx, p[1] + dy)
        if not floor(b, p):
            return s  # 壁への入力はターンを使わない
    book = s.book or p == b.book
    status = 'won' if book and p == b.exit else 'play'
    return State(p, book, s.turns + 1, status, 'complete' if status == 'won' else 'walk')

class Game:
    """Pyxel抜きでも試せる操作窓口。描画は状態を変更しない。"""
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()

    def act(self, action):
        self.state = step(LEVEL, self.state, action)


KEY_ACTIONS = {'UP': 'U', 'DOWN': 'D', 'LEFT': 'L', 'RIGHT': 'R', 'SPACE': 'W'}

class Controller:
    """1押下1手。同時押しを棄却し、リセット後は全キーを離す。"""
    def __init__(self):
        self.game = Game()
        self.release_gate = False

    def tick(self, pressed=(), held=()):
        pressed, held = set(pressed), set(held)
        controls = set(KEY_ACTIONS) | {'R', 'X'}
        if self.release_gate:
            if not (held & controls):
                self.release_gate = False
            return
        if 'R' in pressed:
            self.game.reset()
            self.release_gate = True
            return
        chosen = [a for key, a in KEY_ACTIONS.items() if key in pressed]
        held_actions = held & set(KEY_ACTIONS)
        if len(chosen) == 1 and len(held_actions) == 1:
            self.game.act(chosen[0])


def main(headless=False, screenshot=None, actions='', frames=12, report=None):
    import json
    from pathlib import Path
    # Pyxel may change cwd to this script's directory at init. Resolve user paths first.
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel

    controller = Controller()
    events = []
    tick = 0
    width, height, cell, ox, oy = 320, 302, 27, 38, 39
    keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')}
    reverse_keys = {value: key for key, value in KEY_ACTIONS.items()}
    reverse_keys.update({'K': 'R', 'X': 'X'})

    def update():
        nonlocal tick
        tick += 1
        if headless:
            if tick <= len(actions):
                # QA専用の合成押下。OSや人が押したキーではない。
                key = reverse_keys[actions[tick - 1]]
                controller.tick((), ())
                controller.tick((key,), (key,))
                events.append({'tick': tick, 'action': actions[tick - 1],
                               'state': asdict(controller.game.state)})
        else:
            controller.tick([k for k, v in keys.items() if pyxel.btnp(v)],
                            [k for k, v in keys.items() if pyxel.btn(v)])

    def center(p):
        return ox + p[0] * cell + cell // 2, oy + p[1] * cell + cell // 2

    def draw():
        s, b = controller.game.state, LEVEL
        pyxel.cls(1)
        pyxel.text(8, 8, 'QUIET FOOTSTEPS / STEP 1', 10)
        pyxel.text(8, 20, f'TURN {s.turns:03}  BOOK {int(s.book)}  {s.status.upper()}', 7)
        cone = set()
        for y in range(b.height):
            for x in range(b.width):
                p = (x, y)
                cx, cy = center(p)
                xx, yy = cx - cell // 2, cy - cell // 2
                if not floor(b, p):
                    pyxel.rect(xx, yy, cell - 1, cell - 1, 5)
                    continue
                pyxel.rect(xx, yy, cell - 1, cell - 1, 2 if p in cone else 1)
                pyxel.rectb(xx, yy, cell - 1, cell - 1, 13)
                if p == b.exit:
                    pyxel.rectb(xx + 5, yy + 4, 16, 19, 11)
                    pyxel.text(cx - 2, cy - 2, 'E', 11)
                if p == b.book and not s.book:
                    pyxel.rect(xx + 8, yy + 7, 10, 13, 10)
        x, y = center(s.player)
        pyxel.circ(x, y, 5, 12)
        if s.book:
            pyxel.rect(x + 3, y - 5, 4, 5, 10)
        pyxel.text(8, 232, 'STATE: ' + s.reason.upper(), 10 if s.status == 'won' else 7)
        pyxel.text(8, 244, 'GET THE BOOK, THEN RETURN TO E.', 6)
        pyxel.text(8, 270, 'ARROWS: STEP  SPACE: WAIT', 7)
        pyxel.text(8, 282, 'R: RESET  ESC: QUIT  SILENT RENDER', 6)
        if headless and tick >= frames:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Synthetic input; actual Pyxel headless rendering; no audio',
                    'frames': tick, 'actions': actions, 'events': events,
                    'final_state': asdict(s),
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(width, height, title='Quiet Footsteps - Stage 1', 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', help='12 rendered frames, then quit')
    parser.add_argument('--screenshot', help='PNG file path (headless only)')
    parser.add_argument('--actions', default='', help='Synthetic UDLRWSXK actions during 12-frame smoke')
    parser.add_argument('--headless-replay', help='Longer synthetic replay, UDLRWSXK; K resets')
    parser.add_argument('--report', help='UTF-8 JSON report (headless only)')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay, not both')
    actions = args.actions if args.headless_replay is None else args.headless_replay
    if any(a not in 'UDLRWK' for a in actions):
        parser.error('Supported actions in this stage: UDLRWK')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.actions or args.screenshot or args.report):
        parser.error('actions/screenshot/report require a headless mode')
    if args.headless_smoke and len(actions) > 12:
        parser.error('Smoke accepts at most 12 actions; use --headless-replay for longer routes')
    frames = 12 if args.headless_replay is None else max(12, len(actions) + 2)
    main(headless, args.screenshot, actions, frames, args.report)
STEP 02 / 05

見張りの視野と一度だけの観測

「見聞きする→判断する→動く」の順番を決める。

前に4マスまで見える視野と、棚で見えなくなる仕組みを追加します。見張りは、動く前の位置と向きで、動いたあとのプレイヤーを1回だけ見ます。見えたらそこへ、見えなければ見回りの地点へ、幅優先探索で求めた道すじを1歩進みます。動いたあとにもう一度見ることはしません。赤茶色の床は次のターンに使う視野で、今回見えたかどうかは observed に入ります。この段階では、追いかけた位置を次の手まで覚えてはいません。

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

02_sight.py / 注目する部分
    o = observe(b, s, p)
    it = policy(o, s.memory, s.guard, b)
    g, f = s.guard, s.facing

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_sight.py
macOS / Linux
.venv/bin/python 02_sight.py
画面で見ること始めたあと何も押さなければ、見張りも止まっています。Space を1回押すと、見張りは (6,2) から (6,3) へ1歩進みます。R のあと 下下右右右右 と押すと STATE: EYE(見つかった)になりますが、ぶつかってはいないので PLAY のままです。
  • 何も押さなければ見張りも止まる
  • 動く前に1回だけ見る
  • 見つかることと、ぶつかることを区別できる
つまずきやすいところ見張りを動かしてからもう一度 visible を呼ぶと、1ターンに2回情報を得てしまいます。絵を描くための vision を、そのターンの observed として使い回してはいけません。
少し変えてみよう

見張り (6,2)・下向き、プレイヤー (5,3) を紙に描いてみましょう。動く前の位置からは見えるのに、見張りが (6,3) に動いたあとは真横になるので見えないことを説明してみましょう。テストの test_old_position_once_only も確かめます。

02_sight.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(326行)
02_sight.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.
# 教材はNoto字形を同梱せず、ASCIIと図形のみを使います。

"""静かな足音・教材 2/5。原作Aの盤面を段階的に作る独立ファイル。"""
from dataclasses import dataclass, replace, asdict
from collections import deque

DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0))  # 上・下・左・右の同距離優先順
ACTIONS = {'U': (0, -1), 'D': (0, 1), 'L': (-1, 0), 'R': (1, 0)}

@dataclass(frozen=True)
class Level:
    title: str
    width: int
    height: int
    walls: tuple
    start: tuple
    book: tuple
    exit: tuple
    guard: tuple
    facing: tuple
    patrol: tuple
    metal: tuple
    boards: tuple

LEVEL = Level(
    '棚の角', 9, 7, ((4, 2), (4, 3), (4, 4)),
    (1, 3), (6, 4), (1, 5), (6, 2), (0, 1),
    ((6, 2), (6, 4)), ((3, 2),), ((3, 1), (4, 1)),
)

@dataclass(frozen=True)
class Memory:
    target: tuple | None = None
    source: str = 'patrol'
    look: int = 0
    patrol_done: int = 0

@dataclass(frozen=True)
class Observation:
    visible: tuple | None
    heard: tuple | None

@dataclass(frozen=True)
class Intent:
    target: tuple | None
    rotate: bool
    memory: Memory

@dataclass(frozen=True)
class State:
    player: tuple
    guard: tuple
    facing: tuple
    memory: Memory
    book: bool = False
    turns: int = 0
    status: str = 'play'
    reason: str = 'ready'
    observed: Observation = Observation(None, None)

def initial():
    return State(LEVEL.start, LEVEL.guard, LEVEL.facing, Memory())

def floor(b, p):
    return 0 < p[0] < b.width - 1 and 0 < p[1] < b.height - 1 and (p not in b.walls)

def neighbors(b, p):
    return tuple(((p[0] + dx, p[1] + dy) for dx, dy in DIRS if floor(b, (p[0] + dx, p[1] + dy))))

def path(b, a, z):
    """床だけを幅優先探索。戻り値には出発点と終点も含める。"""
    if not floor(b, a) or not floor(b, z):
        return ()
    q = deque([a])
    prev = {a: None}
    while q:
        p = q.popleft()
        if p == z:
            out = []
            while p is not None:
                out.append(p)
                p = prev[p]
            return tuple(reversed(out))
        for n in neighbors(b, p):
            if n not in prev:
                prev[n] = p
                q.append(n)
    return ()

def crosses_box(a, z, w):
    """線分a→zと棚の閉じた四角の交差。角に触れても遮る。"""
    lo, hi = (0.0, 1.0)
    for av, dv, c in zip(a, (z[0] - a[0], z[1] - a[1]), w):
        if dv == 0:
            if not c - 0.5 <= av <= c + 0.5:
                return False
        else:
            u, v = ((c - 0.5 - av) / dv, (c + 0.5 - av) / dv)
            lo = max(lo, min(u, v))
            hi = min(hi, max(u, v))
            if lo > hi:
                return False
    return True

def visible(b, g, f, p):
    """前方4マス・45度以内。音の経路とは違い、直線を棚で遮る。"""
    if p == g:
        return True
    dx, dy = (p[0] - g[0], p[1] - g[1])
    front = dx * f[0] + dy * f[1]
    side = dx * f[1] - dy * f[0]
    return 1 <= front <= 4 and abs(side) <= front and (not any((crosses_box(g, p, w) for w in b.walls)))

def vision(b, g, f):
    return tuple(((x, y) for y in range(1, b.height - 1) for x in range(1, b.width - 1) if floor(b, (x, y)) and visible(b, g, f, (x, y))))

def observe(b, s, player):
    # 移動する前の見張りの位置・向きで、一度だけ観測する。
    seen = player if visible(b, s.guard, s.facing, player) else None
    return Observation(seen, None)

def policy(o, m, g, b):
    # 入力は観測と記憶。隠れたプレイヤー座標は受け取らない。
    if o.visible is not None:
        return Intent(o.visible, False, Memory(patrol_done=m.patrol_done))
    done = m.patrol_done
    nxt = (done + 1) % len(b.patrol)
    if g == b.patrol[nxt]:
        done = nxt
        nxt = (done + 1) % len(b.patrol)
    return Intent(b.patrol[nxt], False, Memory(patrol_done=done))

def step(b, s, action):
    if s.status != 'play' or action not in (*ACTIONS, 'W'):
        return s
    p = s.player
    if action in ACTIONS:
        dx, dy = ACTIONS[action]
        p = (p[0] + dx, p[1] + dy)
        if not floor(b, p):
            return s
    book = s.book or p == b.book
    if p == s.guard:
        return replace(s, player=p, book=book, turns=s.turns + 1,
                       status='caught', reason='contact')
    o = observe(b, s, p)
    it = policy(o, s.memory, s.guard, b)
    g, f = s.guard, s.facing
    route = path(b, g, it.target)
    if len(route) > 1:
        f = (route[1][0] - g[0], route[1][1] - g[1])
        g = route[1]
    mem = it.memory
    nxt = (mem.patrol_done + 1) % len(b.patrol)
    if g == b.patrol[nxt]:
        mem = replace(mem, patrol_done=nxt)
    status = 'caught' if p == g else 'won' if book and p == b.exit else 'play'
    reason = ('contact' if status == 'caught' else 'complete' if status == 'won'
              else 'eye' if o.visible else 'patrol')
    return State(p, g, f, mem, book, s.turns + 1, status, reason, o)

class Game:
    """Pyxel抜きでも試せる操作窓口。描画は状態を変更しない。"""
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()

    def act(self, action):
        self.state = step(LEVEL, self.state, action)


KEY_ACTIONS = {'UP': 'U', 'DOWN': 'D', 'LEFT': 'L', 'RIGHT': 'R', 'SPACE': 'W'}

class Controller:
    """1押下1手。同時押しを棄却し、リセット後は全キーを離す。"""
    def __init__(self):
        self.game = Game()
        self.release_gate = False

    def tick(self, pressed=(), held=()):
        pressed, held = set(pressed), set(held)
        controls = set(KEY_ACTIONS) | {'R', 'X'}
        if self.release_gate:
            if not (held & controls):
                self.release_gate = False
            return
        if 'R' in pressed:
            self.game.reset()
            self.release_gate = True
            return
        chosen = [a for key, a in KEY_ACTIONS.items() if key in pressed]
        held_actions = held & set(KEY_ACTIONS)
        if len(chosen) == 1 and len(held_actions) == 1:
            self.game.act(chosen[0])


def main(headless=False, screenshot=None, actions='', frames=12, report=None):
    import json
    from pathlib import Path
    # Pyxel may change cwd to this script's directory at init. Resolve user paths first.
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel

    controller = Controller()
    events = []
    tick = 0
    width, height, cell, ox, oy = 320, 302, 27, 38, 39
    keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')}
    reverse_keys = {value: key for key, value in KEY_ACTIONS.items()}
    reverse_keys.update({'K': 'R', 'X': 'X'})

    def update():
        nonlocal tick
        tick += 1
        if headless:
            if tick <= len(actions):
                # QA専用の合成押下。OSや人が押したキーではない。
                key = reverse_keys[actions[tick - 1]]
                controller.tick((), ())
                controller.tick((key,), (key,))
                events.append({'tick': tick, 'action': actions[tick - 1],
                               'state': asdict(controller.game.state)})
        else:
            controller.tick([k for k, v in keys.items() if pyxel.btnp(v)],
                            [k for k, v in keys.items() if pyxel.btn(v)])

    def center(p):
        return ox + p[0] * cell + cell // 2, oy + p[1] * cell + cell // 2

    def draw():
        s, b = controller.game.state, LEVEL
        pyxel.cls(1)
        pyxel.text(8, 8, 'QUIET FOOTSTEPS / STEP 2', 10)
        pyxel.text(8, 20, f'TURN {s.turns:03}  BOOK {int(s.book)}  {s.status.upper()}', 7)
        cone = set(vision(b, s.guard, s.facing))
        for y in range(b.height):
            for x in range(b.width):
                p = (x, y)
                cx, cy = center(p)
                xx, yy = cx - cell // 2, cy - cell // 2
                if not floor(b, p):
                    pyxel.rect(xx, yy, cell - 1, cell - 1, 5)
                    continue
                pyxel.rect(xx, yy, cell - 1, cell - 1, 2 if p in cone else 1)
                pyxel.rectb(xx, yy, cell - 1, cell - 1, 13)
                if p == b.exit:
                    pyxel.rectb(xx + 5, yy + 4, 16, 19, 11)
                    pyxel.text(cx - 2, cy - 2, 'E', 11)
                if p == b.book and not s.book:
                    pyxel.rect(xx + 8, yy + 7, 10, 13, 10)
        x, y = center(s.guard)
        pyxel.rect(x - 5, y - 5, 11, 11, 8)
        dx, dy = s.facing
        pyxel.line(x, y, x + dx * 10, y + dy * 10, 7)
        x, y = center(s.player)
        pyxel.circ(x, y, 5, 12)
        if s.book:
            pyxel.rect(x + 3, y - 5, 4, 5, 10)
        pyxel.text(8, 232, 'STATE: ' + s.reason.upper(), 10 if s.status == 'won' else 7)
        pyxel.text(8, 244, 'RED FLOOR: NEXT VIEW. ONLY CONTACT LOSES.', 6)
        pyxel.text(8, 270, 'ARROWS: STEP  SPACE: WAIT', 7)
        pyxel.text(8, 282, 'R: RESET  ESC: QUIT  SILENT RENDER', 6)
        if headless and tick >= frames:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Synthetic input; actual Pyxel headless rendering; no audio',
                    'frames': tick, 'actions': actions, 'events': events,
                    'final_state': asdict(s),
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(width, height, title='Quiet Footsteps - Stage 2', 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', help='12 rendered frames, then quit')
    parser.add_argument('--screenshot', help='PNG file path (headless only)')
    parser.add_argument('--actions', default='', help='Synthetic UDLRWSXK actions during 12-frame smoke')
    parser.add_argument('--headless-replay', help='Longer synthetic replay, UDLRWSXK; K resets')
    parser.add_argument('--report', help='UTF-8 JSON report (headless only)')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay, not both')
    actions = args.actions if args.headless_replay is None else args.headless_replay
    if any(a not in 'UDLRWK' for a in actions):
        parser.error('Supported actions in this stage: UDLRWK')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.actions or args.screenshot or args.report):
        parser.error('actions/screenshot/report require a headless mode')
    if args.headless_smoke and len(actions) > 12:
        parser.error('Smoke accepts at most 12 actions; use --headless-replay for longer routes')
    frames = 12 if args.headless_replay is None else max(12, len(actions) + 2)
    main(headless, args.screenshot, actions, frames, args.report)
STEP 03 / 05

棚を迂回する音の距離

幅優先探索で、道すじと、音が届く範囲を調べる。

床ごとに音が届く範囲(半径)を追加します。布は0、板は3、金属は6です。Z(踏み鳴らす)はその床の値に2を足し、Space で待つときは金属の上でも必ず0です。音が届くかどうかは、まっすぐの距離ではなく、path で求めた床の道のりの長さで決めます。目と耳の両方が反応したら、目を優先します。この段階では、音がした場所は覚えず、その手のあいだだけ調べます。なお、ここでいう「音」はゲームの中の情報で、スピーカーから音を出す処理はありません。

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

03_sound.py / 注目する部分
    route = path(b, s.guard, sound) if sound is not None and radius else ()
    heard = sound if route and len(route) - 1 <= radius else None

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_sound.py
macOS / Linux
.venv/bin/python 03_sound.py
画面で見ることR のあと 上右右Z と押して金属の床 (3,2) を踏み鳴らすと、SOUND 8、STATE: EAR(聞こえた)になり、音が届いた道すじが黄色い輪で見えます。R のあと 上右右Space なら SOUND 0 です。
  • 床ごとの音の範囲がわかる
  • Space で待つときは、いつも無音
  • 音が棚を通り抜けない
つまずきやすいところabs(dx)+abs(dy) だけで判定すると、棚の向こうまで音が抜けてしまいます。また、path の点の個数ではなく、len(route)-1 が動く距離です。
少し変えてみよう

見張り (6,2) から金属の床 (3,2) までの床の道のりを数えてみましょう。まっすぐなら3ですが、棚を回りこむ最短の道は5歩です。半径4なら届かず、5なら届くという境目を、テストの test_path_wall_detour_and_inclusive_sound で確かめましょう。

03_sound.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(349行)
03_sound.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.
# 教材はNoto字形を同梱せず、ASCIIと図形のみを使います。

"""静かな足音・教材 3/5。原作Aの盤面を段階的に作る独立ファイル。"""
from dataclasses import dataclass, replace, asdict
from collections import deque

DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0))  # 上・下・左・右の同距離優先順
ACTIONS = {'U': (0, -1), 'D': (0, 1), 'L': (-1, 0), 'R': (1, 0)}

@dataclass(frozen=True)
class Level:
    title: str
    width: int
    height: int
    walls: tuple
    start: tuple
    book: tuple
    exit: tuple
    guard: tuple
    facing: tuple
    patrol: tuple
    metal: tuple
    boards: tuple

LEVEL = Level(
    '棚の角', 9, 7, ((4, 2), (4, 3), (4, 4)),
    (1, 3), (6, 4), (1, 5), (6, 2), (0, 1),
    ((6, 2), (6, 4)), ((3, 2),), ((3, 1), (4, 1)),
)

@dataclass(frozen=True)
class Memory:
    target: tuple | None = None
    source: str = 'patrol'
    look: int = 0
    patrol_done: int = 0

@dataclass(frozen=True)
class Observation:
    visible: tuple | None
    heard: tuple | None

@dataclass(frozen=True)
class Intent:
    target: tuple | None
    rotate: bool
    memory: Memory

@dataclass(frozen=True)
class State:
    player: tuple
    guard: tuple
    facing: tuple
    memory: Memory
    book: bool = False
    turns: int = 0
    status: str = 'play'
    reason: str = 'ready'
    sound: tuple | None = None
    radius: int = 0
    heard_path: tuple = ()
    observed: Observation = Observation(None, None)

def initial():
    return State(LEVEL.start, LEVEL.guard, LEVEL.facing, Memory())

def floor(b, p):
    return 0 < p[0] < b.width - 1 and 0 < p[1] < b.height - 1 and (p not in b.walls)

def neighbors(b, p):
    return tuple(((p[0] + dx, p[1] + dy) for dx, dy in DIRS if floor(b, (p[0] + dx, p[1] + dy))))

def path(b, a, z):
    """床だけを幅優先探索。戻り値には出発点と終点も含める。"""
    if not floor(b, a) or not floor(b, z):
        return ()
    q = deque([a])
    prev = {a: None}
    while q:
        p = q.popleft()
        if p == z:
            out = []
            while p is not None:
                out.append(p)
                p = prev[p]
            return tuple(reversed(out))
        for n in neighbors(b, p):
            if n not in prev:
                prev[n] = p
                q.append(n)
    return ()

def crosses_box(a, z, w):
    """線分a→zと棚の閉じた四角の交差。角に触れても遮る。"""
    lo, hi = (0.0, 1.0)
    for av, dv, c in zip(a, (z[0] - a[0], z[1] - a[1]), w):
        if dv == 0:
            if not c - 0.5 <= av <= c + 0.5:
                return False
        else:
            u, v = ((c - 0.5 - av) / dv, (c + 0.5 - av) / dv)
            lo = max(lo, min(u, v))
            hi = min(hi, max(u, v))
            if lo > hi:
                return False
    return True

def visible(b, g, f, p):
    """前方4マス・45度以内。音の経路とは違い、直線を棚で遮る。"""
    if p == g:
        return True
    dx, dy = (p[0] - g[0], p[1] - g[1])
    front = dx * f[0] + dy * f[1]
    side = dx * f[1] - dy * f[0]
    return 1 <= front <= 4 and abs(side) <= front and (not any((crosses_box(g, p, w) for w in b.walls)))

def vision(b, g, f):
    return tuple(((x, y) for y in range(1, b.height - 1) for x in range(1, b.width - 1) if floor(b, (x, y)) and visible(b, g, f, (x, y))))

def sound_radius(b, p, metal_radius=6):
    return metal_radius if p in b.metal else 3 if p in b.boards else 0

def observe(b, s, player, sound, radius):
    """この手の観測。s.guardとs.facingは移動する前の値。"""
    route = path(b, s.guard, sound) if sound is not None and radius else ()
    heard = sound if route and len(route) - 1 <= radius else None
    return Observation(player if visible(b, s.guard, s.facing, player) else None, heard)

def policy(o, m, g, b):
    # 入力は観測と記憶。隠れたプレイヤー座標は受け取らない。
    if o.visible is not None:
        return Intent(o.visible, False, Memory(patrol_done=m.patrol_done))
    if o.heard is not None:
        return Intent(o.heard, False, Memory(patrol_done=m.patrol_done))
    done = m.patrol_done
    nxt = (done + 1) % len(b.patrol)
    if g == b.patrol[nxt]:
        done = nxt
        nxt = (done + 1) % len(b.patrol)
    return Intent(b.patrol[nxt], False, Memory(patrol_done=done))

def step(b, s, action, metal_radius=6):
    if s.status != 'play':
        return s
    if action not in (*ACTIONS, 'W', 'S'):
        return s
    p = s.player
    if action in ACTIONS:
        dx, dy = ACTIONS[action]
        p = (p[0] + dx, p[1] + dy)
        if not floor(b, p):
            return s
    radius = sound_radius(b, p, metal_radius) + (2 if action == 'S' else 0) if action != 'W' else 0
    sound = p if radius else None
    book = s.book or p == b.book
    if p == s.guard:
        return replace(s, player=p, book=book, turns=s.turns + 1, status='caught', reason='contact', sound=sound, radius=radius)
    # この後に見張りを動かす。移動後の再観測はしない。
    o = observe(b, s, p, sound, radius)
    it = policy(o, s.memory, s.guard, b)
    g = s.guard
    f = s.facing
    if it.rotate:
        f = (-f[1], f[0])
    elif it.target is not None:
        route = path(b, g, it.target)
        if len(route) > 1:
            f = (route[1][0] - g[0], route[1][1] - g[1])
            g = route[1]
    mem = it.memory
    if mem.target is None and g == b.patrol[(mem.patrol_done + 1) % len(b.patrol)]:
        mem = replace(mem, patrol_done=(mem.patrol_done + 1) % len(b.patrol))
    status = 'caught' if p == g else 'won' if book and p == b.exit else 'play'
    reason = 'contact' if status == 'caught' else 'complete' if status == 'won' else 'eye' if o.visible else 'ear' if o.heard else 'search' if it.memory.target else 'patrol'
    return State(p, g, f, mem, book, s.turns + 1, status, reason, sound, radius, path(b, s.guard, sound) if o.heard else (), o)

class Game:
    """Pyxel抜きでも試せる操作窓口。描画は状態を変更しない。"""
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()

    def act(self, action):
        self.state = step(LEVEL, self.state, action)


KEY_ACTIONS = {'UP': 'U', 'DOWN': 'D', 'LEFT': 'L', 'RIGHT': 'R', 'SPACE': 'W', 'Z': 'S'}

class Controller:
    """1押下1手。同時押しを棄却し、リセット後は全キーを離す。"""
    def __init__(self):
        self.game = Game()
        self.release_gate = False

    def tick(self, pressed=(), held=()):
        pressed, held = set(pressed), set(held)
        controls = set(KEY_ACTIONS) | {'R', 'X'}
        if self.release_gate:
            if not (held & controls):
                self.release_gate = False
            return
        if 'R' in pressed:
            self.game.reset()
            self.release_gate = True
            return
        chosen = [a for key, a in KEY_ACTIONS.items() if key in pressed]
        held_actions = held & set(KEY_ACTIONS)
        if len(chosen) == 1 and len(held_actions) == 1:
            self.game.act(chosen[0])


def main(headless=False, screenshot=None, actions='', frames=12, report=None):
    import json
    from pathlib import Path
    # Pyxel may change cwd to this script's directory at init. Resolve user paths first.
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel

    controller = Controller()
    events = []
    tick = 0
    width, height, cell, ox, oy = 320, 302, 27, 38, 39
    keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')}
    reverse_keys = {value: key for key, value in KEY_ACTIONS.items()}
    reverse_keys.update({'K': 'R', 'X': 'X'})

    def update():
        nonlocal tick
        tick += 1
        if headless:
            if tick <= len(actions):
                # QA専用の合成押下。OSや人が押したキーではない。
                key = reverse_keys[actions[tick - 1]]
                controller.tick((), ())
                controller.tick((key,), (key,))
                events.append({'tick': tick, 'action': actions[tick - 1],
                               'state': asdict(controller.game.state)})
        else:
            controller.tick([k for k, v in keys.items() if pyxel.btnp(v)],
                            [k for k, v in keys.items() if pyxel.btn(v)])

    def center(p):
        return ox + p[0] * cell + cell // 2, oy + p[1] * cell + cell // 2

    def draw():
        s, b = controller.game.state, LEVEL
        pyxel.cls(1)
        pyxel.text(8, 8, 'QUIET FOOTSTEPS / STEP 3', 10)
        pyxel.text(8, 20, f'TURN {s.turns:03}  BOOK {int(s.book)}  {s.status.upper()}', 7)
        cone = set(vision(b, s.guard, s.facing))
        for y in range(b.height):
            for x in range(b.width):
                p = (x, y)
                cx, cy = center(p)
                xx, yy = cx - cell // 2, cy - cell // 2
                if not floor(b, p):
                    pyxel.rect(xx, yy, cell - 1, cell - 1, 5)
                    continue
                pyxel.rect(xx, yy, cell - 1, cell - 1, 2 if p in cone else 1)
                pyxel.rectb(xx, yy, cell - 1, cell - 1, 13)
                if p in b.metal:
                    pyxel.rect(xx + 5, yy + 5, 16, 16, 13)
                    pyxel.text(xx + 9, yy + 9, 'M', 7)
                elif p in b.boards:
                    for offset in (7, 13, 19):
                        pyxel.line(xx + 3, yy + offset, xx + 22, yy + offset, 4)
                if p in s.heard_path:
                    pyxel.circb(cx, cy, 3, 10)
                if p == b.exit:
                    pyxel.rectb(xx + 5, yy + 4, 16, 19, 11)
                    pyxel.text(cx - 2, cy - 2, 'E', 11)
                if p == b.book and not s.book:
                    pyxel.rect(xx + 8, yy + 7, 10, 13, 10)
        x, y = center(s.guard)
        pyxel.rect(x - 5, y - 5, 11, 11, 8)
        dx, dy = s.facing
        pyxel.line(x, y, x + dx * 10, y + dy * 10, 7)
        x, y = center(s.player)
        pyxel.circ(x, y, 5, 12)
        if s.book:
            pyxel.rect(x + 3, y - 5, 4, 5, 10)
        pyxel.text(8, 232, 'STATE: ' + s.reason.upper(), 10 if s.status == 'won' else 7)
        pyxel.text(8, 244, f'SOUND {s.radius}  CLOTH 0 / BOARD 3 / METAL 6', 6)
        pyxel.text(8, 270, 'ARROWS: STEP  SPACE: WAIT  Z: STOMP', 7)
        pyxel.text(8, 282, 'R: RESET  ESC: QUIT  SILENT RENDER', 6)
        if headless and tick >= frames:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Synthetic input; actual Pyxel headless rendering; no audio',
                    'frames': tick, 'actions': actions, 'events': events,
                    'final_state': asdict(s),
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(width, height, title='Quiet Footsteps - Stage 3', 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', help='12 rendered frames, then quit')
    parser.add_argument('--screenshot', help='PNG file path (headless only)')
    parser.add_argument('--actions', default='', help='Synthetic UDLRWSXK actions during 12-frame smoke')
    parser.add_argument('--headless-replay', help='Longer synthetic replay, UDLRWSXK; K resets')
    parser.add_argument('--report', help='UTF-8 JSON report (headless only)')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay, not both')
    actions = args.actions if args.headless_replay is None else args.headless_replay
    if any(a not in 'UDLRWKS' for a in actions):
        parser.error('Supported actions in this stage: UDLRWKS')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.actions or args.screenshot or args.report):
        parser.error('actions/screenshot/report require a headless mode')
    if args.headless_smoke and len(actions) > 12:
        parser.error('Smoke accepts at most 12 actions; use --headless-replay for longer routes')
    frames = 12 if args.headless_replay is None else max(12, len(actions) + 2)
    main(headless, args.screenshot, actions, frames, args.report)
STEP 04 / 05

注目点を覚え、見回して巡回へ戻る

今見聞きしたことと、覚えておいたことを分ける。

判断の順番を「今回見えた位置→今回聞こえた音→覚えている気になる場所→見回りの地点」にします。新しく見聞きしたことがあれば target・source・look を書き換え、見回りをどこまで終えたか(patrol_done)は残しておきます。気になる場所に着いた手では向きを変えず、ほかに何も見聞きしなければ、その後の2ターンで右回りに見回します。3ターン目に記憶を消し、最後に終えた見回り地点の次へ戻ります。この段階で、元のゲームの A のルールがそろいます。

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

04_memory.py / 注目する部分
    if m.target is not None:
        if g != m.target:
            return Intent(m.target, False, m)
        if m.look > 0:
            return Intent(None, True, replace(m, look=m.look - 1))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_memory.py
macOS / Linux
.venv/bin/python 04_memory.py
画面で見ることR のあと 上右右Z下下下右右 と押すと、見張りは (3,2) に着いて LOOK 2 になります。続けて 上 で LOOK 1、右 で LOOK 0、下 で PATROL(見回り)に戻ります。最後に 左 を5回押すと、帳面を出口まで運べます。
  • 隠れても、最後に聞こえた場所を覚えている
  • 着いたあと、2回だけ向きを変える
  • 見回りの順番を忘れない
つまずきやすいところ覚えている target を毎ターン、プレイヤーの今の位置に書き換えると、棚の裏まで見透かしてしまいます。policy の引数には、プレイヤーの本当の今の位置を渡しません。
少し変えてみよう

policy の2を3に変えたら、着いたあとに何回向きを変えるか予想し、テストの期待する値も書き換えて確かめましょう。これは、元のゲームの「2ターン探す」とは違う改造です。

04_memory.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(371行)
04_memory.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.
# 教材はNoto字形を同梱せず、ASCIIと図形のみを使います。

"""静かな足音・教材 4/5。原作Aの盤面を段階的に作る独立ファイル。"""
from dataclasses import dataclass, replace, asdict
from collections import deque

DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0))  # 上・下・左・右の同距離優先順
ACTIONS = {'U': (0, -1), 'D': (0, 1), 'L': (-1, 0), 'R': (1, 0)}

@dataclass(frozen=True)
class Level:
    title: str
    width: int
    height: int
    walls: tuple
    start: tuple
    book: tuple
    exit: tuple
    guard: tuple
    facing: tuple
    patrol: tuple
    metal: tuple
    boards: tuple

LEVEL = Level(
    '棚の角', 9, 7, ((4, 2), (4, 3), (4, 4)),
    (1, 3), (6, 4), (1, 5), (6, 2), (0, 1),
    ((6, 2), (6, 4)), ((3, 2),), ((3, 1), (4, 1)),
)

@dataclass(frozen=True)
class Memory:
    target: tuple | None = None
    source: str = 'patrol'
    look: int = 0
    patrol_done: int = 0

@dataclass(frozen=True)
class Observation:
    visible: tuple | None
    heard: tuple | None

@dataclass(frozen=True)
class Intent:
    target: tuple | None
    rotate: bool
    memory: Memory

@dataclass(frozen=True)
class State:
    player: tuple
    guard: tuple
    facing: tuple
    memory: Memory
    book: bool = False
    turns: int = 0
    status: str = 'play'
    reason: str = 'ready'
    sound: tuple | None = None
    radius: int = 0
    heard_path: tuple = ()
    observed: Observation = Observation(None, None)

def initial():
    return State(LEVEL.start, LEVEL.guard, LEVEL.facing, Memory())

def floor(b, p):
    return 0 < p[0] < b.width - 1 and 0 < p[1] < b.height - 1 and (p not in b.walls)

def neighbors(b, p):
    return tuple(((p[0] + dx, p[1] + dy) for dx, dy in DIRS if floor(b, (p[0] + dx, p[1] + dy))))

def path(b, a, z):
    """床だけを幅優先探索。戻り値には出発点と終点も含める。"""
    if not floor(b, a) or not floor(b, z):
        return ()
    q = deque([a])
    prev = {a: None}
    while q:
        p = q.popleft()
        if p == z:
            out = []
            while p is not None:
                out.append(p)
                p = prev[p]
            return tuple(reversed(out))
        for n in neighbors(b, p):
            if n not in prev:
                prev[n] = p
                q.append(n)
    return ()

def crosses_box(a, z, w):
    """線分a→zと棚の閉じた四角の交差。角に触れても遮る。"""
    lo, hi = (0.0, 1.0)
    for av, dv, c in zip(a, (z[0] - a[0], z[1] - a[1]), w):
        if dv == 0:
            if not c - 0.5 <= av <= c + 0.5:
                return False
        else:
            u, v = ((c - 0.5 - av) / dv, (c + 0.5 - av) / dv)
            lo = max(lo, min(u, v))
            hi = min(hi, max(u, v))
            if lo > hi:
                return False
    return True

def visible(b, g, f, p):
    """前方4マス・45度以内。音の経路とは違い、直線を棚で遮る。"""
    if p == g:
        return True
    dx, dy = (p[0] - g[0], p[1] - g[1])
    front = dx * f[0] + dy * f[1]
    side = dx * f[1] - dy * f[0]
    return 1 <= front <= 4 and abs(side) <= front and (not any((crosses_box(g, p, w) for w in b.walls)))

def vision(b, g, f):
    return tuple(((x, y) for y in range(1, b.height - 1) for x in range(1, b.width - 1) if floor(b, (x, y)) and visible(b, g, f, (x, y))))

def sound_radius(b, p, metal_radius=6):
    return metal_radius if p in b.metal else 3 if p in b.boards else 0

def observe(b, s, player, sound, radius):
    """この手の観測。s.guardとs.facingは移動する前の値。"""
    route = path(b, s.guard, sound) if sound is not None and radius else ()
    heard = sound if route and len(route) - 1 <= radius else None
    return Observation(player if visible(b, s.guard, s.facing, player) else None, heard)

def policy(o, m, g, b):
    # 新しい目 → 新しい耳 → 記憶 → 巡回。隠れた現在位置は引数にない。
    if o.visible is not None:
        return Intent(o.visible, False, Memory(o.visible, 'eye', 2, m.patrol_done))
    if o.heard is not None:
        return Intent(o.heard, False, Memory(o.heard, 'ear', 2, m.patrol_done))
    if m.target is not None:
        if g != m.target:
            return Intent(m.target, False, m)
        if m.look > 0:
            return Intent(None, True, replace(m, look=m.look - 1))
        m = Memory(patrol_done=m.patrol_done)
    done = m.patrol_done
    nxt = (done + 1) % len(b.patrol)
    if g == b.patrol[nxt]:
        done = nxt
        nxt = (done + 1) % len(b.patrol)
    return Intent(b.patrol[nxt], False, Memory(patrol_done=done))

def step(b, s, action, metal_radius=6):
    if s.status != 'play':
        return s
    if action not in (*ACTIONS, 'W', 'S'):
        return s
    p = s.player
    if action in ACTIONS:
        dx, dy = ACTIONS[action]
        p = (p[0] + dx, p[1] + dy)
        if not floor(b, p):
            return s
    radius = sound_radius(b, p, metal_radius) + (2 if action == 'S' else 0) if action != 'W' else 0
    sound = p if radius else None
    book = s.book or p == b.book
    if p == s.guard:
        return replace(s, player=p, book=book, turns=s.turns + 1, status='caught', reason='contact', sound=sound, radius=radius)
    # この後に見張りを動かす。移動後の再観測はしない。
    o = observe(b, s, p, sound, radius)
    it = policy(o, s.memory, s.guard, b)
    g = s.guard
    f = s.facing
    if it.rotate:
        f = (-f[1], f[0])
    elif it.target is not None:
        route = path(b, g, it.target)
        if len(route) > 1:
            f = (route[1][0] - g[0], route[1][1] - g[1])
            g = route[1]
    mem = it.memory
    if mem.target is None and g == b.patrol[(mem.patrol_done + 1) % len(b.patrol)]:
        mem = replace(mem, patrol_done=(mem.patrol_done + 1) % len(b.patrol))
    status = 'caught' if p == g else 'won' if book and p == b.exit else 'play'
    reason = 'contact' if status == 'caught' else 'complete' if status == 'won' else 'eye' if o.visible else 'ear' if o.heard else 'search' if it.memory.target else 'patrol'
    return State(p, g, f, mem, book, s.turns + 1, status, reason, sound, radius, path(b, s.guard, sound) if o.heard else (), o)

def preview(b, s):
    """新しい観測がないと仮定した次歩。確定した未来ではない。"""
    it = policy(Observation(None, None), s.memory, s.guard, b)
    route = path(b, s.guard, it.target) if it.target is not None else ()
    return route[1] if len(route) > 1 else s.guard

class Game:
    """Pyxel抜きでも試せる操作窓口。描画は状態を変更しない。"""
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()

    def act(self, action):
        self.state = step(LEVEL, self.state, action)


KEY_ACTIONS = {'UP': 'U', 'DOWN': 'D', 'LEFT': 'L', 'RIGHT': 'R', 'SPACE': 'W', 'Z': 'S'}

class Controller:
    """1押下1手。同時押しを棄却し、リセット後は全キーを離す。"""
    def __init__(self):
        self.game = Game()
        self.release_gate = False

    def tick(self, pressed=(), held=()):
        pressed, held = set(pressed), set(held)
        controls = set(KEY_ACTIONS) | {'R', 'X'}
        if self.release_gate:
            if not (held & controls):
                self.release_gate = False
            return
        if 'R' in pressed:
            self.game.reset()
            self.release_gate = True
            return
        chosen = [a for key, a in KEY_ACTIONS.items() if key in pressed]
        held_actions = held & set(KEY_ACTIONS)
        if len(chosen) == 1 and len(held_actions) == 1:
            self.game.act(chosen[0])


def main(headless=False, screenshot=None, actions='', frames=12, report=None):
    import json
    from pathlib import Path
    # Pyxel may change cwd to this script's directory at init. Resolve user paths first.
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel

    controller = Controller()
    events = []
    tick = 0
    width, height, cell, ox, oy = 320, 302, 27, 38, 39
    keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')}
    reverse_keys = {value: key for key, value in KEY_ACTIONS.items()}
    reverse_keys.update({'K': 'R', 'X': 'X'})

    def update():
        nonlocal tick
        tick += 1
        if headless:
            if tick <= len(actions):
                # QA専用の合成押下。OSや人が押したキーではない。
                key = reverse_keys[actions[tick - 1]]
                controller.tick((), ())
                controller.tick((key,), (key,))
                events.append({'tick': tick, 'action': actions[tick - 1],
                               'state': asdict(controller.game.state)})
        else:
            controller.tick([k for k, v in keys.items() if pyxel.btnp(v)],
                            [k for k, v in keys.items() if pyxel.btn(v)])

    def center(p):
        return ox + p[0] * cell + cell // 2, oy + p[1] * cell + cell // 2

    def draw():
        s, b = controller.game.state, LEVEL
        pyxel.cls(1)
        pyxel.text(8, 8, 'QUIET FOOTSTEPS / STEP 4', 10)
        pyxel.text(8, 20, f'TURN {s.turns:03}  BOOK {int(s.book)}  {s.status.upper()}', 7)
        cone = set(vision(b, s.guard, s.facing))
        for y in range(b.height):
            for x in range(b.width):
                p = (x, y)
                cx, cy = center(p)
                xx, yy = cx - cell // 2, cy - cell // 2
                if not floor(b, p):
                    pyxel.rect(xx, yy, cell - 1, cell - 1, 5)
                    continue
                pyxel.rect(xx, yy, cell - 1, cell - 1, 2 if p in cone else 1)
                pyxel.rectb(xx, yy, cell - 1, cell - 1, 13)
                if p in b.metal:
                    pyxel.rect(xx + 5, yy + 5, 16, 16, 13)
                    pyxel.text(xx + 9, yy + 9, 'M', 7)
                elif p in b.boards:
                    for offset in (7, 13, 19):
                        pyxel.line(xx + 3, yy + offset, xx + 22, yy + offset, 4)
                if p in s.heard_path:
                    pyxel.circb(cx, cy, 3, 10)
                if p == b.exit:
                    pyxel.rectb(xx + 5, yy + 4, 16, 19, 11)
                    pyxel.text(cx - 2, cy - 2, 'E', 11)
                if p == b.book and not s.book:
                    pyxel.rect(xx + 8, yy + 7, 10, 13, 10)
        if s.memory.target is not None:
            x, y = center(s.memory.target)
            pyxel.circb(x, y, 11, 10 if s.memory.source == 'ear' else 8)
        nxt = preview(b, s)
        if nxt != s.guard:
            x, y = center(s.guard)
            xx, yy = center(nxt)
            pyxel.line(x, y, xx, yy, 6)
            pyxel.circb(xx, yy, 4, 6)
        x, y = center(s.guard)
        pyxel.rect(x - 5, y - 5, 11, 11, 8)
        dx, dy = s.facing
        pyxel.line(x, y, x + dx * 10, y + dy * 10, 7)
        x, y = center(s.player)
        pyxel.circ(x, y, 5, 12)
        if s.book:
            pyxel.rect(x + 3, y - 5, 4, 5, 10)
        pyxel.text(8, 232, 'STATE: ' + s.reason.upper(), 10 if s.status == 'won' else 7)
        pyxel.text(8, 244, f'SOUND {s.radius}  CLOTH 0 / BOARD 3 / METAL 6', 6)
        pyxel.text(8, 256, f'MEM {s.memory.source.upper()}  LOOK {s.memory.look}  PATROL {s.memory.patrol_done}', 6)
        pyxel.text(8, 270, 'ARROWS: STEP  SPACE: WAIT  Z: STOMP', 7)
        pyxel.text(8, 282, 'R: RESET  ESC: QUIT  SILENT RENDER', 6)
        if headless and tick >= frames:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Synthetic input; actual Pyxel headless rendering; no audio',
                    'frames': tick, 'actions': actions, 'events': events,
                    'final_state': asdict(s),
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(width, height, title='Quiet Footsteps - Stage 4', 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', help='12 rendered frames, then quit')
    parser.add_argument('--screenshot', help='PNG file path (headless only)')
    parser.add_argument('--actions', default='', help='Synthetic UDLRWSXK actions during 12-frame smoke')
    parser.add_argument('--headless-replay', help='Longer synthetic replay, UDLRWSXK; K resets')
    parser.add_argument('--report', help='UTF-8 JSON report (headless only)')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay, not both')
    actions = args.actions if args.headless_replay is None else args.headless_replay
    if any(a not in 'UDLRWKS' for a in actions):
        parser.error('Supported actions in this stage: UDLRWKS')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.actions or args.screenshot or args.report):
        parser.error('actions/screenshot/report require a headless mode')
    if args.headless_smoke and len(actions) > 12:
        parser.error('Smoke accepts at most 12 actions; use --headless-replay for longer routes')
    frames = 12 if args.headless_replay is None else max(12, len(actions) + 2)
    main(headless, args.screenshot, actions, frames, args.report)
STEP 05 / 05

向きと記憶も丸ごと一手戻す

書き換えられない状態と履歴で、Undo(1手戻す)を作る。

State と Memory は frozen(書き換えられない)データです。受け付けた手の前の State を Timeline.past に保存し、X キーではその State 全体を戻します。座標だけでなく、向き・気になる場所・残りの探す回数・見回りの進み具合・帳面・音・見聞きしたこと・成功や失敗・ターン数もすべて戻ります。壁にぶつかった入力は履歴に積みません。R は最初の状態と空の履歴に戻すので、R のあとに X を押しても、前の挑戦には戻れません。

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

05_undo.py / 注目する部分
    n = step(b, t.state, a, metal_radius)
    return t if n is t.state else Timeline(n, t.past + (t.state,))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること上右右Z下 まで進めて X を押すと、音を聞いた直後の TURN 4・SOUND 8・MEM EAR に戻ります。下 をやり直すと、同じ状態になります。攻略の例で WON になったあとも、X で最後の手の前に戻せます。
  • 記憶と向きも戻る
  • 成功や捕まる直前にも戻れる
  • R キーで履歴も空になる
つまずきやすいところ書き換えられる辞書をそのまま履歴に入れると、あとで変えた内容が過去の記録にも反映されてしまいます。ここでは書き換えられない State と Memory を使い、変えるときは新しい値を作っています。
少し変えてみよう

「上右右Z下X下」の結果と「上右右Z下」の結果が、asdict で完全に同じになることを確かめましょう。次に、R のあとに X を押しても、前に聞こえた音が戻ってこないことを試しましょう。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(393行)
05_undo.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.
# 教材はNoto字形を同梱せず、ASCIIと図形のみを使います。

"""静かな足音・教材 5/5。原作Aの盤面を段階的に作る独立ファイル。"""
from dataclasses import dataclass, replace, asdict
from collections import deque

DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0))  # 上・下・左・右の同距離優先順
ACTIONS = {'U': (0, -1), 'D': (0, 1), 'L': (-1, 0), 'R': (1, 0)}

@dataclass(frozen=True)
class Level:
    title: str
    width: int
    height: int
    walls: tuple
    start: tuple
    book: tuple
    exit: tuple
    guard: tuple
    facing: tuple
    patrol: tuple
    metal: tuple
    boards: tuple

LEVEL = Level(
    '棚の角', 9, 7, ((4, 2), (4, 3), (4, 4)),
    (1, 3), (6, 4), (1, 5), (6, 2), (0, 1),
    ((6, 2), (6, 4)), ((3, 2),), ((3, 1), (4, 1)),
)

@dataclass(frozen=True)
class Memory:
    target: tuple | None = None
    source: str = 'patrol'
    look: int = 0
    patrol_done: int = 0

@dataclass(frozen=True)
class Observation:
    visible: tuple | None
    heard: tuple | None

@dataclass(frozen=True)
class Intent:
    target: tuple | None
    rotate: bool
    memory: Memory

@dataclass(frozen=True)
class State:
    player: tuple
    guard: tuple
    facing: tuple
    memory: Memory
    book: bool = False
    turns: int = 0
    status: str = 'play'
    reason: str = 'ready'
    sound: tuple | None = None
    radius: int = 0
    heard_path: tuple = ()
    observed: Observation = Observation(None, None)

def initial():
    return State(LEVEL.start, LEVEL.guard, LEVEL.facing, Memory())

def floor(b, p):
    return 0 < p[0] < b.width - 1 and 0 < p[1] < b.height - 1 and (p not in b.walls)

def neighbors(b, p):
    return tuple(((p[0] + dx, p[1] + dy) for dx, dy in DIRS if floor(b, (p[0] + dx, p[1] + dy))))

def path(b, a, z):
    """床だけを幅優先探索。戻り値には出発点と終点も含める。"""
    if not floor(b, a) or not floor(b, z):
        return ()
    q = deque([a])
    prev = {a: None}
    while q:
        p = q.popleft()
        if p == z:
            out = []
            while p is not None:
                out.append(p)
                p = prev[p]
            return tuple(reversed(out))
        for n in neighbors(b, p):
            if n not in prev:
                prev[n] = p
                q.append(n)
    return ()

def crosses_box(a, z, w):
    """線分a→zと棚の閉じた四角の交差。角に触れても遮る。"""
    lo, hi = (0.0, 1.0)
    for av, dv, c in zip(a, (z[0] - a[0], z[1] - a[1]), w):
        if dv == 0:
            if not c - 0.5 <= av <= c + 0.5:
                return False
        else:
            u, v = ((c - 0.5 - av) / dv, (c + 0.5 - av) / dv)
            lo = max(lo, min(u, v))
            hi = min(hi, max(u, v))
            if lo > hi:
                return False
    return True

def visible(b, g, f, p):
    """前方4マス・45度以内。音の経路とは違い、直線を棚で遮る。"""
    if p == g:
        return True
    dx, dy = (p[0] - g[0], p[1] - g[1])
    front = dx * f[0] + dy * f[1]
    side = dx * f[1] - dy * f[0]
    return 1 <= front <= 4 and abs(side) <= front and (not any((crosses_box(g, p, w) for w in b.walls)))

def vision(b, g, f):
    return tuple(((x, y) for y in range(1, b.height - 1) for x in range(1, b.width - 1) if floor(b, (x, y)) and visible(b, g, f, (x, y))))

def sound_radius(b, p, metal_radius=6):
    return metal_radius if p in b.metal else 3 if p in b.boards else 0

def observe(b, s, player, sound, radius):
    """この手の観測。s.guardとs.facingは移動する前の値。"""
    route = path(b, s.guard, sound) if sound is not None and radius else ()
    heard = sound if route and len(route) - 1 <= radius else None
    return Observation(player if visible(b, s.guard, s.facing, player) else None, heard)

def policy(o, m, g, b):
    # 新しい目 → 新しい耳 → 記憶 → 巡回。隠れた現在位置は引数にない。
    if o.visible is not None:
        return Intent(o.visible, False, Memory(o.visible, 'eye', 2, m.patrol_done))
    if o.heard is not None:
        return Intent(o.heard, False, Memory(o.heard, 'ear', 2, m.patrol_done))
    if m.target is not None:
        if g != m.target:
            return Intent(m.target, False, m)
        if m.look > 0:
            return Intent(None, True, replace(m, look=m.look - 1))
        m = Memory(patrol_done=m.patrol_done)
    done = m.patrol_done
    nxt = (done + 1) % len(b.patrol)
    if g == b.patrol[nxt]:
        done = nxt
        nxt = (done + 1) % len(b.patrol)
    return Intent(b.patrol[nxt], False, Memory(patrol_done=done))

def step(b, s, action, metal_radius=6):
    if s.status != 'play':
        return s
    if action not in (*ACTIONS, 'W', 'S'):
        return s
    p = s.player
    if action in ACTIONS:
        dx, dy = ACTIONS[action]
        p = (p[0] + dx, p[1] + dy)
        if not floor(b, p):
            return s
    radius = sound_radius(b, p, metal_radius) + (2 if action == 'S' else 0) if action != 'W' else 0
    sound = p if radius else None
    book = s.book or p == b.book
    if p == s.guard:
        return replace(s, player=p, book=book, turns=s.turns + 1, status='caught', reason='contact', sound=sound, radius=radius)
    # この後に見張りを動かす。移動後の再観測はしない。
    o = observe(b, s, p, sound, radius)
    it = policy(o, s.memory, s.guard, b)
    g = s.guard
    f = s.facing
    if it.rotate:
        f = (-f[1], f[0])
    elif it.target is not None:
        route = path(b, g, it.target)
        if len(route) > 1:
            f = (route[1][0] - g[0], route[1][1] - g[1])
            g = route[1]
    mem = it.memory
    if mem.target is None and g == b.patrol[(mem.patrol_done + 1) % len(b.patrol)]:
        mem = replace(mem, patrol_done=(mem.patrol_done + 1) % len(b.patrol))
    status = 'caught' if p == g else 'won' if book and p == b.exit else 'play'
    reason = 'contact' if status == 'caught' else 'complete' if status == 'won' else 'eye' if o.visible else 'ear' if o.heard else 'search' if it.memory.target else 'patrol'
    return State(p, g, f, mem, book, s.turns + 1, status, reason, sound, radius, path(b, s.guard, sound) if o.heard else (), o)

def preview(b, s):
    """新しい観測がないと仮定した次歩。確定した未来ではない。"""
    it = policy(Observation(None, None), s.memory, s.guard, b)
    route = path(b, s.guard, it.target) if it.target is not None else ()
    return route[1] if len(route) > 1 else s.guard

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

def apply(b, t, a, metal_radius=6):
    n = step(b, t.state, a, metal_radius)
    return t if n is t.state else Timeline(n, t.past + (t.state,))

def undo(t):
    return Timeline(t.past[-1], t.past[:-1]) if t.past else t

class Game:
    """Pyxel抜きでも試せる操作窓口。描画は状態を変更しない。"""
    def __init__(self):
        self.reset()

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

    def reset(self):
        self.timeline = Timeline(initial())

    def act(self, action):
        self.timeline = apply(LEVEL, self.timeline, action)

    def back(self):
        self.timeline = undo(self.timeline)


KEY_ACTIONS = {'UP': 'U', 'DOWN': 'D', 'LEFT': 'L', 'RIGHT': 'R', 'SPACE': 'W', 'Z': 'S'}

class Controller:
    """1押下1手。同時押しを棄却し、リセット後は全キーを離す。"""
    def __init__(self):
        self.game = Game()
        self.release_gate = False

    def tick(self, pressed=(), held=()):
        pressed, held = set(pressed), set(held)
        controls = set(KEY_ACTIONS) | {'R', 'X'}
        if self.release_gate:
            if not (held & controls):
                self.release_gate = False
            return
        if 'R' in pressed:
            self.game.reset()
            self.release_gate = True
            return
        if 'X' in pressed:
            self.game.back()
            return
        chosen = [a for key, a in KEY_ACTIONS.items() if key in pressed]
        held_actions = held & set(KEY_ACTIONS)
        if len(chosen) == 1 and len(held_actions) == 1:
            self.game.act(chosen[0])


def main(headless=False, screenshot=None, actions='', frames=12, report=None):
    import json
    from pathlib import Path
    # Pyxel may change cwd to this script's directory at init. Resolve user paths first.
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel

    controller = Controller()
    events = []
    tick = 0
    width, height, cell, ox, oy = 320, 302, 27, 38, 39
    keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')}
    reverse_keys = {value: key for key, value in KEY_ACTIONS.items()}
    reverse_keys.update({'K': 'R', 'X': 'X'})

    def update():
        nonlocal tick
        tick += 1
        if headless:
            if tick <= len(actions):
                # QA専用の合成押下。OSや人が押したキーではない。
                key = reverse_keys[actions[tick - 1]]
                controller.tick((), ())
                controller.tick((key,), (key,))
                events.append({'tick': tick, 'action': actions[tick - 1],
                               'state': asdict(controller.game.state)})
        else:
            controller.tick([k for k, v in keys.items() if pyxel.btnp(v)],
                            [k for k, v in keys.items() if pyxel.btn(v)])

    def center(p):
        return ox + p[0] * cell + cell // 2, oy + p[1] * cell + cell // 2

    def draw():
        s, b = controller.game.state, LEVEL
        pyxel.cls(1)
        pyxel.text(8, 8, 'QUIET FOOTSTEPS / STEP 5', 10)
        pyxel.text(8, 20, f'TURN {s.turns:03}  BOOK {int(s.book)}  {s.status.upper()}', 7)
        cone = set(vision(b, s.guard, s.facing))
        for y in range(b.height):
            for x in range(b.width):
                p = (x, y)
                cx, cy = center(p)
                xx, yy = cx - cell // 2, cy - cell // 2
                if not floor(b, p):
                    pyxel.rect(xx, yy, cell - 1, cell - 1, 5)
                    continue
                pyxel.rect(xx, yy, cell - 1, cell - 1, 2 if p in cone else 1)
                pyxel.rectb(xx, yy, cell - 1, cell - 1, 13)
                if p in b.metal:
                    pyxel.rect(xx + 5, yy + 5, 16, 16, 13)
                    pyxel.text(xx + 9, yy + 9, 'M', 7)
                elif p in b.boards:
                    for offset in (7, 13, 19):
                        pyxel.line(xx + 3, yy + offset, xx + 22, yy + offset, 4)
                if p in s.heard_path:
                    pyxel.circb(cx, cy, 3, 10)
                if p == b.exit:
                    pyxel.rectb(xx + 5, yy + 4, 16, 19, 11)
                    pyxel.text(cx - 2, cy - 2, 'E', 11)
                if p == b.book and not s.book:
                    pyxel.rect(xx + 8, yy + 7, 10, 13, 10)
        if s.memory.target is not None:
            x, y = center(s.memory.target)
            pyxel.circb(x, y, 11, 10 if s.memory.source == 'ear' else 8)
        nxt = preview(b, s)
        if nxt != s.guard:
            x, y = center(s.guard)
            xx, yy = center(nxt)
            pyxel.line(x, y, xx, yy, 6)
            pyxel.circb(xx, yy, 4, 6)
        x, y = center(s.guard)
        pyxel.rect(x - 5, y - 5, 11, 11, 8)
        dx, dy = s.facing
        pyxel.line(x, y, x + dx * 10, y + dy * 10, 7)
        x, y = center(s.player)
        pyxel.circ(x, y, 5, 12)
        if s.book:
            pyxel.rect(x + 3, y - 5, 4, 5, 10)
        pyxel.text(8, 232, 'STATE: ' + s.reason.upper(), 10 if s.status == 'won' else 7)
        pyxel.text(8, 244, f'SOUND {s.radius}  CLOTH 0 / BOARD 3 / METAL 6', 6)
        pyxel.text(8, 256, f'MEM {s.memory.source.upper()}  LOOK {s.memory.look}  PATROL {s.memory.patrol_done}', 6)
        pyxel.text(8, 270, 'ARROWS: STEP  SPACE: WAIT  Z: STOMP', 7)
        pyxel.text(8, 282, 'R: RESET  X: UNDO  ESC: QUIT  SILENT RENDER', 6)
        if headless and tick >= frames:
            if screenshot:
                pyxel.screenshot(screenshot, scale=2)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Synthetic input; actual Pyxel headless rendering; no audio',
                    'frames': tick, 'actions': actions, 'events': events,
                    'final_state': asdict(s),
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(width, height, title='Quiet Footsteps - Stage 5', 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', help='12 rendered frames, then quit')
    parser.add_argument('--screenshot', help='PNG file path (headless only)')
    parser.add_argument('--actions', default='', help='Synthetic UDLRWSXK actions during 12-frame smoke')
    parser.add_argument('--headless-replay', help='Longer synthetic replay, UDLRWSXK; K resets')
    parser.add_argument('--report', help='UTF-8 JSON report (headless only)')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay, not both')
    actions = args.actions if args.headless_replay is None else args.headless_replay
    if any(a not in 'UDLRWKSX' for a in actions):
        parser.error('Supported actions in this stage: UDLRWKSX')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.actions or args.screenshot or args.report):
        parser.error('actions/screenshot/report require a headless mode')
    if args.headless_smoke and len(actions) > 12:
        parser.error('Smoke accepts at most 12 actions; use --headless-replay for longer routes')
    frames = 12 if args.headless_replay is None else max(12, len(actions) + 2)
    main(headless, args.screenshot, actions, frames, args.report)
AFTER STEP 05

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

元のゲームには3つの書庫があります。帳面を出口まで運べば成功で、見張りと同じマスに入ると捕まります。音の範囲は布0・板3・金属6で、Z は+2、Space は無音です。見張りは動く前の位置で1回だけ見て、「見えたもの→今回の音→覚えていること→見回り」の順に優先します。音は床の最短の道のりで届き、気になる場所に着いたあと何も見聞きしなければ、2ターン向きを変えて探します。X は記憶も含めて戻します。

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

比べる点5ステップの教材元の完成版
書庫元のゲームのA「棚の角」の1面。05は同じ配置で、金属の音6のモデル。A「棚の角」、B「見えているうちは」、C「遠い金属、近い布」の3書庫。
段階01は見張りなし、02は音なし・追いかけた記憶なし、03は追いかけた記憶なし、04でAのルールがそろい、05でUndo。最初から、見る・音・記憶・Undoのすべてが有効。
表示と操作英字(ASCII)と図形。すぐにゲーム開始。Rのあと、すべてのキーが離されるまで待つ仕組みがある。日本語の文字、タイトルで選択、Pで一時停止、Mで選択、Nで次の面、F12で記録、詳しい説明。
実験と音音はゲーム内部の情報だけで、実際の音は出ない。確認用の自動入力とJSONがついている。元のゲームも音を出さない作り。金属の範囲を3にした比較版、答えを探すプログラム、動画や出来事の記録などがついている。教材ではそれらの画面は省いている。

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

  1. model.py の observe と step を読み、動く前の位置で見る順番を比べましょう。
  2. model.py の path と crosses_box を読み、音の道すじと、視線をさえぎる計算が別のものであることを確かめましょう。
  3. model.py の policy と preview で、本当の位置を渡さず、覚えていることだけで次の行動を決める作りを読みましょう。
  4. model.py の apply・undo と、app.py の ReleaseGate で、状態とキー入力の役割の分け方を比べましょう。
  5. LEVELS の B と C、plans.py の攻略例、solver.py の探索を読み、別の書庫へ広げてみましょう。
自分のゲームに変えるなら

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

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