推理・観察LESSON 35観察と証拠整理の物語パズル(架空のゲーム)原作095

忘れもの列車の作り方

3つの時刻の写真を、手がかりにしてつなぐ。
写真と「読んだ記録」から、忘れものの持ち主を考えます。事実・画面の表示・推理のメモを分けて作る、小さな物語のゲームです。

完成版の遊び方3両の車両と3つの時刻の写真を読み、うそのない決まった事件で、忘れものを持ち主に返します。

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

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

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

完成版の主な操作:左右車両/Space写真時刻/上下記録/Z読む、Tab証拠帳、Enter返却案・提出、X戻す

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

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

うまく表示されないとき

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

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

1つの資料から、写真と手がかりを作る。

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

1. データの表・タプル・None、表を読むだけの処理、今の選択と過去の記録を分ける
2. 「かつ」の条件、enumerate、集合で重なりを消す、読んだ記録と読んでいない記録、入力のチェック
3. 画面の場面(phase)、事実と発言、一部分の集合、選ぶ・外す、上限
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上と Pyxel 2.9.9 / 変数・if・for・関数・リスト / タプル・集合・frozen dataclass・replace は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
←→:調査中は車両、04以降の返却案では候補 / Space:調査中は写真の時刻、返却案では調査へ戻る / ↑↓・Z:02以降、記録の選択・読取り。03の証拠帳と04以降の返却案では根拠の付け外し / Tab:03以降、調査と証拠帳の切替 / Enter:04以降、返却案。05では返却案のEnterで提出 / X:05で直前の読取り・根拠操作・提出をUndo / R:確認なしで同じ教材を初期化 / Esc:直接終了
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_snapshots.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

三つの時刻を動かない写真として並べる

データの表・タプル・None、表を読むだけの処理、今の選択と過去の記録を分ける。

列車は、すでに終点に止まっています。Space は時間を進める操作ではなく、時刻0・1・2の写真を選ぶ操作です。CASE の people には、人の名前と、3つの時刻にいた車両の番号が入っています。前の車両=0、中の車両=1、後ろの車両=2 です。サキの (1,0,0) は「途中で前の車両に移った」という記録、レンの (None,1,1) は「時刻0にはまだ乗っていない」ことを表します。photo_people は、その表から条件に合う人を取り出すだけです。State.car と State.time は「今見ている車両と写真」なので、変えても表は書きかわりません。画面の3列は3つの時刻、各列の3段は3両の車両です。

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

01_snapshots.py / 注目する部分
def photo_people(time, car):
    return tuple(person[0] for person in CASE['people'] if person[1][time] == car)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_snapshots.py
macOS / Linux
.venv/bin/python 01_snapshots.py
画面で見ること起動すると、中の車両・時刻2から始まります。黄色い外わくは選んでいる時刻、緑の内わくは見ている車両です。Space で時刻0を選ぶと中の車両にサキが、時刻1ではレンがいます。時刻0の青い印はリボンの一部、時刻1の印は見つかった手帳で、同じ意味の絵ではありません。
  • 時刻を選ぶのはタイムトラベルではない
  • None は「まだ乗っていない」
  • 人の位置は、1つの CASE の表から読む
  • 選択の State と、記録の CASE を分ける
つまずきやすいところ写真を切りかえるたびに人の位置を上書きすると、過去の証拠が変わってしまいます。None を「車両0」と同じに扱わないでください。また、青い色が見えるだけで、持ち主が決まるわけではありません。
少し変えてみよう

Space を3回押して、時刻2に戻ることを確かめましょう。左を2回押すと、中 → 前 → 後ろの車両の順に変わります。サキ・レン・ミオが3つの時刻でどう動いたかを紙に書き、photo_people の結果と比べてみてください。表を書きかえずに、写真を選ぶだけで説明できるでしょうか。

01_snapshots.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(277行)
01_snapshots.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.

"""忘れもの列車・教材1/5。原作A、単独実行、ASCII表示。"""
from dataclasses import dataclass, replace, asdict

# 日本語は原作Aの表。displayは同じ記録のASCII表示で、判定には使わない。
CASE = {'title': '青いリボンの手帳',
 'item': '青い手帳',
 'found': (1, 1),
 'owner': 0,
 'people': (('サキ', (1, 0, 0), '花を写生しています。青いしおりを使います。'),
            ('レン', (None, 1, 1), '途中駅1で乗りました。地図の糸は赤色です。'),
            ('ミオ', (2, 2, 2), 'ずっと後両です。新聞を小さく折っています。')),
 'evidence': (('発見メモ', 1, 1, '事実', '時刻1、中両の窓席で手帳を発見。'),
              ('リボンの写真', 1, 0, '事実', '時刻0、窓席の膝に青いリボン。物の全形は不明。'),
              ('花びらの鉛筆', 0, 2, '事実', 'サキの鉛筆には花びらの跡。花の写生に使う。'),
              ('レンの乗車券', 1, 2, '事実', '乗車は時刻1。時刻0にはまだ車内にいない。'),
              ('サキの話', 0, 2, '発言', 'サキ「途中駅1で、中両から前両へ移りました」'),
              ('レンの話', 1, 2, '発言', 'レン「旅行地図の目印には赤い糸を使います」'),
              ('ミオの話', 2, 2, '発言', 'ミオ「発車前から後両で、新聞を読んでいました」')),
 'contradictions': ((), (1, 3), (1, 6)),
 'refusal': ('', '時刻0には、私はまだ乗っていません。', '私は発車前から後両です。窓席は中両ですね。'),
 'ending': ('サキ「この花の線、私の続きです。ありがとう」', '青いしおりを戻して、窓の向こうの花を描き始めた。'),
 'display': {'people': ('SAKI', 'REN', 'MIO'),
             'titles': ('FOUND NOTE',
                        'RIBBON PHOTO',
                        'FLOWER PENCIL',
                        'REN TICKET',
                        'SAKI SAYS',
                        'REN SAYS',
                        'MIO SAYS'),
             'evidence': ('T1: blue notebook found at the middle-car window.',
                          'T0: blue ribbon on a lap at the middle-car window. The full object is '
                          'not visible.',
                          'Saki has petal marks on her pencil. She sketches flowers.',
                          'Ren boarded at T1. Ren was not on the train at T0.',
                          'Saki: I moved from the middle car to the front at T1.',
                          'Ren: I mark my travel map with red thread.',
                          'Mio: I have read a newspaper in the rear car since T0.'),
             'ending': ('Saki checks the flower drawing: This continues my sketch. Thank you.',
                        'She replaces her blue bookmark and draws flowers outside.'),
             'refusal': ('',
                         'Ren: I was not on this train at T0.',
                         'Mio: I stayed in the rear. That window is in the middle car.')}}
CARS = ('FRONT', 'MIDDLE', 'REAR')
TIMES = ('T0 BEFORE', 'T1 STOP', 'T2 TERMINAL')


def canonical_case():
    return {key: value for key, value in CASE.items() if key != 'display'}


def photo_people(time, car):
    return tuple(person[0] for person in CASE['people'] if person[1][time] == car)


def photo_item(time, car):
    if (car, time) == CASE['found']:
        return '発見された ' + CASE['item']
    if (car, time) == (1, 0):
        return '膝から青いリボン'
    return ''

@dataclass(frozen=True)
class State:
    car: int = 1
    time: int = 2


def new():
    return State(car=CASE['found'][0])


def step(state, action):
    if not isinstance(action, str):
        raise ValueError('入力形式が不明です。')
    parts = action.split(':')
    kind = parts[0]
    if kind == 'car':
        return replace(state, car=(state.car + int(parts[1])) % 3)
    if kind == 'time':
        return replace(state, time=(state.time + 1) % 3)
    raise ValueError('入力が不明です。')


STAGE = 1
STAGE_TITLE = 'THREE SNAPSHOTS'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'Z', 'TAB', 'RETURN', 'X', 'R')
REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'S': 'SPACE',
          'Z': 'Z', 'B': 'TAB', 'E': 'RETURN', 'X': 'X', 'R': 'R'}


