完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:上下キーで資料を選ぶ、Zで提示する、左右キーで候補を選ぶ、Spaceで残す・外す、Enterで報告、Aで補助、Xで戻す
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
もう受け取った回答だけを使って、候補の集合をしぼりこむ。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
dataclass(frozen)、固定のデータ表、値を取り出す関数(投影関数)、画面(UI)での選択と出来事を分けるAnswer、タプルへの追加、書き換えない State と replace、最初の操作、重複や正しくない入力を受け付けないall で「全部満たす(AND)」を調べる、候補の集合、ビットマスク、条件が空のとき、合わない理由(反例)の説明- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- Python 3.10以上。動作確認(QA)は Python 3.12.14 と Pyxel 2.9.9 で行いました。標準ライブラリだけのテストには Pyxel は不要です / 変数・if・for・関数・タプルの基本 / dataclass とビットマスク(数の1つ1つのビットを、オンとオフの印として使う方法)は本文で説明します
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- ↑↓:資料を選ぶ / ←→:候補を選ぶ / Space:01では精度(細部か大分類か)の表示を切り替える、04以降は候補を残す・外す / Z:02以降は選んだ資料を提示する / A:04以降は、受け取った回答と矛盾しない候補をまとめて選ぶ / Enter:04以降は選んだ候補の集合を報告する / X:05でゲームの状態(モデル)を1つ戻す(Undo) / R:同じ事件をすぐに最初からやり直す / Esc:終了
- 難易度の目安
- 初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
同じ候補から細部と大分類を取り出す
書き換えない dataclass(frozen)、固定のデータ表、値を取り出す関数(投影関数)、画面(UI)での選択と出来事を分ける。
最初に作るのは、聞き取りではありません。公開されている4つの候補を調べるための表です。元のゲームの事件A「返された箱」では、候補は次のとおりです。H1は場所Aの近い門・赤い四角・0刻、H2は場所Bの近い机・赤い丸・0刻、H3は場所Cの遠い門・青い三角・2刻、H4は場所Dの遠い机・青い三角・3刻です。Fact は、この組み合わせを1つの値にまとめたものです。frozen=True で、あとから変更しないことを宣言します。project は、同じ候補から、指定した資料と精度(細部か大分類か)に合わせて値を取り出す関数です。地図の細部は場所A〜D、大分類は「近い」か「遠い」です。写真の細部は形、大分類は色です。時計の細部は刻(時刻)、大分類は「早い」か「遅い」です。これは、このゲームで決めた対応表です。現実の記憶や、証人の評価を表すものではありません。画面の右の列を上下キーや Space で切り替えても、候補そのものは変わりません。
下のコードは 01_projections.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if document == 'photo':
return SHAPES[fact.shape] if detailed else fact.color実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_projections.py.venv/bin/python 01_projections.py- 同じ
Factから、資料ごとの値を取り出す - 公開されている候補と、受け取った回答を区別する
- 細部が分かっても、候補が複数残ることがある
- カーソルを動かしても、決まっている事実は書き換えない
truth=1 は、作者が決めた本当の出来事(真相)の番号です。画面に出る「根拠」ではありません。↓→↓→Space と順に押して、時計の大分類を確かめましょう。さらに↑で写真に移り、Space で細部にすると、SQUARE・CIRCLE・TRIANGLE が出ます。H1とH2は、写真の細部なら区別できます。でも、時計の細部では区別できません。その理由を、表を使って説明してみましょう。R で最初の表示に戻し、←を押すと選択の枠がH4に回ることも確かめます。
このステップのコード全文(258行)
# MIT License
#
# Copyright (c) 2026
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""沈黙の証人・教材1/5。原作A固定、単独実行。架空の情報ルール。"""
from dataclasses import dataclass, asdict, replace
# 原作Aの公開候補。選ぶ順番でfactsやtruthは変わらない。
DOCUMENTS = ('map', 'photo', 'clock')
NAMES = {'map': '地図', 'photo': '写真', 'clock': '時計'}
PLACES = ('近い門', '近い机', '遠い門', '遠い机')
SHAPES = ('四角', '丸', '三角')
@dataclass(frozen=True)
class Fact:
place: int
shape: int
color: str
time: int
@dataclass(frozen=True)
class Level:
id: str
title: str
facts: tuple
truth: int
LEVEL = Level('A', '返された箱', (
Fact(0, 0, '赤', 0), Fact(1, 1, '赤', 0),
Fact(2, 2, '青', 2), Fact(3, 2, '青', 3)), 1)
def project(fact, document, detailed):
if document == 'map':
return ('ABCD'[fact.place] + '・' + PLACES[fact.place] if detailed
else ('近い側 A/B' if fact.place < 2 else '遠い側 C/D'))
if document == 'photo':
return SHAPES[fact.shape] if detailed else fact.color
if document == 'clock':
return str(fact.time) + '刻' if detailed else ('早い 0/1刻' if fact.time < 2 else '遅い 2/3刻')
raise ValueError('unknown document')
@dataclass(frozen=True)
class State:
detailed: bool = True
STAGE = 1
STAGE_TITLE = 'PUBLIC POSSIBLE WORLDS'
CONTROLS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'A', 'RETURN', 'X', 'R')
REPLAY = {'U': 'UP', 'D': 'DOWN', 'L': 'LEFT', 'T': 'RIGHT', 'S': 'SPACE',
'Z': 'Z', 'A': 'A', 'E': 'RETURN', 'X': 'X', 'R': 'R'}
class Controller:
def __init__(self):
self.previous = set()
self.gate = False
self.reset()
def reset(self):
self.state = State()
self.document = 0
self.candidate = 0
self.notice = 'ready'
def apply(self, action):
self.state = replace(self.state, detailed=not self.state.detailed)
return action
def feed(self, down):
# 教材は同時押しを拒否。全操作キーが離れるまで次を受け付けない。
down = set(down) & set(CONTROLS)
fresh = down - self.previous
self.previous = down
if self.gate:
if not down:
self.gate = False
return None
if not fresh:
return None
self.gate = True
if len(fresh) != 1:
self.notice = 'ignored:multiple'
return self.notice
self.notice = self.handle(next(iter(fresh)))
return self.notice
def handle(self, key):
if key == 'R':
self.reset()
return 'reset'
if key in ('UP', 'DOWN'):
self.document = (self.document + (-1 if key == 'UP' else 1)) % 3
return 'document'
if key in ('LEFT', 'RIGHT'):
self.candidate = (self.candidate + (-1 if key == 'LEFT' else 1)) % 4
return 'candidate'
if key == 'SPACE':
return self.apply('precision')
return 'ignored:key'
def snapshot(self):
data = {'state': asdict(self.state), 'document': self.document,
'candidate': self.candidate, 'notice': self.notice}
return data
# 日本語の本文/判定は上に保持。ウィンドウだけ標準フォントで読めるASCII訳。
DISPLAY = {
'A・近い門': 'A / NEAR GATE', 'B・近い机': 'B / NEAR DESK',
'C・遠い門': 'C / FAR GATE', 'D・遠い机': 'D / FAR DESK',
'近い側 A/B': 'NEAR A/B', '遠い側 C/D': 'FAR C/D',
'四角': 'SQUARE', '丸': 'CIRCLE', '三角': 'TRIANGLE', '赤': 'RED', '青': 'BLUE',
'0刻': 'TIME 0', '1刻': 'TIME 1', '2刻': 'TIME 2', '3刻': 'TIME 3',
'早い 0/1刻': 'EARLY 0/1', '遅い 2/3刻': 'LATE 2/3',
}
def wrap(p, x, y, text, width=96, color=7):
import textwrap
for row, line in enumerate(textwrap.wrap(text, width=width)):
p.text(x, y + row * 11, line, color)
def draw_shape(p, x, y, fact):
color = 8 if fact.color == '赤' else 12
if fact.shape == 0:
p.rect(x, y, 12, 12, color)
elif fact.shape == 1:
p.circ(x + 6, y + 6, 6, color)
else:
p.tri(x + 6, y, x, y + 12, x + 12, y + 12, color)
def draw_board(p, c):
state = c.state
p.cls(1)
p.text(14, 12, 'SILENT WITNESS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
p.text(14, 29, 'FICTIONAL RULE PUZZLE. Not a model of real witnesses or legal interviews.', 6)
p.text(14, 44, 'CASE A / RETURNED BOX. The event stays fixed; only available detail changes.', 6)
for i, document in enumerate(DOCUMENTS):
x = 14 + i * 216
p.rect(x, 65, 207, 61, 2)
p.rectb(x, 65, 207, 61, 10 if c.document == i else 5)
p.text(x + 9, 74, document.upper(), 7)
p.text(x + 9, 93, 'PREVIEW: DETAIL + CATEGORY', 11)
p.text(14, 141, 'PUBLIC POSSIBLE WORLDS / LEFT-RIGHT: SELECT H1-H4', 10)
for i, fact in enumerate(LEVEL.facts):
y = 158 + i * 36
p.rect(14, y, 632, 32, 0)
p.rectb(14, y, 632, 32, 10 if c.candidate == i else 5)
draw_shape(p, 35, y + 9, fact)
p.text(61, y + 7, 'H' + str(i + 1) + ' ' + DISPLAY[project(fact, 'map', True)], 7)
p.text(198, y + 7, DISPLAY[fact.color] + ' ' + DISPLAY[project(fact, 'photo', True)], 7)
p.text(317, y + 7, DISPLAY[project(fact, 'clock', True)], 7)
p.text(399, y + 7, DISPLAY[project(fact, DOCUMENTS[c.document], state.detailed)], 11)
p.text(14, 316, 'TABLE PREVIEW ONLY. These are possibilities, not received answers.', 10)
p.text(14, 340, 'UP/DOWN: preview document SPACE: detail/category', 7)
p.text(14, 364, 'MODE: ' + ('DETAIL' if state.detailed else 'CATEGORY'), 11)
wrap(p, 14, 391, 'Notice: a detailed CLOCK can still leave H1 and H2 together. Both have TIME 0. More precise does not mean unique.')
wrap(p, 14, 435, 'MAP detail: A/B/C/D. PHOTO detail: shape, category: color. CLOCK detail: 0/1/2/3, category: early/late.')
p.rect(14, 479, 632, 23, 5)
p.text(22, 487, 'LAST KEY: ' + c.notice, 7)
p.text(14, 513, 'UP/DOWN: DOCUMENT LEFT/RIGHT: CANDIDATE SPACE: PRECISION', 6)
p.text(14, 529, 'R: RESET SAME CASE ESC: QUIT TAKE YOUR TIME; NO COUNTDOWN', 6)
def main():
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-replay', metavar='KEYS')
parser.add_argument('--screenshot', type=Path)
parser.add_argument('--report', type=Path)
args = parser.parse_args()
# 引数エラーはPyxelのimportより先。モデル試験は標準ライブラリだけで動く。
if args.headless_replay is None and (args.screenshot or args.report):
parser.error('output paths require --headless-replay')
if args.headless_replay is not None and any(c not in REPLAY for c in args.headless_replay):
parser.error('replay keys must use: ' + ''.join(REPLAY))
if args.headless_replay is not None and len(args.headless_replay) > 1000:
parser.error('at most 1000 replay keys')
args.screenshot = args.screenshot.resolve() if args.screenshot else None
args.report = args.report.resolve() if args.report else None
import pyxel
c = Controller()
frames = 0
events = []
sequence = args.headless_replay
limit = max(12, len(sequence or '') * 2 + 2)
mapping = {name: getattr(pyxel, 'KEY_' + name) for name in CONTROLS}
pyxel.init(660, 550, title='069 Silent Witness - Stage ' + str(STAGE), fps=30,
display_scale=1, quit_key=pyxel.KEY_NONE, headless=sequence is not None)
def update():
nonlocal frames
if pyxel.btnp(pyxel.KEY_ESCAPE):
pyxel.quit()
return
if sequence is None:
down = {name for name, key in mapping.items() if pyxel.btn(key)}
elif frames % 2 == 0 and frames // 2 < len(sequence):
down = {REPLAY[sequence[frames // 2]]}
else:
down = set()
event = c.feed(down)
if event is not None:
events.append({'frame': frames, 'result': event, 'snapshot': c.snapshot()})
frames += 1
def draw():
draw_board(pyxel, c)
if sequence is not None and frames >= limit:
if args.screenshot:
args.screenshot.parent.mkdir(parents=True, exist_ok=True)
pyxel.screenshot(str(args.screenshot), scale=1)
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps({'stage': STAGE, 'actions': sequence,
'frames': frames, 'events': events, 'snapshot': c.snapshot()},
ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.run(update, draw)
if __name__ == '__main__':
main()最初の一枚だけ細かい回答を記録する
Answer、タプルへの追加、書き換えない State と replace、最初の操作、重複や正しくない入力を受け付けない。
Z で、選んだ資料を提示できるようにします。State.answers は、受け取った回答を順番どおりに保存するタプルです。まだ空なら not state.answers が True になり、その1枚は細部の回答になります。2枚目からは False なので、大分類の回答です。回答の値は、決まっている LEVEL.facts[LEVEL.truth](真相の候補)を project で投影して(値を取り出して)作ります。資料を見せる順番を変えても、真相が変わるわけではありません。知らない資料(unknown)と、もう提示した資料は、回答を追加する前に受け付けないようにします。そのため、同じ資料を何度も見せて「細部をもう一度もらう」ことはできません。新しい State は replace で作るので、古い状態はあとから変わりません。
下のコードは 02_answers.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if document not in DOCUMENTS or document in [a.document for a in state.answers]:
return state
detailed = not state.answers
answer = Answer(document, detailed, project(LEVEL.facts[LEVEL.truth], document, detailed))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_answers.py.venv/bin/python 02_answers.pyanswersが空のときだけ、細部の回答になる- 受け付けた順番を、タプルに残す
- もう提示した資料と知らない資料では、何も変わらない
- やり直しても、
LEVELの出来事は同じ
時計→地図→写真の順に見せて、DETAIL が最初の1行だけかどうかを確かめましょう。最後の Z をもう一度押しても、回答が3件のままであることも確かめます。次に R→↓→Z で写真を先に見せ、回答をノートに写して比べます。↓で資料を選んだだけで Z を押さずに地図へ戻ったとき、地図が細部になる理由も説明してみましょう。
このステップのコード全文(295行)
# MIT License
#
# Copyright (c) 2026
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""沈黙の証人・教材2/5。原作A固定、単独実行。架空の情報ルール。"""
from dataclasses import dataclass, asdict, replace
# 原作Aの公開候補。選ぶ順番でfactsやtruthは変わらない。
DOCUMENTS = ('map', 'photo', 'clock')
NAMES = {'map': '地図', 'photo': '写真', 'clock': '時計'}
PLACES = ('近い門', '近い机', '遠い門', '遠い机')
SHAPES = ('四角', '丸', '三角')
@dataclass(frozen=True)
class Fact:
place: int
shape: int
color: str
time: int
@dataclass(frozen=True)
class Level:
id: str
title: str
facts: tuple
truth: int
LEVEL = Level('A', '返された箱', (
Fact(0, 0, '赤', 0), Fact(1, 1, '赤', 0),
Fact(2, 2, '青', 2), Fact(3, 2, '青', 3)), 1)
def project(fact, document, detailed):
if document == 'map':
return ('ABCD'[fact.place] + '・' + PLACES[fact.place] if detailed
else ('近い側 A/B' if fact.place < 2 else '遠い側 C/D'))
if document == 'photo':
return SHAPES[fact.shape] if detailed else fact.color
if document == 'clock':
return str(fact.time) + '刻' if detailed else ('早い 0/1刻' if fact.time < 2 else '遅い 2/3刻')
raise ValueError('unknown document')
@dataclass(frozen=True)
class Answer:
document: str
detailed: bool
value: str
@dataclass(frozen=True)
class State:
answers: tuple = ()
feedback: str = '何を思い出してほしいか、最初の資料を選びましょう。'
def transition(state, action):
if action.startswith('show:'):
document = action[5:]
if document not in DOCUMENTS or document in [a.document for a in state.answers]:
return state
detailed = not state.answers
answer = Answer(document, detailed, project(LEVEL.facts[LEVEL.truth], document, detailed))
return replace(state, answers=state.answers + (answer,),
feedback=NAMES[document] + 'を提示: ' + answer.value + '。' +
('細部の回答。' if answer.detailed else '大分類の回答。'))
return state
STAGE = 2
STAGE_TITLE = 'FIRST DOCUMENT DETAIL'
CONTROLS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'A', 'RETURN', 'X', 'R')
REPLAY = {'U': 'UP', 'D': 'DOWN', 'L': 'LEFT', 'T': 'RIGHT', 'S': 'SPACE',
'Z': 'Z', 'A': 'A', 'E': 'RETURN', 'X': 'X', 'R': 'R'}
class Controller:
def __init__(self):
self.previous = set()
self.gate = False
self.reset()
def reset(self):
self.state = State()
self.document = 0
self.candidate = 0
self.notice = 'ready'
def apply(self, action):
self.state = transition(self.state, action)
return action
def feed(self, down):
# 教材は同時押しを拒否。全操作キーが離れるまで次を受け付けない。
down = set(down) & set(CONTROLS)
fresh = down - self.previous
self.previous = down
if self.gate:
if not down:
self.gate = False
return None
if not fresh:
return None
self.gate = True
if len(fresh) != 1:
self.notice = 'ignored:multiple'
return self.notice
self.notice = self.handle(next(iter(fresh)))
return self.notice
def handle(self, key):
if key == 'R':
self.reset()
return 'reset'
if key in ('UP', 'DOWN'):
self.document = (self.document + (-1 if key == 'UP' else 1)) % 3
return 'document'
if key in ('LEFT', 'RIGHT'):
self.candidate = (self.candidate + (-1 if key == 'LEFT' else 1)) % 4
return 'candidate'
if key == 'Z':
return self.apply('show:' + DOCUMENTS[self.document])
return 'ignored:key'
def snapshot(self):
data = {'state': asdict(self.state), 'document': self.document,
'candidate': self.candidate, 'notice': self.notice}
return data
# 日本語の本文/判定は上に保持。ウィンドウだけ標準フォントで読めるASCII訳。
DISPLAY = {
'A・近い門': 'A / NEAR GATE', 'B・近い机': 'B / NEAR DESK',
'C・遠い門': 'C / FAR GATE', 'D・遠い机': 'D / FAR DESK',
'近い側 A/B': 'NEAR A/B', '遠い側 C/D': 'FAR C/D',
'四角': 'SQUARE', '丸': 'CIRCLE', '三角': 'TRIANGLE', '赤': 'RED', '青': 'BLUE',
'0刻': 'TIME 0', '1刻': 'TIME 1', '2刻': 'TIME 2', '3刻': 'TIME 3',
'早い 0/1刻': 'EARLY 0/1', '遅い 2/3刻': 'LATE 2/3',
}
def wrap(p, x, y, text, width=96, color=7):
import textwrap
for row, line in enumerate(textwrap.wrap(text, width=width)):
p.text(x, y + row * 11, line, color)
def draw_shape(p, x, y, fact):
color = 8 if fact.color == '赤' else 12
if fact.shape == 0:
p.rect(x, y, 12, 12, color)
elif fact.shape == 1:
p.circ(x + 6, y + 6, 6, color)
else:
p.tri(x + 6, y, x, y + 12, x + 12, y + 12, color)
def answer_reason_ascii(state, candidate):
for answer in state.answers:
got = project(LEVEL.facts[candidate], answer.document, answer.detailed)
if got != answer.value:
return answer.document.upper() + ': heard ' + DISPLAY[answer.value] + ', but H' + str(candidate + 1) + ' gives ' + DISPLAY[got]
return 'No conflict with known answers. This candidate cannot yet be removed.'
def draw_board(p, c):
state = c.state
p.cls(1)
p.text(14, 12, 'SILENT WITNESS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
p.text(14, 29, 'FICTIONAL RULE PUZZLE. Not a model of real witnesses or legal interviews.', 6)
p.text(14, 44, 'CASE A / RETURNED BOX. The event stays fixed; only available detail changes.', 6)
for i, document in enumerate(DOCUMENTS):
x = 14 + i * 216
p.rect(x, 65, 207, 61, 2)
p.rectb(x, 65, 207, 61, 10 if c.document == i else 5)
p.text(x + 9, 74, document.upper(), 7)
done = any(a.document == document for a in state.answers)
p.text(x + 9, 92, 'ALREADY SHOWN' if done else 'Z: SHOW THIS DOCUMENT', 6 if done else 7)
p.text(x + 9, 110, 'NEXT: ' + ('DETAIL' if not state.answers else 'CATEGORY'), 11)
p.text(14, 141, 'PUBLIC POSSIBLE WORLDS / LEFT-RIGHT: SELECT H1-H4', 10)
for i, fact in enumerate(LEVEL.facts):
y = 158 + i * 36
p.rect(14, y, 632, 32, 0)
p.rectb(14, y, 632, 32, 10 if c.candidate == i else 5)
draw_shape(p, 35, y + 9, fact)
p.text(61, y + 7, 'H' + str(i + 1) + ' ' + DISPLAY[project(fact, 'map', True)], 7)
p.text(198, y + 7, DISPLAY[fact.color] + ' ' + DISPLAY[project(fact, 'photo', True)], 7)
p.text(317, y + 7, DISPLAY[project(fact, 'clock', True)], 7)
p.text(405, y + 7, 'PUBLIC POSSIBILITY', 6)
p.text(14, 311, 'RECEIVED ANSWERS / FIRST DETAIL, THEN CATEGORIES', 10)
for i in range(3):
y = 330 + i * 18
if i < len(state.answers):
answer = state.answers[i]
text = str(i + 1) + '. ' + answer.document.upper() + ' / ' + ('DETAIL' if answer.detailed else 'CATEGORY') + ' -> ' + DISPLAY[answer.value]
p.text(22, y, text, 7)
else:
p.text(22, y, str(i + 1) + '. Not yet shown', 5)
wrap(p, 14, 398, 'Show CLOCK first: TIME 0. Then MAP: NEAR A/B. Then PHOTO: RED. Showing the same document again adds nothing.')
wrap(p, 14, 443, 'R repeats the same case. Show PHOTO first instead: CIRCLE. The event did not change; the available answer changed.')
p.rect(14, 479, 632, 23, 5)
p.text(22, 487, 'LAST KEY: ' + c.notice, 7)
p.text(14, 513, 'UP/DOWN: DOC Z: SHOW LEFT/RIGHT: CANDIDATE', 6)
p.text(14, 529, 'R: RESET SAME CASE ESC: QUIT TAKE YOUR TIME; NO COUNTDOWN', 6)
def main():
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-replay', metavar='KEYS')
parser.add_argument('--screenshot', type=Path)
parser.add_argument('--report', type=Path)
args = parser.parse_args()
# 引数エラーはPyxelのimportより先。モデル試験は標準ライブラリだけで動く。
if args.headless_replay is None and (args.screenshot or args.report):
parser.error('output paths require --headless-replay')
if args.headless_replay is not None and any(c not in REPLAY for c in args.headless_replay):
parser.error('replay keys must use: ' + ''.join(REPLAY))
if args.headless_replay is not None and len(args.headless_replay) > 1000:
parser.error('at most 1000 replay keys')
args.screenshot = args.screenshot.resolve() if args.screenshot else None
args.report = args.report.resolve() if args.report else None
import pyxel
c = Controller()
frames = 0
events = []
sequence = args.headless_replay
limit = max(12, len(sequence or '') * 2 + 2)
mapping = {name: getattr(pyxel, 'KEY_' + name) for name in CONTROLS}
pyxel.init(660, 550, title='069 Silent Witness - Stage ' + str(STAGE), fps=30,
display_scale=1, quit_key=pyxel.KEY_NONE, headless=sequence is not None)
def update():
nonlocal frames
if pyxel.btnp(pyxel.KEY_ESCAPE):
pyxel.quit()
return
if sequence is None:
down = {name for name, key in mapping.items() if pyxel.btn(key)}
elif frames % 2 == 0 and frames // 2 < len(sequence):
down = {REPLAY[sequence[frames // 2]]}
else:
down = set()
event = c.feed(down)
if event is not None:
events.append({'frame': frames, 'result': event, 'snapshot': c.snapshot()})
frames += 1
def draw():
draw_board(pyxel, c)
if sequence is not None and frames >= limit:
if args.screenshot:
args.screenshot.parent.mkdir(parents=True, exist_ok=True)
pyxel.screenshot(str(args.screenshot), scale=1)
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps({'stage': STAGE, 'actions': sequence,
'frames': frames, 'events': events, 'snapshot': c.snapshot()},
ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.run(update, draw)
if __name__ == '__main__':
main()聞いた回答だけで候補を絞る
all で「全部満たす(AND)」を調べる、候補の集合、ビットマスク、条件が空のとき、合わない理由(反例)の説明。
compatible は、4つの候補それぞれについて、「受け取った回答を全部説明できるか」を調べます。候補ごとに、各 Answer が記録している document(資料)と detailed(細部かどうか)をそのまま project に渡し、value(回答の値)と同じかを比べます。all を使うので、1つでもちがえば候補から外れます。まだ回答がないときは、all に空の並びを渡すことになり、結果は True です。そのため、4つとも残ります。戻り値は 1<<i(1を i ビット左にずらした数)の合計です。H1は1、H2は2、H3は4、H4は8です。15は全部、3はH1とH2、2はH2だけを表します。reason は、受け取った順に見て、最初の矛盾を説明します。これは、決まった表と回答を照らし合わせる仕組みです。自由な文章を読んで、うそを見抜く仕組みではありません。
下のコードは 03_consistency.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
return sum(1 << i for i, fact in enumerate(LEVEL.facts)
if all(project(fact, a.document, a.detailed) == a.value for a in answers))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_consistency.py.venv/bin/python 03_consistency.py- 回答を全部満たす(AND)候補だけを残す
- まだ見せていない資料の情報は使わない
- 回答が空なら、すべての候補が残る
- 候補が複数残るのは、解けなかった失敗ではなく、今の情報で分かる範囲
any で条件をつなぐと、1つだけ合う候補まで残ってしまいます。sum には候補の番号そのものではなく、1<<i を足すことにも気をつけます。↓→↓→Z→↓→Z→↓→Z と押して全部の資料を見せ、H1とH2が残ることを確かめましょう。→を2回押してH3を選び、理由を読みます。R のあと、何も見せずにH1を選んだときに「No conflict」と表示される理由を説明してみましょう。最後に、写真を先に見せて、その回答1つだけでH2だけになる手順を試します。
このステップのコード全文(311行)
# MIT License
#
# Copyright (c) 2026
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""沈黙の証人・教材3/5。原作A固定、単独実行。架空の情報ルール。"""
from dataclasses import dataclass, asdict, replace
# 原作Aの公開候補。選ぶ順番でfactsやtruthは変わらない。
DOCUMENTS = ('map', 'photo', 'clock')
NAMES = {'map': '地図', 'photo': '写真', 'clock': '時計'}
PLACES = ('近い門', '近い机', '遠い門', '遠い机')
SHAPES = ('四角', '丸', '三角')
@dataclass(frozen=True)
class Fact:
place: int
shape: int
color: str
time: int
@dataclass(frozen=True)
class Level:
id: str
title: str
facts: tuple
truth: int
LEVEL = Level('A', '返された箱', (
Fact(0, 0, '赤', 0), Fact(1, 1, '赤', 0),
Fact(2, 2, '青', 2), Fact(3, 2, '青', 3)), 1)
def project(fact, document, detailed):
if document == 'map':
return ('ABCD'[fact.place] + '・' + PLACES[fact.place] if detailed
else ('近い側 A/B' if fact.place < 2 else '遠い側 C/D'))
if document == 'photo':
return SHAPES[fact.shape] if detailed else fact.color
if document == 'clock':
return str(fact.time) + '刻' if detailed else ('早い 0/1刻' if fact.time < 2 else '遅い 2/3刻')
raise ValueError('unknown document')
@dataclass(frozen=True)
class Answer:
document: str
detailed: bool
value: str
@dataclass(frozen=True)
class State:
answers: tuple = ()
feedback: str = '何を思い出してほしいか、最初の資料を選びましょう。'
def compatible(answers):
# 空のallはTrue。まだ聞かなければ4候補とも除外できない。
return sum(1 << i for i, fact in enumerate(LEVEL.facts)
if all(project(fact, a.document, a.detailed) == a.value for a in answers))
def reason(state, candidate):
for answer in state.answers:
got = project(LEVEL.facts[candidate], answer.document, answer.detailed)
if got != answer.value:
return NAMES[answer.document] + 'の答え「' + answer.value + '」と候補「' + got + '」が違う'
return '聞いた回答と矛盾なし。まだ除外できません。'
def transition(state, action):
if action.startswith('show:'):
document = action[5:]
if document not in DOCUMENTS or document in [a.document for a in state.answers]:
return state
detailed = not state.answers
answer = Answer(document, detailed, project(LEVEL.facts[LEVEL.truth], document, detailed))
return replace(state, answers=state.answers + (answer,),
feedback=NAMES[document] + 'を提示: ' + answer.value + '。' +
('細部の回答。' if answer.detailed else '大分類の回答。'))
return state
STAGE = 3
STAGE_TITLE = 'FILTER KNOWN ANSWERS'
CONTROLS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'A', 'RETURN', 'X', 'R')
REPLAY = {'U': 'UP', 'D': 'DOWN', 'L': 'LEFT', 'T': 'RIGHT', 'S': 'SPACE',
'Z': 'Z', 'A': 'A', 'E': 'RETURN', 'X': 'X', 'R': 'R'}
class Controller:
def __init__(self):
self.previous = set()
self.gate = False
self.reset()
def reset(self):
self.state = State()
self.document = 0
self.candidate = 0
self.notice = 'ready'
def apply(self, action):
self.state = transition(self.state, action)
return action
def feed(self, down):
# 教材は同時押しを拒否。全操作キーが離れるまで次を受け付けない。
down = set(down) & set(CONTROLS)
fresh = down - self.previous
self.previous = down
if self.gate:
if not down:
self.gate = False
return None
if not fresh:
return None
self.gate = True
if len(fresh) != 1:
self.notice = 'ignored:multiple'
return self.notice
self.notice = self.handle(next(iter(fresh)))
return self.notice
def handle(self, key):
if key == 'R':
self.reset()
return 'reset'
if key in ('UP', 'DOWN'):
self.document = (self.document + (-1 if key == 'UP' else 1)) % 3
return 'document'
if key in ('LEFT', 'RIGHT'):
self.candidate = (self.candidate + (-1 if key == 'LEFT' else 1)) % 4
return 'candidate'
if key == 'Z':
return self.apply('show:' + DOCUMENTS[self.document])
return 'ignored:key'
def snapshot(self):
data = {'state': asdict(self.state), 'document': self.document,
'candidate': self.candidate, 'notice': self.notice}
data['compatible'] = compatible(self.state.answers)
return data
# 日本語の本文/判定は上に保持。ウィンドウだけ標準フォントで読めるASCII訳。
DISPLAY = {
'A・近い門': 'A / NEAR GATE', 'B・近い机': 'B / NEAR DESK',
'C・遠い門': 'C / FAR GATE', 'D・遠い机': 'D / FAR DESK',
'近い側 A/B': 'NEAR A/B', '遠い側 C/D': 'FAR C/D',
'四角': 'SQUARE', '丸': 'CIRCLE', '三角': 'TRIANGLE', '赤': 'RED', '青': 'BLUE',
'0刻': 'TIME 0', '1刻': 'TIME 1', '2刻': 'TIME 2', '3刻': 'TIME 3',
'早い 0/1刻': 'EARLY 0/1', '遅い 2/3刻': 'LATE 2/3',
}
def wrap(p, x, y, text, width=96, color=7):
import textwrap
for row, line in enumerate(textwrap.wrap(text, width=width)):
p.text(x, y + row * 11, line, color)
def draw_shape(p, x, y, fact):
color = 8 if fact.color == '赤' else 12
if fact.shape == 0:
p.rect(x, y, 12, 12, color)
elif fact.shape == 1:
p.circ(x + 6, y + 6, 6, color)
else:
p.tri(x + 6, y, x, y + 12, x + 12, y + 12, color)
def answer_reason_ascii(state, candidate):
for answer in state.answers:
got = project(LEVEL.facts[candidate], answer.document, answer.detailed)
if got != answer.value:
return answer.document.upper() + ': heard ' + DISPLAY[answer.value] + ', but H' + str(candidate + 1) + ' gives ' + DISPLAY[got]
return 'No conflict with known answers. This candidate cannot yet be removed.'
def draw_board(p, c):
state = c.state
p.cls(1)
p.text(14, 12, 'SILENT WITNESS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
p.text(14, 29, 'FICTIONAL RULE PUZZLE. Not a model of real witnesses or legal interviews.', 6)
p.text(14, 44, 'CASE A / RETURNED BOX. The event stays fixed; only available detail changes.', 6)
for i, document in enumerate(DOCUMENTS):
x = 14 + i * 216
p.rect(x, 65, 207, 61, 2)
p.rectb(x, 65, 207, 61, 10 if c.document == i else 5)
p.text(x + 9, 74, document.upper(), 7)
done = any(a.document == document for a in state.answers)
p.text(x + 9, 92, 'ALREADY SHOWN' if done else 'Z: SHOW THIS DOCUMENT', 6 if done else 7)
p.text(x + 9, 110, 'NEXT: ' + ('DETAIL' if not state.answers else 'CATEGORY'), 11)
p.text(14, 141, 'PUBLIC POSSIBLE WORLDS / LEFT-RIGHT: SELECT H1-H4', 10)
for i, fact in enumerate(LEVEL.facts):
y = 158 + i * 36
p.rect(14, y, 632, 32, 0)
p.rectb(14, y, 632, 32, 10 if c.candidate == i else 5)
draw_shape(p, 35, y + 9, fact)
p.text(61, y + 7, 'H' + str(i + 1) + ' ' + DISPLAY[project(fact, 'map', True)], 7)
p.text(198, y + 7, DISPLAY[fact.color] + ' ' + DISPLAY[project(fact, 'photo', True)], 7)
p.text(317, y + 7, DISPLAY[project(fact, 'clock', True)], 7)
consistent = bool(compatible(state.answers) & (1 << i))
p.text(405, y + 7, 'CONSISTENT' if consistent else 'CONFLICT', 11 if consistent else 8)
p.text(14, 311, 'RECEIVED ANSWERS / FIRST DETAIL, THEN CATEGORIES', 10)
for i in range(3):
y = 330 + i * 18
if i < len(state.answers):
answer = state.answers[i]
text = str(i + 1) + '. ' + answer.document.upper() + ' / ' + ('DETAIL' if answer.detailed else 'CATEGORY') + ' -> ' + DISPLAY[answer.value]
p.text(22, y, text, 7)
else:
p.text(22, y, str(i + 1) + '. Not yet shown', 5)
p.text(14, 392, 'H' + str(c.candidate + 1) + ' CHECK: ' + answer_reason_ascii(state, c.candidate), 6)
wrap(p, 14, 419, 'Green means compatible with every known answer. Empty answers keep all four. Do not use an unshown detail to remove a possibility.')
wrap(p, 14, 451, 'CLOCK -> MAP -> PHOTO keeps H1 and H2. The true event is not the same thing as what the available evidence can establish.')
p.rect(14, 479, 632, 23, 5)
p.text(22, 487, 'LAST KEY: ' + c.notice, 7)
p.text(14, 513, 'UP/DOWN: DOC Z: SHOW LEFT/RIGHT: CANDIDATE', 6)
p.text(14, 529, 'R: RESET SAME CASE ESC: QUIT TAKE YOUR TIME; NO COUNTDOWN', 6)
def main():
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-replay', metavar='KEYS')
parser.add_argument('--screenshot', type=Path)
parser.add_argument('--report', type=Path)
args = parser.parse_args()
# 引数エラーはPyxelのimportより先。モデル試験は標準ライブラリだけで動く。
if args.headless_replay is None and (args.screenshot or args.report):
parser.error('output paths require --headless-replay')
if args.headless_replay is not None and any(c not in REPLAY for c in args.headless_replay):
parser.error('replay keys must use: ' + ''.join(REPLAY))
if args.headless_replay is not None and len(args.headless_replay) > 1000:
parser.error('at most 1000 replay keys')
args.screenshot = args.screenshot.resolve() if args.screenshot else None
args.report = args.report.resolve() if args.report else None
import pyxel
c = Controller()
frames = 0
events = []
sequence = args.headless_replay
limit = max(12, len(sequence or '') * 2 + 2)
mapping = {name: getattr(pyxel, 'KEY_' + name) for name in CONTROLS}
pyxel.init(660, 550, title='069 Silent Witness - Stage ' + str(STAGE), fps=30,
display_scale=1, quit_key=pyxel.KEY_NONE, headless=sequence is not None)
def update():
nonlocal frames
if pyxel.btnp(pyxel.KEY_ESCAPE):
pyxel.quit()
return
if sequence is None:
down = {name for name, key in mapping.items() if pyxel.btn(key)}
elif frames % 2 == 0 and frames // 2 < len(sequence):
down = {REPLAY[sequence[frames // 2]]}
else:
down = set()
event = c.feed(down)
if event is not None:
events.append({'frame': frames, 'result': event, 'snapshot': c.snapshot()})
frames += 1
def draw():
draw_board(pyxel, c)
if sequence is not None and frames >= limit:
if args.screenshot:
args.screenshot.parent.mkdir(parents=True, exist_ok=True)
pyxel.screenshot(str(args.screenshot), scale=1)
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps({'stage': STAGE, 'actions': sequence,
'frames': frames, 'events': events, 'snapshot': c.snapshot()},
ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.run(update, draw)
if __name__ == '__main__':
main()残す集合と根拠の集合を照合して報告する
回答から求めた集合と自分で選んだ集合、XOR での切り替え、完全な一致、全部見せる前の報告、2種類の成功、終わった状態。
selected は、自分が報告に残す候補のビットマスクです。compatible が計算した集合とは別に持ちます。←→で候補を選び、Space で 1<<candidate のビットを XOR(そのビットだけを反転させる計算)して、残す・外すを切り替えます。A は補助のキーです。今の compatible の結果を selected に写すだけで、新しい資料は読みません。Enter で成功になる条件は、少なくとも1枚を提示していて、selected==compatible(answers) であることです。候補が1つなら specific(特定)、複数なら bounded(しぼりこみ)という、どちらも正式な成功です。元のゲームと同じく、資料を全部見せる必要はありません。時計1枚だけで、H1とH2を報告することも認められます。真相のH2だけを勘で選ぶと、まだ否定できないH1を落とすので失敗です。
下のコードは 04_report.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
mask = compatible(state.answers)
count = mask.bit_count()
ok = state.selected == mask実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_report.py.venv/bin/python 04_report.py- 自分で選んだ集合と、回答から計算した集合を分ける
- 報告は、2つの集合が完全に一致したときだけ成功
- 最低1枚は見せる。全部見せる必要はない
- 1つに特定しても、複数にしぼりこんでも成功
selected が compatible の部分集合(一部)であればよい、という判定では足りません。まだ残っている可能性も、全部報告する必要があります。一方で、真相とちがうH1を残しただけで不正解にすると、このゲームの「集合で報告する」という目的がなくなってしまいます。bounded を、まだ終わっていない状態として扱わないようにしましょう。時計を最初に見せたあと、→→でH3を選んで Space で外し、→でH4を選んで Space で外してから Enter を押します。A を使わなくても、H1とH2の正式な報告になることを確かめましょう。次に、R→時計→Enter で、矛盾する候補が残っているために受け付けられない場合を見ます。候補を削りすぎて受け付けられないときと、残しすぎて受け付けられないときのちがいを、selected と mask の2つの数で説明してみましょう。
このステップのコード全文(357行)
# MIT License
#
# Copyright (c) 2026
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""沈黙の証人・教材4/5。原作A固定、単独実行。架空の情報ルール。"""
from dataclasses import dataclass, asdict, replace
# 原作Aの公開候補。選ぶ順番でfactsやtruthは変わらない。
DOCUMENTS = ('map', 'photo', 'clock')
NAMES = {'map': '地図', 'photo': '写真', 'clock': '時計'}
PLACES = ('近い門', '近い机', '遠い門', '遠い机')
SHAPES = ('四角', '丸', '三角')
@dataclass(frozen=True)
class Fact:
place: int
shape: int
color: str
time: int
@dataclass(frozen=True)
class Level:
id: str
title: str
facts: tuple
truth: int
LEVEL = Level('A', '返された箱', (
Fact(0, 0, '赤', 0), Fact(1, 1, '赤', 0),
Fact(2, 2, '青', 2), Fact(3, 2, '青', 3)), 1)
def project(fact, document, detailed):
if document == 'map':
return ('ABCD'[fact.place] + '・' + PLACES[fact.place] if detailed
else ('近い側 A/B' if fact.place < 2 else '遠い側 C/D'))
if document == 'photo':
return SHAPES[fact.shape] if detailed else fact.color
if document == 'clock':
return str(fact.time) + '刻' if detailed else ('早い 0/1刻' if fact.time < 2 else '遅い 2/3刻')
raise ValueError('unknown document')
@dataclass(frozen=True)
class Answer:
document: str
detailed: bool
value: str
@dataclass(frozen=True)
class State:
answers: tuple = ()
selected: int = 15
result: str = ''
feedback: str = '何を思い出してほしいか、最初の資料を選びましょう。'
reports: tuple = ()
def compatible(answers):
# 空のallはTrue。まだ聞かなければ4候補とも除外できない。
return sum(1 << i for i, fact in enumerate(LEVEL.facts)
if all(project(fact, a.document, a.detailed) == a.value for a in answers))
def reason(state, candidate):
for answer in state.answers:
got = project(LEVEL.facts[candidate], answer.document, answer.detailed)
if got != answer.value:
return NAMES[answer.document] + 'の答え「' + answer.value + '」と候補「' + got + '」が違う'
return '聞いた回答と矛盾なし。まだ除外できません。'
def transition(state, action):
if state.result:
return state
if action.startswith('show:'):
document = action[5:]
if document not in DOCUMENTS or document in [a.document for a in state.answers]:
return state
detailed = not state.answers
answer = Answer(document, detailed, project(LEVEL.facts[LEVEL.truth], document, detailed))
return replace(state, answers=state.answers + (answer,),
feedback=NAMES[document] + 'を提示: ' + answer.value + '。' +
('細部の回答。' if answer.detailed else '大分類の回答。'))
if action.startswith('toggle:'):
try:
candidate = int(action[7:])
except ValueError:
return state
if 0 <= candidate < 4:
return replace(state, selected=state.selected ^ (1 << candidate),
feedback='候補に線を引きました。Enterで根拠と照合。')
return state
if action == 'assist':
return replace(state, selected=compatible(state.answers),
feedback='補助で回答と両立する候補を残しました。理由欄で確認できます。')
if action == 'submit':
if not state.answers:
return replace(state, feedback='資料を一枚以上示してから報告してください。')
mask = compatible(state.answers)
count = mask.bit_count()
ok = state.selected == mask
if ok:
message = '根拠に沿った特定。' if count == 1 else '根拠に沿った報告。この証言では' + str(count) + '件まで。'
else:
message = ('まだ否定できない候補があります。' if mask & ~state.selected
else '回答と矛盾する候補が残っています。')
return replace(state, result=('specific' if count == 1 else 'bounded') if ok else '',
feedback=message, reports=state.reports + ((state.selected, mask, ok),))
return state
STAGE = 4
STAGE_TITLE = 'REPORT THE WHOLE SET'
CONTROLS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'A', 'RETURN', 'X', 'R')
REPLAY = {'U': 'UP', 'D': 'DOWN', 'L': 'LEFT', 'T': 'RIGHT', 'S': 'SPACE',
'Z': 'Z', 'A': 'A', 'E': 'RETURN', 'X': 'X', 'R': 'R'}
class Controller:
def __init__(self):
self.previous = set()
self.gate = False
self.reset()
def reset(self):
self.state = State()
self.document = 0
self.candidate = 0
self.notice = 'ready'
def apply(self, action):
self.state = transition(self.state, action)
return action
def feed(self, down):
# 教材は同時押しを拒否。全操作キーが離れるまで次を受け付けない。
down = set(down) & set(CONTROLS)
fresh = down - self.previous
self.previous = down
if self.gate:
if not down:
self.gate = False
return None
if not fresh:
return None
self.gate = True
if len(fresh) != 1:
self.notice = 'ignored:multiple'
return self.notice
self.notice = self.handle(next(iter(fresh)))
return self.notice
def handle(self, key):
if key == 'R':
self.reset()
return 'reset'
if self.state.result:
return 'ignored:finished'
if key in ('UP', 'DOWN'):
self.document = (self.document + (-1 if key == 'UP' else 1)) % 3
return 'document'
if key in ('LEFT', 'RIGHT'):
self.candidate = (self.candidate + (-1 if key == 'LEFT' else 1)) % 4
return 'candidate'
if key == 'Z':
return self.apply('show:' + DOCUMENTS[self.document])
if key == 'SPACE':
return self.apply('toggle:' + str(self.candidate))
if key == 'A':
return self.apply('assist')
if key == 'RETURN':
return self.apply('submit')
return 'ignored:key'
def snapshot(self):
data = {'state': asdict(self.state), 'document': self.document,
'candidate': self.candidate, 'notice': self.notice}
data['compatible'] = compatible(self.state.answers)
return data
# 日本語の本文/判定は上に保持。ウィンドウだけ標準フォントで読めるASCII訳。
DISPLAY = {
'A・近い門': 'A / NEAR GATE', 'B・近い机': 'B / NEAR DESK',
'C・遠い門': 'C / FAR GATE', 'D・遠い机': 'D / FAR DESK',
'近い側 A/B': 'NEAR A/B', '遠い側 C/D': 'FAR C/D',
'四角': 'SQUARE', '丸': 'CIRCLE', '三角': 'TRIANGLE', '赤': 'RED', '青': 'BLUE',
'0刻': 'TIME 0', '1刻': 'TIME 1', '2刻': 'TIME 2', '3刻': 'TIME 3',
'早い 0/1刻': 'EARLY 0/1', '遅い 2/3刻': 'LATE 2/3',
}
def wrap(p, x, y, text, width=96, color=7):
import textwrap
for row, line in enumerate(textwrap.wrap(text, width=width)):
p.text(x, y + row * 11, line, color)
def draw_shape(p, x, y, fact):
color = 8 if fact.color == '赤' else 12
if fact.shape == 0:
p.rect(x, y, 12, 12, color)
elif fact.shape == 1:
p.circ(x + 6, y + 6, 6, color)
else:
p.tri(x + 6, y, x, y + 12, x + 12, y + 12, color)
def answer_reason_ascii(state, candidate):
for answer in state.answers:
got = project(LEVEL.facts[candidate], answer.document, answer.detailed)
if got != answer.value:
return answer.document.upper() + ': heard ' + DISPLAY[answer.value] + ', but H' + str(candidate + 1) + ' gives ' + DISPLAY[got]
return 'No conflict with known answers. This candidate cannot yet be removed.'
def draw_board(p, c):
state = c.state
p.cls(1)
p.text(14, 12, 'SILENT WITNESS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
p.text(14, 29, 'FICTIONAL RULE PUZZLE. Not a model of real witnesses or legal interviews.', 6)
p.text(14, 44, 'CASE A / RETURNED BOX. The event stays fixed; only available detail changes.', 6)
for i, document in enumerate(DOCUMENTS):
x = 14 + i * 216
p.rect(x, 65, 207, 61, 2)
p.rectb(x, 65, 207, 61, 10 if c.document == i else 5)
p.text(x + 9, 74, document.upper(), 7)
done = any(a.document == document for a in state.answers)
p.text(x + 9, 92, 'ALREADY SHOWN' if done else 'Z: SHOW THIS DOCUMENT', 6 if done else 7)
p.text(x + 9, 110, 'NEXT: ' + ('DETAIL' if not state.answers else 'CATEGORY'), 11)
p.text(14, 141, 'PUBLIC POSSIBLE WORLDS / LEFT-RIGHT: SELECT H1-H4', 10)
for i, fact in enumerate(LEVEL.facts):
y = 158 + i * 36
p.rect(14, y, 632, 32, 0)
p.rectb(14, y, 632, 32, 10 if c.candidate == i else 5)
draw_shape(p, 35, y + 9, fact)
p.text(61, y + 7, 'H' + str(i + 1) + ' ' + DISPLAY[project(fact, 'map', True)], 7)
p.text(198, y + 7, DISPLAY[fact.color] + ' ' + DISPLAY[project(fact, 'photo', True)], 7)
p.text(317, y + 7, DISPLAY[project(fact, 'clock', True)], 7)
consistent = bool(compatible(state.answers) & (1 << i))
p.text(405, y + 7, 'CONSISTENT' if consistent else 'CONFLICT', 11 if consistent else 8)
p.text(538, y + 7, '[KEEP]' if state.selected & (1 << i) else '[OUT]', 10)
p.text(14, 311, 'RECEIVED ANSWERS / FIRST DETAIL, THEN CATEGORIES', 10)
for i in range(3):
y = 330 + i * 18
if i < len(state.answers):
answer = state.answers[i]
text = str(i + 1) + '. ' + answer.document.upper() + ' / ' + ('DETAIL' if answer.detailed else 'CATEGORY') + ' -> ' + DISPLAY[answer.value]
p.text(22, y, text, 7)
else:
p.text(22, y, str(i + 1) + '. Not yet shown', 5)
p.text(14, 392, 'H' + str(c.candidate + 1) + ' CHECK: ' + answer_reason_ascii(state, c.candidate), 6)
if state.result:
p.text(14, 416, 'REPORT ACCEPTED: ' + state.result.upper() + ' / ' + str(compatible(state.answers).bit_count()) + ' candidate(s)', 11)
elif state.feedback == '資料を一枚以上示してから報告してください。':
p.text(14, 416, 'SHOW AT LEAST ONE DOCUMENT BEFORE REPORTING.', 8)
elif state.reports and not state.reports[-1][2] and state.feedback in ('まだ否定できない候補があります。', '回答と矛盾する候補が残っています。'):
selected, mask, ok = state.reports[-1]
p.text(14, 416, 'REPORT REJECTED: ' + ('a still-possible candidate was removed.' if mask & ~selected else 'a conflicting candidate remains.'), 8)
else:
p.text(14, 416, 'Report ALL consistent candidates. One or more can be correct.', 11)
p.text(14, 448, 'A copies the evidence set into KEEP. ENTER checks exact equality.', 6)
p.rect(14, 479, 632, 23, 5)
p.text(22, 487, 'LAST KEY: ' + c.notice, 7)
p.text(14, 513, 'UP/DOWN: DOC Z: SHOW LEFT/RIGHT: CANDIDATE SPACE: KEEP A: ASSIST ENTER: REPORT', 6)
p.text(14, 529, 'R: RESET SAME CASE ESC: QUIT TAKE YOUR TIME; NO COUNTDOWN', 6)
def main():
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-replay', metavar='KEYS')
parser.add_argument('--screenshot', type=Path)
parser.add_argument('--report', type=Path)
args = parser.parse_args()
# 引数エラーはPyxelのimportより先。モデル試験は標準ライブラリだけで動く。
if args.headless_replay is None and (args.screenshot or args.report):
parser.error('output paths require --headless-replay')
if args.headless_replay is not None and any(c not in REPLAY for c in args.headless_replay):
parser.error('replay keys must use: ' + ''.join(REPLAY))
if args.headless_replay is not None and len(args.headless_replay) > 1000:
parser.error('at most 1000 replay keys')
args.screenshot = args.screenshot.resolve() if args.screenshot else None
args.report = args.report.resolve() if args.report else None
import pyxel
c = Controller()
frames = 0
events = []
sequence = args.headless_replay
limit = max(12, len(sequence or '') * 2 + 2)
mapping = {name: getattr(pyxel, 'KEY_' + name) for name in CONTROLS}
pyxel.init(660, 550, title='069 Silent Witness - Stage ' + str(STAGE), fps=30,
display_scale=1, quit_key=pyxel.KEY_NONE, headless=sequence is not None)
def update():
nonlocal frames
if pyxel.btnp(pyxel.KEY_ESCAPE):
pyxel.quit()
return
if sequence is None:
down = {name for name, key in mapping.items() if pyxel.btn(key)}
elif frames % 2 == 0 and frames // 2 < len(sequence):
down = {REPLAY[sequence[frames // 2]]}
else:
down = set()
event = c.feed(down)
if event is not None:
events.append({'frame': frames, 'result': event, 'snapshot': c.snapshot()})
frames += 1
def draw():
draw_board(pyxel, c)
if sequence is not None and frames >= limit:
if args.screenshot:
args.screenshot.parent.mkdir(parents=True, exist_ok=True)
pyxel.screenshot(str(args.screenshot), scale=1)
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps({'stage': STAGE, 'actions': sequence,
'frames': frames, 'events': events, 'snapshot': c.snapshot()},
ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.run(update, draw)
if __name__ == '__main__':
main()知った情報も報告もまとめてUndoする
Timeline、状態が変わったときだけの履歴、書き換えないスナップショット(その時点の状態の写し)、終わった状態から戻る、上限のある記録と再現。
最後に Timeline を追加し、変更する前の State を past に積みます。state には、回答、選んだ集合、結果、フィードバック(画面に出す説明)、報告の履歴が入っています。そのため X は、それらをまとめて戻します。選択のビットだけを戻すのではありません。同じ資料をもう一度見せたときのように、State が変わらないなら、履歴は増やしません。一方、まちがった報告でも、feedback や reports が変われば履歴に入ります。元のゲームと同じく、1回の挑戦で保存できるのは、状態の変更128回までです。上限に達していても undo を先に処理するので、1つ戻せば、また新しい変更ができます。画面(UI)の資料と候補のカーソルは Timeline の外にあるので、X では戻りません。replay は、意味のある入力(意味入力)を最大512件受け取ります。そして、形をそろえた JSON(正規化 JSON)と SHA-256(データから決まった短い値を作る方法)で、同じ手順の最後の状態を比べます。
下のコードは 05_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if action == 'undo':
return Timeline(timeline.past[-1], timeline.past[:-1]) if timeline.past else timeline
if len(timeline.past) >= 128:
return timeline
new_state = transition(timeline.state, action)
return Timeline(new_state, timeline.past + (timeline.state,)) if new_state != timeline.state else timeline実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_undo.py.venv/bin/python 05_undo.pybounded の成功になります。R→↓→↓→Z→↓→X では、時計の回答が取り消されます。カーソルは、移動したあとの地図のままです。ここで Z を押すと、最初の回答として、地図の細部 B / NEAR DESK が得られます。提示を取り消すことは、「知った情報」を戻すことでもあります。State全体を、過去の値に戻す- 何も変わらない操作は、履歴に積まない
- 成功したあとや、上限に達したあとも Undo できる
- カーソルと、知っている情報(状態)を区別する
- 元のゲームの事件Aの状態の移り変わり(遷移)と、毎回同じになる記録を照らし合わせる
frozen の dataclass とタプルを使い、変更する前の値を保存します。また、成功した状態で、先に全部の操作を受け付けないようにすると、X まで使えなくなります。undo と R は、終わった状態で操作を止める処理(終端ロック)より前に扱います。受け付けなかった入力をすべて履歴に積むのも、元のゲームとはちがいます。R→↓→↓→Z→Z(同じ時計をもう一度見せる)と押して、UNDO HISTORY が1のままかを確かめましょう。X を1回押すと、回答が空になるはずです。次に、時計→Enter で、候補を残しすぎて受け付けられない報告をします。X でその報告の前に戻し、報告の履歴が消えることを確かめます。やってもやらなくてもよい比べ方として、tools/verify_original.py に元のソースのフォルダー(ディレクトリ)を渡すことも試しましょう。3584の遷移と24の記録が一致することの意味を説明してみてください。
このステップのコード全文(400行)
# MIT License
#
# Copyright (c) 2026
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""沈黙の証人・教材5/5。原作A固定、単独実行。架空の情報ルール。"""
from dataclasses import dataclass, asdict, replace
# 原作Aの公開候補。選ぶ順番でfactsやtruthは変わらない。
DOCUMENTS = ('map', 'photo', 'clock')
NAMES = {'map': '地図', 'photo': '写真', 'clock': '時計'}
PLACES = ('近い門', '近い机', '遠い門', '遠い机')
SHAPES = ('四角', '丸', '三角')
@dataclass(frozen=True)
class Fact:
place: int
shape: int
color: str
time: int
@dataclass(frozen=True)
class Level:
id: str
title: str
facts: tuple
truth: int
LEVEL = Level('A', '返された箱', (
Fact(0, 0, '赤', 0), Fact(1, 1, '赤', 0),
Fact(2, 2, '青', 2), Fact(3, 2, '青', 3)), 1)
def project(fact, document, detailed):
if document == 'map':
return ('ABCD'[fact.place] + '・' + PLACES[fact.place] if detailed
else ('近い側 A/B' if fact.place < 2 else '遠い側 C/D'))
if document == 'photo':
return SHAPES[fact.shape] if detailed else fact.color
if document == 'clock':
return str(fact.time) + '刻' if detailed else ('早い 0/1刻' if fact.time < 2 else '遅い 2/3刻')
raise ValueError('unknown document')
@dataclass(frozen=True)
class Answer:
document: str
detailed: bool
value: str
@dataclass(frozen=True)
class State:
answers: tuple = ()
selected: int = 15
result: str = ''
feedback: str = '何を思い出してほしいか、最初の資料を選びましょう。'
reports: tuple = ()
def compatible(answers):
# 空のallはTrue。まだ聞かなければ4候補とも除外できない。
return sum(1 << i for i, fact in enumerate(LEVEL.facts)
if all(project(fact, a.document, a.detailed) == a.value for a in answers))
def reason(state, candidate):
for answer in state.answers:
got = project(LEVEL.facts[candidate], answer.document, answer.detailed)
if got != answer.value:
return NAMES[answer.document] + 'の答え「' + answer.value + '」と候補「' + got + '」が違う'
return '聞いた回答と矛盾なし。まだ除外できません。'
def transition(state, action):
if state.result:
return state
if action.startswith('show:'):
document = action[5:]
if document not in DOCUMENTS or document in [a.document for a in state.answers]:
return state
detailed = not state.answers
answer = Answer(document, detailed, project(LEVEL.facts[LEVEL.truth], document, detailed))
return replace(state, answers=state.answers + (answer,),
feedback=NAMES[document] + 'を提示: ' + answer.value + '。' +
('細部の回答。' if answer.detailed else '大分類の回答。'))
if action.startswith('toggle:'):
try:
candidate = int(action[7:])
except ValueError:
return state
if 0 <= candidate < 4:
return replace(state, selected=state.selected ^ (1 << candidate),
feedback='候補に線を引きました。Enterで根拠と照合。')
return state
if action == 'assist':
return replace(state, selected=compatible(state.answers),
feedback='補助で回答と両立する候補を残しました。理由欄で確認できます。')
if action == 'submit':
if not state.answers:
return replace(state, feedback='資料を一枚以上示してから報告してください。')
mask = compatible(state.answers)
count = mask.bit_count()
ok = state.selected == mask
if ok:
message = '根拠に沿った特定。' if count == 1 else '根拠に沿った報告。この証言では' + str(count) + '件まで。'
else:
message = ('まだ否定できない候補があります。' if mask & ~state.selected
else '回答と矛盾する候補が残っています。')
return replace(state, result=('specific' if count == 1 else 'bounded') if ok else '',
feedback=message, reports=state.reports + ((state.selected, mask, ok),))
return state
@dataclass(frozen=True)
class Timeline:
state: State = State()
past: tuple = ()
def step(timeline, action):
if action == 'undo':
return Timeline(timeline.past[-1], timeline.past[:-1]) if timeline.past else timeline
if len(timeline.past) >= 128:
return timeline
new_state = transition(timeline.state, action)
return Timeline(new_state, timeline.past + (timeline.state,)) if new_state != timeline.state else timeline
def canonical(value):
import json
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
def digest(value):
import hashlib
return hashlib.sha256(canonical(value).encode('utf-8')).hexdigest()
def replay(actions):
if len(actions) > 512:
raise ValueError('bounded record: max 512 actions')
timeline = Timeline()
events = []
for tick, action in enumerate(actions):
timeline = step(timeline, action)
events.append({'tick': tick, 'action': action, 'state': asdict(timeline.state),
'sha256': digest(asdict(timeline.state))})
return {'version': '069-v1', 'level': LEVEL.id, 'precision': 'first_detail',
'events': events, 'final': asdict(timeline.state), 'sha256': digest(asdict(timeline.state))}
STAGE = 5
STAGE_TITLE = 'RESTORE KNOWLEDGE WITH UNDO'
CONTROLS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'A', 'RETURN', 'X', 'R')
REPLAY = {'U': 'UP', 'D': 'DOWN', 'L': 'LEFT', 'T': 'RIGHT', 'S': 'SPACE',
'Z': 'Z', 'A': 'A', 'E': 'RETURN', 'X': 'X', 'R': 'R'}
class Controller:
def __init__(self):
self.previous = set()
self.gate = False
self.reset()
def reset(self):
self.timeline = Timeline()
self.document = 0
self.candidate = 0
self.notice = 'ready'
@property
def state(self):
return self.timeline.state
def apply(self, action):
self.timeline = step(self.timeline, action)
return action
def feed(self, down):
# 教材は同時押しを拒否。全操作キーが離れるまで次を受け付けない。
down = set(down) & set(CONTROLS)
fresh = down - self.previous
self.previous = down
if self.gate:
if not down:
self.gate = False
return None
if not fresh:
return None
self.gate = True
if len(fresh) != 1:
self.notice = 'ignored:multiple'
return self.notice
self.notice = self.handle(next(iter(fresh)))
return self.notice
def handle(self, key):
if key == 'R':
self.reset()
return 'reset'
if key == 'X':
return self.apply('undo')
if self.state.result:
return 'ignored:finished'
if key in ('UP', 'DOWN'):
self.document = (self.document + (-1 if key == 'UP' else 1)) % 3
return 'document'
if key in ('LEFT', 'RIGHT'):
self.candidate = (self.candidate + (-1 if key == 'LEFT' else 1)) % 4
return 'candidate'
if key == 'Z':
return self.apply('show:' + DOCUMENTS[self.document])
if key == 'SPACE':
return self.apply('toggle:' + str(self.candidate))
if key == 'A':
return self.apply('assist')
if key == 'RETURN':
return self.apply('submit')
return 'ignored:key'
def snapshot(self):
data = {'state': asdict(self.state), 'document': self.document,
'candidate': self.candidate, 'notice': self.notice}
data['compatible'] = compatible(self.state.answers)
data['history_depth'] = len(self.timeline.past)
return data
# 日本語の本文/判定は上に保持。ウィンドウだけ標準フォントで読めるASCII訳。
DISPLAY = {
'A・近い門': 'A / NEAR GATE', 'B・近い机': 'B / NEAR DESK',
'C・遠い門': 'C / FAR GATE', 'D・遠い机': 'D / FAR DESK',
'近い側 A/B': 'NEAR A/B', '遠い側 C/D': 'FAR C/D',
'四角': 'SQUARE', '丸': 'CIRCLE', '三角': 'TRIANGLE', '赤': 'RED', '青': 'BLUE',
'0刻': 'TIME 0', '1刻': 'TIME 1', '2刻': 'TIME 2', '3刻': 'TIME 3',
'早い 0/1刻': 'EARLY 0/1', '遅い 2/3刻': 'LATE 2/3',
}
def wrap(p, x, y, text, width=96, color=7):
import textwrap
for row, line in enumerate(textwrap.wrap(text, width=width)):
p.text(x, y + row * 11, line, color)
def draw_shape(p, x, y, fact):
color = 8 if fact.color == '赤' else 12
if fact.shape == 0:
p.rect(x, y, 12, 12, color)
elif fact.shape == 1:
p.circ(x + 6, y + 6, 6, color)
else:
p.tri(x + 6, y, x, y + 12, x + 12, y + 12, color)
def answer_reason_ascii(state, candidate):
for answer in state.answers:
got = project(LEVEL.facts[candidate], answer.document, answer.detailed)
if got != answer.value:
return answer.document.upper() + ': heard ' + DISPLAY[answer.value] + ', but H' + str(candidate + 1) + ' gives ' + DISPLAY[got]
return 'No conflict with known answers. This candidate cannot yet be removed.'
def draw_board(p, c):
state = c.state
p.cls(1)
p.text(14, 12, 'SILENT WITNESS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
p.text(14, 29, 'FICTIONAL RULE PUZZLE. Not a model of real witnesses or legal interviews.', 6)
p.text(14, 44, 'CASE A / RETURNED BOX. The event stays fixed; only available detail changes.', 6)
for i, document in enumerate(DOCUMENTS):
x = 14 + i * 216
p.rect(x, 65, 207, 61, 2)
p.rectb(x, 65, 207, 61, 10 if c.document == i else 5)
p.text(x + 9, 74, document.upper(), 7)
done = any(a.document == document for a in state.answers)
p.text(x + 9, 92, 'ALREADY SHOWN' if done else 'Z: SHOW THIS DOCUMENT', 6 if done else 7)
p.text(x + 9, 110, 'NEXT: ' + ('DETAIL' if not state.answers else 'CATEGORY'), 11)
p.text(14, 141, 'PUBLIC POSSIBLE WORLDS / LEFT-RIGHT: SELECT H1-H4', 10)
for i, fact in enumerate(LEVEL.facts):
y = 158 + i * 36
p.rect(14, y, 632, 32, 0)
p.rectb(14, y, 632, 32, 10 if c.candidate == i else 5)
draw_shape(p, 35, y + 9, fact)
p.text(61, y + 7, 'H' + str(i + 1) + ' ' + DISPLAY[project(fact, 'map', True)], 7)
p.text(198, y + 7, DISPLAY[fact.color] + ' ' + DISPLAY[project(fact, 'photo', True)], 7)
p.text(317, y + 7, DISPLAY[project(fact, 'clock', True)], 7)
consistent = bool(compatible(state.answers) & (1 << i))
p.text(405, y + 7, 'CONSISTENT' if consistent else 'CONFLICT', 11 if consistent else 8)
p.text(538, y + 7, '[KEEP]' if state.selected & (1 << i) else '[OUT]', 10)
p.text(14, 311, 'RECEIVED ANSWERS / FIRST DETAIL, THEN CATEGORIES', 10)
for i in range(3):
y = 330 + i * 18
if i < len(state.answers):
answer = state.answers[i]
text = str(i + 1) + '. ' + answer.document.upper() + ' / ' + ('DETAIL' if answer.detailed else 'CATEGORY') + ' -> ' + DISPLAY[answer.value]
p.text(22, y, text, 7)
else:
p.text(22, y, str(i + 1) + '. Not yet shown', 5)
p.text(14, 392, 'H' + str(c.candidate + 1) + ' CHECK: ' + answer_reason_ascii(state, c.candidate), 6)
if state.result:
p.text(14, 416, 'REPORT ACCEPTED: ' + state.result.upper() + ' / ' + str(compatible(state.answers).bit_count()) + ' candidate(s)', 11)
elif state.feedback == '資料を一枚以上示してから報告してください。':
p.text(14, 416, 'SHOW AT LEAST ONE DOCUMENT BEFORE REPORTING.', 8)
elif state.reports and not state.reports[-1][2] and state.feedback in ('まだ否定できない候補があります。', '回答と矛盾する候補が残っています。'):
selected, mask, ok = state.reports[-1]
p.text(14, 416, 'REPORT REJECTED: ' + ('a still-possible candidate was removed.' if mask & ~selected else 'a conflicting candidate remains.'), 8)
else:
p.text(14, 416, 'Report ALL consistent candidates. One or more can be correct.', 11)
p.text(14, 448, 'UNDO HISTORY: ' + str(len(c.timeline.past)) + '/128. X restores the entire previous model state.', 6)
p.rect(14, 479, 632, 23, 5)
p.text(22, 487, 'LAST KEY: ' + c.notice, 7)
p.text(14, 513, 'UP/DOWN: DOC Z: SHOW LEFT/RIGHT: CANDIDATE SPACE: KEEP A: ASSIST ENTER: REPORT X: UNDO', 6)
p.text(14, 529, 'R: RESET SAME CASE ESC: QUIT TAKE YOUR TIME; NO COUNTDOWN', 6)
def main():
import argparse
import json
from pathlib import Path
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-replay', metavar='KEYS')
parser.add_argument('--screenshot', type=Path)
parser.add_argument('--report', type=Path)
args = parser.parse_args()
# 引数エラーはPyxelのimportより先。モデル試験は標準ライブラリだけで動く。
if args.headless_replay is None and (args.screenshot or args.report):
parser.error('output paths require --headless-replay')
if args.headless_replay is not None and any(c not in REPLAY for c in args.headless_replay):
parser.error('replay keys must use: ' + ''.join(REPLAY))
if args.headless_replay is not None and len(args.headless_replay) > 1000:
parser.error('at most 1000 replay keys')
args.screenshot = args.screenshot.resolve() if args.screenshot else None
args.report = args.report.resolve() if args.report else None
import pyxel
c = Controller()
frames = 0
events = []
sequence = args.headless_replay
limit = max(12, len(sequence or '') * 2 + 2)
mapping = {name: getattr(pyxel, 'KEY_' + name) for name in CONTROLS}
pyxel.init(660, 550, title='069 Silent Witness - Stage ' + str(STAGE), fps=30,
display_scale=1, quit_key=pyxel.KEY_NONE, headless=sequence is not None)
def update():
nonlocal frames
if pyxel.btnp(pyxel.KEY_ESCAPE):
pyxel.quit()
return
if sequence is None:
down = {name for name, key in mapping.items() if pyxel.btn(key)}
elif frames % 2 == 0 and frames // 2 < len(sequence):
down = {REPLAY[sequence[frames // 2]]}
else:
down = set()
event = c.feed(down)
if event is not None:
events.append({'frame': frames, 'result': event, 'snapshot': c.snapshot()})
frames += 1
def draw():
draw_board(pyxel, c)
if sequence is not None and frames >= limit:
if args.screenshot:
args.screenshot.parent.mkdir(parents=True, exist_ok=True)
pyxel.screenshot(str(args.screenshot), scale=1)
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps({'stage': STAGE, 'actions': sequence,
'frames': frames, 'events': events, 'snapshot': c.snapshot()},
ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
pyxel.quit()
pyxel.run(update, draw)
if __name__ == '__main__':
main()仕組みがわかったら、元の完成版を読んでみよう。
元のゲームの事件A「返された箱」です。候補はH1〜H4に決まっていて、真相は truth=1(H2)です。最初に受け付けた資料だけが細部で、それ以降は大分類の回答です。1枚以上の回答を受け取ったあと、selected と compatible が完全に一致すれば成功です。1つに決まる specific と、複数にしぼる bounded の両方が、正式な達成です。まだ見せていない資料の情報や、表情・沈黙・速さは、根拠にしません。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 段階 | 01は公開された候補の投影、02は回答、03は照らし合わせ、04は集合の報告、05は Undo の5つの段階。どのファイルも、それだけで実行できる。 | 最初から、全部の機能を使える。 |
| 収録範囲 | 事件Aの1つだけで、元の版の first_detail(最初だけ細部)のルールのみ。最後のモデルは、その範囲で元のゲームと一致。関数の使い方(API)は、固定の LEVEL を内側から参照する形に整理した。 | A・B・Cの3つの事件、全部の資料が細部になる比較版、表紙、一時停止と説明、保存、動画の出力。 |
| 表示 | Pyxel の標準フォントで、英字(ASCII)に訳して表示。本文・候補・判定の文字列は、日本語のまま残している。Noto の文字の形(字形)は使わないが、元のゲームの LICENSE から参照される通知の全文を同梱。 | 日本語の文字を、ドット絵(ビットマップ)で表示。 |
| 入力 | どの操作も、キーを離すまで待つ。新しいキーを同時に複数押すと受け付けない。R ですぐにやり直し、Esc ですぐに終了。 | 決定の入力が同時にあったら、優先順位で1件だけ受け付ける。カーソル操作と決定が、同じフレーム(画面1枚分の時間)に起こることがある。表紙からの開始と、P での一時停止がある。 |
| 境界 | 05の Timeline は128回の変更まで、replay 関数は512件の意味入力まで。動作確認(QA)用の --headless-replay(画面を出さずに再生する機能)は、最大1000文字のキーの並びで、元のセッションの記録とは別のもの。 | 1回の挑戦で128回の変更まで。画面(UI)のセッションは、意味入力512件まで。S で記録を出力。 |
| 検証 | 本物の Pyxel を画面なし(ヘッドレス)・音なしで動かした描画、合成した Controller の入力、標準ライブラリ(stdlib)のテストで確認。OS からのキー入力、人による試しプレイ、音、ブラウザ、Windows と macOS の画面(GUI)は、まだ確かめていない。 | 親の作業で、元のゲームの10個のテストと、本物の Pyxel での自動デモを、別に確かめてある。 |
完成版のコードで、次に読むところ
model.py:LEVELSに事件BとCを追加して、最初に見せる資料がちがう場合を比べるmodel.py:transitionのvariant=Trueで全部の資料を細部にする、1か所だけ変えた比較app.py:updateの優先順位・gate・pauseと、表紙を元に戻すapp.pyの S での保存と、model.pyのreplayによるセッションの記録app.py:日本語の文字の表示、F12での記録。experiments.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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
