完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:左右反射板/Z送信、Space一刻、Tab候補/上下選択/Z残す、Enter報告、X戻す、V消音
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
聞き終えた観測から、ありえる候補の集まりを残す。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- 変数・if・for・関数 /
tuple・list・set/dataclassとreplaceは本文で説明します - 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- Space:一刻 / Z:03以降送信、04以降候補編集時は切替 / 左右:03以降の次の送信設定 / Tab/上下:04以降候補モード/候補選択 / A/Enter:04以降根拠へ合わせる/報告 / X:05判断単位Undo / B:05苔待ち0の比較へ切替・初期化 / 1/2/3:03以降A/B/C / R:読込済みコードの初期化 / Esc:終了
- 難易度の目安
- 中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
石まで往復する一つの音
書き換えない状態・位置と向き・往復にかかる時間。
この教材は、整数で作った架空の音の仕組みです。前提は、変数、if、関数、tuple の番号(添字)です。dataclass は関係のある値を1つにまとめる型、frozen=True は記録をあとから書き換えない指定です。Lab には、刻 t、位置 pos、進み方の段階 phase、届いた音 events を保存します。最初は入口 pos=0 から、out(行き)として進みます。Space を1回押して離すと、advance が1回だけ呼ばれます。描いた回数や待った時間は、音の進み方に関係しません。
DISTANCE=3 は、この段階で見せている練習用の壁です。元のゲームの、隠された本当の地図ではありません。advance の最初で、もう終わっていれば同じ状態を返し、終わったあとの入力を止めます。行きは位置+1、帰りは-1です。pos が DISTANCE になった刻に phase を back(帰り)に変えますが、同じ刻に帰り道を1歩進めることはしません。別の刻の計算として分けるので、入口に戻るのは距離×2、ここでは6刻です。
帰り道で入口に着いたときだけ、(t,石の数,苔の数) を events に記録します。画面の黄色い丸は、答えがわかっている実験なので見せています。あとの段階の隠された洞窟では、中の音の波は見せません。draw は App.view を読んで描くだけで、advance は呼びません。App.control は押し続けを1回として扱い、操作キーが全部離されるまで次の入力を受け付けません。同時押しも受け付けません。
このファイルには、あとの段階と共通の App の条件分岐が残っています。STAGE=1 では、あとで出てくる名前を使う分かれ道には入りません。LEVELS や候補の判定をこの段階にこっそり作ってあるわけではなく、それらの定義そのものがまだありません。次は、02 の Wave と、advance の wait の分かれ道、元のゲームの model.py の Wave と step を読みます。
下のコードは 01_stone_roundtrip.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
pos = s.pos + (1 if s.phase == 'out' else -1)
phase = 'back' if pos == DISTANCE else s.phase
events = s.events実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_stone_roundtrip.py.venv/bin/python 01_stone_roundtrip.pyevents は ((6,1,0),)、状態は DONE です。7回目からは6刻のままです。- 本文の順番どおりに、キーを1つずつ離して入力する
- 観測(実際に聞いた音)と、予測を区別する
- 保存→ゲームを閉じる→再実行で、変更が反映される
DISTANCE = 3 を DISTANCE = 4 に1か所変えてみましょう。保存 → Esc で終了 → python 01_stone_roundtrip.py で実行し直します。Space を1回ずつ離しながら6回押しても、まだ BACK です。さらに2回で ((8,1,0),) になります。R は、読みこみ済みのコードの最初の状態に戻すだけです。自動の確認は python exercises/01_distance.py です。
このステップのコード全文(254行)
# 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.
"""079 こだまする洞窟: 教材段階 1。"""
STAGE = 1
from dataclasses import dataclass, replace, asdict
DISTANCE = 3
@dataclass(frozen=True)
class Lab:
t: int = 0
pos: int = 0
phase: str = 'out'
events: tuple = ()
def advance(s):
if s.phase == 'done':
return s
pos = s.pos + (1 if s.phase == 'out' else -1)
phase = 'back' if pos == DISTANCE else s.phase
events = s.events
if phase == 'back' and pos == 0:
phase = 'done'
events = ((s.t + 1, 1, 0),)
return Lab(s.t + 1, pos, phase, events)
# ASCII teaching UI. No original glyphs, controller, sound or assets are loaded.
class App:
def __init__(self):
self.level_index = 0
self.mode = 'base'
self.gated = False
self.reset()
@property
def level(self):
return LEVELS[self.level_index] if STAGE >= 3 else None
@property
def state(self):
return self.timeline.state if STAGE == 5 else self.s
def reset(self):
if STAGE == 5:
self.timeline = new(self.level)
else:
self.s = State(self.level.truth) if STAGE >= 3 else Lab()
self.setting = 0
self.cursor = 0
self.marking = False
self.message = 'READY / RELEASE EACH KEY'
def commit(self, action):
before = self.state
if STAGE == 5:
self.timeline = act(self.level, self.timeline, action, self.mode)
elif STAGE == 4:
self.s = judge(self.level, self.s, action, self.mode)
elif STAGE == 3:
if action == 'tick':
self.s = step(self.level, self.s, self.mode)
elif action.startswith('send:') and self.s.status == 'idle':
self.s = launch(self.s, int(action.split(':')[1]))
elif action == 'tick':
self.s = advance(self.s)
self.message = ('ACCEPTED: ' if before != self.state else 'UNCHANGED: ') + action.upper()
def press(self, key):
if key == 'R':
self.reset()
return
if STAGE >= 3 and key in ('1','2','3'):
self.level_index = int(key) - 1
self.reset()
return
if STAGE == 5 and key == 'B':
self.mode = 'compare' if self.mode == 'base' else 'base'
self.reset()
return
if STAGE == 5 and key == 'X':
self.commit('undo')
return
if key == 'SPACE':
self.commit('tick')
elif STAGE >= 3:
if key in ('LEFT','RIGHT'):
self.setting = (self.setting + (1 if key == 'RIGHT' else -1)) % 3
elif STAGE >= 4 and key == 'TAB':
self.marking = not self.marking
elif STAGE >= 4 and key in ('UP','DOWN'):
self.cursor = (self.cursor + (1 if key == 'DOWN' else -1)) % 4
elif key == 'Z':
self.commit('mark:' + str(self.cursor) if self.marking else 'send:' + str(self.setting))
elif STAGE >= 4 and key == 'A':
self.commit('assist')
elif STAGE >= 4 and key == 'RETURN':
self.commit('submit')
def control(self, pressed, held):
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.gated = True
self.message = 'CHORD REJECTED / RELEASE ALL KEYS'
elif len(pressed) == 1:
self.press(next(iter(pressed)))
self.gated = True
def view(self):
s = self.state
if STAGE == 5:
return public_view(self.level, s, self.mode)
if STAGE < 3:
return {'t': s.t, 'status': s.phase, 'events': s.events,
'window': 2 * DISTANCE if STAGE == 1 else max(2 * d + (MOSS_WAIT if m == 'M' else 0) for d,m in WALLS)}
result = {'t': s.t, 'status': s.status, 'events': s.events, 'window': window(s.setting),
'observations': s.observations, 'setting': s.setting, 'report': s.report}
if STAGE == 4:
result['compatible'] = compatible(self.level, s, self.mode)
return result
def draw(self, p):
v = self.view()
p.cls(1)
p.text(14, 12, 'ECHO CAVE / STAGE ' + str(STAGE), 10)
p.text(14, 28, 'FICTIONAL ACOUSTICS / ONE PRESS, ONE TICK / NO AUDIO', 6)
p.text(14, 48, 'TIME ' + str(v['t']) + ' / ' + str(v['window']) + ' STATUS ' + v['status'].upper(), 7)
p.rect(12, 66, 624, 154, 0)
if STAGE == 1:
p.text(24, 78, 'KNOWN LAB: STONE DISTANCE ' + str(DISTANCE) + ' / RETURN = 2 * DISTANCE', 10)
p.line(55, 142, 560, 142, 5)
p.circ(55 + int(505 * self.state.pos / DISTANCE), 142, 7, 10)
p.rect(560, 119, 8, 44, 7)
p.text(30, 180, 'VISIBLE WAVE IN A KNOWN EXERCISE, NOT A HIDDEN CAVE', 6)
elif STAGE == 2:
p.text(24, 78, 'KNOWN LAB: L=2 STONE / R=1 MOSS / MOSS WAIT ' + str(MOSS_WAIT), 10)
for branch in (0,1):
y = 118 + branch * 50
p.line(55,y,555,y,5)
for wave in self.state.waves:
if wave.branch == branch:
x = 55 + wave.pos * 180
p.circ(x,y,6,7 if branch == 0 else 11)
p.text(x+14,y-3,wave.phase.upper() + ' ' + str(wave.wait),10)
p.text(570,y-3,'STONE' if branch == 0 else 'MOSS',7)
else:
p.text(24, 78, 'HIDDEN CAVE ' + self.level.id + ' / NO SECRET OR WAVE POSITIONS HERE', 10)
p.line(105,126,318,174,5);p.line(530,126,318,174,5);p.circ(318,174,8,10)
p.text(54,111,'LEFT: UNKNOWN',6);p.text(466,111,'RIGHT: UNKNOWN',6)
p.text(24,195,'SELECTED ' + ('WIDE / REACH 2 EACH','LEFT / REACH 4','RIGHT / REACH 4')[self.setting],7)
p.text(14, 233, 'RECEIVED EVENTS (TICK, STONE COUNT, MOSS COUNT):', 10)
p.text(14, 249, str(v['events']) if v['events'] else 'NONE YET', 7)
if STAGE >= 3:
p.text(14,270,'COMPLETED WINDOWS ' + str(len(v['observations'])) + ' / NO EARLY ELIMINATION',11)
p.text(14,287,'ACTIVE SETTING ' + str(v['setting']) + ' / SELECTING DOES NOT SEND',6)
if STAGE >= 4:
for i, candidate in enumerate(self.level.maps):
y = 312 + i * 24
line = ('>' if self.marking and i == self.cursor else ' ') + 'H' + str(i+1)
line += ' [KEEP] ' if i in v['report'] else ' [DROP] '
line += 'LEFT ' + str(candidate[0]) + ' RIGHT ' + str(candidate[1])
line += ' POSSIBLE' if i in v['compatible'] else ' CONTRADICTED'
p.text(14,y,line,10 if i in v['compatible'] else 6)
p.text(14,413,'SUBMITTED: ALL POSSIBLE MAPS REPORTED' if v['status']=='submitted' else 'KEEP ALL COMPATIBLE MAPS; MULTIPLE MAPS IS A VALID REPORT',11)
else:
p.text(14,325,'DISTANCE = (ARRIVAL TICK - MATERIAL WAIT) / 2',11)
p.text(14,350,'SILENCE DOES NOT PROVE THAT A WALL DOES NOT EXIST.',6)
if STAGE == 5:
p.text(14,440,'NEXT POSSIBILITIES (STONE,MOSS): ' + str(v['next_events']),10)
p.text(14,458,'MOSS WAIT ' + str(v['wait']) + ' / X UNDO / B RESTARTS COMPARISON',6)
p.text(14,487,self.message,10)
p.text(14,510,'SPACE TICK / R RESET SAME CODE / ESC QUIT',7)
if STAGE >= 3:
p.text(14,526,'LEFT/RIGHT SETTING / Z SEND / 1-3 CAVE / RELEASE EVERY KEY',7)
if STAGE >= 4:
p.text(14,542,'TAB MAP MODE / UP/DOWN MAP / Z TOGGLE / A ASSIST / ENTER REPORT',6)
def demo_keys():
if STAGE == 1:
return ('SPACE',) * 6
if STAGE == 2:
return ('SPACE',) * 4
return ('Z',) + ('SPACE',) * 6 + (('A','RETURN') if STAGE >= 4 else ())
def main(headless=False, screenshot=None, state_json=None, demo=False):
import pyxel as p
import json
from pathlib import Path
p.init(648, 564, title='Echo cave lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless)
app = App()
sequence = demo_keys() if demo else ()
keynames = ('LEFT','RIGHT','UP','DOWN','Z','SPACE','RETURN','TAB','A','X','R','B','1','2','3')
keys = {k: getattr(p, 'KEY_' + k) for k in keynames}
limit = max(8, len(sequence) * 2 + 6)
def update():
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
if screenshot:
app.draw(p)
p.screen.save(screenshot, 1)
if state_json:
# Public evidence only: do not export the secret or hidden waves for puzzle stages.
Path(state_json).write_text(json.dumps({'stage': STAGE, 'public': app.view(), 'scope': 'Actual Linux Pyxel, synthetic controls; no human playtest'}, ensure_ascii=False, indent=2, default=lambda o: asdict(o)) + '\n', encoding='utf-8')
p.quit()
if headless:
i = p.frame_count // 2
pressed = {sequence[i]} if demo and p.frame_count % 2 == 0 and i < len(sequence) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
p.run(update, lambda: app.draw(p))
if __name__ == '__main__':
import argparse
from pathlib import Path
parser = argparse.ArgumentParser(description='Standalone echo cave teaching stage')
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--demo-checkpoint', action='store_true')
parser.add_argument('--screenshot')
parser.add_argument('--state-json')
args = parser.parse_args()
if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
parser.error('demo and outputs require --headless-smoke')
for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
value = getattr(args, field)
if value:
target = Path(value).resolve()
if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
parser.error('output must be a new ' + suffix + ' in an existing directory')
setattr(args, field, str(target))
main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)苔の待ち時間と二つの反響
波のリスト・同時の更新・材質ごとの集計。
01 の往復を、2本に広げます。前提は、for、list への append、tuple、replace です。Wave は、branch(分かれ道の番号)、pos、phase、wait を持つ、1つの音の波です。Lab.waves は2つの Wave を入れた tuple で、左の石は距離2、右の苔は距離1という、答えが見える実験に決めています。元のゲームの状況 B で起きる「2つの音の重なり」を、秘密なしで観察する段階です。
advance は、古い s.waves を for で読み、新しい waves に、それぞれの波の次の状態を入れていきます。進めている途中で、元の tuple を書き換えません。石は壁に着くとすぐ back、苔は MOSS_WAIT=2 なら wait(待ち)に入ります。wait が2から1になる刻はまだ待ち、1から0になる刻に back に切り替え、実際に戻って位置が動くのは次の刻です。苔の往復は 1+2+1=4刻、石は 2+2=4刻になります。
入口に届いた波は、次の waves には入れず、counts の S(石)か M(苔)を増やします。すべての枝の計算が終わってから1つの出来事にまとめるので、同じ刻の2つの音は ((4,1,1),) になります。左右どちらの枝かは、観測の記録に含めません。この段階では、答えのわかっている実験として枝の途中を描きますが、次の段階では、描画に渡す情報から外します。波が全部消えたら done で、そのあと Space を押しても何も変わりません。
観測の窓の途中で、波が届かない場合はまだ扱いません。どちらも距離2以内です。01 の Lab.pos は Wave.pos に移り、01 の1つの phase も、それぞれの Wave の phase に分かれました。共通の画面には、あとの段階の分かれ道が残っていますが、候補の地図・観測の窓・推理の関数は、このファイルにはありません。次は、03 の launch、window、step と、元のゲームの model.py の moss_wait を追います。
下のコードは 02_moss_wait.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
elif w.phase == 'wait':
waves.append(replace(w, wait=w.wait - 1,
phase='back' if w.wait == 1 else 'wait'))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_moss_wait.py.venv/bin/python 02_moss_wait.py- 本文の順番どおりに、キーを1つずつ離して入力する
- 観測(実際に聞いた音)と、予測を区別する
- 保存→ゲームを閉じる→再実行で、変更が反映される
counts にまとめて数え、for の外で1行を作ります。MOSS_WAIT = 2 を MOSS_WAIT = 0 に変えてみましょう。保存 → Esc で終了 → python 02_moss_wait.py で実行し直します。Space を4回、1回ずつ離して押すと、((2,0,1),(4,1,0)) です。苔が先に帰り、石は4刻のままです。R では書き換えは読み直されません。自動の確認は python exercises/02_moss.py です。
このステップのコード全文(279行)
# 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.
"""079 こだまする洞窟: 教材段階 2。"""
STAGE = 2
from dataclasses import dataclass, replace, asdict
MOSS_WAIT = 2
WALLS = ((2, 'S'), (1, 'M'))
@dataclass(frozen=True)
class Wave:
branch: int
pos: int = 0
phase: str = 'out'
wait: int = 0
@dataclass(frozen=True)
class Lab:
t: int = 0
waves: tuple = (Wave(0), Wave(1))
events: tuple = ()
@property
def phase(self):
return 'listen' if self.waves else 'done'
def advance(s):
if not s.waves:
return s
waves, counts = [], {'S': 0, 'M': 0}
for w in s.waves:
distance, material = WALLS[w.branch]
if w.phase == 'out':
pos = w.pos + 1
if pos == distance:
wait = MOSS_WAIT if material == 'M' else 0
waves.append(Wave(w.branch, pos, 'wait' if wait else 'back', wait))
else:
waves.append(replace(w, pos=pos))
elif w.phase == 'wait':
waves.append(replace(w, wait=w.wait - 1,
phase='back' if w.wait == 1 else 'wait'))
else:
pos = w.pos - 1
if pos == 0:
counts[material] += 1
else:
waves.append(replace(w, pos=pos))
event = ((s.t + 1, counts['S'], counts['M']),) if sum(counts.values()) else ()
return Lab(s.t + 1, tuple(waves), s.events + event)
# ASCII teaching UI. No original glyphs, controller, sound or assets are loaded.
class App:
def __init__(self):
self.level_index = 0
self.mode = 'base'
self.gated = False
self.reset()
@property
def level(self):
return LEVELS[self.level_index] if STAGE >= 3 else None
@property
def state(self):
return self.timeline.state if STAGE == 5 else self.s
def reset(self):
if STAGE == 5:
self.timeline = new(self.level)
else:
self.s = State(self.level.truth) if STAGE >= 3 else Lab()
self.setting = 0
self.cursor = 0
self.marking = False
self.message = 'READY / RELEASE EACH KEY'
def commit(self, action):
before = self.state
if STAGE == 5:
self.timeline = act(self.level, self.timeline, action, self.mode)
elif STAGE == 4:
self.s = judge(self.level, self.s, action, self.mode)
elif STAGE == 3:
if action == 'tick':
self.s = step(self.level, self.s, self.mode)
elif action.startswith('send:') and self.s.status == 'idle':
self.s = launch(self.s, int(action.split(':')[1]))
elif action == 'tick':
self.s = advance(self.s)
self.message = ('ACCEPTED: ' if before != self.state else 'UNCHANGED: ') + action.upper()
def press(self, key):
if key == 'R':
self.reset()
return
if STAGE >= 3 and key in ('1','2','3'):
self.level_index = int(key) - 1
self.reset()
return
if STAGE == 5 and key == 'B':
self.mode = 'compare' if self.mode == 'base' else 'base'
self.reset()
return
if STAGE == 5 and key == 'X':
self.commit('undo')
return
if key == 'SPACE':
self.commit('tick')
elif STAGE >= 3:
if key in ('LEFT','RIGHT'):
self.setting = (self.setting + (1 if key == 'RIGHT' else -1)) % 3
elif STAGE >= 4 and key == 'TAB':
self.marking = not self.marking
elif STAGE >= 4 and key in ('UP','DOWN'):
self.cursor = (self.cursor + (1 if key == 'DOWN' else -1)) % 4
elif key == 'Z':
self.commit('mark:' + str(self.cursor) if self.marking else 'send:' + str(self.setting))
elif STAGE >= 4 and key == 'A':
self.commit('assist')
elif STAGE >= 4 and key == 'RETURN':
self.commit('submit')
def control(self, pressed, held):
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.gated = True
self.message = 'CHORD REJECTED / RELEASE ALL KEYS'
elif len(pressed) == 1:
self.press(next(iter(pressed)))
self.gated = True
def view(self):
s = self.state
if STAGE == 5:
return public_view(self.level, s, self.mode)
if STAGE < 3:
return {'t': s.t, 'status': s.phase, 'events': s.events,
'window': 2 * DISTANCE if STAGE == 1 else max(2 * d + (MOSS_WAIT if m == 'M' else 0) for d,m in WALLS)}
result = {'t': s.t, 'status': s.status, 'events': s.events, 'window': window(s.setting),
'observations': s.observations, 'setting': s.setting, 'report': s.report}
if STAGE == 4:
result['compatible'] = compatible(self.level, s, self.mode)
return result
def draw(self, p):
v = self.view()
p.cls(1)
p.text(14, 12, 'ECHO CAVE / STAGE ' + str(STAGE), 10)
p.text(14, 28, 'FICTIONAL ACOUSTICS / ONE PRESS, ONE TICK / NO AUDIO', 6)
p.text(14, 48, 'TIME ' + str(v['t']) + ' / ' + str(v['window']) + ' STATUS ' + v['status'].upper(), 7)
p.rect(12, 66, 624, 154, 0)
if STAGE == 1:
p.text(24, 78, 'KNOWN LAB: STONE DISTANCE ' + str(DISTANCE) + ' / RETURN = 2 * DISTANCE', 10)
p.line(55, 142, 560, 142, 5)
p.circ(55 + int(505 * self.state.pos / DISTANCE), 142, 7, 10)
p.rect(560, 119, 8, 44, 7)
p.text(30, 180, 'VISIBLE WAVE IN A KNOWN EXERCISE, NOT A HIDDEN CAVE', 6)
elif STAGE == 2:
p.text(24, 78, 'KNOWN LAB: L=2 STONE / R=1 MOSS / MOSS WAIT ' + str(MOSS_WAIT), 10)
for branch in (0,1):
y = 118 + branch * 50
p.line(55,y,555,y,5)
for wave in self.state.waves:
if wave.branch == branch:
x = 55 + wave.pos * 180
p.circ(x,y,6,7 if branch == 0 else 11)
p.text(x+14,y-3,wave.phase.upper() + ' ' + str(wave.wait),10)
p.text(570,y-3,'STONE' if branch == 0 else 'MOSS',7)
else:
p.text(24, 78, 'HIDDEN CAVE ' + self.level.id + ' / NO SECRET OR WAVE POSITIONS HERE', 10)
p.line(105,126,318,174,5);p.line(530,126,318,174,5);p.circ(318,174,8,10)
p.text(54,111,'LEFT: UNKNOWN',6);p.text(466,111,'RIGHT: UNKNOWN',6)
p.text(24,195,'SELECTED ' + ('WIDE / REACH 2 EACH','LEFT / REACH 4','RIGHT / REACH 4')[self.setting],7)
p.text(14, 233, 'RECEIVED EVENTS (TICK, STONE COUNT, MOSS COUNT):', 10)
p.text(14, 249, str(v['events']) if v['events'] else 'NONE YET', 7)
if STAGE >= 3:
p.text(14,270,'COMPLETED WINDOWS ' + str(len(v['observations'])) + ' / NO EARLY ELIMINATION',11)
p.text(14,287,'ACTIVE SETTING ' + str(v['setting']) + ' / SELECTING DOES NOT SEND',6)
if STAGE >= 4:
for i, candidate in enumerate(self.level.maps):
y = 312 + i * 24
line = ('>' if self.marking and i == self.cursor else ' ') + 'H' + str(i+1)
line += ' [KEEP] ' if i in v['report'] else ' [DROP] '
line += 'LEFT ' + str(candidate[0]) + ' RIGHT ' + str(candidate[1])
line += ' POSSIBLE' if i in v['compatible'] else ' CONTRADICTED'
p.text(14,y,line,10 if i in v['compatible'] else 6)
p.text(14,413,'SUBMITTED: ALL POSSIBLE MAPS REPORTED' if v['status']=='submitted' else 'KEEP ALL COMPATIBLE MAPS; MULTIPLE MAPS IS A VALID REPORT',11)
else:
p.text(14,325,'DISTANCE = (ARRIVAL TICK - MATERIAL WAIT) / 2',11)
p.text(14,350,'SILENCE DOES NOT PROVE THAT A WALL DOES NOT EXIST.',6)
if STAGE == 5:
p.text(14,440,'NEXT POSSIBILITIES (STONE,MOSS): ' + str(v['next_events']),10)
p.text(14,458,'MOSS WAIT ' + str(v['wait']) + ' / X UNDO / B RESTARTS COMPARISON',6)
p.text(14,487,self.message,10)
p.text(14,510,'SPACE TICK / R RESET SAME CODE / ESC QUIT',7)
if STAGE >= 3:
p.text(14,526,'LEFT/RIGHT SETTING / Z SEND / 1-3 CAVE / RELEASE EVERY KEY',7)
if STAGE >= 4:
p.text(14,542,'TAB MAP MODE / UP/DOWN MAP / Z TOGGLE / A ASSIST / ENTER REPORT',6)
def demo_keys():
if STAGE == 1:
return ('SPACE',) * 6
if STAGE == 2:
return ('SPACE',) * 4
return ('Z',) + ('SPACE',) * 6 + (('A','RETURN') if STAGE >= 4 else ())
def main(headless=False, screenshot=None, state_json=None, demo=False):
import pyxel as p
import json
from pathlib import Path
p.init(648, 564, title='Echo cave lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless)
app = App()
sequence = demo_keys() if demo else ()
keynames = ('LEFT','RIGHT','UP','DOWN','Z','SPACE','RETURN','TAB','A','X','R','B','1','2','3')
keys = {k: getattr(p, 'KEY_' + k) for k in keynames}
limit = max(8, len(sequence) * 2 + 6)
def update():
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
if screenshot:
app.draw(p)
p.screen.save(screenshot, 1)
if state_json:
# Public evidence only: do not export the secret or hidden waves for puzzle stages.
Path(state_json).write_text(json.dumps({'stage': STAGE, 'public': app.view(), 'scope': 'Actual Linux Pyxel, synthetic controls; no human playtest'}, ensure_ascii=False, indent=2, default=lambda o: asdict(o)) + '\n', encoding='utf-8')
p.quit()
if headless:
i = p.frame_count // 2
pressed = {sequence[i]} if demo and p.frame_count % 2 == 0 and i < len(sequence) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
p.run(update, lambda: app.draw(p))
if __name__ == '__main__':
import argparse
from pathlib import Path
parser = argparse.ArgumentParser(description='Standalone echo cave teaching stage')
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--demo-checkpoint', action='store_true')
parser.add_argument('--screenshot')
parser.add_argument('--state-json')
args = parser.parse_args()
if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
parser.error('demo and outputs require --headless-smoke')
for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
value = getattr(args, field)
if value:
target = Path(value).resolve()
if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
parser.error('output must be a new ' + suffix + ' in an existing directory')
setattr(args, field, str(target))
main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)窓を閉じてから観測を記録する
音が届く距離・音がしないこと・聞き終えた証拠。
02 までは、すべての波が帰ってくる、答えがわかっている実験でした。03 からは、元のゲームの状況 A/B/C の4つの候補と、本当の答えを持つ、隠された洞窟を使います。画面は、本当の答えの番号 secret も、中の波 waves も受け取りません。前提は、関数の引数、条件式、書き換えない dataclass、replace です。Z で音を送り、Space で刻を進めます。左右キーは、次に送る設定を選ぶだけで、選ぶだけでは送らず、時刻も進みません。1/2/3 で A/B/C を最初の状態にします。
SETTINGS の0は左右それぞれ距離2まで、1と2は左か右の片方だけ距離4までです。window(観測の窓)は、0なら6刻、片側なら10刻です。step の out(行き)の分かれ道では、まず壁に着いたかを調べ、壁に当たらずに届く限界に来た波を消します。波が消えても、窓が終わるまで刻を進めます。そのため、状況 C で広く送ったとき、窓の間ずっと音がしなくても、「壁がない」とは言えません。届かない距離に壁がある場合も含むからです。
State.events は今の窓に届いた音、observations は最後まで聞き終えた窓の記録です。done=n==window(s.setting) が成り立つと Observation(setting,events) を1件追加し、idle(待機)に戻ります。途中では observations を増やしません。観測を始めたら設定は s.setting に固定され、左右で次の選択を変えても、聞いている途中の届く距離は変わりません。03 の App.view は t、status、events などだけを作り、中の本当の答えを draw に出さない境目になっています。
Level、LEVELS、Wave、Observation、State、window、moss_wait、launch、step は、元のゲームと同じ定義です。State.report と reason は、元のゲームの形を保つために先に置いている足場で、この段階では候補の編集・判定・予告には使いません。signature/compatible/act/public_view はまだありません。次は、04 と元のゲームの model.py の signature、compatible を読み、聞き終えた観測だけで候補を比べます。
下のコードは 03_observation_window.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
def window(setting):return 6 if setting==0 else 10
def moss_wait(mode):return 0 if mode=='compare' else 2実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_observation_window.py.venv/bin/python 03_observation_window.py- 本文の順番どおりに、キーを1つずつ離して入力する
- 観測(実際に聞いた音)と、予測を区別する
- 保存→ゲームを閉じる→再実行で、変更が反映される
idle に戻すと、証拠になる窓が短くなってしまいます。waves が空かどうかではなく、window と一致したかで終えます。window の return 6 if setting==0 else 10 を return 4 if setting==0 else 10 に変えてみましょう。保存 → Esc で終了 → python 03_observation_window.py で実行し直します。3 → Z → Space を4回、1つずつ離して入力します。状況 C の広い窓が4刻で終わり、音はしません。片側の10刻は変わりません。これは窓を変えた教材用の実験で、元のゲームのルールとは違います。R はコードを読み直しません。自動の確認は python exercises/03_window.py です。
このステップのコード全文(273行)
# 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.
"""079 こだまする洞窟: 教材段階 3。"""
STAGE = 3
from dataclasses import dataclass, replace, asdict
VERSION='079-v1.0.1'
SETTINGS=('左右へ広げる','左へ遠く','右へ遠く')
@dataclass(frozen=True)
class Level:
id:str;title:str;maps:tuple;truth:int
LEVELS=(Level('A','最初の反響',(((1,'S'),(3,'M')),((1,'M'),(3,'S')),((2,'S'),(3,'M')),((1,'S'),(4,'M'))),0),Level('B','重なった二つの音',(((2,'S'),(1,'M')),((1,'M'),(2,'S')),((2,'M'),(1,'S')),((3,'S'),(1,'M'))),1),Level('C','何も返らない入口',(((4,'M'),(3,'S')),((3,'M'),(4,'S')),((4,'S'),(3,'M')),((4,'M'),(4,'M'))),2))
@dataclass(frozen=True)
class Wave:
branch:int;pos:int=0;phase:str='out';wait:int=0
@dataclass(frozen=True)
class Observation:
setting:int;events:tuple
@dataclass(frozen=True)
class State:
secret:int;status:str='idle';setting:int=0;t:int=0;waves:tuple=();events:tuple=();observations:tuple=();report:tuple=(0,1,2,3);reason:str='まず音を送り、窓の終わりまで聞きます。'
def window(setting):return 6 if setting==0 else 10
def moss_wait(mode):return 0 if mode=='compare' else 2
def launch(secret,setting):return replace(secret,status='listen',setting=setting,t=0,events=(),waves=tuple(Wave(b) for b in ((0,1) if setting==0 else (setting-1,))),reason='観測中。途中の無音だけでは壁を否定しません。')
def step(level,s,mode='base'):
if s.status!='listen':return s
n=s.t+1;waves=[];counts={'S':0,'M':0};reach=2 if s.setting==0 else 4
for w in s.waves:
d,mat=level.maps[s.secret][w.branch]
if w.phase=='out':
p=w.pos+1
if p==d:
wait=moss_wait(mode) if mat=='M' else 0
waves.append(Wave(w.branch,p,'wait' if wait else 'back',wait))
elif p<reach:waves.append(replace(w,pos=p))
elif w.phase=='wait':waves.append(replace(w,wait=w.wait-1,phase='back' if w.wait==1 else 'wait'))
else:
p=w.pos-1
if p==0:counts[mat]+=1
else:waves.append(replace(w,pos=p))
events=s.events+(((n,counts['S'],counts['M']),) if sum(counts.values()) else ())
done=n==window(s.setting)
return replace(s,t=n,waves=tuple(waves),events=events,status='idle' if done else 'listen',observations=s.observations+((Observation(s.setting,events),) if done else ()),reason=('観測完了。返らない枝は、この設定では未確認です。' if done else ('反響が届きました。材質と時刻を一緒に読みます。' if sum(counts.values()) else 'この刻に新しい反響はありません。')))
# ASCII teaching UI. No original glyphs, controller, sound or assets are loaded.
class App:
def __init__(self):
self.level_index = 0
self.mode = 'base'
self.gated = False
self.reset()
@property
def level(self):
return LEVELS[self.level_index] if STAGE >= 3 else None
@property
def state(self):
return self.timeline.state if STAGE == 5 else self.s
def reset(self):
if STAGE == 5:
self.timeline = new(self.level)
else:
self.s = State(self.level.truth) if STAGE >= 3 else Lab()
self.setting = 0
self.cursor = 0
self.marking = False
self.message = 'READY / RELEASE EACH KEY'
def commit(self, action):
before = self.state
if STAGE == 5:
self.timeline = act(self.level, self.timeline, action, self.mode)
elif STAGE == 4:
self.s = judge(self.level, self.s, action, self.mode)
elif STAGE == 3:
if action == 'tick':
self.s = step(self.level, self.s, self.mode)
elif action.startswith('send:') and self.s.status == 'idle':
self.s = launch(self.s, int(action.split(':')[1]))
elif action == 'tick':
self.s = advance(self.s)
self.message = ('ACCEPTED: ' if before != self.state else 'UNCHANGED: ') + action.upper()
def press(self, key):
if key == 'R':
self.reset()
return
if STAGE >= 3 and key in ('1','2','3'):
self.level_index = int(key) - 1
self.reset()
return
if STAGE == 5 and key == 'B':
self.mode = 'compare' if self.mode == 'base' else 'base'
self.reset()
return
if STAGE == 5 and key == 'X':
self.commit('undo')
return
if key == 'SPACE':
self.commit('tick')
elif STAGE >= 3:
if key in ('LEFT','RIGHT'):
self.setting = (self.setting + (1 if key == 'RIGHT' else -1)) % 3
elif STAGE >= 4 and key == 'TAB':
self.marking = not self.marking
elif STAGE >= 4 and key in ('UP','DOWN'):
self.cursor = (self.cursor + (1 if key == 'DOWN' else -1)) % 4
elif key == 'Z':
self.commit('mark:' + str(self.cursor) if self.marking else 'send:' + str(self.setting))
elif STAGE >= 4 and key == 'A':
self.commit('assist')
elif STAGE >= 4 and key == 'RETURN':
self.commit('submit')
def control(self, pressed, held):
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.gated = True
self.message = 'CHORD REJECTED / RELEASE ALL KEYS'
elif len(pressed) == 1:
self.press(next(iter(pressed)))
self.gated = True
def view(self):
s = self.state
if STAGE == 5:
return public_view(self.level, s, self.mode)
if STAGE < 3:
return {'t': s.t, 'status': s.phase, 'events': s.events,
'window': 2 * DISTANCE if STAGE == 1 else max(2 * d + (MOSS_WAIT if m == 'M' else 0) for d,m in WALLS)}
result = {'t': s.t, 'status': s.status, 'events': s.events, 'window': window(s.setting),
'observations': s.observations, 'setting': s.setting, 'report': s.report}
if STAGE == 4:
result['compatible'] = compatible(self.level, s, self.mode)
return result
def draw(self, p):
v = self.view()
p.cls(1)
p.text(14, 12, 'ECHO CAVE / STAGE ' + str(STAGE), 10)
p.text(14, 28, 'FICTIONAL ACOUSTICS / ONE PRESS, ONE TICK / NO AUDIO', 6)
p.text(14, 48, 'TIME ' + str(v['t']) + ' / ' + str(v['window']) + ' STATUS ' + v['status'].upper(), 7)
p.rect(12, 66, 624, 154, 0)
if STAGE == 1:
p.text(24, 78, 'KNOWN LAB: STONE DISTANCE ' + str(DISTANCE) + ' / RETURN = 2 * DISTANCE', 10)
p.line(55, 142, 560, 142, 5)
p.circ(55 + int(505 * self.state.pos / DISTANCE), 142, 7, 10)
p.rect(560, 119, 8, 44, 7)
p.text(30, 180, 'VISIBLE WAVE IN A KNOWN EXERCISE, NOT A HIDDEN CAVE', 6)
elif STAGE == 2:
p.text(24, 78, 'KNOWN LAB: L=2 STONE / R=1 MOSS / MOSS WAIT ' + str(MOSS_WAIT), 10)
for branch in (0,1):
y = 118 + branch * 50
p.line(55,y,555,y,5)
for wave in self.state.waves:
if wave.branch == branch:
x = 55 + wave.pos * 180
p.circ(x,y,6,7 if branch == 0 else 11)
p.text(x+14,y-3,wave.phase.upper() + ' ' + str(wave.wait),10)
p.text(570,y-3,'STONE' if branch == 0 else 'MOSS',7)
else:
p.text(24, 78, 'HIDDEN CAVE ' + self.level.id + ' / NO SECRET OR WAVE POSITIONS HERE', 10)
p.line(105,126,318,174,5);p.line(530,126,318,174,5);p.circ(318,174,8,10)
p.text(54,111,'LEFT: UNKNOWN',6);p.text(466,111,'RIGHT: UNKNOWN',6)
p.text(24,195,'SELECTED ' + ('WIDE / REACH 2 EACH','LEFT / REACH 4','RIGHT / REACH 4')[self.setting],7)
p.text(14, 233, 'RECEIVED EVENTS (TICK, STONE COUNT, MOSS COUNT):', 10)
p.text(14, 249, str(v['events']) if v['events'] else 'NONE YET', 7)
if STAGE >= 3:
p.text(14,270,'COMPLETED WINDOWS ' + str(len(v['observations'])) + ' / NO EARLY ELIMINATION',11)
p.text(14,287,'ACTIVE SETTING ' + str(v['setting']) + ' / SELECTING DOES NOT SEND',6)
if STAGE >= 4:
for i, candidate in enumerate(self.level.maps):
y = 312 + i * 24
line = ('>' if self.marking and i == self.cursor else ' ') + 'H' + str(i+1)
line += ' [KEEP] ' if i in v['report'] else ' [DROP] '
line += 'LEFT ' + str(candidate[0]) + ' RIGHT ' + str(candidate[1])
line += ' POSSIBLE' if i in v['compatible'] else ' CONTRADICTED'
p.text(14,y,line,10 if i in v['compatible'] else 6)
p.text(14,413,'SUBMITTED: ALL POSSIBLE MAPS REPORTED' if v['status']=='submitted' else 'KEEP ALL COMPATIBLE MAPS; MULTIPLE MAPS IS A VALID REPORT',11)
else:
p.text(14,325,'DISTANCE = (ARRIVAL TICK - MATERIAL WAIT) / 2',11)
p.text(14,350,'SILENCE DOES NOT PROVE THAT A WALL DOES NOT EXIST.',6)
if STAGE == 5:
p.text(14,440,'NEXT POSSIBILITIES (STONE,MOSS): ' + str(v['next_events']),10)
p.text(14,458,'MOSS WAIT ' + str(v['wait']) + ' / X UNDO / B RESTARTS COMPARISON',6)
p.text(14,487,self.message,10)
p.text(14,510,'SPACE TICK / R RESET SAME CODE / ESC QUIT',7)
if STAGE >= 3:
p.text(14,526,'LEFT/RIGHT SETTING / Z SEND / 1-3 CAVE / RELEASE EVERY KEY',7)
if STAGE >= 4:
p.text(14,542,'TAB MAP MODE / UP/DOWN MAP / Z TOGGLE / A ASSIST / ENTER REPORT',6)
def demo_keys():
if STAGE == 1:
return ('SPACE',) * 6
if STAGE == 2:
return ('SPACE',) * 4
return ('Z',) + ('SPACE',) * 6 + (('A','RETURN') if STAGE >= 4 else ())
def main(headless=False, screenshot=None, state_json=None, demo=False):
import pyxel as p
import json
from pathlib import Path
p.init(648, 564, title='Echo cave lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless)
app = App()
sequence = demo_keys() if demo else ()
keynames = ('LEFT','RIGHT','UP','DOWN','Z','SPACE','RETURN','TAB','A','X','R','B','1','2','3')
keys = {k: getattr(p, 'KEY_' + k) for k in keynames}
limit = max(8, len(sequence) * 2 + 6)
def update():
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
if screenshot:
app.draw(p)
p.screen.save(screenshot, 1)
if state_json:
# Public evidence only: do not export the secret or hidden waves for puzzle stages.
Path(state_json).write_text(json.dumps({'stage': STAGE, 'public': app.view(), 'scope': 'Actual Linux Pyxel, synthetic controls; no human playtest'}, ensure_ascii=False, indent=2, default=lambda o: asdict(o)) + '\n', encoding='utf-8')
p.quit()
if headless:
i = p.frame_count // 2
pressed = {sequence[i]} if demo and p.frame_count % 2 == 0 and i < len(sequence) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
p.run(update, lambda: app.draw(p))
if __name__ == '__main__':
import argparse
from pathlib import Path
parser = argparse.ArgumentParser(description='Standalone echo cave teaching stage')
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--demo-checkpoint', action='store_true')
parser.add_argument('--screenshot')
parser.add_argument('--state-json')
args = parser.parse_args()
if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
parser.error('demo and outputs require --headless-smoke')
for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
value = getattr(args, field)
if value:
target = Path(value).resolve()
if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
parser.error('output must be a new ' + suffix + ' in an existing directory')
setattr(args, field, str(target))
main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)両立する候補を全部報告する
仮説の再生・「すべてで成り立つ」という条件・1つに絞らない成功。
03 で聞き終えた観測を証拠として、4枚の公開された地図を比べます。前提は、tuple の内包表記、all、set の対称差です。候補の地図は公開された仮説なので画面に描けますが、どれが本当の答えかを直接見て判定してはいけません。signature は、指定した候補 i に音を送り、window 刻だけ step で再生して、出来事の列を返します。式だけで別の近似の計算を作らず、実際の観測と同じルールを使うところが大切です。
compatible は、それぞれの候補 i について、保存した全部の Observation の setting を signature で再生し、events がすべて一致する候補だけを tuple にします。all の対象が空なら、全部の候補が残ります。聞いている途中の s.events はここでは使わず、03 の「聞き終えた窓だけ」を守ります。状況 A で広く6刻聞くと、H1 と H4 の両方がありえます。A キーは、ありえる候補すべてに report(報告)を合わせる手助けです。Tab で候補の編集に入り、上下で選び、Z で残す・外すを切り替えられます。
Enter での報告は、聞き終えた観測が1件以上あって、report と compatible が完全に一致したときに submitted(提出済み)になります。1枚に絞ることは必須ではありません。H1/H4 の両方を残す、結論を保留した報告も、正しい達成です。まちがった候補を余計に残すことも、まだありえる候補を根拠なく捨てることも、受け付けません。submitted のあとは、step も追加の編集も進まず、R でやり直します。03 では観測だけだった処理が、judge にまとまりました。
この段階の judge は教材用で、元のゲームの act の履歴・理由の文の更新・Undo はまだありません。signature と compatible、観測の定義は、元のゲームと同じです。画面の POSSIBLE は、聞き終えた窓だけから出した結果で、見えない波から判定してはいません。次は、05 と元のゲームの model.py の act の past、public_view の prefix と next_events を読みます。
下のコードは 04_evidence_candidates.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
def compatible(level,s,mode='base'):
return tuple(i for i in range(4) if all(signature(level,i,o.setting,mode)==o.events for o in s.observations))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_evidence_candidates.py.venv/bin/python 04_evidence_candidates.py- 本文の順番どおりに、キーを1つずつ離して入力する
- 観測(実際に聞いた音)と、予測を区別する
- 保存→ゲームを閉じる→再実行で、変更が反映される
compatible に本当の答えの番号を返させると、ゲームの推理がなくなってしまいます。候補ごとの予測と、聞き終えた実際の音だけを比べます。State の report:tuple=(0,1,2,3) を report:tuple=(0,) に変えてみましょう。保存 → Esc で終了 → python 04_evidence_candidates.py で実行し直します。Z → Space を6回 → Enter では IDLE のままです。A → Enter で H1/H4 を残して SUBMITTED になります。キーは1つずつ離してから次を押します。R は読み直しをしません。自動の確認は python exercises/04_report.py です。
このステップのコード全文(296行)
# 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.
"""079 こだまする洞窟: 教材段階 4。"""
STAGE = 4
from dataclasses import dataclass, replace, asdict
VERSION='079-v1.0.1'
SETTINGS=('左右へ広げる','左へ遠く','右へ遠く')
@dataclass(frozen=True)
class Level:
id:str;title:str;maps:tuple;truth:int
LEVELS=(Level('A','最初の反響',(((1,'S'),(3,'M')),((1,'M'),(3,'S')),((2,'S'),(3,'M')),((1,'S'),(4,'M'))),0),Level('B','重なった二つの音',(((2,'S'),(1,'M')),((1,'M'),(2,'S')),((2,'M'),(1,'S')),((3,'S'),(1,'M'))),1),Level('C','何も返らない入口',(((4,'M'),(3,'S')),((3,'M'),(4,'S')),((4,'S'),(3,'M')),((4,'M'),(4,'M'))),2))
@dataclass(frozen=True)
class Wave:
branch:int;pos:int=0;phase:str='out';wait:int=0
@dataclass(frozen=True)
class Observation:
setting:int;events:tuple
@dataclass(frozen=True)
class State:
secret:int;status:str='idle';setting:int=0;t:int=0;waves:tuple=();events:tuple=();observations:tuple=();report:tuple=(0,1,2,3);reason:str='まず音を送り、窓の終わりまで聞きます。'
def window(setting):return 6 if setting==0 else 10
def moss_wait(mode):return 0 if mode=='compare' else 2
def launch(secret,setting):return replace(secret,status='listen',setting=setting,t=0,events=(),waves=tuple(Wave(b) for b in ((0,1) if setting==0 else (setting-1,))),reason='観測中。途中の無音だけでは壁を否定しません。')
def step(level,s,mode='base'):
if s.status!='listen':return s
n=s.t+1;waves=[];counts={'S':0,'M':0};reach=2 if s.setting==0 else 4
for w in s.waves:
d,mat=level.maps[s.secret][w.branch]
if w.phase=='out':
p=w.pos+1
if p==d:
wait=moss_wait(mode) if mat=='M' else 0
waves.append(Wave(w.branch,p,'wait' if wait else 'back',wait))
elif p<reach:waves.append(replace(w,pos=p))
elif w.phase=='wait':waves.append(replace(w,wait=w.wait-1,phase='back' if w.wait==1 else 'wait'))
else:
p=w.pos-1
if p==0:counts[mat]+=1
else:waves.append(replace(w,pos=p))
events=s.events+(((n,counts['S'],counts['M']),) if sum(counts.values()) else ())
done=n==window(s.setting)
return replace(s,t=n,waves=tuple(waves),events=events,status='idle' if done else 'listen',observations=s.observations+((Observation(s.setting,events),) if done else ()),reason=('観測完了。返らない枝は、この設定では未確認です。' if done else ('反響が届きました。材質と時刻を一緒に読みます。' if sum(counts.values()) else 'この刻に新しい反響はありません。')))
def signature(level,index,setting,mode='base'):
s=launch(State(index),setting)
for _ in range(window(setting)):s=step(level,s,mode)
return s.events
def compatible(level,s,mode='base'):
return tuple(i for i in range(4) if all(signature(level,i,o.setting,mode)==o.events for o in s.observations))
def judge(level, s, action, mode='base'):
if action == 'tick':
return step(level, s, mode)
if s.status != 'idle':
return s
if action.startswith('send:'):
return launch(s, int(action.split(':')[1]))
if action == 'assist':
return replace(s, report=compatible(level, s, mode))
if action.startswith('mark:'):
report = set(s.report)
report.symmetric_difference_update({int(action.split(':')[1])})
return replace(s, report=tuple(sorted(report)))
if action == 'submit' and s.observations and s.report == compatible(level, s, mode):
return replace(s, status='submitted')
return s
# ASCII teaching UI. No original glyphs, controller, sound or assets are loaded.
class App:
def __init__(self):
self.level_index = 0
self.mode = 'base'
self.gated = False
self.reset()
@property
def level(self):
return LEVELS[self.level_index] if STAGE >= 3 else None
@property
def state(self):
return self.timeline.state if STAGE == 5 else self.s
def reset(self):
if STAGE == 5:
self.timeline = new(self.level)
else:
self.s = State(self.level.truth) if STAGE >= 3 else Lab()
self.setting = 0
self.cursor = 0
self.marking = False
self.message = 'READY / RELEASE EACH KEY'
def commit(self, action):
before = self.state
if STAGE == 5:
self.timeline = act(self.level, self.timeline, action, self.mode)
elif STAGE == 4:
self.s = judge(self.level, self.s, action, self.mode)
elif STAGE == 3:
if action == 'tick':
self.s = step(self.level, self.s, self.mode)
elif action.startswith('send:') and self.s.status == 'idle':
self.s = launch(self.s, int(action.split(':')[1]))
elif action == 'tick':
self.s = advance(self.s)
self.message = ('ACCEPTED: ' if before != self.state else 'UNCHANGED: ') + action.upper()
def press(self, key):
if key == 'R':
self.reset()
return
if STAGE >= 3 and key in ('1','2','3'):
self.level_index = int(key) - 1
self.reset()
return
if STAGE == 5 and key == 'B':
self.mode = 'compare' if self.mode == 'base' else 'base'
self.reset()
return
if STAGE == 5 and key == 'X':
self.commit('undo')
return
if key == 'SPACE':
self.commit('tick')
elif STAGE >= 3:
if key in ('LEFT','RIGHT'):
self.setting = (self.setting + (1 if key == 'RIGHT' else -1)) % 3
elif STAGE >= 4 and key == 'TAB':
self.marking = not self.marking
elif STAGE >= 4 and key in ('UP','DOWN'):
self.cursor = (self.cursor + (1 if key == 'DOWN' else -1)) % 4
elif key == 'Z':
self.commit('mark:' + str(self.cursor) if self.marking else 'send:' + str(self.setting))
elif STAGE >= 4 and key == 'A':
self.commit('assist')
elif STAGE >= 4 and key == 'RETURN':
self.commit('submit')
def control(self, pressed, held):
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.gated = True
self.message = 'CHORD REJECTED / RELEASE ALL KEYS'
elif len(pressed) == 1:
self.press(next(iter(pressed)))
self.gated = True
def view(self):
s = self.state
if STAGE == 5:
return public_view(self.level, s, self.mode)
if STAGE < 3:
return {'t': s.t, 'status': s.phase, 'events': s.events,
'window': 2 * DISTANCE if STAGE == 1 else max(2 * d + (MOSS_WAIT if m == 'M' else 0) for d,m in WALLS)}
result = {'t': s.t, 'status': s.status, 'events': s.events, 'window': window(s.setting),
'observations': s.observations, 'setting': s.setting, 'report': s.report}
if STAGE == 4:
result['compatible'] = compatible(self.level, s, self.mode)
return result
def draw(self, p):
v = self.view()
p.cls(1)
p.text(14, 12, 'ECHO CAVE / STAGE ' + str(STAGE), 10)
p.text(14, 28, 'FICTIONAL ACOUSTICS / ONE PRESS, ONE TICK / NO AUDIO', 6)
p.text(14, 48, 'TIME ' + str(v['t']) + ' / ' + str(v['window']) + ' STATUS ' + v['status'].upper(), 7)
p.rect(12, 66, 624, 154, 0)
if STAGE == 1:
p.text(24, 78, 'KNOWN LAB: STONE DISTANCE ' + str(DISTANCE) + ' / RETURN = 2 * DISTANCE', 10)
p.line(55, 142, 560, 142, 5)
p.circ(55 + int(505 * self.state.pos / DISTANCE), 142, 7, 10)
p.rect(560, 119, 8, 44, 7)
p.text(30, 180, 'VISIBLE WAVE IN A KNOWN EXERCISE, NOT A HIDDEN CAVE', 6)
elif STAGE == 2:
p.text(24, 78, 'KNOWN LAB: L=2 STONE / R=1 MOSS / MOSS WAIT ' + str(MOSS_WAIT), 10)
for branch in (0,1):
y = 118 + branch * 50
p.line(55,y,555,y,5)
for wave in self.state.waves:
if wave.branch == branch:
x = 55 + wave.pos * 180
p.circ(x,y,6,7 if branch == 0 else 11)
p.text(x+14,y-3,wave.phase.upper() + ' ' + str(wave.wait),10)
p.text(570,y-3,'STONE' if branch == 0 else 'MOSS',7)
else:
p.text(24, 78, 'HIDDEN CAVE ' + self.level.id + ' / NO SECRET OR WAVE POSITIONS HERE', 10)
p.line(105,126,318,174,5);p.line(530,126,318,174,5);p.circ(318,174,8,10)
p.text(54,111,'LEFT: UNKNOWN',6);p.text(466,111,'RIGHT: UNKNOWN',6)
p.text(24,195,'SELECTED ' + ('WIDE / REACH 2 EACH','LEFT / REACH 4','RIGHT / REACH 4')[self.setting],7)
p.text(14, 233, 'RECEIVED EVENTS (TICK, STONE COUNT, MOSS COUNT):', 10)
p.text(14, 249, str(v['events']) if v['events'] else 'NONE YET', 7)
if STAGE >= 3:
p.text(14,270,'COMPLETED WINDOWS ' + str(len(v['observations'])) + ' / NO EARLY ELIMINATION',11)
p.text(14,287,'ACTIVE SETTING ' + str(v['setting']) + ' / SELECTING DOES NOT SEND',6)
if STAGE >= 4:
for i, candidate in enumerate(self.level.maps):
y = 312 + i * 24
line = ('>' if self.marking and i == self.cursor else ' ') + 'H' + str(i+1)
line += ' [KEEP] ' if i in v['report'] else ' [DROP] '
line += 'LEFT ' + str(candidate[0]) + ' RIGHT ' + str(candidate[1])
line += ' POSSIBLE' if i in v['compatible'] else ' CONTRADICTED'
p.text(14,y,line,10 if i in v['compatible'] else 6)
p.text(14,413,'SUBMITTED: ALL POSSIBLE MAPS REPORTED' if v['status']=='submitted' else 'KEEP ALL COMPATIBLE MAPS; MULTIPLE MAPS IS A VALID REPORT',11)
else:
p.text(14,325,'DISTANCE = (ARRIVAL TICK - MATERIAL WAIT) / 2',11)
p.text(14,350,'SILENCE DOES NOT PROVE THAT A WALL DOES NOT EXIST.',6)
if STAGE == 5:
p.text(14,440,'NEXT POSSIBILITIES (STONE,MOSS): ' + str(v['next_events']),10)
p.text(14,458,'MOSS WAIT ' + str(v['wait']) + ' / X UNDO / B RESTARTS COMPARISON',6)
p.text(14,487,self.message,10)
p.text(14,510,'SPACE TICK / R RESET SAME CODE / ESC QUIT',7)
if STAGE >= 3:
p.text(14,526,'LEFT/RIGHT SETTING / Z SEND / 1-3 CAVE / RELEASE EVERY KEY',7)
if STAGE >= 4:
p.text(14,542,'TAB MAP MODE / UP/DOWN MAP / Z TOGGLE / A ASSIST / ENTER REPORT',6)
def demo_keys():
if STAGE == 1:
return ('SPACE',) * 6
if STAGE == 2:
return ('SPACE',) * 4
return ('Z',) + ('SPACE',) * 6 + (('A','RETURN') if STAGE >= 4 else ())
def main(headless=False, screenshot=None, state_json=None, demo=False):
import pyxel as p
import json
from pathlib import Path
p.init(648, 564, title='Echo cave lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless)
app = App()
sequence = demo_keys() if demo else ()
keynames = ('LEFT','RIGHT','UP','DOWN','Z','SPACE','RETURN','TAB','A','X','R','B','1','2','3')
keys = {k: getattr(p, 'KEY_' + k) for k in keynames}
limit = max(8, len(sequence) * 2 + 6)
def update():
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
if screenshot:
app.draw(p)
p.screen.save(screenshot, 1)
if state_json:
# Public evidence only: do not export the secret or hidden waves for puzzle stages.
Path(state_json).write_text(json.dumps({'stage': STAGE, 'public': app.view(), 'scope': 'Actual Linux Pyxel, synthetic controls; no human playtest'}, ensure_ascii=False, indent=2, default=lambda o: asdict(o)) + '\n', encoding='utf-8')
p.quit()
if headless:
i = p.frame_count // 2
pressed = {sequence[i]} if demo and p.frame_count % 2 == 0 and i < len(sequence) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
p.run(update, lambda: app.draw(p))
if __name__ == '__main__':
import argparse
from pathlib import Path
parser = argparse.ArgumentParser(description='Standalone echo cave teaching stage')
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--demo-checkpoint', action='store_true')
parser.add_argument('--screenshot')
parser.add_argument('--state-json')
args = parser.parse_args()
if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
parser.error('demo and outputs require --headless-smoke')
for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
value = getattr(args, field)
if value:
target = Path(value).resolve()
if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
parser.error('output must be a new ' + suffix + ' in an existing directory')
setattr(args, field, str(target))
main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)秘密を漏らさない予告とUndo
見せてよい情報の境目・ありえる候補の集まり・判断の履歴。
最後は、元のゲームの model.py の定義・定数を全部そのまま入れます。03/04 で組み立てた観測と推理を、Timeline と act につなぎます。前提は、tuple のスライス、集合、関数の使い回しです。Timeline.state は今の状態、past は判断する前の状態です。音を送る・候補を編集する・手助け・受け付けた報告では、過去の状態を積みます。tick は波を進めますが、past には積みません。X は1刻戻る操作ではなく、直前の判断に戻る操作です。観測の途中や、聞き終えた直後なら、音を送る前まで戻って、その観測を取り消します。
public_view は、描画に渡してよい情報だけを作ります。まず、聞き終えた窓で compatible を求めます。観測の途中では、それぞれの候補の signature のうち、今の t までの始めの部分が、実際の events と一致する候補を prefix に残します。次の刻の予告 next_events は、prefix の各候補が次の刻に返す (石の数,苔の数) の集まりです。秘密の s.secret で1枚を選んで作った予告ではありません。同じ観測の履歴なら、本当の答えが違っても、同じ予告になります。
途中で音がしないことも「ここまでの始めの部分」に含まれるので、予告に出る可能性を減らせます。一方、画面の compatible と報告の根拠は、聞き終えた窓だけで更新します。予告の1件を「必ず起きる未来」として説明しないことが大切です。状況 C の広い窓では、どの候補も音がしないので、4枚とも残す、正しい保留の報告ができます。3つの設定を全部聞けば、元の版でも比較版でも、どの洞窟も1枚に絞れますが、基本の達成に1枚に絞ることは不要です。
B は、苔の待ちだけを 2 から 0 に変える、元のゲームの比較の切り替えで、洞窟を最初の状態にします。1/2/3 は洞窟の切り替え、R は同じコードと今のモードで最初に戻します。元のゲームの Controller・日本語の文字・表紙・ヘルプ・自動の連続再生・音・S/F12・長いデモは、教材には入れていません。簡単な英数字(ASCII)の手動の画面でも、候補のデータ全部、時間のルール、判定、見せてよい予告、判断ごとの Undo はそのまま持っています。画面は安全な整数だけを act に渡します。元のゲームの act に、おかしな send: の文字列を直接渡すと ValueError になり、mark:9 は範囲外でも受け付けてしまうので、これを、どんな入力でも確かめ済みの仕様とは主張しません。
次に、元のゲームの controller.py の feed のキーを待つ仕組み、automatic_tick、app.py の audio と、public_view を使う draw を読むと、同じ出来事から表示と音を出す作りに進めます。digest は再現用の記録のハッシュ(内容から作る短い値)で、秘密を画面に出してよいと許可する関数ではありません。
下のコードは 05_public_forecast.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
prefix=tuple(i for i in c if tuple(e for e in signature(level,i,s.setting,mode) if e[0]<=s.t)==s.events) if s.status=='listen' else c
next_events=sorted(set(next((e[1:] for e in signature(level,i,s.setting,mode) if e[0]==s.t+1),(0,0)) for i in prefix)) if s.status=='listen' else []実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_public_forecast.py.venv/bin/python 05_public_forecast.py- 本文の順番どおりに、キーを1つずつ離して入力する
- 観測(実際に聞いた音)と、予測を区別する
- 保存→ゲームを閉じる→再実行で、変更が反映される
secret から直接計算して1件だけ表示すると、隠された地図がばれてしまいます。prefix の全候補から集まりを作り、表示している間も、元の状態は変えません。moss_wait の return 0 if mode=='compare' else 2 を return 0 if mode=='compare' else 1 に変えてみましょう。保存 → Esc で終了 → python 05_public_forecast.py で実行し直します。2 → Z → Space を6回 → A → Enter を、1つずつ離しながら入力します。状況 B の洞窟の出来事は ((3,0,1),(4,1,0)) で、2つの候補での報告が受け付けられます。X を1回押すと、報告する前の IDLE に戻ります。R はコードを読み直しません。自動の確認は python exercises/05_wait_undo.py です。
このステップのコード全文(321行)
# 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.
"""079 こだまする洞窟: 教材段階 5。"""
STAGE = 5
"""Finite toy acoustics. Rendering receives public_view, never hidden wave positions."""
from dataclasses import dataclass,replace,asdict
import hashlib,json,itertools
VERSION='079-v1.0.1'
SETTINGS=('左右へ広げる','左へ遠く','右へ遠く')
@dataclass(frozen=True)
class Level:
id:str;title:str;maps:tuple;truth:int
LEVELS=(Level('A','最初の反響',(((1,'S'),(3,'M')),((1,'M'),(3,'S')),((2,'S'),(3,'M')),((1,'S'),(4,'M'))),0),Level('B','重なった二つの音',(((2,'S'),(1,'M')),((1,'M'),(2,'S')),((2,'M'),(1,'S')),((3,'S'),(1,'M'))),1),Level('C','何も返らない入口',(((4,'M'),(3,'S')),((3,'M'),(4,'S')),((4,'S'),(3,'M')),((4,'M'),(4,'M'))),2))
@dataclass(frozen=True)
class Wave:
branch:int;pos:int=0;phase:str='out';wait:int=0
@dataclass(frozen=True)
class Observation:
setting:int;events:tuple
@dataclass(frozen=True)
class State:
secret:int;status:str='idle';setting:int=0;t:int=0;waves:tuple=();events:tuple=();observations:tuple=();report:tuple=(0,1,2,3);reason:str='まず音を送り、窓の終わりまで聞きます。'
@dataclass(frozen=True)
class Timeline:
state:State;past:tuple=();actions:tuple=()
def new(level):return Timeline(State(level.truth))
def window(setting):return 6 if setting==0 else 10
def moss_wait(mode):return 0 if mode=='compare' else 2
def launch(secret,setting):return replace(secret,status='listen',setting=setting,t=0,events=(),waves=tuple(Wave(b) for b in ((0,1) if setting==0 else (setting-1,))),reason='観測中。途中の無音だけでは壁を否定しません。')
def step(level,s,mode='base'):
if s.status!='listen':return s
n=s.t+1;waves=[];counts={'S':0,'M':0};reach=2 if s.setting==0 else 4
for w in s.waves:
d,mat=level.maps[s.secret][w.branch]
if w.phase=='out':
p=w.pos+1
if p==d:
wait=moss_wait(mode) if mat=='M' else 0
waves.append(Wave(w.branch,p,'wait' if wait else 'back',wait))
elif p<reach:waves.append(replace(w,pos=p))
elif w.phase=='wait':waves.append(replace(w,wait=w.wait-1,phase='back' if w.wait==1 else 'wait'))
else:
p=w.pos-1
if p==0:counts[mat]+=1
else:waves.append(replace(w,pos=p))
events=s.events+(((n,counts['S'],counts['M']),) if sum(counts.values()) else ())
done=n==window(s.setting)
return replace(s,t=n,waves=tuple(waves),events=events,status='idle' if done else 'listen',observations=s.observations+((Observation(s.setting,events),) if done else ()),reason=('観測完了。返らない枝は、この設定では未確認です。' if done else ('反響が届きました。材質と時刻を一緒に読みます。' if sum(counts.values()) else 'この刻に新しい反響はありません。')))
def signature(level,index,setting,mode='base'):
s=launch(State(index),setting)
for _ in range(window(setting)):s=step(level,s,mode)
return s.events
def compatible(level,s,mode='base'):
return tuple(i for i in range(4) if all(signature(level,i,o.setting,mode)==o.events for o in s.observations))
def act(level,tl,action,mode='base'):
s=tl.state
if action=='undo':
if not tl.past:return tl
return Timeline(tl.past[-1],tl.past[:-1],tl.actions+('undo',))
if action.startswith('send:') and s.status=='idle':
cfg=int(action.split(':')[1])
if cfg not in range(3):return tl
return Timeline(launch(s,cfg),tl.past+(s,),tl.actions+(action,))
if action=='tick' and s.status=='listen':return replace(tl,state=step(level,s,mode),actions=tl.actions+(action,))
if action=='assist' and s.status=='idle':return Timeline(replace(s,report=compatible(level,s,mode),reason='完了した観測と両立する全候補を残しました。'),tl.past+(s,),tl.actions+(action,))
if action.startswith('mark:') and s.status=='idle':
i=int(action.split(':')[1]);r=set(s.report);r.symmetric_difference_update({i});return Timeline(replace(s,report=tuple(sorted(r))),tl.past+(s,),tl.actions+(action,))
if action=='submit' and s.status=='idle':
c=compatible(level,s,mode)
if not s.observations:return replace(tl,state=replace(s,reason='まだ未確認。少なくとも一回、観測を完了してください。'))
if s.report!=c:return replace(tl,state=replace(s,reason='証拠と両立する全候補を残してください。Aで根拠を確認。'))
return Timeline(replace(s,status='submitted',reason='形が分かった。根拠のある一枚を報告。' if len(c)==1 else '保留の報告を受理。残る候補を正確に伝えました。'),tl.past+(s,),tl.actions+(action,))
return tl
def public_view(level,s,mode='base'):
"""No secret, waves, unseen wall coordinates or future realized events."""
c=compatible(level,s,mode)
# During a listening window, only observed prefixes can refine next-tick possibilities.
prefix=tuple(i for i in c if tuple(e for e in signature(level,i,s.setting,mode) if e[0]<=s.t)==s.events) if s.status=='listen' else c
next_events=sorted(set(next((e[1:] for e in signature(level,i,s.setting,mode) if e[0]==s.t+1),(0,0)) for i in prefix)) if s.status=='listen' else []
reasons=[]
for i in range(4):
mismatch=next((o for o in s.observations if signature(level,i,o.setting,mode)!=o.events),None)
if mismatch is None:reasons.append('候補')
else:
predicted={t:(a,b) for t,a,b in signature(level,i,mismatch.setting,mode)};actual={t:(a,b) for t,a,b in mismatch.events}
tick=next(t for t in sorted(set(predicted)|set(actual)) if predicted.get(t,(0,0))!=actual.get(t,(0,0)))
a=predicted.get(tick,(0,0));b=actual.get(tick,(0,0));mat=0 if a[0]!=b[0] else 1
reasons.append('不成立 '+str(tick)+'刻 '+('石' if mat==0 else '苔')+str(a[mat])+' / 実測'+str(b[mat]))
return {'status':s.status,'setting':s.setting,'t':s.t,'window':window(s.setting),'events':s.events,'observations':[asdict(o) for o in s.observations],'report':s.report,'compatible':c,'reasons':reasons,'next_events':next_events,'reason':s.reason,'wait':moss_wait(mode)}
def digest(value):return hashlib.sha256(json.dumps(value,ensure_ascii=False,sort_keys=True,separators=(',',':')).encode('utf-8')).hexdigest()
# ASCII teaching UI. No original glyphs, controller, sound or assets are loaded.
class App:
def __init__(self):
self.level_index = 0
self.mode = 'base'
self.gated = False
self.reset()
@property
def level(self):
return LEVELS[self.level_index] if STAGE >= 3 else None
@property
def state(self):
return self.timeline.state if STAGE == 5 else self.s
def reset(self):
if STAGE == 5:
self.timeline = new(self.level)
else:
self.s = State(self.level.truth) if STAGE >= 3 else Lab()
self.setting = 0
self.cursor = 0
self.marking = False
self.message = 'READY / RELEASE EACH KEY'
def commit(self, action):
before = self.state
if STAGE == 5:
self.timeline = act(self.level, self.timeline, action, self.mode)
elif STAGE == 4:
self.s = judge(self.level, self.s, action, self.mode)
elif STAGE == 3:
if action == 'tick':
self.s = step(self.level, self.s, self.mode)
elif action.startswith('send:') and self.s.status == 'idle':
self.s = launch(self.s, int(action.split(':')[1]))
elif action == 'tick':
self.s = advance(self.s)
self.message = ('ACCEPTED: ' if before != self.state else 'UNCHANGED: ') + action.upper()
def press(self, key):
if key == 'R':
self.reset()
return
if STAGE >= 3 and key in ('1','2','3'):
self.level_index = int(key) - 1
self.reset()
return
if STAGE == 5 and key == 'B':
self.mode = 'compare' if self.mode == 'base' else 'base'
self.reset()
return
if STAGE == 5 and key == 'X':
self.commit('undo')
return
if key == 'SPACE':
self.commit('tick')
elif STAGE >= 3:
if key in ('LEFT','RIGHT'):
self.setting = (self.setting + (1 if key == 'RIGHT' else -1)) % 3
elif STAGE >= 4 and key == 'TAB':
self.marking = not self.marking
elif STAGE >= 4 and key in ('UP','DOWN'):
self.cursor = (self.cursor + (1 if key == 'DOWN' else -1)) % 4
elif key == 'Z':
self.commit('mark:' + str(self.cursor) if self.marking else 'send:' + str(self.setting))
elif STAGE >= 4 and key == 'A':
self.commit('assist')
elif STAGE >= 4 and key == 'RETURN':
self.commit('submit')
def control(self, pressed, held):
if self.gated:
if not pressed and not held:
self.gated = False
return
if len(pressed | held) > 1:
self.gated = True
self.message = 'CHORD REJECTED / RELEASE ALL KEYS'
elif len(pressed) == 1:
self.press(next(iter(pressed)))
self.gated = True
def view(self):
s = self.state
if STAGE == 5:
return public_view(self.level, s, self.mode)
if STAGE < 3:
return {'t': s.t, 'status': s.phase, 'events': s.events,
'window': 2 * DISTANCE if STAGE == 1 else max(2 * d + (MOSS_WAIT if m == 'M' else 0) for d,m in WALLS)}
result = {'t': s.t, 'status': s.status, 'events': s.events, 'window': window(s.setting),
'observations': s.observations, 'setting': s.setting, 'report': s.report}
if STAGE == 4:
result['compatible'] = compatible(self.level, s, self.mode)
return result
def draw(self, p):
v = self.view()
p.cls(1)
p.text(14, 12, 'ECHO CAVE / STAGE ' + str(STAGE), 10)
p.text(14, 28, 'FICTIONAL ACOUSTICS / ONE PRESS, ONE TICK / NO AUDIO', 6)
p.text(14, 48, 'TIME ' + str(v['t']) + ' / ' + str(v['window']) + ' STATUS ' + v['status'].upper(), 7)
p.rect(12, 66, 624, 154, 0)
if STAGE == 1:
p.text(24, 78, 'KNOWN LAB: STONE DISTANCE ' + str(DISTANCE) + ' / RETURN = 2 * DISTANCE', 10)
p.line(55, 142, 560, 142, 5)
p.circ(55 + int(505 * self.state.pos / DISTANCE), 142, 7, 10)
p.rect(560, 119, 8, 44, 7)
p.text(30, 180, 'VISIBLE WAVE IN A KNOWN EXERCISE, NOT A HIDDEN CAVE', 6)
elif STAGE == 2:
p.text(24, 78, 'KNOWN LAB: L=2 STONE / R=1 MOSS / MOSS WAIT ' + str(MOSS_WAIT), 10)
for branch in (0,1):
y = 118 + branch * 50
p.line(55,y,555,y,5)
for wave in self.state.waves:
if wave.branch == branch:
x = 55 + wave.pos * 180
p.circ(x,y,6,7 if branch == 0 else 11)
p.text(x+14,y-3,wave.phase.upper() + ' ' + str(wave.wait),10)
p.text(570,y-3,'STONE' if branch == 0 else 'MOSS',7)
else:
p.text(24, 78, 'HIDDEN CAVE ' + self.level.id + ' / NO SECRET OR WAVE POSITIONS HERE', 10)
p.line(105,126,318,174,5);p.line(530,126,318,174,5);p.circ(318,174,8,10)
p.text(54,111,'LEFT: UNKNOWN',6);p.text(466,111,'RIGHT: UNKNOWN',6)
p.text(24,195,'SELECTED ' + ('WIDE / REACH 2 EACH','LEFT / REACH 4','RIGHT / REACH 4')[self.setting],7)
p.text(14, 233, 'RECEIVED EVENTS (TICK, STONE COUNT, MOSS COUNT):', 10)
p.text(14, 249, str(v['events']) if v['events'] else 'NONE YET', 7)
if STAGE >= 3:
p.text(14,270,'COMPLETED WINDOWS ' + str(len(v['observations'])) + ' / NO EARLY ELIMINATION',11)
p.text(14,287,'ACTIVE SETTING ' + str(v['setting']) + ' / SELECTING DOES NOT SEND',6)
if STAGE >= 4:
for i, candidate in enumerate(self.level.maps):
y = 312 + i * 24
line = ('>' if self.marking and i == self.cursor else ' ') + 'H' + str(i+1)
line += ' [KEEP] ' if i in v['report'] else ' [DROP] '
line += 'LEFT ' + str(candidate[0]) + ' RIGHT ' + str(candidate[1])
line += ' POSSIBLE' if i in v['compatible'] else ' CONTRADICTED'
p.text(14,y,line,10 if i in v['compatible'] else 6)
p.text(14,413,'SUBMITTED: ALL POSSIBLE MAPS REPORTED' if v['status']=='submitted' else 'KEEP ALL COMPATIBLE MAPS; MULTIPLE MAPS IS A VALID REPORT',11)
else:
p.text(14,325,'DISTANCE = (ARRIVAL TICK - MATERIAL WAIT) / 2',11)
p.text(14,350,'SILENCE DOES NOT PROVE THAT A WALL DOES NOT EXIST.',6)
if STAGE == 5:
p.text(14,440,'NEXT POSSIBILITIES (STONE,MOSS): ' + str(v['next_events']),10)
p.text(14,458,'MOSS WAIT ' + str(v['wait']) + ' / X UNDO / B RESTARTS COMPARISON',6)
p.text(14,487,self.message,10)
p.text(14,510,'SPACE TICK / R RESET SAME CODE / ESC QUIT',7)
if STAGE >= 3:
p.text(14,526,'LEFT/RIGHT SETTING / Z SEND / 1-3 CAVE / RELEASE EVERY KEY',7)
if STAGE >= 4:
p.text(14,542,'TAB MAP MODE / UP/DOWN MAP / Z TOGGLE / A ASSIST / ENTER REPORT',6)
def demo_keys():
if STAGE == 1:
return ('SPACE',) * 6
if STAGE == 2:
return ('SPACE',) * 4
return ('Z',) + ('SPACE',) * 6 + (('A','RETURN') if STAGE >= 4 else ())
def main(headless=False, screenshot=None, state_json=None, demo=False):
import pyxel as p
import json
from pathlib import Path
p.init(648, 564, title='Echo cave lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless)
app = App()
sequence = demo_keys() if demo else ()
keynames = ('LEFT','RIGHT','UP','DOWN','Z','SPACE','RETURN','TAB','A','X','R','B','1','2','3')
keys = {k: getattr(p, 'KEY_' + k) for k in keynames}
limit = max(8, len(sequence) * 2 + 6)
def update():
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
if screenshot:
app.draw(p)
p.screen.save(screenshot, 1)
if state_json:
# Public evidence only: do not export the secret or hidden waves for puzzle stages.
Path(state_json).write_text(json.dumps({'stage': STAGE, 'public': app.view(), 'scope': 'Actual Linux Pyxel, synthetic controls; no human playtest'}, ensure_ascii=False, indent=2, default=lambda o: asdict(o)) + '\n', encoding='utf-8')
p.quit()
if headless:
i = p.frame_count // 2
pressed = {sequence[i]} if demo and p.frame_count % 2 == 0 and i < len(sequence) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
p.run(update, lambda: app.draw(p))
if __name__ == '__main__':
import argparse
from pathlib import Path
parser = argparse.ArgumentParser(description='Standalone echo cave teaching stage')
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--demo-checkpoint', action='store_true')
parser.add_argument('--screenshot')
parser.add_argument('--state-json')
args = parser.parse_args()
if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
parser.error('demo and outputs require --headless-smoke')
for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
value = getattr(args, field)
if value:
target = Path(value).resolve()
if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
parser.error('output must be a new ' + suffix + ' in an existing directory')
setattr(args, field, str(target))
main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)仕組みがわかったら、元の完成版を読んでみよう。
ルールのまとめ:石は待ち0、苔は待ち2。広く聞くと両側2区間で6刻、片側だと4区間で10刻。聞き終えた観測と矛盾しない候補を全部報告すれば成功。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 段階 | 01 答えのわかる石の1本、02 答えのわかる2本、03 元のゲームの観測、04 教材の judge、05 元のゲームの model 全部。 | 最初から、ルール全部と専用の Controller。 |
| 表示・操作 | 648×564の英数字(ASCII)・手で刻を進める。03 以降は、秘密と波の位置を描画に渡さない。 | 900×640の日本語・音・表紙・説明・P での再生・S/F12 があります。 |
| 足場 | 共通の App には、あとの段階の、まだ実行されない分かれ道があります。03/04 の State.report/reason を先に持っています。 | すべての項目が使われます。 |
| 単独実行 | コード全部と MIT ライセンスの全文を各段階に入れているので、assets は不要。 | model/controller/assets を別々のファイルとして読みこみます。 |
完成版のコードで、次に読むところ
- model.py の
launch/step:out→wait→back - model.py の
signature/compatible:聞き終えた窓 - model.py の
act:pastとundo - model.py の
public_view:prefixとnext_events - controller.py の
feed/automatic_tick - app.py の
audio/draw
最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
