推理・観察LESSON 91音の時刻と材質で公開候補を照合する推論パズル原作079

こだまする洞窟の作り方

返ってきた音から、まだありえる候補を全部答える。
石と苔での跳ね返るまでの待ち時間、音が届く距離、聞き終えた観測の区切り(窓)を分けて扱います。音の記録と矛盾しない候補をすべて残す仕組みと、判断ごとに戻せる Undo を作ります。

完成版の遊び方こだまが返ってくる時刻と材質(石か苔か)を読み、公開された4枚の洞窟の地図のうち、ありえる候補の集まりを報告します。

こだまする洞窟の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Bの自動描画です。下の教材は既知の石一枝から始め、03で原作の観測、05で全候補と判断単位Undoを扱います。音声の実聴や人の試遊は未確認です。
PLAY / 試験公開

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

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

完成版の主な操作:左右反射板/Z送信、Space一刻、Tab候補/上下選択/Z残す、Enter報告、X戻す、V消音

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

画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。

うまく表示されないとき

ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。

同じPyxelアプリをダウンロード(.pyxapp) / 別のタブで開く

聞き終えた観測から、ありえる候補の集まりを残す。

いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。

1. 書き換えない状態・位置と向き・往復にかかる時間
2. 波のリスト・同時の更新・材質ごとの集計
3. 音が届く距離・音がしないこと・聞き終えた証拠
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / 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:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_stone_roundtrip.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

石まで往復する一つの音

書き換えない状態・位置と向き・往復にかかる時間。

この教材は、整数で作った架空の音の仕組みです。前提は、変数、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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

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を展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 01_stone_roundtrip.py
macOS / Linux
.venv/bin/python 01_stone_roundtrip.py
画面で見ること起動したときは t=0 です。Space を3回押すと壁に、さらに3回で入口に着きます。events は ((6,1,0),)、状態は DONE です。7回目からは6刻のままです。
  • 本文の順番どおりに、キーを1つずつ離して入力する
  • 観測(実際に聞いた音)と、予測を区別する
  • 保存→ゲームを閉じる→再実行で、変更が反映される
つまずきやすいところ壁に着いた同じ刻に戻してしまうと、5刻で届いてしまいます。位置を一度だけ更新してから、向きを次の刻のために変えます。
少し変えてみよう

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 です。

01_stone_roundtrip.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(254行)
01_stone_roundtrip.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""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)
STEP 02 / 05

苔の待ち時間と二つの反響

波のリスト・同時の更新・材質ごとの集計。

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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

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を展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 02_moss_wait.py
macOS / Linux
.venv/bin/python 02_moss_wait.py
画面で見ることSpace 1回で苔が WAIT 2、2回目で石が BACK・苔が WAIT 1、4回目で同時に ((4,1,1),) です。
  • 本文の順番どおりに、キーを1つずつ離して入力する
  • 観測(実際に聞いた音)と、予測を区別する
  • 保存→ゲームを閉じる→再実行で、変更が反映される
つまずきやすいところ枝ごとにすぐ出来事を追加すると、同時の音が2行になってしまいます。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 です。

02_moss_wait.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(279行)
02_moss_wait.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""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)
STEP 03 / 05

窓を閉じてから観測を記録する

音が届く距離・音がしないこと・聞き終えた証拠。

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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

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を展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_observation_window.py
macOS / Linux
.venv/bin/python 03_observation_window.py
画面で見ること状況 A で Z → Space を6回押すと ((2,1,0),) で、聞き終えた窓が1件です。3 で C → Z → Space を6回押すと、音がしないまま終わります。右を2回 → Z → Space を10回押すと、C の右の苔が8刻目に届きます。
  • 本文の順番どおりに、キーを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 です。

03_observation_window.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(273行)
03_observation_window.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""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)
STEP 04 / 05

両立する候補を全部報告する

仮説の再生・「すべてで成り立つ」という条件・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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

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を展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 04_evidence_candidates.py
macOS / Linux
.venv/bin/python 04_evidence_candidates.py
画面で見ること最初は4枚とも KEEP です。Z → Space を6回で、H1/H4 だけが POSSIBLE になります。A → Enter で、2枚のまま SUBMITTED です。観測なしで Enter を押しても、受け付けられません。
  • 本文の順番どおりに、キーを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 です。

04_evidence_candidates.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(296行)
04_evidence_candidates.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""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)
STEP 05 / 05

秘密を漏らさない予告と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 の一部です。前のステップから追加・変更したところだけを抜き出しています。

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を展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 05_public_forecast.py
macOS / Linux
.venv/bin/python 05_public_forecast.py
画面で見ること状況 A の最初の予告は空です。Z で観測を始めると、次の刻にありえる音が出ます。Z → Space 1回 → X で、音を送る前の0刻に戻り、聞き終えた窓は0です。状況 A で広く聞き終えて → A → Enter → X と押すと、IDLE に戻り、2つの候補は残ったままです。
  • 本文の順番どおりに、キーを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 です。

05_public_forecast.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(321行)
05_public_forecast.py / 全文
# MIT License
# 
# Copyright (c) 2026
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.

"""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)
AFTER STEP 05

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

ルールのまとめ:石は待ち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 を別々のファイルとして読みこみます。

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

  1. model.py の launch/step:out → wait → back
  2. model.py の signature/compatible:聞き終えた窓
  3. model.py の act:past と undo
  4. model.py の public_view:prefix と next_events
  5. controller.py の feed/automatic_tick
  6. 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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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