class Controller:
    def __init__(self):
        self.previous = set()
        self.gate = False
        self.reset()

    def reset(self):
        self.state = new()
        self.phase = 'explore'
        self.choice = 0
        self.notice = 'ready'

    def apply(self, action):
        old = self.state
        self.state = step(old, action)
        return action

    def feed(self, down):
        # 一度押したら全操作キーの解放を待つ。同時押しも状態を変えない。
        down = set(down) & set(CONTROLS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if not fresh:
            return None
        self.gate = True
        if len(fresh) != 1:
            self.notice = 'ignored:multiple'
            return self.notice
        key = next(iter(fresh))
        self.notice = self.handle(key)
        return self.notice

    def handle(self, key):
        if key == 'R':
            self.reset()
            return 'reset'
        if key in ('LEFT', 'RIGHT'):
            self.choice = 0
            return self.apply('car:' + str(-1 if key == 'LEFT' else 1))
        if key == 'SPACE':
            self.choice = 0
            return self.apply('time')
        return 'ignored:key'

    def snapshot(self):
        data = {'state': asdict(self.state), 'phase': self.phase, 'choice': self.choice, 'notice': self.notice}
        return data


def wrap(p, x, y, text, width=70, color=7):
    import textwrap
    for row, line in enumerate(textwrap.wrap(text, width=width)):
        p.text(x, y + row * 10, line, color)


def draw_photos(p, state):
    # 時刻を切り替えても人を動かさない。同じ表を毎回参照するだけ。
    for time, label in enumerate(TIMES):
        x = 12 + time * 208
        p.rect(x, 45, 200, 164, 0)
        p.rectb(x, 45, 200, 164, 10 if time == state.time else 5)
        p.text(x + 8, 53, label, 10 if time == state.time else 6)
        for car, car_name in enumerate(CARS):
            y = 71 + car * 43
            p.rect(x + 8, y, 184, 36, 1)
            p.rectb(x + 8, y, 184, 36, 11 if car == state.car else 5)
            p.text(x + 13, y + 4, car_name, 11 if car == state.car else 6)
            names = photo_people(time, car)
            for index, name in enumerate(names):
                person_id = next(i for i, row in enumerate(CASE['people']) if row[0] == name)
                px = x + 65 + index * 47
                p.circ(px + 8, y + 11, 4, 15)
                p.rect(px + 4, y + 15, 9, 8, 8 + person_id)
                p.text(px - 1, y + 25, CASE['display']['people'][person_id], 7)
            item = photo_item(time, car)
            if item:
                p.rect(x + 166, y + 10, 12, 16, 12)
                p.text(x + 137, y + 28, 'RIBBON' if time == 0 else 'FOUND', 12)


def draw_board(p, controller):
    state = controller.state
    p.cls(1)
    p.text(12, 9, 'LOST PROPERTY TRAIN / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
    p.text(12, 25, 'FICTIONAL CASE A. Photographs are records, not time travel.', 6)
    draw_photos(p, state)
    p.text(16, 230, 'ARROWS: CHOOSE A CAR    SPACE: CHOOSE A SNAPSHOT', 11)
    wrap(p, 16, 256, 'SAKI: middle at T0, front at T1 and T2. REN: not on the train at T0. MIO: rear in every photograph.', width=115)
    wrap(p, 16, 300, 'Blue ribbon at T0 is a partial clue. The notebook was found at T1. Seeing a color does not establish ownership.', width=115)
    p.text(16, 342, 'THIS STEP ONLY READS FIXED PHOTOGRAPHS. NO ENDING YET.', 6)
    p.rect(8, 360, 624, 30, 5)
    p.text(16, 368, 'LAST INPUT: ' + controller.notice, 7)
    p.text(12, 402, 'LEFT/RIGHT: CAR   SPACE: TIME', 6)
    p.text(12, 418, 'R: RESET   ESC: QUIT', 6)


def main():
    import argparse
    import json
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-replay', metavar='KEYS')
    parser.add_argument('--screenshot', type=Path)
    parser.add_argument('--report', type=Path)
    args = parser.parse_args()
    # 引数検査はPyxelのimport/initより前。モデル試験にPyxelは不要。
    if args.headless_replay is None and (args.screenshot or args.report):
        parser.error('output paths require --headless-replay')
    if args.headless_replay is not None and any(c not in REPLAY for c in args.headless_replay):
        parser.error('replay keys must use: ' + ''.join(REPLAY))
    if args.headless_replay is not None and len(args.headless_replay) > 1000:
        parser.error('at most 1000 replay keys')
    args.screenshot = args.screenshot.resolve() if args.screenshot else None
    args.report = args.report.resolve() if args.report else None
    import pyxel
    controller = Controller()
    frames = 0
    events = []
    replay = args.headless_replay
    limit = max(12, len(replay or '') * 2 + 2)
    mapping = {name: getattr(pyxel, 'KEY_' + name) for name in CONTROLS}
    pyxel.init(640, 440, title='095 Lost Property Train - Stage ' + str(STAGE),
               fps=30, display_scale=1, quit_key=pyxel.KEY_NONE,
               headless=replay is not None)

    def update():
        nonlocal frames
        if pyxel.btnp(pyxel.KEY_ESCAPE):
            pyxel.quit()
            return
        if replay is None:
            down = {name for name, key in mapping.items() if pyxel.btn(key)}
        elif frames % 2 == 0 and frames // 2 < len(replay):
            down = {REPLAY[replay[frames // 2]]}
        else:
            down = set()
        result = controller.feed(down)
        if result is not None:
            events.append({'frame': frames, 'result': result, 'snapshot': controller.snapshot()})
        frames += 1

    def draw():
        draw_board(pyxel, controller)
        if replay is not None and frames >= limit:
            if args.screenshot:
                args.screenshot.parent.mkdir(parents=True, exist_ok=True)
                pyxel.screenshot(str(args.screenshot), scale=1)
            if args.report:
                args.report.parent.mkdir(parents=True, exist_ok=True)
                args.report.write_text(json.dumps({'stage': STAGE, 'actions': replay,
                    'frames': frames, 'events': events, 'snapshot': controller.snapshot()},
                    ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()
    pyxel.run(update, draw)


if __name__ == '__main__':
    main()
STEP 02 / 05

同じ車両かつ同じ時刻の記録だけを読む

「かつ」の条件、enumerate、集合で重なりを消す、読んだ記録と読んでいない記録、入力のチェック。

「写真を見ること」と「記録を読むこと」を分けます。それぞれの evidence(証拠)は、題名・車両・時刻・種類・本文の5つの項目を持ちます。visible は、車両と時刻の両方が今の写真と同じ記録の番号だけを返します。↑↓でその一覧から選び、Z で inspect(読む)します。読めた番号は known(読んだ記録)に保存します。同じ記録を何回開いても、集合で重なりを消してから、番号順のタプルに戻します。画面の ? は未読、* は既読です。青い写真や表が画面に出ていても、Z で開いていない記録を推理の根拠にはしません。

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

02_records.py / 注目する部分
def visible(state):
    return tuple(i for i, record in enumerate(CASE['evidence'])
                 if record[1:3] == (state.car, state.time))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_records.py
macOS / Linux
.venv/bin/python 02_records.py
画面で見ること最初の一覧には、E3「レンの乗車券」と E5「レンの話」があります。Z で E3 が既読になります。Space → Z で時刻0の E1「リボンの写真」を読むと、既読は2件です。写真と証言を同じ表から取り出すので、ほかの場所の記録が一覧にまざることはありません。
  • 車両と時刻の「両方が同じ」で絞る
  • 読んだ番号だけを known に入れる
  • もう一度読んでも重ならない
  • 空の一覧や、ありえない読みこみにも対応する
つまずきやすいところ条件を「同じ車両、または同じ時刻」にすると、別の写真の記録まで開けてしまいます。known に毎回 append するだけだと、既読がどんどん増えてしまいます。画面からは呼ばれないはずの操作でも、プログラムのほうで「見えていない番号」は受け付けないようにします。
少し変えてみよう

Z → Z と押しても、既読が1件のままか確かめましょう。R → Space → 左で時刻0の前の車両に行き、記録がないときに ↑↓Z を押してもこわれないことも試してください。発展として、visible を一時的に「または」の条件に変えたとき、test_07 が失敗する理由を説明してみましょう。

02_records.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(325行)
02_records.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.

"""忘れもの列車・教材2/5。原作A、単独実行、ASCII表示。"""
from dataclasses import dataclass, replace, asdict

# 日本語は原作Aの表。displayは同じ記録のASCII表示で、判定には使わない。
CASE = {'title': '青いリボンの手帳',
 'item': '青い手帳',
 'found': (1, 1),
 'owner': 0,
 'people': (('サキ', (1, 0, 0), '花を写生しています。青いしおりを使います。'),
            ('レン', (None, 1, 1), '途中駅1で乗りました。地図の糸は赤色です。'),
            ('ミオ', (2, 2, 2), 'ずっと後両です。新聞を小さく折っています。')),
 'evidence': (('発見メモ', 1, 1, '事実', '時刻1、中両の窓席で手帳を発見。'),
              ('リボンの写真', 1, 0, '事実', '時刻0、窓席の膝に青いリボン。物の全形は不明。'),
              ('花びらの鉛筆', 0, 2, '事実', 'サキの鉛筆には花びらの跡。花の写生に使う。'),
              ('レンの乗車券', 1, 2, '事実', '乗車は時刻1。時刻0にはまだ車内にいない。'),
              ('サキの話', 0, 2, '発言', 'サキ「途中駅1で、中両から前両へ移りました」'),
              ('レンの話', 1, 2, '発言', 'レン「旅行地図の目印には赤い糸を使います」'),
              ('ミオの話', 2, 2, '発言', 'ミオ「発車前から後両で、新聞を読んでいました」')),
 'contradictions': ((), (1, 3), (1, 6)),
 'refusal': ('', '時刻0には、私はまだ乗っていません。', '私は発車前から後両です。窓席は中両ですね。'),
 'ending': ('サキ「この花の線、私の続きです。ありがとう」', '青いしおりを戻して、窓の向こうの花を描き始めた。'),
 'display': {'people': ('SAKI', 'REN', 'MIO'),
             'titles': ('FOUND NOTE',
                        'RIBBON PHOTO',
                        'FLOWER PENCIL',
                        'REN TICKET',
                        'SAKI SAYS',
                        'REN SAYS',
                        'MIO SAYS'),
             'evidence': ('T1: blue notebook found at the middle-car window.',
                          'T0: blue ribbon on a lap at the middle-car window. The full object is '
                          'not visible.',
                          'Saki has petal marks on her pencil. She sketches flowers.',
                          'Ren boarded at T1. Ren was not on the train at T0.',
                          'Saki: I moved from the middle car to the front at T1.',
                          'Ren: I mark my travel map with red thread.',
                          'Mio: I have read a newspaper in the rear car since T0.'),
             'ending': ('Saki checks the flower drawing: This continues my sketch. Thank you.',
                        'She replaces her blue bookmark and draws flowers outside.'),
             'refusal': ('',
                         'Ren: I was not on this train at T0.',
                         'Mio: I stayed in the rear. That window is in the middle car.')}}
CARS = ('FRONT', 'MIDDLE', 'REAR')
TIMES = ('T0 BEFORE', 'T1 STOP', 'T2 TERMINAL')


def canonical_case():
    return {key: value for key, value in CASE.items() if key != 'display'}


def photo_people(time, car):
    return tuple(person[0] for person in CASE['people'] if person[1][time] == car)


def photo_item(time, car):
    if (car, time) == CASE['found']:
        return '発見された ' + CASE['item']
    if (car, time) == (1, 0):
        return '膝から青いリボン'
    return ''

@dataclass(frozen=True)
class State:
    car: int = 1
    time: int = 2
    known: tuple = ()
    last: tuple = ()


def new():
    return State(car=CASE['found'][0])


def visible(state):
    return tuple(i for i, record in enumerate(CASE['evidence'])
                 if record[1:3] == (state.car, state.time))


def step(state, action):
    if not isinstance(action, str):
        raise ValueError('入力形式が不明です。')
    parts = action.split(':')
    kind = parts[0]
    if kind == 'car':
        return replace(state, car=(state.car + int(parts[1])) % 3, last=())
    if kind == 'time':
        return replace(state, time=(state.time + 1) % 3, last=())
    if kind == 'inspect':
        i = int(parts[1])
        if i not in visible(state):
            return replace(state, last=('この時刻・車両では調べられません。',))
        return replace(state, known=tuple(sorted(set(state.known + (i,)))),
                       last=(CASE['evidence'][i][4],))
    raise ValueError('入力が不明です。')


STAGE = 2
STAGE_TITLE = 'READ THE EVIDENCE'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'Z', 'TAB', 'RETURN', 'X', 'R')
REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'S': 'SPACE',
          'Z': 'Z', 'B': 'TAB', 'E': 'RETURN', 'X': 'X', 'R': 'R'}


class Controller:
    def __init__(self):
        self.previous = set()
        self.gate = False
        self.reset()

    def reset(self):
        self.state = new()
        self.phase = 'explore'
        self.choice = 0
        self.notice = 'ready'

    def apply(self, action):
        old = self.state
        self.state = step(old, action)
        return action

    def feed(self, down):
        # 一度押したら全操作キーの解放を待つ。同時押しも状態を変えない。
        down = set(down) & set(CONTROLS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if not fresh:
            return None
        self.gate = True
        if len(fresh) != 1:
            self.notice = 'ignored:multiple'
            return self.notice
        key = next(iter(fresh))
        self.notice = self.handle(key)
        return self.notice

    def handle(self, key):
        if key == 'R':
            self.reset()
            return 'reset'
        if key in ('LEFT', 'RIGHT'):
            self.choice = 0
            return self.apply('car:' + str(-1 if key == 'LEFT' else 1))
        if key == 'SPACE':
            self.choice = 0
            return self.apply('time')
        ids = visible(self.state)
        if key in ('UP', 'DOWN'):
            self.choice = (self.choice + (-1 if key == 'UP' else 1)) % max(1, len(ids))
            return 'select:evidence'
        if key == 'Z' and ids:
            return self.apply('inspect:' + str(ids[self.choice % len(ids)]))
        return 'ignored:key'

    def snapshot(self):
        data = {'state': asdict(self.state), 'phase': self.phase, 'choice': self.choice, 'notice': self.notice}
        data['visible'] = list(visible(self.state))
        return data


def display_message(state):
    if not state.last:
        return ''
    for i, record in enumerate(CASE['evidence']):
        if state.last == (record[4],):
            return CASE['display']['evidence'][i]
    if state.last == CASE['ending']:
        return CASE['display']['ending'][0]
    for i, refusal in enumerate(CASE['refusal']):
        if state.last == (CASE['people'][i][0] + '「' + refusal + '」',):
            return CASE['display']['refusal'][i]
    errors = {'この時刻・車両では調べられません。': 'NOT VISIBLE HERE.',
              'まだ読んでいない記録は根拠に選べません。': 'READ THIS RECORD BEFORE SELECTING IT.',
              '根拠は最大2件。先に一つ外してください。': 'TWO REASONS MAX. REMOVE ONE FIRST.'}
    return errors.get(state.last[0], 'RECORD UPDATED')


def wrap(p, x, y, text, width=70, color=7):
    import textwrap
    for row, line in enumerate(textwrap.wrap(text, width=width)):
        p.text(x, y + row * 10, line, color)


def draw_photos(p, state):
    # 時刻を切り替えても人を動かさない。同じ表を毎回参照するだけ。
    for time, label in enumerate(TIMES):
        x = 12 + time * 208
        p.rect(x, 45, 200, 164, 0)
        p.rectb(x, 45, 200, 164, 10 if time == state.time else 5)
        p.text(x + 8, 53, label, 10 if time == state.time else 6)
        for car, car_name in enumerate(CARS):
            y = 71 + car * 43
            p.rect(x + 8, y, 184, 36, 1)
            p.rectb(x + 8, y, 184, 36, 11 if car == state.car else 5)
            p.text(x + 13, y + 4, car_name, 11 if car == state.car else 6)
            names = photo_people(time, car)
            for index, name in enumerate(names):
                person_id = next(i for i, row in enumerate(CASE['people']) if row[0] == name)
                px = x + 65 + index * 47
                p.circ(px + 8, y + 11, 4, 15)
                p.rect(px + 4, y + 15, 9, 8, 8 + person_id)
                p.text(px - 1, y + 25, CASE['display']['people'][person_id], 7)
            item = photo_item(time, car)
            if item:
                p.rect(x + 166, y + 10, 12, 16, 12)
                p.text(x + 137, y + 28, 'RIBBON' if time == 0 else 'FOUND', 12)


def draw_board(p, controller):
    state = controller.state
    p.cls(1)
    p.text(12, 9, 'LOST PROPERTY TRAIN / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
    p.text(12, 25, 'FICTIONAL CASE A. Photographs are records, not time travel.', 6)
    draw_photos(p, state)
    p.text(12, 218, 'VIEW: ' + CARS[state.car] + ' / ' + TIMES[state.time], 11)
    ids = visible(state)
    p.text(12, 234, 'RECORDS HERE / Z TO READ', 6)
    for row, i in enumerate(ids):
        selected = controller.phase == 'explore' and row == controller.choice
        prefix = '> ' if selected else '  '
        read = '*' if i in state.known else '?'
        p.text(12, 248 + row * 12, prefix + read + ' E' + str(i) + ' ' + CASE['display']['titles'][i], 10 if selected else 7)
    if not ids:
        p.text(12, 248, 'NO RECORD AT THIS TIME / CAR', 5)
    p.text(328, 234, 'READ: ' + str(len(state.known)) + ' / 7', 6)
    if state.last:
        wrap(p, 328, 252, display_message(state), width=72, color=7)
    p.rect(8, 360, 624, 30, 5)
    p.text(16, 368, 'LAST INPUT: ' + controller.notice, 7)
    if state.last:
        wrap(p, 260, 368, display_message(state), width=89, color=10)
    p.text(12, 402, 'LEFT/RIGHT: CAR   SPACE: TIME   UP/DOWN: RECORD   Z: READ', 6)
    p.text(12, 418, 'R: RESET   ESC: QUIT   * READ / ? UNREAD', 6)


def main():
    import argparse
    import json
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-replay', metavar='KEYS')
    parser.add_argument('--screenshot', type=Path)
    parser.add_argument('--report', type=Path)
    args = parser.parse_args()
    # 引数検査はPyxelのimport/initより前。モデル試験にPyxelは不要。
    if args.headless_replay is None and (args.screenshot or args.report):
        parser.error('output paths require --headless-replay')
    if args.headless_replay is not None and any(c not in REPLAY for c in args.headless_replay):
        parser.error('replay keys must use: ' + ''.join(REPLAY))
    if args.headless_replay is not None and len(args.headless_replay) > 1000:
        parser.error('at most 1000 replay keys')
    args.screenshot = args.screenshot.resolve() if args.screenshot else None
    args.report = args.report.resolve() if args.report else None
    import pyxel
    controller = Controller()
    frames = 0
    events = []
    replay = args.headless_replay
    limit = max(12, len(replay or '') * 2 + 2)
    mapping = {name: getattr(pyxel, 'KEY_' + name) for name in CONTROLS}
    pyxel.init(640, 440, title='095 Lost Property Train - Stage ' + str(STAGE),
               fps=30, display_scale=1, quit_key=pyxel.KEY_NONE,
               headless=replay is not None)

    def update():
        nonlocal frames
        if pyxel.btnp(pyxel.KEY_ESCAPE):
            pyxel.quit()
            return
        if replay is None:
            down = {name for name, key in mapping.items() if pyxel.btn(key)}
        elif frames % 2 == 0 and frames // 2 < len(replay):
            down = {REPLAY[replay[frames // 2]]}
        else:
            down = set()
        result = controller.feed(down)
        if result is not None:
            events.append({'frame': frames, 'result': result, 'snapshot': controller.snapshot()})
        frames += 1

    def draw():
        draw_board(pyxel, controller)
        if replay is not None and frames >= limit:
            if args.screenshot:
                args.screenshot.parent.mkdir(parents=True, exist_ok=True)
                pyxel.screenshot(str(args.screenshot), scale=1)
            if args.report:
                args.report.parent.mkdir(parents=True, exist_ok=True)
                args.report.write_text(json.dumps({'stage': STAGE, 'actions': replay,
                    'frames': frames, 'events': events, 'snapshot': controller.snapshot()},
                    ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()
    pyxel.run(update, draw)


if __name__ == '__main__':
    main()
STEP 03 / 05

既読の証拠帳と最大二つの根拠を作る

画面の場面(phase)、事実と発言、一部分の集合、選ぶ・外す、上限。

Tab で証拠帳を開きます。一覧に出るのは CASE の全部ではなく、known(読んだ記録)だけです。FACT は観察した記録、SAID は人の発言で、どちらも内容を読んで扱います。この段階だけ、証拠帳で Z を押して、根拠を付けたり外したりできます。「読んでいなければ受け付けない → 選んでいれば外す → すでに2件なら新しく追加しない → それ以外なら追加」という順番が大切です。先に「2件まで」のチェックを置くと、2件選んだあとに外すこともできなくなります。reasons(根拠)は、いつも known の中から選ばれた一部分にしておきます。

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

03_notebook.py / 注目する部分
        if i in state.reasons:
            return replace(state, reasons=tuple(j for j in state.reasons if j != i), last=())
        if len(state.reasons) == 2:
            return replace(state, last=('根拠は最大2件。先に一つ外してください。',))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_notebook.py
macOS / Linux
.venv/bin/python 03_notebook.py
画面で見ることSpace → Z → 左 → Space → Space → 下 → Z と押すと、E1 と E4 が既読になります。Tab → Z → 下 → Z と押すと「SELECTED: E1 AND E4」です。証拠帳には、FACT のリボンの写真と、SAID のサキの話が、別の種類として並びます。この AND は「選んだ2つの記録の組」で、正解かどうかの判定ではありません。
  • 証拠帳には読んだ記録だけが出る
  • FACT と SAID の種類を残す
  • 外す処理を、上限のチェックより先に行う
  • 2つの記録の AND を「持ち主の証明」とまちがえない
つまずきやすいところ読んでいない記録まで全部選べるようにすると、読んでいない情報から推理したことになってしまいます。事実か発言かを、「正しい/うそ」と読みかえないでください。この決まった物語では、だれもうそをつきません。
少し変えてみよう

上の手順で2件選んでから、もう一度 Z を押して、E4 が外れることを確かめましょう。R から E3 と E5 を読み、Space → Z で E1 も読んだあと、Tab の中で3件目を選ぶと、上限のメッセージが出ます。先に1件外せば追加できます。

03_notebook.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(368行)
03_notebook.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.

"""忘れもの列車・教材3/5。原作A、単独実行、ASCII表示。"""
from dataclasses import dataclass, replace, asdict

# 日本語は原作Aの表。displayは同じ記録のASCII表示で、判定には使わない。
CASE = {'title': '青いリボンの手帳',
 'item': '青い手帳',
 'found': (1, 1),
 'owner': 0,
 'people': (('サキ', (1, 0, 0), '花を写生しています。青いしおりを使います。'),
            ('レン', (None, 1, 1), '途中駅1で乗りました。地図の糸は赤色です。'),
            ('ミオ', (2, 2, 2), 'ずっと後両です。新聞を小さく折っています。')),
 'evidence': (('発見メモ', 1, 1, '事実', '時刻1、中両の窓席で手帳を発見。'),
              ('リボンの写真', 1, 0, '事実', '時刻0、窓席の膝に青いリボン。物の全形は不明。'),
              ('花びらの鉛筆', 0, 2, '事実', 'サキの鉛筆には花びらの跡。花の写生に使う。'),
              ('レンの乗車券', 1, 2, '事実', '乗車は時刻1。時刻0にはまだ車内にいない。'),
              ('サキの話', 0, 2, '発言', 'サキ「途中駅1で、中両から前両へ移りました」'),
              ('レンの話', 1, 2, '発言', 'レン「旅行地図の目印には赤い糸を使います」'),
              ('ミオの話', 2, 2, '発言', 'ミオ「発車前から後両で、新聞を読んでいました」')),
 'contradictions': ((), (1, 3), (1, 6)),
 'refusal': ('', '時刻0には、私はまだ乗っていません。', '私は発車前から後両です。窓席は中両ですね。'),
 'ending': ('サキ「この花の線、私の続きです。ありがとう」', '青いしおりを戻して、窓の向こうの花を描き始めた。'),
 'display': {'people': ('SAKI', 'REN', 'MIO'),
             'titles': ('FOUND NOTE',
                        'RIBBON PHOTO',
                        'FLOWER PENCIL',
                        'REN TICKET',
                        'SAKI SAYS',
                        'REN SAYS',
                        'MIO SAYS'),
             'evidence': ('T1: blue notebook found at the middle-car window.',
                          'T0: blue ribbon on a lap at the middle-car window. The full object is '
                          'not visible.',
                          'Saki has petal marks on her pencil. She sketches flowers.',
                          'Ren boarded at T1. Ren was not on the train at T0.',
                          'Saki: I moved from the middle car to the front at T1.',
                          'Ren: I mark my travel map with red thread.',
                          'Mio: I have read a newspaper in the rear car since T0.'),
             'ending': ('Saki checks the flower drawing: This continues my sketch. Thank you.',
                        'She replaces her blue bookmark and draws flowers outside.'),
             'refusal': ('',
                         'Ren: I was not on this train at T0.',
                         'Mio: I stayed in the rear. That window is in the middle car.')}}
CARS = ('FRONT', 'MIDDLE', 'REAR')
TIMES = ('T0 BEFORE', 'T1 STOP', 'T2 TERMINAL')


def canonical_case():
    return {key: value for key, value in CASE.items() if key != 'display'}


def photo_people(time, car):
    return tuple(person[0] for person in CASE['people'] if person[1][time] == car)


def photo_item(time, car):
    if (car, time) == CASE['found']:
        return '発見された ' + CASE['item']
    if (car, time) == (1, 0):
        return '膝から青いリボン'
    return ''

@dataclass(frozen=True)
class State:
    car: int = 1
    time: int = 2
    known: tuple = ()
    reasons: tuple = ()
    last: tuple = ()


def new():
    return State(car=CASE['found'][0])


def visible(state):
    return tuple(i for i, record in enumerate(CASE['evidence'])
                 if record[1:3] == (state.car, state.time))


def selected_conjunction(state):
    # 「AND」は選んだ既読記録の組。所有者を証明する関数ではない。
    return tuple(CASE['evidence'][i][0] for i in state.reasons if i in state.known)


def step(state, action):
    if not isinstance(action, str):
        raise ValueError('入力形式が不明です。')
    parts = action.split(':')
    kind = parts[0]
    if kind == 'car':
        return replace(state, car=(state.car + int(parts[1])) % 3, last=())
    if kind == 'time':
        return replace(state, time=(state.time + 1) % 3, last=())
    if kind == 'inspect':
        i = int(parts[1])
        if i not in visible(state):
            return replace(state, last=('この時刻・車両では調べられません。',))
        return replace(state, known=tuple(sorted(set(state.known + (i,)))),
                       last=(CASE['evidence'][i][4],))
    if kind == 'reason':
        i = int(parts[1])
        if i not in state.known:
            return replace(state, last=('まだ読んでいない記録は根拠に選べません。',))
        if i in state.reasons:
            return replace(state, reasons=tuple(j for j in state.reasons if j != i), last=())
        if len(state.reasons) == 2:
            return replace(state, last=('根拠は最大2件。先に一つ外してください。',))
        return replace(state, reasons=state.reasons + (i,), last=())
    raise ValueError('入力が不明です。')


STAGE = 3
STAGE_TITLE = 'KNOWN RECORDS'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'Z', 'TAB', 'RETURN', 'X', 'R')
REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'S': 'SPACE',
          'Z': 'Z', 'B': 'TAB', 'E': 'RETURN', 'X': 'X', 'R': 'R'}


class Controller:
    def __init__(self):
        self.previous = set()
        self.gate = False
        self.reset()

    def reset(self):
        self.state = new()
        self.phase = 'explore'
        self.choice = 0
        self.notice = 'ready'

    def apply(self, action):
        old = self.state
        self.state = step(old, action)
        return action

    def feed(self, down):
        # 一度押したら全操作キーの解放を待つ。同時押しも状態を変えない。
        down = set(down) & set(CONTROLS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if not fresh:
            return None
        self.gate = True
        if len(fresh) != 1:
            self.notice = 'ignored:multiple'
            return self.notice
        key = next(iter(fresh))
        self.notice = self.handle(key)
        return self.notice

    def handle(self, key):
        if key == 'R':
            self.reset()
            return 'reset'
        if key == 'TAB':
            self.phase = 'explore' if self.phase == 'notebook' else 'notebook'
            self.choice = 0
            return 'phase'
        if self.phase == 'notebook':
            if key in ('UP', 'DOWN'):
                self.choice = (self.choice + (-1 if key == 'UP' else 1)) % max(1, len(self.state.known))
                return 'select:note'
            if key == 'Z' and self.state.known:
                return self.apply('reason:' + str(self.state.known[self.choice % len(self.state.known)]))
            return 'ignored:notebook'
        if key in ('LEFT', 'RIGHT'):
            self.choice = 0
            return self.apply('car:' + str(-1 if key == 'LEFT' else 1))
        if key == 'SPACE':
            self.choice = 0
            return self.apply('time')
        ids = visible(self.state)
        if key in ('UP', 'DOWN'):
            self.choice = (self.choice + (-1 if key == 'UP' else 1)) % max(1, len(ids))
            return 'select:evidence'
        if key == 'Z' and ids:
            return self.apply('inspect:' + str(ids[self.choice % len(ids)]))
        return 'ignored:key'

    def snapshot(self):
        data = {'state': asdict(self.state), 'phase': self.phase, 'choice': self.choice, 'notice': self.notice}
        data['visible'] = list(visible(self.state))
        data['conjunction'] = list(selected_conjunction(self.state))
        return data


def display_message(state):
    if not state.last:
        return ''
    for i, record in enumerate(CASE['evidence']):
        if state.last == (record[4],):
            return CASE['display']['evidence'][i]
    if state.last == CASE['ending']:
        return CASE['display']['ending'][0]
    for i, refusal in enumerate(CASE['refusal']):
        if state.last == (CASE['people'][i][0] + '「' + refusal + '」',):
            return CASE['display']['refusal'][i]
    errors = {'この時刻・車両では調べられません。': 'NOT VISIBLE HERE.',
              'まだ読んでいない記録は根拠に選べません。': 'READ THIS RECORD BEFORE SELECTING IT.',
              '根拠は最大2件。先に一つ外してください。': 'TWO REASONS MAX. REMOVE ONE FIRST.'}
    return errors.get(state.last[0], 'RECORD UPDATED')


def wrap(p, x, y, text, width=70, color=7):
    import textwrap
    for row, line in enumerate(textwrap.wrap(text, width=width)):
        p.text(x, y + row * 10, line, color)


def draw_photos(p, state):
    # 時刻を切り替えても人を動かさない。同じ表を毎回参照するだけ。
    for time, label in enumerate(TIMES):
        x = 12 + time * 208
        p.rect(x, 45, 200, 164, 0)
        p.rectb(x, 45, 200, 164, 10 if time == state.time else 5)
        p.text(x + 8, 53, label, 10 if time == state.time else 6)
        for car, car_name in enumerate(CARS):
            y = 71 + car * 43
            p.rect(x + 8, y, 184, 36, 1)
            p.rectb(x + 8, y, 184, 36, 11 if car == state.car else 5)
            p.text(x + 13, y + 4, car_name, 11 if car == state.car else 6)
            names = photo_people(time, car)
            for index, name in enumerate(names):
                person_id = next(i for i, row in enumerate(CASE['people']) if row[0] == name)
                px = x + 65 + index * 47
                p.circ(px + 8, y + 11, 4, 15)
                p.rect(px + 4, y + 15, 9, 8, 8 + person_id)
                p.text(px - 1, y + 25, CASE['display']['people'][person_id], 7)
            item = photo_item(time, car)
            if item:
                p.rect(x + 166, y + 10, 12, 16, 12)
                p.text(x + 137, y + 28, 'RIBBON' if time == 0 else 'FOUND', 12)


def draw_board(p, controller):
    state = controller.state
    p.cls(1)
    p.text(12, 9, 'LOST PROPERTY TRAIN / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
    p.text(12, 25, 'FICTIONAL CASE A. Photographs are records, not time travel.', 6)
    draw_photos(p, state)
    p.text(12, 218, 'VIEW: ' + CARS[state.car] + ' / ' + TIMES[state.time], 11)
    ids = visible(state)
    p.text(12, 234, 'RECORDS HERE / Z TO READ', 6)
    for row, i in enumerate(ids):
        selected = controller.phase == 'explore' and row == controller.choice
        prefix = '> ' if selected else '  '
        read = '*' if i in state.known else '?'
        p.text(12, 248 + row * 12, prefix + read + ' E' + str(i) + ' ' + CASE['display']['titles'][i], 10 if selected else 7)
    if not ids:
        p.text(12, 248, 'NO RECORD AT THIS TIME / CAR', 5)
    p.text(328, 234, 'READ: ' + str(len(state.known)) + ' / 7', 6)
    if state.last:
        wrap(p, 328, 252, display_message(state), width=72, color=7)
    if controller.phase != 'explore':
        p.rect(8, 214, 624, 140, 0)
        p.text(16, 222, controller.phase.upper() + ' / ONLY READ RECORDS', 11)
        for row, i in enumerate(state.known):
            marker = '*' if i in state.reasons else ' '
            selected = row == controller.choice
            kind = 'FACT' if CASE['evidence'][i][3] == '事実' else 'SAID'
            p.text(16, 240 + row * 13, ('> ' if selected else '  ') + marker + ' E' + str(i)
                   + ' ' + kind + ' ' + CASE['display']['titles'][i], 10 if selected else 7)
        if not state.known:
            p.text(16, 244, 'EMPTY: READ A RECORD WITH Z FIRST', 6)
        elif controller.phase == 'notebook':
            i = state.known[controller.choice % len(state.known)]
            wrap(p, 312, 244, CASE['display']['evidence'][i], width=74)
        pair = ' AND '.join('E' + str(i) for i in state.reasons)
        p.text(16, 344, 'SELECTED: ' + (pair or '(NONE)'), 10)
    p.rect(8, 360, 624, 30, 5)
    p.text(16, 368, 'LAST INPUT: ' + controller.notice, 7)
    if state.last:
        wrap(p, 260, 368, display_message(state), width=89, color=10)
    p.text(12, 402, 'ARROWS/SPACE/Z: EXPLORE   TAB: NOTEBOOK   NOTEBOOK Z: SELECT REASON', 6)
    p.text(12, 418, 'UP/DOWN: CHOOSE   R: RESET   ESC: QUIT   TWO REASONS MAX', 6)


def main():
    import argparse
    import json
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-replay', metavar='KEYS')
    parser.add_argument('--screenshot', type=Path)
    parser.add_argument('--report', type=Path)
    args = parser.parse_args()
    # 引数検査はPyxelのimport/initより前。モデル試験にPyxelは不要。
    if args.headless_replay is None and (args.screenshot or args.report):
        parser.error('output paths require --headless-replay')
    if args.headless_replay is not None and any(c not in REPLAY for c in args.headless_replay):
        parser.error('replay keys must use: ' + ''.join(REPLAY))
    if args.headless_replay is not None and len(args.headless_replay) > 1000:
        parser.error('at most 1000 replay keys')
    args.screenshot = args.screenshot.resolve() if args.screenshot else None
    args.report = args.report.resolve() if args.report else None
    import pyxel
    controller = Controller()
    frames = 0
    events = []
    replay = args.headless_replay
    limit = max(12, len(replay or '') * 2 + 2)
    mapping = {name: getattr(pyxel, 'KEY_' + name) for name in CONTROLS}
    pyxel.init(640, 440, title='095 Lost Property Train - Stage ' + str(STAGE),
               fps=30, display_scale=1, quit_key=pyxel.KEY_NONE,
               headless=replay is not None)

    def update():
        nonlocal frames
        if pyxel.btnp(pyxel.KEY_ESCAPE):
            pyxel.quit()
            return
        if replay is None:
            down = {name for name, key in mapping.items() if pyxel.btn(key)}
        elif frames % 2 == 0 and frames // 2 < len(replay):
            down = {REPLAY[replay[frames // 2]]}
        else:
            down = set()
        result = controller.feed(down)
        if result is not None:
            events.append({'frame': frames, 'result': result, 'snapshot': controller.snapshot()})
        frames += 1

    def draw():
        draw_board(pyxel, controller)
        if replay is not None and frames >= limit:
            if args.screenshot:
                args.screenshot.parent.mkdir(parents=True, exist_ok=True)
                pyxel.screenshot(str(args.screenshot), scale=1)
            if args.report:
                args.report.parent.mkdir(parents=True, exist_ok=True)
                args.report.write_text(json.dumps({'stage': STAGE, 'actions': replay,
                    'frames': frames, 'events': events, 'snapshot': controller.snapshot()},
                    ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()
    pyxel.run(update, draw)


if __name__ == '__main__':
    main()
STEP 04 / 05

相手の仮説と既知の矛盾注記を照らす

候補と証拠を分ける、集合の共通部分、「知らない」と「矛盾がない」のちがい、画面の状態。

Enter で返す相手を考える練習の画面に入り、←→で候補を選びます。矛盾のもとになる記録の番号は同じ CASE の表にあり、contradictions は、そのうち known(読んだ記録)に入っているものだけを表示します。元のゲームの状況 A では、レンには E1・E3、ミオには E1・E6 の注記があります。これは作者が用意した決まった注記で、どんな文章からでも矛盾を自動で見つける AI ではありません。注記が空でも、まだ読んでいないだけかもしれません。この段階から、根拠を選ぶ Z は返す相手を考える画面に移り、Tab の証拠帳は見るだけになります。

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

04_hypothesis.py / 注目する部分
def contradictions(state):
    return tuple(CASE['evidence'][i][0]
                 for i in CASE['contradictions'][state.candidate] if i in state.known)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_hypothesis.py
macOS / Linux
.venv/bin/python 04_hypothesis.py
画面で見ること何も読まずに Enter → 右でレンを選ぶと、「NO KNOWN CONFLICT IS NOT PROOF」(知っている矛盾がないことは、証明ではない)と出ます。R → Z → Enter → 右 では、E3 の注記だけが出ます。E1 も読めば、E1 と E3 が出ます。候補を選ぶ矢印では、車両は変わりません。Space で調査に戻っても、写真の時刻は進みません。
  • 矛盾は、候補ごとに決まった注記
  • 表示するのは、読んだ番号と重なる分だけ
  • 注記がないのは、正解の保証ではない
  • phase で、同じキーの意味を切りかえる
つまずきやすいところCASE に書いてある矛盾を全部表示すると、まだ読んでいない情報がもれてしまいます。「矛盾が0件」を「正しい相手」の証拠にしないでください。E1 AND E4 は2つの記録を合わせて考えるためのメモで、件数をうめれば正解になる条件ではありません。
少し変えてみよう

R → Space → Z → 左 → Space → Space → 下 → Z → Enter → Z → 下 → Z と押して、E1 AND E4 を選びます。←→で候補を変えても、根拠が残ることを確かめましょう。次に Space で調査に戻り、同じ矢印キーが、phase によって「候補を選ぶ」と「車両を選ぶ」に切りかわるところを、コードの中で探してみてください。

04_hypothesis.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(404行)
04_hypothesis.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.

"""忘れもの列車・教材4/5。原作A、単独実行、ASCII表示。"""
from dataclasses import dataclass, replace, asdict

# 日本語は原作Aの表。displayは同じ記録のASCII表示で、判定には使わない。
CASE = {'title': '青いリボンの手帳',
 'item': '青い手帳',
 'found': (1, 1),
 'owner': 0,
 'people': (('サキ', (1, 0, 0), '花を写生しています。青いしおりを使います。'),
            ('レン', (None, 1, 1), '途中駅1で乗りました。地図の糸は赤色です。'),
            ('ミオ', (2, 2, 2), 'ずっと後両です。新聞を小さく折っています。')),
 'evidence': (('発見メモ', 1, 1, '事実', '時刻1、中両の窓席で手帳を発見。'),
              ('リボンの写真', 1, 0, '事実', '時刻0、窓席の膝に青いリボン。物の全形は不明。'),
              ('花びらの鉛筆', 0, 2, '事実', 'サキの鉛筆には花びらの跡。花の写生に使う。'),
              ('レンの乗車券', 1, 2, '事実', '乗車は時刻1。時刻0にはまだ車内にいない。'),
              ('サキの話', 0, 2, '発言', 'サキ「途中駅1で、中両から前両へ移りました」'),
              ('レンの話', 1, 2, '発言', 'レン「旅行地図の目印には赤い糸を使います」'),
              ('ミオの話', 2, 2, '発言', 'ミオ「発車前から後両で、新聞を読んでいました」')),
 'contradictions': ((), (1, 3), (1, 6)),
 'refusal': ('', '時刻0には、私はまだ乗っていません。', '私は発車前から後両です。窓席は中両ですね。'),
 'ending': ('サキ「この花の線、私の続きです。ありがとう」', '青いしおりを戻して、窓の向こうの花を描き始めた。'),
 'display': {'people': ('SAKI', 'REN', 'MIO'),
             'titles': ('FOUND NOTE',
                        'RIBBON PHOTO',
                        'FLOWER PENCIL',
                        'REN TICKET',
                        'SAKI SAYS',
                        'REN SAYS',
                        'MIO SAYS'),
             'evidence': ('T1: blue notebook found at the middle-car window.',
                          'T0: blue ribbon on a lap at the middle-car window. The full object is '
                          'not visible.',
                          'Saki has petal marks on her pencil. She sketches flowers.',
                          'Ren boarded at T1. Ren was not on the train at T0.',
                          'Saki: I moved from the middle car to the front at T1.',
                          'Ren: I mark my travel map with red thread.',
                          'Mio: I have read a newspaper in the rear car since T0.'),
             'ending': ('Saki checks the flower drawing: This continues my sketch. Thank you.',
                        'She replaces her blue bookmark and draws flowers outside.'),
             'refusal': ('',
                         'Ren: I was not on this train at T0.',
                         'Mio: I stayed in the rear. That window is in the middle car.')}}
CARS = ('FRONT', 'MIDDLE', 'REAR')
TIMES = ('T0 BEFORE', 'T1 STOP', 'T2 TERMINAL')


def canonical_case():
    return {key: value for key, value in CASE.items() if key != 'display'}


def photo_people(time, car):
    return tuple(person[0] for person in CASE['people'] if person[1][time] == car)


def photo_item(time, car):
    if (car, time) == CASE['found']:
        return '発見された ' + CASE['item']
    if (car, time) == (1, 0):
        return '膝から青いリボン'
    return ''

@dataclass(frozen=True)
class State:
    car: int = 1
    time: int = 2
    known: tuple = ()
    candidate: int = 0
    reasons: tuple = ()
    last: tuple = ()


def new():
    return State(car=CASE['found'][0])


def visible(state):
    return tuple(i for i, record in enumerate(CASE['evidence'])
                 if record[1:3] == (state.car, state.time))


def selected_conjunction(state):
    # 「AND」は選んだ既読記録の組。所有者を証明する関数ではない。
    return tuple(CASE['evidence'][i][0] for i in state.reasons if i in state.known)


def contradictions(state):
    return tuple(CASE['evidence'][i][0]
                 for i in CASE['contradictions'][state.candidate] if i in state.known)


def step(state, action):
    if not isinstance(action, str):
        raise ValueError('入力形式が不明です。')
    parts = action.split(':')
    kind = parts[0]
    if kind == 'car':
        return replace(state, car=(state.car + int(parts[1])) % 3, last=())
    if kind == 'time':
        return replace(state, time=(state.time + 1) % 3, last=())
    if kind == 'inspect':
        i = int(parts[1])
        if i not in visible(state):
            return replace(state, last=('この時刻・車両では調べられません。',))
        return replace(state, known=tuple(sorted(set(state.known + (i,)))),
                       last=(CASE['evidence'][i][4],))
    if kind == 'reason':
        i = int(parts[1])
        if i not in state.known:
            return replace(state, last=('まだ読んでいない記録は根拠に選べません。',))
        if i in state.reasons:
            return replace(state, reasons=tuple(j for j in state.reasons if j != i), last=())
        if len(state.reasons) == 2:
            return replace(state, last=('根拠は最大2件。先に一つ外してください。',))
        return replace(state, reasons=state.reasons + (i,), last=())
    if kind == 'candidate':
        return replace(state, candidate=(state.candidate + int(parts[1])) % 3, last=())
    raise ValueError('入力が不明です。')


STAGE = 4
STAGE_TITLE = 'TEST AN IDEA'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'Z', 'TAB', 'RETURN', 'X', 'R')
REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'S': 'SPACE',
          'Z': 'Z', 'B': 'TAB', 'E': 'RETURN', 'X': 'X', 'R': 'R'}


class Controller:
    def __init__(self):
        self.previous = set()
        self.gate = False
        self.reset()

    def reset(self):
        self.state = new()
        self.phase = 'explore'
        self.choice = 0
        self.notice = 'ready'

    def apply(self, action):
        old = self.state
        self.state = step(old, action)
        return action

    def feed(self, down):
        # 一度押したら全操作キーの解放を待つ。同時押しも状態を変えない。
        down = set(down) & set(CONTROLS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if not fresh:
            return None
        self.gate = True
        if len(fresh) != 1:
            self.notice = 'ignored:multiple'
            return self.notice
        key = next(iter(fresh))
        self.notice = self.handle(key)
        return self.notice

    def handle(self, key):
        if key == 'R':
            self.reset()
            return 'reset'
        if key == 'TAB':
            self.phase = 'explore' if self.phase == 'notebook' else 'notebook'
            self.choice = 0
            return 'phase'
        if self.phase == 'submit':
            if key in ('LEFT', 'RIGHT'):
                return self.apply('candidate:' + str(-1 if key == 'LEFT' else 1))
            if key in ('UP', 'DOWN'):
                self.choice = (self.choice + (-1 if key == 'UP' else 1)) % max(1, len(self.state.known))
                return 'select:reason'
            if key == 'Z' and self.state.known:
                return self.apply('reason:' + str(self.state.known[self.choice % len(self.state.known)]))
            if key == 'SPACE':
                self.phase = 'explore'
                self.choice = 0
                return 'back'
            return 'ignored:submit'
        if key == 'RETURN':
            self.phase = 'submit'
            self.choice = 0
            return 'proposal'
        if self.phase == 'notebook':
            if key in ('UP', 'DOWN'):
                self.choice = (self.choice + (-1 if key == 'UP' else 1)) % max(1, len(self.state.known))
                return 'select:note'
            return 'ignored:notebook'
        if key in ('LEFT', 'RIGHT'):
            self.choice = 0
            return self.apply('car:' + str(-1 if key == 'LEFT' else 1))
        if key == 'SPACE':
            self.choice = 0
            return self.apply('time')
        ids = visible(self.state)
        if key in ('UP', 'DOWN'):
            self.choice = (self.choice + (-1 if key == 'UP' else 1)) % max(1, len(ids))
            return 'select:evidence'
        if key == 'Z' and ids:
            return self.apply('inspect:' + str(ids[self.choice % len(ids)]))
        return 'ignored:key'

    def snapshot(self):
        data = {'state': asdict(self.state), 'phase': self.phase, 'choice': self.choice, 'notice': self.notice}
        data['visible'] = list(visible(self.state))
        data['conjunction'] = list(selected_conjunction(self.state))
        data['contradictions'] = list(contradictions(self.state))
        return data


def display_message(state):
    if not state.last:
        return ''
    for i, record in enumerate(CASE['evidence']):
        if state.last == (record[4],):
            return CASE['display']['evidence'][i]
    if state.last == CASE['ending']:
        return CASE['display']['ending'][0]
    for i, refusal in enumerate(CASE['refusal']):
        if state.last == (CASE['people'][i][0] + '「' + refusal + '」',):
            return CASE['display']['refusal'][i]
    errors = {'この時刻・車両では調べられません。': 'NOT VISIBLE HERE.',
              'まだ読んでいない記録は根拠に選べません。': 'READ THIS RECORD BEFORE SELECTING IT.',
              '根拠は最大2件。先に一つ外してください。': 'TWO REASONS MAX. REMOVE ONE FIRST.'}
    return errors.get(state.last[0], 'RECORD UPDATED')


def wrap(p, x, y, text, width=70, color=7):
    import textwrap
    for row, line in enumerate(textwrap.wrap(text, width=width)):
        p.text(x, y + row * 10, line, color)


def draw_photos(p, state):
    # 時刻を切り替えても人を動かさない。同じ表を毎回参照するだけ。
    for time, label in enumerate(TIMES):
        x = 12 + time * 208
        p.rect(x, 45, 200, 164, 0)
        p.rectb(x, 45, 200, 164, 10 if time == state.time else 5)
        p.text(x + 8, 53, label, 10 if time == state.time else 6)
        for car, car_name in enumerate(CARS):
            y = 71 + car * 43
            p.rect(x + 8, y, 184, 36, 1)
            p.rectb(x + 8, y, 184, 36, 11 if car == state.car else 5)
            p.text(x + 13, y + 4, car_name, 11 if car == state.car else 6)
            names = photo_people(time, car)
            for index, name in enumerate(names):
                person_id = next(i for i, row in enumerate(CASE['people']) if row[0] == name)
                px = x + 65 + index * 47
                p.circ(px + 8, y + 11, 4, 15)
                p.rect(px + 4, y + 15, 9, 8, 8 + person_id)
                p.text(px - 1, y + 25, CASE['display']['people'][person_id], 7)
            item = photo_item(time, car)
            if item:
                p.rect(x + 166, y + 10, 12, 16, 12)
                p.text(x + 137, y + 28, 'RIBBON' if time == 0 else 'FOUND', 12)


def draw_board(p, controller):
    state = controller.state
    p.cls(1)
    p.text(12, 9, 'LOST PROPERTY TRAIN / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
    p.text(12, 25, 'FICTIONAL CASE A. Photographs are records, not time travel.', 6)
    draw_photos(p, state)
    p.text(12, 218, 'VIEW: ' + CARS[state.car] + ' / ' + TIMES[state.time], 11)
    ids = visible(state)
    p.text(12, 234, 'RECORDS HERE / Z TO READ', 6)
    for row, i in enumerate(ids):
        selected = controller.phase == 'explore' and row == controller.choice
        prefix = '> ' if selected else '  '
        read = '*' if i in state.known else '?'
        p.text(12, 248 + row * 12, prefix + read + ' E' + str(i) + ' ' + CASE['display']['titles'][i], 10 if selected else 7)
    if not ids:
        p.text(12, 248, 'NO RECORD AT THIS TIME / CAR', 5)
    p.text(328, 234, 'READ: ' + str(len(state.known)) + ' / 7', 6)
    if state.last:
        wrap(p, 328, 252, display_message(state), width=72, color=7)
    if controller.phase != 'explore':
        p.rect(8, 214, 624, 140, 0)
        p.text(16, 222, controller.phase.upper() + ' / ONLY READ RECORDS', 11)
        for row, i in enumerate(state.known):
            marker = '*' if i in state.reasons else ' '
            selected = row == controller.choice
            kind = 'FACT' if CASE['evidence'][i][3] == '事実' else 'SAID'
            p.text(16, 240 + row * 13, ('> ' if selected else '  ') + marker + ' E' + str(i)
                   + ' ' + kind + ' ' + CASE['display']['titles'][i], 10 if selected else 7)
        if not state.known:
            p.text(16, 244, 'EMPTY: READ A RECORD WITH Z FIRST', 6)
        elif controller.phase == 'notebook':
            i = state.known[controller.choice % len(state.known)]
            wrap(p, 312, 244, CASE['display']['evidence'][i], width=74)
        pair = ' AND '.join('E' + str(i) for i in state.reasons)
        p.text(16, 344, 'SELECTED: ' + (pair or '(NONE)'), 10)
        if controller.phase == 'submit':
            p.text(312, 240, 'OWNER IDEA: ' + CASE['display']['people'][state.candidate], 10)
            conflicts = contradictions(state)
            if conflicts:
                p.text(312, 258, 'CHECK KNOWN CONFLICTS:', 8)
                for n, title in enumerate(conflicts):
                    i = next(i for i, row in enumerate(CASE['evidence']) if row[0] == title)
                    p.text(312, 273 + n * 12, 'E' + str(i) + ' ' + CASE['display']['titles'][i], 8)
            else:
                p.text(312, 258, 'NO KNOWN CONFLICT IS NOT PROOF.', 6)
            p.text(312, 308, 'LEFT/RIGHT: PERSON   Z: TOGGLE REASON', 6)
            p.text(312, 323, 'TWO SELECTED RECORDS FORM AN AND.', 6)
    p.rect(8, 360, 624, 30, 5)
    p.text(16, 368, 'LAST INPUT: ' + controller.notice, 7)
    if state.last:
        wrap(p, 260, 368, display_message(state), width=89, color=10)
    p.text(12, 402, 'ENTER: IDEA   LEFT/RIGHT: PERSON   Z: REASON   SPACE: BACK', 6)
    p.text(12, 418, 'TAB: NOTEBOOK   R: RESET   ESC: QUIT   NO RETURN IN STEP 4', 6)


def main():
    import argparse
    import json
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-replay', metavar='KEYS')
    parser.add_argument('--screenshot', type=Path)
    parser.add_argument('--report', type=Path)
    args = parser.parse_args()
    # 引数検査はPyxelのimport/initより前。モデル試験にPyxelは不要。
    if args.headless_replay is None and (args.screenshot or args.report):
        parser.error('output paths require --headless-replay')
    if args.headless_replay is not None and any(c not in REPLAY for c in args.headless_replay):
        parser.error('replay keys must use: ' + ''.join(REPLAY))
    if args.headless_replay is not None and len(args.headless_replay) > 1000:
        parser.error('at most 1000 replay keys')
    args.screenshot = args.screenshot.resolve() if args.screenshot else None
    args.report = args.report.resolve() if args.report else None
    import pyxel
    controller = Controller()
    frames = 0
    events = []
    replay = args.headless_replay
    limit = max(12, len(replay or '') * 2 + 2)
    mapping = {name: getattr(pyxel, 'KEY_' + name) for name in CONTROLS}
    pyxel.init(640, 440, title='095 Lost Property Train - Stage ' + str(STAGE),
               fps=30, display_scale=1, quit_key=pyxel.KEY_NONE,
               headless=replay is not None)

    def update():
        nonlocal frames
        if pyxel.btnp(pyxel.KEY_ESCAPE):
            pyxel.quit()
            return
        if replay is None:
            down = {name for name, key in mapping.items() if pyxel.btn(key)}
        elif frames % 2 == 0 and frames // 2 < len(replay):
            down = {REPLAY[replay[frames // 2]]}
        else:
            down = set()
        result = controller.feed(down)
        if result is not None:
            events.append({'frame': frames, 'result': result, 'snapshot': controller.snapshot()})
        frames += 1

    def draw():
        draw_board(pyxel, controller)
        if replay is not None and frames >= limit:
            if args.screenshot:
                args.screenshot.parent.mkdir(parents=True, exist_ok=True)
                pyxel.screenshot(str(args.screenshot), scale=1)
            if args.report:
                args.report.parent.mkdir(parents=True, exist_ok=True)
                args.report.write_text(json.dumps({'stage': STAGE, 'actions': replay,
                    'frames': frames, 'events': events, 'snapshot': controller.snapshot()},
                    ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()
    pyxel.run(update, draw)


if __name__ == '__main__':
    main()
STEP 05 / 05

本人確認で返却し、記録ごとUndoする

ゲームの終わりの状態、書きかえないデータの履歴、成功と根拠の記録を分ける、元のゲームとの比較。

返す相手の画面で Enter を押すと、その人に渡します。元のゲームの状況 A では、サキに渡すと本人が手帳を確かめて完了し、レンやミオに渡すと、具体的な理由を言って受け取りません。根拠が0件でも、正しい相手なら完了するというルールは、そのまま残しています。apply は、読む(inspect)・根拠を選ぶ(reason)・渡す(submit)で状態が変わったときだけ、前の State を history に積みます。X は、その State 全体を戻して、調査の画面に戻ります。位置を変えるだけの操作は履歴に積みませんが、あとで Undo すると、記録を読む前の位置まで戻る、という点が大切です。

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

05_return.py / 注目する部分
        if state.candidate == CASE['owner']:
            return replace(state, returned=True, last=CASE['ending'])

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_return.py
macOS / Linux
.venv/bin/python 05_return.py
画面で見ることR → Enter → Enter と押すと、既読0・根拠0のまま完了します。X で渡す前に戻ります。R → Enter → 右 → Enter では、レンに断られて完了しません。根拠を2件選んでも、レンに返しても成功しません。成功したあとは移動や読みこみが止まり、X と R が使えます。
  • 正しい本人が確かめたら完了
  • 根拠の件数は、クリアの条件ではない
  • 変わる前の State を保存して、X で戻す
  • 元のゲームの状況 A と、3930通りの変化・24の操作記録を照らし合わせる
つまずきやすいところ「根拠が2件必要」という条件を加えると、元のゲームとは別のゲームになってしまいます。また、履歴に「今書きかえているリスト」への参照だけを保存すると、あとの操作で過去まで変わってしまいます。frozen dataclass とタプルを使い、変わる前の値を残しておきます。
少し変えてみよう

Z → Space → X と押してみて、E3 を読む前の「中の車両・時刻2」に戻ることを説明してみましょう。E1 と E4 を読み、返す相手の画面で両方を選んでサキに返したら、X で「根拠2件を選んだまま、渡す前」の状態に戻ります。最後に Space と左を同時に押し、片方だけ離しても動かず、全部のキーを離すと次の入力を受け付けることを試してください。

05_return.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(440行)
05_return.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.

"""忘れもの列車・教材5/5。原作A、単独実行、ASCII表示。"""
from dataclasses import dataclass, replace, asdict

# 日本語は原作Aの表。displayは同じ記録のASCII表示で、判定には使わない。
CASE = {'title': '青いリボンの手帳',
 'item': '青い手帳',
 'found': (1, 1),
 'owner': 0,
 'people': (('サキ', (1, 0, 0), '花を写生しています。青いしおりを使います。'),
            ('レン', (None, 1, 1), '途中駅1で乗りました。地図の糸は赤色です。'),
            ('ミオ', (2, 2, 2), 'ずっと後両です。新聞を小さく折っています。')),
 'evidence': (('発見メモ', 1, 1, '事実', '時刻1、中両の窓席で手帳を発見。'),
              ('リボンの写真', 1, 0, '事実', '時刻0、窓席の膝に青いリボン。物の全形は不明。'),
              ('花びらの鉛筆', 0, 2, '事実', 'サキの鉛筆には花びらの跡。花の写生に使う。'),
              ('レンの乗車券', 1, 2, '事実', '乗車は時刻1。時刻0にはまだ車内にいない。'),
              ('サキの話', 0, 2, '発言', 'サキ「途中駅1で、中両から前両へ移りました」'),
              ('レンの話', 1, 2, '発言', 'レン「旅行地図の目印には赤い糸を使います」'),
              ('ミオの話', 2, 2, '発言', 'ミオ「発車前から後両で、新聞を読んでいました」')),
 'contradictions': ((), (1, 3), (1, 6)),
 'refusal': ('', '時刻0には、私はまだ乗っていません。', '私は発車前から後両です。窓席は中両ですね。'),
 'ending': ('サキ「この花の線、私の続きです。ありがとう」', '青いしおりを戻して、窓の向こうの花を描き始めた。'),
 'display': {'people': ('SAKI', 'REN', 'MIO'),
             'titles': ('FOUND NOTE',
                        'RIBBON PHOTO',
                        'FLOWER PENCIL',
                        'REN TICKET',
                        'SAKI SAYS',
                        'REN SAYS',
                        'MIO SAYS'),
             'evidence': ('T1: blue notebook found at the middle-car window.',
                          'T0: blue ribbon on a lap at the middle-car window. The full object is '
                          'not visible.',
                          'Saki has petal marks on her pencil. She sketches flowers.',
                          'Ren boarded at T1. Ren was not on the train at T0.',
                          'Saki: I moved from the middle car to the front at T1.',
                          'Ren: I mark my travel map with red thread.',
                          'Mio: I have read a newspaper in the rear car since T0.'),
             'ending': ('Saki checks the flower drawing: This continues my sketch. Thank you.',
                        'She replaces her blue bookmark and draws flowers outside.'),
             'refusal': ('',
                         'Ren: I was not on this train at T0.',
                         'Mio: I stayed in the rear. That window is in the middle car.')}}
CARS = ('FRONT', 'MIDDLE', 'REAR')
TIMES = ('T0 BEFORE', 'T1 STOP', 'T2 TERMINAL')


def canonical_case():
    return {key: value for key, value in CASE.items() if key != 'display'}


def photo_people(time, car):
    return tuple(person[0] for person in CASE['people'] if person[1][time] == car)


def photo_item(time, car):
    if (car, time) == CASE['found']:
        return '発見された ' + CASE['item']
    if (car, time) == (1, 0):
        return '膝から青いリボン'
    return ''

@dataclass(frozen=True)
class State:
    case: str = 'A'
    car: int = 1
    time: int = 2
    known: tuple = ()
    candidate: int = 0
    reasons: tuple = ()
    refused: tuple = ()
    returned: bool = False
    last: tuple = ()


def new():
    return State(car=CASE['found'][0])


def visible(state):
    return tuple(i for i, record in enumerate(CASE['evidence'])
                 if record[1:3] == (state.car, state.time))


def selected_conjunction(state):
    # 「AND」は選んだ既読記録の組。所有者を証明する関数ではない。
    return tuple(CASE['evidence'][i][0] for i in state.reasons if i in state.known)


def contradictions(state):
    return tuple(CASE['evidence'][i][0]
                 for i in CASE['contradictions'][state.candidate] if i in state.known)


def step(state, action):
    if not isinstance(action, str):
        raise ValueError('入力形式が不明です。')
    parts = action.split(':')
    kind = parts[0]
    if state.returned:
        return state
    if kind == 'car':
        return replace(state, car=(state.car + int(parts[1])) % 3, last=())
    if kind == 'time':
        return replace(state, time=(state.time + 1) % 3, last=())
    if kind == 'inspect':
        i = int(parts[1])
        if i not in visible(state):
            return replace(state, last=('この時刻・車両では調べられません。',))
        return replace(state, known=tuple(sorted(set(state.known + (i,)))),
                       last=(CASE['evidence'][i][4],))
    if kind == 'reason':
        i = int(parts[1])
        if i not in state.known:
            return replace(state, last=('まだ読んでいない記録は根拠に選べません。',))
        if i in state.reasons:
            return replace(state, reasons=tuple(j for j in state.reasons if j != i), last=())
        if len(state.reasons) == 2:
            return replace(state, last=('根拠は最大2件。先に一つ外してください。',))
        return replace(state, reasons=state.reasons + (i,), last=())
    if kind == 'candidate':
        return replace(state, candidate=(state.candidate + int(parts[1])) % 3, last=())
    if kind == 'submit':
        # 原作同様、正しい相手なら根拠0件でも完了する。
        if state.candidate == CASE['owner']:
            return replace(state, returned=True, last=CASE['ending'])
        return replace(state, refused=tuple(sorted(set(state.refused + (state.candidate,)))),
                       last=(CASE['people'][state.candidate][0] + '「'
                             + CASE['refusal'][state.candidate] + '」',))
    raise ValueError('入力が不明です。')


STAGE = 5
STAGE_TITLE = 'RETURN AND UNDO'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'Z', 'TAB', 'RETURN', 'X', 'R')
REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'S': 'SPACE',
          'Z': 'Z', 'B': 'TAB', 'E': 'RETURN', 'X': 'X', 'R': 'R'}


class Controller:
    def __init__(self):
        self.previous = set()
        self.gate = False
        self.reset()

    def reset(self):
        self.state = new()
        self.phase = 'explore'
        self.choice = 0
        self.notice = 'ready'
        self.history = []

    def apply(self, action):
        old = self.state
        self.state = step(old, action)
        if old != self.state and action.split(':')[0] in ('inspect', 'reason', 'submit'):
            self.history.append(old)
        return action

    def feed(self, down):
        # 一度押したら全操作キーの解放を待つ。同時押しも状態を変えない。
        down = set(down) & set(CONTROLS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if not fresh:
            return None
        self.gate = True
        if len(fresh) != 1:
            self.notice = 'ignored:multiple'
            return self.notice
        key = next(iter(fresh))
        self.notice = self.handle(key)
        return self.notice

    def handle(self, key):
        if key == 'R':
            self.reset()
            return 'reset'
        if key == 'X':
            if self.history:
                self.state = self.history.pop()
                self.phase = 'explore'
                self.choice = 0
                return 'undo'
            return 'ignored:undo'
        if self.state.returned:
            return 'ignored:result'
        if key == 'TAB':
            self.phase = 'explore' if self.phase == 'notebook' else 'notebook'
            self.choice = 0
            return 'phase'
        if self.phase == 'submit':
            if key in ('LEFT', 'RIGHT'):
                return self.apply('candidate:' + str(-1 if key == 'LEFT' else 1))
            if key in ('UP', 'DOWN'):
                self.choice = (self.choice + (-1 if key == 'UP' else 1)) % max(1, len(self.state.known))
                return 'select:reason'
            if key == 'Z' and self.state.known:
                return self.apply('reason:' + str(self.state.known[self.choice % len(self.state.known)]))
            if key == 'RETURN':
                return self.apply('submit')
            if key == 'SPACE':
                self.phase = 'explore'
                self.choice = 0
                return 'back'
            return 'ignored:submit'
        if key == 'RETURN':
            self.phase = 'submit'
            self.choice = 0
            return 'proposal'
        if self.phase == 'notebook':
            if key in ('UP', 'DOWN'):
                self.choice = (self.choice + (-1 if key == 'UP' else 1)) % max(1, len(self.state.known))
                return 'select:note'
            return 'ignored:notebook'
        if key in ('LEFT', 'RIGHT'):
            self.choice = 0
            return self.apply('car:' + str(-1 if key == 'LEFT' else 1))
        if key == 'SPACE':
            self.choice = 0
            return self.apply('time')
        ids = visible(self.state)
        if key in ('UP', 'DOWN'):
            self.choice = (self.choice + (-1 if key == 'UP' else 1)) % max(1, len(ids))
            return 'select:evidence'
        if key == 'Z' and ids:
            return self.apply('inspect:' + str(ids[self.choice % len(ids)]))
        return 'ignored:key'

    def snapshot(self):
        data = {'state': asdict(self.state), 'phase': self.phase, 'choice': self.choice, 'notice': self.notice}
        data['visible'] = list(visible(self.state))
        data['conjunction'] = list(selected_conjunction(self.state))
        data['contradictions'] = list(contradictions(self.state))
        data['history_depth'] = len(self.history)
        return data


def display_message(state):
    if not state.last:
        return ''
    for i, record in enumerate(CASE['evidence']):
        if state.last == (record[4],):
            return CASE['display']['evidence'][i]
    if state.last == CASE['ending']:
        return CASE['display']['ending'][0]
    for i, refusal in enumerate(CASE['refusal']):
        if state.last == (CASE['people'][i][0] + '「' + refusal + '」',):
            return CASE['display']['refusal'][i]
    errors = {'この時刻・車両では調べられません。': 'NOT VISIBLE HERE.',
              'まだ読んでいない記録は根拠に選べません。': 'READ THIS RECORD BEFORE SELECTING IT.',
              '根拠は最大2件。先に一つ外してください。': 'TWO REASONS MAX. REMOVE ONE FIRST.'}
    return errors.get(state.last[0], 'RECORD UPDATED')


def wrap(p, x, y, text, width=70, color=7):
    import textwrap
    for row, line in enumerate(textwrap.wrap(text, width=width)):
        p.text(x, y + row * 10, line, color)


def draw_photos(p, state):
    # 時刻を切り替えても人を動かさない。同じ表を毎回参照するだけ。
    for time, label in enumerate(TIMES):
        x = 12 + time * 208
        p.rect(x, 45, 200, 164, 0)
        p.rectb(x, 45, 200, 164, 10 if time == state.time else 5)
        p.text(x + 8, 53, label, 10 if time == state.time else 6)
        for car, car_name in enumerate(CARS):
            y = 71 + car * 43
            p.rect(x + 8, y, 184, 36, 1)
            p.rectb(x + 8, y, 184, 36, 11 if car == state.car else 5)
            p.text(x + 13, y + 4, car_name, 11 if car == state.car else 6)
            names = photo_people(time, car)
            for index, name in enumerate(names):
                person_id = next(i for i, row in enumerate(CASE['people']) if row[0] == name)
                px = x + 65 + index * 47
                p.circ(px + 8, y + 11, 4, 15)
                p.rect(px + 4, y + 15, 9, 8, 8 + person_id)
                p.text(px - 1, y + 25, CASE['display']['people'][person_id], 7)
            item = photo_item(time, car)
            if item:
                p.rect(x + 166, y + 10, 12, 16, 12)
                p.text(x + 137, y + 28, 'RIBBON' if time == 0 else 'FOUND', 12)


def draw_board(p, controller):
    state = controller.state
    p.cls(1)
    p.text(12, 9, 'LOST PROPERTY TRAIN / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
    p.text(12, 25, 'FICTIONAL CASE A. Photographs are records, not time travel.', 6)
    draw_photos(p, state)
    p.text(12, 218, 'VIEW: ' + CARS[state.car] + ' / ' + TIMES[state.time], 11)
    ids = visible(state)
    p.text(12, 234, 'RECORDS HERE / Z TO READ', 6)
    for row, i in enumerate(ids):
        selected = controller.phase == 'explore' and row == controller.choice
        prefix = '> ' if selected else '  '
        read = '*' if i in state.known else '?'
        p.text(12, 248 + row * 12, prefix + read + ' E' + str(i) + ' ' + CASE['display']['titles'][i], 10 if selected else 7)
    if not ids:
        p.text(12, 248, 'NO RECORD AT THIS TIME / CAR', 5)
    p.text(328, 234, 'READ: ' + str(len(state.known)) + ' / 7', 6)
    if state.last:
        wrap(p, 328, 252, display_message(state), width=72, color=7)
    if controller.phase != 'explore':
        p.rect(8, 214, 624, 140, 0)
        p.text(16, 222, controller.phase.upper() + ' / ONLY READ RECORDS', 11)
        for row, i in enumerate(state.known):
            marker = '*' if i in state.reasons else ' '
            selected = row == controller.choice
            kind = 'FACT' if CASE['evidence'][i][3] == '事実' else 'SAID'
            p.text(16, 240 + row * 13, ('> ' if selected else '  ') + marker + ' E' + str(i)
                   + ' ' + kind + ' ' + CASE['display']['titles'][i], 10 if selected else 7)
        if not state.known:
            p.text(16, 244, 'EMPTY: READ A RECORD WITH Z FIRST', 6)
        elif controller.phase == 'notebook':
            i = state.known[controller.choice % len(state.known)]
            wrap(p, 312, 244, CASE['display']['evidence'][i], width=74)
        pair = ' AND '.join('E' + str(i) for i in state.reasons)
        p.text(16, 344, 'SELECTED: ' + (pair or '(NONE)'), 10)
        if controller.phase == 'submit':
            p.text(312, 240, 'OWNER IDEA: ' + CASE['display']['people'][state.candidate], 10)
            conflicts = contradictions(state)
            if conflicts:
                p.text(312, 258, 'CHECK KNOWN CONFLICTS:', 8)
                for n, title in enumerate(conflicts):
                    i = next(i for i, row in enumerate(CASE['evidence']) if row[0] == title)
                    p.text(312, 273 + n * 12, 'E' + str(i) + ' ' + CASE['display']['titles'][i], 8)
            else:
                p.text(312, 258, 'NO KNOWN CONFLICT IS NOT PROOF.', 6)
            p.text(312, 308, 'LEFT/RIGHT: PERSON   Z: TOGGLE REASON', 6)
            p.text(312, 323, 'TWO SELECTED RECORDS FORM AN AND.', 6)
    p.rect(8, 360, 624, 30, 5)
    p.text(16, 368, 'LAST INPUT: ' + controller.notice, 7)
    if state.last:
        wrap(p, 260, 368, display_message(state), width=89, color=10)
    p.text(12, 402, 'ENTER: IDEA / RETURN   TAB: NOTEBOOK   SPACE: BACK IN IDEA', 6)
    p.text(12, 418, 'X: UNDO RECORD/REASON/RETURN   R: RESET   ESC: QUIT', 6)
    if state.returned:
        p.rect(48, 90, 544, 230, 3)
        p.rectb(48, 90, 544, 230, 11)
        p.text(72, 112, 'RETURNED: SAKI CHECKED HER OWN NOTEBOOK', 7)
        wrap(p, 72, 140, CASE['display']['ending'][0], width=116, color=7)
        wrap(p, 72, 176, CASE['display']['ending'][1], width=116, color=7)
        p.text(72, 224, 'SELECTED REASONS: ' + str(len(state.reasons)) + ' / NOT A REQUIRED SCORE', 10)
        p.text(72, 247, 'ZERO REASONS MAY STILL FINISH, JUST AS IN THE ORIGINAL.', 7)
        p.text(72, 283, 'X: UNDO RETURN     R: RESET     ESC: QUIT', 10)


def main():
    import argparse
    import json
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-replay', metavar='KEYS')
    parser.add_argument('--screenshot', type=Path)
    parser.add_argument('--report', type=Path)
    args = parser.parse_args()
    # 引数検査はPyxelのimport/initより前。モデル試験にPyxelは不要。
    if args.headless_replay is None and (args.screenshot or args.report):
        parser.error('output paths require --headless-replay')
    if args.headless_replay is not None and any(c not in REPLAY for c in args.headless_replay):
        parser.error('replay keys must use: ' + ''.join(REPLAY))
    if args.headless_replay is not None and len(args.headless_replay) > 1000:
        parser.error('at most 1000 replay keys')
    args.screenshot = args.screenshot.resolve() if args.screenshot else None
    args.report = args.report.resolve() if args.report else None
    import pyxel
    controller = Controller()
    frames = 0
    events = []
    replay = args.headless_replay
    limit = max(12, len(replay or '') * 2 + 2)
    mapping = {name: getattr(pyxel, 'KEY_' + name) for name in CONTROLS}
    pyxel.init(640, 440, title='095 Lost Property Train - Stage ' + str(STAGE),
               fps=30, display_scale=1, quit_key=pyxel.KEY_NONE,
               headless=replay is not None)

    def update():
        nonlocal frames
        if pyxel.btnp(pyxel.KEY_ESCAPE):
            pyxel.quit()
            return
        if replay is None:
            down = {name for name, key in mapping.items() if pyxel.btn(key)}
        elif frames % 2 == 0 and frames // 2 < len(replay):
            down = {REPLAY[replay[frames // 2]]}
        else:
            down = set()
        result = controller.feed(down)
        if result is not None:
            events.append({'frame': frames, 'result': result, 'snapshot': controller.snapshot()})
        frames += 1

    def draw():
        draw_board(pyxel, controller)
        if replay is not None and frames >= limit:
            if args.screenshot:
                args.screenshot.parent.mkdir(parents=True, exist_ok=True)
                pyxel.screenshot(str(args.screenshot), scale=1)
            if args.report:
                args.report.parent.mkdir(parents=True, exist_ok=True)
                args.report.write_text(json.dumps({'stage': STAGE, 'actions': replay,
                    'frames': frames, 'events': events, 'snapshot': controller.snapshot()},
                    ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()
    pyxel.run(update, draw)


if __name__ == '__main__':
    main()
AFTER STEP 05

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

元のゲームの状況 A の決まった事件です。青い手帳、3人・3両・3つの時刻・7つの記録があり、持ち主(owner=0)はサキです。既読の根拠は2件まで選べますが、0件でもサキに返せば完了します。まちがった相手に渡すと断られ、減点や時間制限はありません。

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

比べる点5ステップの教材元の完成版
段階01 は写真、02 は既読、03 は証拠帳と根拠、04 は候補と知っている矛盾、05 は返すことと Undo です。03 だけ、証拠帳で Z を押して根拠を選べます。最初から返すところまで使えます。根拠は、返す相手を考える画面でだけ選べます。
収録範囲状況 A のプログラムを使います。CASE の中の display は文字(ASCII)表示のためのもので、判定には使いません。状況 A・B・C の3つの事件、日本語の文字、並べる表示とタブの表示の比較、保存と読みこみ、表紙、ヘルプ、確認の画面、画面や動画の保存があります。
入力起動するとすぐに調査が始まります。R はすぐリセット、Esc はすぐ終了です。05 のふつうの調査・証拠帳・返す相手の画面・X の意味は、元のゲームに合わせています。表紙から始める必要があります。R や Esc では確認の画面が出ます。M、1〜3、H、B、S/L、F12 のキーもあります。
判定05 の State と step は、元のゲームの状況 A と同じです。候補ごとの矛盾の注記は、known と重なる分だけ出します。2つの記録の AND は、メモの表示です。根拠を選ぶことと、正しい本人が確かめることを、別々に扱います。色や根拠の件数だけでは、持ち主の証明になりません。
検証実際の Pyxel で画面を出さずに描画するテストと、作った入力によるテスト、プログラム単体のテストを行っています。元のゲームの19のテストと、音なしの自動デモの結果を、別の証拠として保存しています。人が遊んだときの感想、OS のキー入力、音、ブラウザー、Windows/macOS の画面での確認はしていません。

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

  1. model.py: CASESのB/Cとstate_fromによる入力検証
  2. controller.py: feedの表紙・help・dialog優先順位
  3. model.py: save/loadとversion照合
  4. app.py: 日本語字形描画・parallel/tabs比較
  5. experiments.py: 表示方法を比較する実験
自分のゲームに変えるなら

最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。

教材をダウンロードする

無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について

検証について

どこまで動作を確認したかを見る

2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。

この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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