完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:矢印キーで1歩、Spaceで音を立てずに待つ、Zで踏み鳴らす、Xで1手戻す、Rでやり直し、Pで一時停止、Mで選択
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
「見える」「聞こえる」「覚えている」を分ける。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、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:終了
- 難易度の目安
- 中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
マスを動き、帳面を出口へ運ぶ
座標・通れるかの判定・目的の状態を分ける。
プレイヤーの位置を (x, y) の組で持ち、矢印キーを1回押すと1マス動くようにします。floor で外側の壁と棚を通れないようにし、通れない入力ではターン(手番)を使いません。book(帳面を持っているか)は、一度取ったら True のままです。出口に行くだけでは成功せず、「帳面を持っている」と「出口に着いた」の両方を調べます。この段階には、見張りも音の判定もありません。Level にある見張りや床の情報は、あとの段階で使います。
下のコードは 01_grid.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
book = s.book or p == b.book
status = 'won' if book and p == b.exit else 'play'実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_grid.py.venv/bin/python 01_grid.py- 矢印キー1回で1マス進む
- 壁に向かう入力ではターンを使わない
- 帳面を取ってから出口に戻る
State はマス目の座標、center は画面の座標に直す役、と役割を分けます。出口だけを (2,5) に移して攻略の道すじを考え、帳面を持っていないと WON にならないことを確かめましょう。次に、壁に向かう入力でも turns を増やした場合、操作した感じがどう変わるか比べてみましょう。
このステップのコード全文(219行)
# 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)見張りの視野と一度だけの観測
「見聞きする→判断する→動く」の順番を決める。
前に4マスまで見える視野と、棚で見えなくなる仕組みを追加します。見張りは、動く前の位置と向きで、動いたあとのプレイヤーを1回だけ見ます。見えたらそこへ、見えなければ見回りの地点へ、幅優先探索で求めた道すじを1歩進みます。動いたあとにもう一度見ることはしません。赤茶色の床は次のターンに使う視野で、今回見えたかどうかは observed に入ります。この段階では、追いかけた位置を次の手まで覚えてはいません。
下のコードは 02_sight.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
o = observe(b, s, p)
it = policy(o, s.memory, s.guard, b)
g, f = s.guard, s.facing実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_sight.py.venv/bin/python 02_sight.py- 何も押さなければ見張りも止まる
- 動く前に1回だけ見る
- 見つかることと、ぶつかることを区別できる
visible を呼ぶと、1ターンに2回情報を得てしまいます。絵を描くための vision を、そのターンの observed として使い回してはいけません。見張り (6,2)・下向き、プレイヤー (5,3) を紙に描いてみましょう。動く前の位置からは見えるのに、見張りが (6,3) に動いたあとは真横になるので見えないことを説明してみましょう。テストの test_old_position_once_only も確かめます。
このステップのコード全文(326行)
# 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)棚を迂回する音の距離
幅優先探索で、道すじと、音が届く範囲を調べる。
床ごとに音が届く範囲(半径)を追加します。布は0、板は3、金属は6です。Z(踏み鳴らす)はその床の値に2を足し、Space で待つときは金属の上でも必ず0です。音が届くかどうかは、まっすぐの距離ではなく、path で求めた床の道のりの長さで決めます。目と耳の両方が反応したら、目を優先します。この段階では、音がした場所は覚えず、その手のあいだだけ調べます。なお、ここでいう「音」はゲームの中の情報で、スピーカーから音を出す処理はありません。
下のコードは 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を展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_sound.py.venv/bin/python 03_sound.py- 床ごとの音の範囲がわかる
- Space で待つときは、いつも無音
- 音が棚を通り抜けない
abs(dx)+abs(dy) だけで判定すると、棚の向こうまで音が抜けてしまいます。また、path の点の個数ではなく、len(route)-1 が動く距離です。見張り (6,2) から金属の床 (3,2) までの床の道のりを数えてみましょう。まっすぐなら3ですが、棚を回りこむ最短の道は5歩です。半径4なら届かず、5なら届くという境目を、テストの test_path_wall_detour_and_inclusive_sound で確かめましょう。
このステップのコード全文(349行)
# 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)注目点を覚え、見回して巡回へ戻る
今見聞きしたことと、覚えておいたことを分ける。
判断の順番を「今回見えた位置→今回聞こえた音→覚えている気になる場所→見回りの地点」にします。新しく見聞きしたことがあれば target・source・look を書き換え、見回りをどこまで終えたか(patrol_done)は残しておきます。気になる場所に着いた手では向きを変えず、ほかに何も見聞きしなければ、その後の2ターンで右回りに見回します。3ターン目に記憶を消し、最後に終えた見回り地点の次へ戻ります。この段階で、元のゲームの A のルールがそろいます。
下のコードは 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を展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_memory.py.venv/bin/python 04_memory.py- 隠れても、最後に聞こえた場所を覚えている
- 着いたあと、2回だけ向きを変える
- 見回りの順番を忘れない
target を毎ターン、プレイヤーの今の位置に書き換えると、棚の裏まで見透かしてしまいます。policy の引数には、プレイヤーの本当の今の位置を渡しません。policy の2を3に変えたら、着いたあとに何回向きを変えるか予想し、テストの期待する値も書き換えて確かめましょう。これは、元のゲームの「2ターン探す」とは違う改造です。
このステップのコード全文(371行)
# 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)向きと記憶も丸ごと一手戻す
書き換えられない状態と履歴で、Undo(1手戻す)を作る。
State と Memory は frozen(書き換えられない)データです。受け付けた手の前の State を Timeline.past に保存し、X キーではその State 全体を戻します。座標だけでなく、向き・気になる場所・残りの探す回数・見回りの進み具合・帳面・音・見聞きしたこと・成功や失敗・ターン数もすべて戻ります。壁にぶつかった入力は履歴に積みません。R は最初の状態と空の履歴に戻すので、R のあとに X を押しても、前の挑戦には戻れません。
下のコードは 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を展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_undo.py.venv/bin/python 05_undo.py- 記憶と向きも戻る
- 成功や捕まる直前にも戻れる
- R キーで履歴も空になる
State と Memory を使い、変えるときは新しい値を作っています。「上右右Z下X下」の結果と「上右右Z下」の結果が、asdict で完全に同じになることを確かめましょう。次に、R のあとに X を押しても、前に聞こえた音が戻ってこないことを試しましょう。
このステップのコード全文(393行)
# 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)仕組みがわかったら、元の完成版を読んでみよう。
元のゲームには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にした比較版、答えを探すプログラム、動画や出来事の記録などがついている。教材ではそれらの画面は省いている。 |
完成版のコードで、次に読むところ
model.pyのobserveとstepを読み、動く前の位置で見る順番を比べましょう。model.pyのpathとcrosses_boxを読み、音の道すじと、視線をさえぎる計算が別のものであることを確かめましょう。model.pyのpolicyとpreviewで、本当の位置を渡さず、覚えていることだけで次の行動を決める作りを読みましょう。model.pyのapply・undoと、app.pyのReleaseGateで、状態とキー入力の役割の分け方を比べましょう。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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
