推理・観察LESSON 69公開写真から可能な候補集合を絞る有限証拠パズル原作063

一枚足りない写真の作り方

欠けた1枚の写真は、1つに決まるとは限らない。
残っている写真と、台車の移動・雨・布のルールから、ありえる写真の候補をまとめて調べます。答えを絞るのに十分な根拠と、判断ごとに戻せる Undo も作ります。

完成版の遊び方残った写真と雨の時刻から、欠けた時刻にありえた写真の候補を、まとめて(集合として)報告します。

一枚足りない写真の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Cの自動描画です。下の教材はAの観測から始め、03以降でA/B/Cを扱います。
PLAY / 試験公開

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

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

完成版の主な操作:Tab欄、左右選択/Z切替、上下重ね濃度、Space注目、Enter提出、X戻す

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

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

うまく表示されないとき

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

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

証拠と矛盾しない候補の集まりを、ルールから確かめる。

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

1. 書き換えないデータ・2枚を選ぶ比較・証拠と操作の状態を分ける
2. 前後のつながり・ビットで表す集合・排他的論理和(XOR)
3. 条件を調べる順番・続く状態・ありえる世界が複数あること
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上・Pyxel 2.9.9・ウィンドウ表示(GUI/OpenGL)ができる環境 / 変数・if・for・関数・タプル・辞書 / dataclass / replace / ビット演算は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右:現在の欄で選択 / Z:写真の二枚選択/候補・根拠を切替 / Tab:01は写真だけ、02/03は写真→候補、04/05は写真→候補→根拠 / Enter:02以降、集合を提出 / 上下:比較の濃さ0/50/100% / 1/2/3:03以降、A/B/Cへ切替し初期化 / X:05だけ、直前の判断をUndo / R:今の状況の状態と表示を初期化 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_observe.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

残っている写真を、時刻を進めずに比べる

書き換えないデータ・2枚を選ぶ比較・証拠と操作の状態を分ける。

このゲームは、欠けた「2刻」(2番目の撮影時刻)の写真を推理するゲームです。最初に残っているのは、0・1・3刻の写真だけです。第1段階では候補を選ばず、この3枚を見比べる道具を作ります。左右キーで黄色い枠を動かし、Z で1枚目、別の時刻で Z を押すと比較が確定します。同じ写真を2回選んでも、判断の回数は増えません。上下キーで、新しい写真の濃さを 0・50・100% に変えられます。赤い点線が古いほうの写真の台車、実線が新しいほうの台車です。

Photo の pos は台車の位置0〜4、roof は荷物の布の有無(0/1)、wet は荷物が濡れているか(0/1)です。Level は、雨の時刻表、わかっている写真 known、あとの段階で使う候補 candidates を持ちます。frozen=True は、過去の証拠を直接書き換えないための指定です。known を辞書にすると、時刻から写真を探せます。known には2というキーはありません。比べる写真を作るために、欠けた写真を勝手に補ってはいけません。

State は、プレイヤーの判断だけを持ちます。pair は比べている2つの時刻、compared は確定した比較の記録、tick は判断した回数です。tick は経過した秒数でも、撮影の時刻でもありません。compare:0:3 は「0刻と3刻を比べる」という意味の入力で、replace が新しい State を返します。証拠の中の台車は動きません。「3刻ぶん離れていれば最大3マス」という表示は上限で、必ず3マス動くという意味ではありません。止まることも、戻ることもできます。

App.control がキーを意味のある入力に変え、draw は読むだけです。操作キーが全部離されるまで次の入力を受け付けないので、Z の長押しで比較が何度も増えることはありません。同時押しは無効です。元のゲームの日本語の文字・タイトル・注目表示の切り替えは省き、1つのファイルだけで動く、英語のラベルと図形の画面にしています。

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

01_observe.py / 注目する部分
        pair = tuple(sorted((a, b)))
        return replace(s, pair=pair, compared=s.compared + (pair,),
                       feedback=f'{pair[0]}刻と{pair[1]}刻: {pair[1]-pair[0]}間隔、最大{(pair[1]-pair[0])*MAX_STEP}升。',
                       tick=s.tick + 1)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_observe.py
macOS / Linux
.venv/bin/python 01_observe.py
画面で見ることZ → 右 → 右 → Z と押します(キーは1つずつ完全に離します)。0刻と3刻が重なり、COMPARE 0 -> 3 / 3 INTERVALS、MAX MOVE 3、JUDGMENTS 1 / COMPARISONS 1 になります。下を2回押すと OPACITY 0% になりますが、証拠や判断の回数は変わりません。
  • known には、2刻の本当の写真はない
  • 2枚を選び終えたときだけ、1回の判断として保存する
  • 比較や描画では、撮影の時刻は進まない
つまずきやすいところtick を撮影の時刻として増やし、比べるたびに台車を動かさないでください。「何を見るか」を変える操作と、「証拠」を変える操作は別のものです。同じ1枚を2回選んでも、比較は確定しません。
少し変えてみよう

ゲームでの操作:R → Z → 右 → Z で0刻と1刻を比べ、最大1マスであることを確かめます。コードの書き換え:MAX_STEP = 1 を MAX_STEP = 2 に1か所変え、Z → 右 → 右 → Z と押してみましょう。MAX MOVE 6 になりますが、3刻の位置は3のままです。ルールの上限と、実際に写っているデータを区別しましょう。確認したら1に戻します。

01_observe.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(287行)
01_observe.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段階。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict

MAX_STEP = 1
MOVE, RAIN, WET = 1, 2, 4

@dataclass(frozen=True)
class Photo:
    pos: int
    roof: int
    wet: int

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    rain: tuple
    known: tuple
    candidates: tuple

LEVEL_A = Level("A", "一升ぶんの行き先", (0, 0, 0, 0),
                ((0, Photo(0, 0, 0)), (1, Photo(1, 0, 0)), (3, Photo(3, 0, 0))),
                tuple(Photo(x, 0, 0) for x in (1, 2, 3, 4)))
LEVELS = (LEVEL_A,)

@dataclass(frozen=True)
class State:
    pair: tuple = (0, 3)
    compared: tuple = ()
    tick: int = 0
    feedback: str = "写真を比べよう。時刻は進みません。"

def apply(state, level, action):
    # 不正な外部入力は無変更。正規の意味入力だけを状態へ反映する。
    if not isinstance(action, str):
        return state
    s = state
    if action.startswith('compare:'):
        try:
            _, a, b = action.split(':')
            a, b = int(a), int(b)
        except (ValueError, TypeError):
            return s
        if a not in (0, 1, 3) or b not in (0, 1, 3) or a == b:
            return s
        pair = tuple(sorted((a, b)))
        return replace(s, pair=pair, compared=s.compared + (pair,),
                       feedback=f'{pair[0]}刻と{pair[1]}刻: {pair[1]-pair[0]}間隔、最大{(pair[1]-pair[0])*MAX_STEP}升。',
                       tick=s.tick + 1)
    return s

STAGE = 1
DEFAULT_LEVEL = 0
FOCUS_COUNT = 1

class App:
    """キーから意味入力への変換。Pyxelなしでも操作テスト可能。"""
    def __init__(self):
        self.level_index = DEFAULT_LEVEL
        self.wait_release = False
        self.reset()

    @property
    def level(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = State()
        self.focus = 0
        self.cursor = 0
        self.pick = None
        self.opacity = 2
        self.message = 'READ THE KNOWN PHOTOS'

    def act(self, action):
        before = self.state
        self.state = apply(self.state, self.level, action)
        if self.state == before:
            self.message = 'NO CHANGE / CHECK LIMITS'
            return False
        if action.startswith('compare:'):
            self.message = 'COMPARISON SAVED / WORLD TIME DID NOT ADVANCE'
        elif action == 'submit':
            self.message = {'won': 'COMPLETE / ALL POSSIBLE CANDIDATES',
                             'contradiction': 'CONTRADICTION / AN IMPOSSIBLE PHOTO IS SELECTED',
                             'narrow': 'TOO NARROW / SOME POSSIBLE PHOTOS ARE MISSING',
                             'unsupported': 'SET MATCHES / REASONS ARE NOT SUFFICIENT'}[self.state.result]
        else:
            self.message = 'SELECTION UPDATED / ENTER TO SUBMIT'
        return True

    def control(self, down):
        down = set(down) & set(KEYS)
        if not down:
            self.wait_release = False
            return
        if self.wait_release:
            return
        self.wait_release = True
        if len(down) != 1:
            self.message = 'RELEASE ALL KEYS'
            return
        key = next(iter(down))
        if key == 'R':
            self.reset()
            return
        if key == 'TAB':
            self.focus = (self.focus + 1) % FOCUS_COUNT
            self.cursor = 0
            self.pick = None
            return
        count = (3, 4, 3)[self.focus]
        if key == 'LEFT':
            self.cursor = (self.cursor - 1) % count
        elif key == 'RIGHT':
            self.cursor = (self.cursor + 1) % count
        elif key == 'UP':
            self.opacity = min(2, self.opacity + 1)
        elif key == 'DOWN':
            self.opacity = max(0, self.opacity - 1)
        elif key == 'Z':
            if self.focus == 0:
                time = (0, 1, 3)[self.cursor]
                if self.pick is None:
                    self.pick = time
                    self.message = 'FIRST PHOTO PICKED / CHOOSE ANOTHER'
                elif self.pick != time:
                    self.act(f'compare:{self.pick}:{time}')
                    self.pick = None

    @staticmethod
    def photo(p, x, y, w, h, photo, rain=0, outline=False, background=True):
        if background:
            p.rect(x, y, w, h, 13)
            p.rect(x, y + h - 21, w, 21, 4)
            for i in range(5):
                p.text(x + 12 + i * ((w - 28) // 5), y + h - 14, str(i), 7)
            if rain:
                for dx in range(30, w - 4, 24):
                    p.line(x + dx, y + 6, x + dx - 3, y + 14, 12)
        cx = x + 15 + photo.pos * ((w - 28) // 5)
        cy = y + h - 38
        if outline:
            for dx in range(-11, 13, 4):
                p.pset(cx + dx, cy, 8)
                p.pset(cx + dx, cy + 9, 8)
            p.circb(cx - 7, cy + 13, 3, 8)
            p.circb(cx + 7, cy + 13, 3, 8)
            if photo.roof:
                for dx in range(-13, 15, 4):
                    p.pset(cx + dx, cy - 16, 8)
        else:
            p.rect(cx - 11, cy, 23, 10, 9)
            p.circ(cx - 7, cy + 13, 3, 0)
            p.circ(cx + 7, cy + 13, 3, 0)
            p.rect(cx - 6, cy - 10, 12, 10, 12 if photo.wet else 10)
            if photo.roof:
                p.tri(cx - 14, cy - 13, cx, cy - 22, cx + 14, cy - 13, 3)
                p.line(cx - 11, cy - 13, cx - 11, cy, 3)
                p.line(cx + 11, cy - 13, cx + 11, cy, 3)

    def draw(self, p):
        # 描画は証拠と現在の操作状態のみを読む。判定はsubmit内で行う。
        s, level = self.state, self.level
        known = dict(level.known)
        p.cls(1)
        p.text(12, 10, 'MISSING PHOTO / STAGE ' + str(STAGE), 10)
        p.text(350, 10, 'LEVEL ' + level.id + ' / MISSING TIME 2', 11)
        p.text(12, 27, 'POS = POSITION   ROOF 0/1   WET 0/1   BLUE BOX = WET', 6)
        rain_times = ','.join(str(t) for t, rain in enumerate(level.rain) if rain) or 'NONE'
        p.text(350, 27, 'RAIN TIMES: ' + rain_times, 11)
        rule_text = 'MOVE <= %d / STOP OR RETURN ALLOWED' % MAX_STEP
        if STAGE >= 3:
            rule_text += ' / ROOF CHANGES / RAIN + NO ROOF WETS / WET STAYS'
        p.text(12, 37, rule_text, 6)
        for i, (time, photo) in enumerate(level.known):
            x = 12 + i * 208
            self.photo(p, x, 48, 196, 106, photo, level.rain[time])
            p.text(x + 6, 54, 'TIME ' + str(time) + (' RAIN' if level.rain[time] else ' CLEAR'), 1)
            p.text(x + 4, 163, 'POS %d  ROOF %d  WET %d' % (photo.pos, photo.roof, photo.wet), 7)
            if self.focus == 0 and self.cursor == i:
                p.rectb(x - 2, 46, 200, 110, 10)
            if self.pick == time:
                p.text(x + 132, 137, 'PICKED', 10)
        a, b = s.pair
        p.text(12, 190, 'COMPARE %d -> %d / %d INTERVALS' % (a, b, b - a), 10)
        self.photo(p, 12, 208, 294, 128, known[a], level.rain[a])
        if self.opacity > 0:
            p.dither(self.opacity / 2)
            self.photo(p, 12, 208, 294, 128, known[b], level.rain[b])
            p.dither(1.0)
            self.photo(p, 12, 208, 294, 128, known[a], outline=True, background=False)
        p.text(16, 342, 'OLD = RED DOTS  NEW = SOLID', 6)
        p.text(16, 355, 'OPACITY %d%% / MAX MOVE %d' % (self.opacity * 50, (b - a) * MAX_STEP), 6)
        p.text(328, 218, 'KNOWN TIMES: 0, 1, 3', 10)
        p.text(328, 238, 'NO PHOTO FROM TIME 2 IS STORED.', 7)
        p.text(328, 258, 'Z PICKS TWO KNOWN PHOTOS.', 7)
        p.text(328, 278, 'COMPARE DOES NOT MOVE THE CART.', 7)
        p.text(328, 298, 'ONE INTERVAL: MOVE UP TO %d CELL(S).' % MAX_STEP, 7)
        p.text(12, 413, 'JUDGMENTS %d / COMPARISONS %d' % (s.tick, len(s.compared)), 6)
        p.text(12, 430, self.message, 11)
        p.text(12, 449, CONTROLS, 7)
        p.text(12, 467, 'R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT', 5)

CONTROLS = 'LEFT/RIGHT SELECT  Z PICK  UP/DOWN OPACITY'
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'TAB', 'RETURN', 'X', 'R', '1', '2', '3')
DEMO_KEYS = ('Z', 'RIGHT', 'RIGHT', 'Z')

def parse_args(argv=None):
    """QA引数はPyxel importより先に検証する。"""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--frames', type=int)
    parser.add_argument('--screenshot')
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.frames is not None or args.screenshot) and not args.headless_smoke:
        parser.error('QA options require --headless-smoke')
    minimum = max(8, 2 * len(DEMO_KEYS) + 4) if args.demo_checkpoint else 8
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error('--frames must be between %d and 600' % minimum)
    if args.screenshot:
        path = Path(args.screenshot).expanduser().resolve()
        if path.suffix.lower() != '.png' or path.exists() or not path.parent.is_dir():
            parser.error('screenshot must be a NEW .png file in an existing directory')
        args.screenshot = str(path)
    return args


def main(argv=None):
    args = parse_args(argv)
    import json
    import pyxel as p
    app = App()
    p.init(640, 480, title='Missing Photo / lesson ' + str(STAGE), fps=30,
           display_scale=1, headless=args.headless_smoke)
    keys = {name: getattr(p, 'KEY_' + name) for name in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= args.frames:
            if args.screenshot:
                p.screenshot(args.screenshot, scale=1)
            print(json.dumps({'state': asdict(app.state), 'level': app.level.id,
                              'focus': app.focus, 'cursor': app.cursor,
                              'history_length': len(app.timeline.history) if STAGE == 5 else 0},
                             ensure_ascii=True), flush=True)
            p.quit()
            return
        if args.headless_smoke:
            i = p.frame_count // 2
            down = {DEMO_KEYS[i]} if args.demo_checkpoint and p.frame_count % 2 == 0 and i < len(DEMO_KEYS) else set()
        else:
            down = {name for name, code in keys.items() if p.btn(code)}
        app.control(down)
    p.run(update, lambda: app.draw(p))


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

前後の写真に合う候補を、集合で残す

前後のつながり・ビットで表す集合・排他的論理和(XOR)。

Tab で候補の欄に移り、左右と Z で、候補 P/Q/R/S の KEEP(残す)を切り替えます。Enter で、選んだ候補の集まりを提出します。状況 A の候補の位置は 1・2・3・4 です。わかっている1刻は位置1、3刻は位置3 です。1刻ぶんで1マスまでしか動けないので、両側とつながれるのは2刻が位置2のとき、つまり Q だけです。1刻から届くかだけを調べると、P も通ってしまいます。supports は 0→1、1→2、2→3 の3つのつながりを、all で全部調べます。

transition は、となり合う時刻の写真どうしだけを比べます。abs(a.pos - b.pos) <= 1 なら、進む・止まる・戻るのどれでもルールに合います。この段階は移動のルールだけです。roof と wet はデータの形をそろえるためにありますが、雨と濡れの検査は第3段階で加えます。rules は、あとでルールの一部だけを使うための引数です。

selected は数ですが、中身は4枚のうちどれを選んだかという集まりです。P/Q/R/S に、1/2/4/8 という重ならないビットを割り当てます。1 << i で i 番の札のビットを作り、selected ^ (1 << i) でその札だけを反転(選ぶ⇔外す)させます。Q と R を選ぶと 2 + 4 = 6 です。XOR をただの足し算にすると、2回押しても外せません。answer は候補を全部検査し、ありえる札のビットだけを足します。「正解の番号を1つ保存しておく」作りではありません。

submit は、ありえない札が含まれていれば contradiction(矛盾)、ありえる札が足りなければ narrow(絞りすぎ)、集まりが一致すれば won です。何も選ばずに提出しても1回の判断です。失敗してもゲームは終わらず、選び直せます。State の reasons は次の段階用で、ここではまだ使いません。画面は提出する前に正解の札を色分けしませんが、提出の結果や矛盾の説明から、ルールが導く答えを学べます。ただし、画面全体が答えに関する情報をまったく出さない、という意味ではありません。

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

02_candidates.py / 注目する部分
        return replace(s, selected=s.selected ^ (1 << i), result='',
                       feedback='残す写真を選択中。Enterで集合を提出。', tick=s.tick + 1)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_candidates.py
macOS / Linux
.venv/bin/python 02_candidates.py
画面で見ることR → Tab → 右 → Z → Enter で Q だけを KEEP にすると、RESULT: WON、JUDGMENTS 2 です。R → Enter は NARROW。R → Tab → Z → Enter は P を残したので CONTRADICTION。どれも、続けて選び直せます。
  • 前後の3つのつながりを全部調べる
  • XOR で、1枚だけを切り替える
  • 1枚の番号ではなく、候補の集まりを提出する
つまずきやすいところ1刻→2刻だけで候補を認めず、2刻→3刻も調べます。また、selected == 2 を正解として直接書くと、証拠や候補を変える練習に対応できません。
少し変えてみよう

ゲームでの操作:Q で Z を2回押すと KEEP→OUT になり、selected は0に戻ります。コードの書き換え:for x in (1, 2, 3, 4) を for x in (1, 2, 2, 4) に変えてみましょう。Q と R は同じ内容でも別の札です。Q だけを提出すると NARROW、Q と R を両方 KEEP にすると WON・集合6です。確認したら元の候補に戻します。

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

"""一枚足りない写真: 第2段階。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict

MAX_STEP = 1
MOVE, RAIN, WET = 1, 2, 4

@dataclass(frozen=True)
class Photo:
    pos: int
    roof: int
    wet: int

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    rain: tuple
    known: tuple
    candidates: tuple

LEVEL_A = Level("A", "一升ぶんの行き先", (0, 0, 0, 0),
                ((0, Photo(0, 0, 0)), (1, Photo(1, 0, 0)), (3, Photo(3, 0, 0))),
                tuple(Photo(x, 0, 0) for x in (1, 2, 3, 4)))
LEVELS = (LEVEL_A,)

def transition(a, b, rain, rules=7):
    if rules & MOVE and abs(a.pos - b.pos) > MAX_STEP:
        return False
    return True


def supports(level, candidate, rules=7):
    photos = dict(level.known)
    photos[2] = candidate
    return all(transition(photos[t - 1], photos[t], level.rain[t], rules)
               for t in range(1, 4))


def answer(level, rules=7):
    return sum(1 << i for i, p in enumerate(level.candidates)
               if supports(level, p, rules))


def witness(level, candidate):
    if not supports(level, candidate):
        return None
    return [asdict(dict(level.known).get(t, candidate)) for t in range(4)]


def contradiction(level, candidate):
    photos = dict(level.known)
    photos[2] = candidate
    for t in range(1, 4):
        a, b = photos[t - 1], photos[t]
        if abs(a.pos - b.pos) > MAX_STEP:
            return f'{t-1}から{t}刻: 一間隔で二升以上は動けません。'
    return ''

@dataclass(frozen=True)
class State:
    pair: tuple = (0, 3)
    selected: int = 0
    reasons: int = 0
    compared: tuple = ()
    submissions: int = 0
    result: str = ''
    feedback: str = 'この四枚のうち、可能な写真を全部残そう。'
    tick: int = 0

def apply(state, level, action):
    # 不正な外部入力は無変更。正規の意味入力だけを状態へ反映する。
    if not isinstance(action, str):
        return state
    s = state
    if action.startswith('compare:'):
        try:
            _, a, b = action.split(':')
            a, b = int(a), int(b)
        except (ValueError, TypeError):
            return s
        if a not in (0, 1, 3) or b not in (0, 1, 3) or a == b:
            return s
        pair = tuple(sorted((a, b)))
        return replace(s, pair=pair, compared=s.compared + (pair,),
                       feedback=f'{pair[0]}刻と{pair[1]}刻: {pair[1]-pair[0]}間隔、最大{(pair[1]-pair[0])*MAX_STEP}升。',
                       tick=s.tick + 1)
    if action.startswith('candidate:'):
        try:
            i = int(action.split(':')[1])
        except (ValueError, IndexError):
            return s
        if not 0 <= i < 4:
            return s
        return replace(s, selected=s.selected ^ (1 << i), result='',
                       feedback='残す写真を選択中。Enterで集合を提出。', tick=s.tick + 1)
    if action == 'submit':
        correct = answer(level)
        bad = s.selected & ~correct
        if bad:
            result = 'contradiction'
            first_bad = (bad & -bad).bit_length() - 1
            message = contradiction(level, level.candidates[first_bad])
        elif s.selected != correct:
            result = 'narrow'
            message = 'まだ否定できない写真があります。可能なものを全部残そう。'
        else:
            result = 'won'
            message = f'候補集合の結論! {correct.bit_count()}枚が可能です。'
        return replace(s, submissions=s.submissions + 1, result=result,
                       feedback=message, tick=s.tick + 1)
    return s

STAGE = 2
DEFAULT_LEVEL = 0
FOCUS_COUNT = 2

class App:
    """キーから意味入力への変換。Pyxelなしでも操作テスト可能。"""
    def __init__(self):
        self.level_index = DEFAULT_LEVEL
        self.wait_release = False
        self.reset()

    @property
    def level(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = State()
        self.focus = 0
        self.cursor = 0
        self.pick = None
        self.opacity = 2
        self.message = 'READ THE KNOWN PHOTOS'

    def act(self, action):
        before = self.state
        self.state = apply(self.state, self.level, action)
        if self.state == before:
            self.message = 'NO CHANGE / CHECK LIMITS'
            return False
        if action.startswith('compare:'):
            self.message = 'COMPARISON SAVED / WORLD TIME DID NOT ADVANCE'
        elif action == 'submit':
            self.message = {'won': 'COMPLETE / ALL POSSIBLE CANDIDATES',
                             'contradiction': 'CONTRADICTION / AN IMPOSSIBLE PHOTO IS SELECTED',
                             'narrow': 'TOO NARROW / SOME POSSIBLE PHOTOS ARE MISSING',
                             'unsupported': 'SET MATCHES / REASONS ARE NOT SUFFICIENT'}[self.state.result]
        else:
            self.message = 'SELECTION UPDATED / ENTER TO SUBMIT'
        return True

    def control(self, down):
        down = set(down) & set(KEYS)
        if not down:
            self.wait_release = False
            return
        if self.wait_release:
            return
        self.wait_release = True
        if len(down) != 1:
            self.message = 'RELEASE ALL KEYS'
            return
        key = next(iter(down))
        if key == 'R':
            self.reset()
            return
        if key == 'TAB':
            self.focus = (self.focus + 1) % FOCUS_COUNT
            self.cursor = 0
            self.pick = None
            return
        count = (3, 4, 3)[self.focus]
        if key == 'LEFT':
            self.cursor = (self.cursor - 1) % count
        elif key == 'RIGHT':
            self.cursor = (self.cursor + 1) % count
        elif key == 'UP':
            self.opacity = min(2, self.opacity + 1)
        elif key == 'DOWN':
            self.opacity = max(0, self.opacity - 1)
        elif key == 'RETURN':
            self.act('submit')
        elif key == 'Z':
            if self.focus == 0:
                time = (0, 1, 3)[self.cursor]
                if self.pick is None:
                    self.pick = time
                    self.message = 'FIRST PHOTO PICKED / CHOOSE ANOTHER'
                elif self.pick != time:
                    self.act(f'compare:{self.pick}:{time}')
                    self.pick = None
            elif self.focus == 1:
                self.act(f'candidate:{self.cursor}')

    @staticmethod
    def photo(p, x, y, w, h, photo, rain=0, outline=False, background=True):
        if background:
            p.rect(x, y, w, h, 13)
            p.rect(x, y + h - 21, w, 21, 4)
            for i in range(5):
                p.text(x + 12 + i * ((w - 28) // 5), y + h - 14, str(i), 7)
            if rain:
                for dx in range(30, w - 4, 24):
                    p.line(x + dx, y + 6, x + dx - 3, y + 14, 12)
        cx = x + 15 + photo.pos * ((w - 28) // 5)
        cy = y + h - 38
        if outline:
            for dx in range(-11, 13, 4):
                p.pset(cx + dx, cy, 8)
                p.pset(cx + dx, cy + 9, 8)
            p.circb(cx - 7, cy + 13, 3, 8)
            p.circb(cx + 7, cy + 13, 3, 8)
            if photo.roof:
                for dx in range(-13, 15, 4):
                    p.pset(cx + dx, cy - 16, 8)
        else:
            p.rect(cx - 11, cy, 23, 10, 9)
            p.circ(cx - 7, cy + 13, 3, 0)
            p.circ(cx + 7, cy + 13, 3, 0)
            p.rect(cx - 6, cy - 10, 12, 10, 12 if photo.wet else 10)
            if photo.roof:
                p.tri(cx - 14, cy - 13, cx, cy - 22, cx + 14, cy - 13, 3)
                p.line(cx - 11, cy - 13, cx - 11, cy, 3)
                p.line(cx + 11, cy - 13, cx + 11, cy, 3)

    def draw(self, p):
        # 描画は証拠と現在の操作状態のみを読む。判定はsubmit内で行う。
        s, level = self.state, self.level
        known = dict(level.known)
        p.cls(1)
        p.text(12, 10, 'MISSING PHOTO / STAGE ' + str(STAGE), 10)
        p.text(350, 10, 'LEVEL ' + level.id + ' / MISSING TIME 2', 11)
        p.text(12, 27, 'POS = POSITION   ROOF 0/1   WET 0/1   BLUE BOX = WET', 6)
        rain_times = ','.join(str(t) for t, rain in enumerate(level.rain) if rain) or 'NONE'
        p.text(350, 27, 'RAIN TIMES: ' + rain_times, 11)
        rule_text = 'MOVE <= %d / STOP OR RETURN ALLOWED' % MAX_STEP
        if STAGE >= 3:
            rule_text += ' / ROOF CHANGES / RAIN + NO ROOF WETS / WET STAYS'
        p.text(12, 37, rule_text, 6)
        for i, (time, photo) in enumerate(level.known):
            x = 12 + i * 208
            self.photo(p, x, 48, 196, 106, photo, level.rain[time])
            p.text(x + 6, 54, 'TIME ' + str(time) + (' RAIN' if level.rain[time] else ' CLEAR'), 1)
            p.text(x + 4, 163, 'POS %d  ROOF %d  WET %d' % (photo.pos, photo.roof, photo.wet), 7)
            if self.focus == 0 and self.cursor == i:
                p.rectb(x - 2, 46, 200, 110, 10)
            if self.pick == time:
                p.text(x + 132, 137, 'PICKED', 10)
        a, b = s.pair
        p.text(12, 190, 'COMPARE %d -> %d / %d INTERVALS' % (a, b, b - a), 10)
        self.photo(p, 12, 208, 294, 128, known[a], level.rain[a])
        if self.opacity > 0:
            p.dither(self.opacity / 2)
            self.photo(p, 12, 208, 294, 128, known[b], level.rain[b])
            p.dither(1.0)
            self.photo(p, 12, 208, 294, 128, known[a], outline=True, background=False)
        p.text(16, 342, 'OLD = RED DOTS  NEW = SOLID', 6)
        p.text(16, 355, 'OPACITY %d%% / MAX MOVE %d' % (self.opacity * 50, (b - a) * MAX_STEP), 6)
        p.text(328, 190, 'TIME 2 CANDIDATES / KEEP ALL POSSIBLE', 10)
        for i, photo in enumerate(level.candidates):
            x, y = 328 + (i % 2) * 154, 208 + (i // 2) * 82
            chosen = bool(s.selected & (1 << i))
            p.rect(x, y, 146, 74, 2)
            p.rectb(x, y, 146, 74, 10 if self.focus == 1 and self.cursor == i else 11 if chosen else 5)
            p.text(x + 6, y + 6, 'PQRS'[i] + (' KEEP' if chosen else ' OUT'), 11 if chosen else 7)
            p.text(x + 6, y + 23, 'POS %d ROOF %d WET %d' % (photo.pos, photo.roof, photo.wet), 7)
            p.text(x + 6, y + 42, 'CANDIDATE / NOT TRUTH', 5)
        p.text(328, 380, 'RESULT: ' + (s.result.upper() or 'NOT SUBMITTED'), 11)
        p.text(12, 413, 'JUDGMENTS %d / COMPARISONS %d' % (s.tick, len(s.compared)), 6)
        p.text(12, 430, self.message, 11)
        p.text(12, 449, CONTROLS, 7)
        p.text(12, 467, 'R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT', 5)

CONTROLS = 'TAB FOCUS  LEFT/RIGHT SELECT  Z TOGGLE  ENTER SUBMIT'
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'TAB', 'RETURN', 'X', 'R', '1', '2', '3')
DEMO_KEYS = ('TAB', 'RIGHT', 'Z', 'RETURN')

def parse_args(argv=None):
    """QA引数はPyxel importより先に検証する。"""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--frames', type=int)
    parser.add_argument('--screenshot')
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.frames is not None or args.screenshot) and not args.headless_smoke:
        parser.error('QA options require --headless-smoke')
    minimum = max(8, 2 * len(DEMO_KEYS) + 4) if args.demo_checkpoint else 8
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error('--frames must be between %d and 600' % minimum)
    if args.screenshot:
        path = Path(args.screenshot).expanduser().resolve()
        if path.suffix.lower() != '.png' or path.exists() or not path.parent.is_dir():
            parser.error('screenshot must be a NEW .png file in an existing directory')
        args.screenshot = str(path)
    return args


def main(argv=None):
    args = parse_args(argv)
    import json
    import pyxel as p
    app = App()
    p.init(640, 480, title='Missing Photo / lesson ' + str(STAGE), fps=30,
           display_scale=1, headless=args.headless_smoke)
    keys = {name: getattr(p, 'KEY_' + name) for name in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= args.frames:
            if args.screenshot:
                p.screenshot(args.screenshot, scale=1)
            print(json.dumps({'state': asdict(app.state), 'level': app.level.id,
                              'focus': app.focus, 'cursor': app.cursor,
                              'history_length': len(app.timeline.history) if STAGE == 5 else 0},
                             ensure_ascii=True), flush=True)
            p.quit()
            return
        if args.headless_smoke:
            i = p.frame_count // 2
            down = {DEMO_KEYS[i]} if args.demo_checkpoint and p.frame_count % 2 == 0 and i < len(DEMO_KEYS) else set()
        else:
            down = {name for name, code in keys.items() if p.btn(code)}
        app.control(down)
    p.run(update, lambda: app.draw(p))


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

雨、到着先の布、一度濡れた荷物を順に読む

条件を調べる順番・続く状態・ありえる世界が複数あること。

状況 B から始まります。雨が降るのは2刻だけです。わかっている1刻は位置1・布あり・乾いている、3刻は位置3・布あり・濡れている、です。候補 Q は位置2・布なし・濡れている。「雨の2刻に布を外して荷物が濡れ、3刻には布を戻した」とつなげられます。P は位置2・布あり・乾いているので、2→3で理由なく濡れてしまう矛盾。R は位置1・布なし・濡れているので、2→3で2マス動く矛盾。S は位置2・布あり・濡れているので、1→2で布があるのに濡れる矛盾です。

移動は、a(前の写真)から b(次の写真)への差で調べます。雨は level.rain[t]、布は着いた先の写真の b.roof を見ます。出発時の布 a.roof を使うと、「途中で布を外せる」というルールが壊れます。もともと乾いているときだけ RAIN(雨と布)のルールを使い、雨が降っていて布がなければ b.wet は1、それ以外は0です。すでに濡れている a.wet=1 なら、WET(濡れの持続)のルールが、b.wet=0(乾く)への変化を禁止します。布をかけることと、乾くことを混同しないようにします。

1・2・3 キーで状況 A/B/C を切り替えられます。数字キーは、候補を選ぶキーではありません。切り替えると、判断と表示の選択が最初に戻ります。R は今の状況をやり直します。状況 C では、1刻と3刻がどちらも位置1です。2刻が位置 0・1・2 なら、「外へ出て戻る」「止まる」「反対側へ行って戻る」の3通りが全部ありえます。S の位置3だけが、1刻ぶんで動くには遠すぎます。

witness は、候補がルールを満たすとき、その候補を2刻に入れた0〜3刻の写真の並びを返します。これは「本当に撮られた隠し写真」を取り戻したものではなく、「この候補でもルールを満たせる」という1つの証拠の例です。状況 C の3つの候補には、それぞれ証拠の例があります。元のゲームにも、ただ1つの隠し写真 hidden_photo や、実際に撮られた2刻の保存値はありません。公開された証拠とルールから、候補の集まりを計算します。好きな写真を作って答えるゲームではなく、4枚の中からありえるものを残すゲームです。

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

03_weather.py / 注目する部分
    if rules & RAIN and not a.wet and b.wet != int(bool(rain and not b.roof)):
        return False
    if rules & WET and a.wet and not b.wet:
        return False

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_weather.py
macOS / Linux
.venv/bin/python 03_weather.py
画面で見ること起動 → Tab → 右 → Z → Enter で、状況 B は Q だけで WON です。3 → Tab → Z → 右 → Z → 右 → Z → Enter なら、状況 C の P/Q/R が KEEP、S が OUT で WON。3 → Tab → 右 → Z → Enter では Q だけなので NARROW です。
  • 雨は着いた時刻、布は着いた写真で見る
  • 一度濡れた荷物は、そのあと乾かない
  • 状況 C の3つの候補は、全部ありえる
つまずきやすいところ雨と布の条件には、a.roof ではなく b.roof を使います。布を戻しただけで、濡れを0にしないでください。状況 C で Q だけを正解にすると、証拠からは決まらないことを、勝手に決めてしまいます。
少し変えてみよう

ゲームでの操作:状況 B の候補を1枚ずつ提出し、矛盾の場所をモデルの contradiction と見比べます。コードの書き換え:状況 B のわかっている3刻にある Photo(3, 1, 1) を Photo(3, 1, 0) に変えてみましょう。3刻が乾いているという証拠に変わるので、P だけがありえる候補になります。起動 → Tab → Z → Enter で WON です。確認したら濡れ1に戻します。

03_weather.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(375行)
03_weather.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段階。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict

MAX_STEP = 1
MOVE, RAIN, WET = 1, 2, 4

@dataclass(frozen=True)
class Photo:
    pos: int
    roof: int
    wet: int

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    rain: tuple
    known: tuple
    candidates: tuple

LEVEL_A = Level("A", "一升ぶんの行き先", (0, 0, 0, 0),
                ((0, Photo(0, 0, 0)), (1, Photo(1, 0, 0)), (3, Photo(3, 0, 0))),
                tuple(Photo(x, 0, 0) for x in (1, 2, 3, 4)))
LEVEL_B = Level("B", "屋根は最後に戻った", (0, 0, 1, 0),
                ((0, Photo(0, 1, 0)), (1, Photo(1, 1, 0)), (3, Photo(3, 1, 1))),
                (Photo(2, 1, 0), Photo(2, 0, 1), Photo(1, 0, 1), Photo(2, 1, 1)))
LEVEL_C = Level("C", "戻ってきた車輪", (0, 0, 0, 0),
                ((0, Photo(1, 1, 0)), (1, Photo(1, 1, 0)), (3, Photo(1, 1, 0))),
                tuple(Photo(x, 1, 0) for x in (0, 1, 2, 3)))
LEVELS = (LEVEL_A, LEVEL_B, LEVEL_C)

def transition(a, b, rain, rules=7):
    if rules & MOVE and abs(a.pos - b.pos) > MAX_STEP:
        return False
    if rules & RAIN and not a.wet and b.wet != int(bool(rain and not b.roof)):
        return False
    if rules & WET and a.wet and not b.wet:
        return False
    return True


def supports(level, candidate, rules=7):
    photos = dict(level.known)
    photos[2] = candidate
    return all(transition(photos[t - 1], photos[t], level.rain[t], rules)
               for t in range(1, 4))


def answer(level, rules=7):
    return sum(1 << i for i, p in enumerate(level.candidates)
               if supports(level, p, rules))


def witness(level, candidate):
    if not supports(level, candidate):
        return None
    return [asdict(dict(level.known).get(t, candidate)) for t in range(4)]


def contradiction(level, candidate):
    photos = dict(level.known)
    photos[2] = candidate
    for t in range(1, 4):
        a, b = photos[t - 1], photos[t]
        if abs(a.pos - b.pos) > MAX_STEP:
            return f'{t-1}から{t}刻: 一間隔で二升以上は動けません。'
        if not a.wet and b.wet != int(bool(level.rain[t] and not b.roof)):
            return f'{t-1}から{t}刻: 乾いた荷物は雨と布の順に確かめよう。'
        if a.wet and not b.wet:
            return f'{t-1}から{t}刻: 濡れた荷物は途中で乾きません。'
    return ''

@dataclass(frozen=True)
class State:
    pair: tuple = (0, 3)
    selected: int = 0
    reasons: int = 0
    compared: tuple = ()
    submissions: int = 0
    result: str = ''
    feedback: str = 'この四枚のうち、可能な写真を全部残そう。'
    tick: int = 0

def apply(state, level, action):
    # 不正な外部入力は無変更。正規の意味入力だけを状態へ反映する。
    if not isinstance(action, str):
        return state
    s = state
    if action.startswith('compare:'):
        try:
            _, a, b = action.split(':')
            a, b = int(a), int(b)
        except (ValueError, TypeError):
            return s
        if a not in (0, 1, 3) or b not in (0, 1, 3) or a == b:
            return s
        pair = tuple(sorted((a, b)))
        return replace(s, pair=pair, compared=s.compared + (pair,),
                       feedback=f'{pair[0]}刻と{pair[1]}刻: {pair[1]-pair[0]}間隔、最大{(pair[1]-pair[0])*MAX_STEP}升。',
                       tick=s.tick + 1)
    if action.startswith('candidate:'):
        try:
            i = int(action.split(':')[1])
        except (ValueError, IndexError):
            return s
        if not 0 <= i < 4:
            return s
        return replace(s, selected=s.selected ^ (1 << i), result='',
                       feedback='残す写真を選択中。Enterで集合を提出。', tick=s.tick + 1)
    if action == 'submit':
        correct = answer(level)
        bad = s.selected & ~correct
        if bad:
            result = 'contradiction'
            first_bad = (bad & -bad).bit_length() - 1
            message = contradiction(level, level.candidates[first_bad])
        elif s.selected != correct:
            result = 'narrow'
            message = 'まだ否定できない写真があります。可能なものを全部残そう。'
        else:
            result = 'won'
            message = f'候補集合の結論! {correct.bit_count()}枚が可能です。'
        return replace(s, submissions=s.submissions + 1, result=result,
                       feedback=message, tick=s.tick + 1)
    return s

STAGE = 3
DEFAULT_LEVEL = 1
FOCUS_COUNT = 2

class App:
    """キーから意味入力への変換。Pyxelなしでも操作テスト可能。"""
    def __init__(self):
        self.level_index = DEFAULT_LEVEL
        self.wait_release = False
        self.reset()

    @property
    def level(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = State()
        self.focus = 0
        self.cursor = 0
        self.pick = None
        self.opacity = 2
        self.message = 'READ THE KNOWN PHOTOS'

    def act(self, action):
        before = self.state
        self.state = apply(self.state, self.level, action)
        if self.state == before:
            self.message = 'NO CHANGE / CHECK LIMITS'
            return False
        if action.startswith('compare:'):
            self.message = 'COMPARISON SAVED / WORLD TIME DID NOT ADVANCE'
        elif action == 'submit':
            self.message = {'won': 'COMPLETE / ALL POSSIBLE CANDIDATES',
                             'contradiction': 'CONTRADICTION / AN IMPOSSIBLE PHOTO IS SELECTED',
                             'narrow': 'TOO NARROW / SOME POSSIBLE PHOTOS ARE MISSING',
                             'unsupported': 'SET MATCHES / REASONS ARE NOT SUFFICIENT'}[self.state.result]
        else:
            self.message = 'SELECTION UPDATED / ENTER TO SUBMIT'
        return True

    def control(self, down):
        down = set(down) & set(KEYS)
        if not down:
            self.wait_release = False
            return
        if self.wait_release:
            return
        self.wait_release = True
        if len(down) != 1:
            self.message = 'RELEASE ALL KEYS'
            return
        key = next(iter(down))
        if key == 'R':
            self.reset()
            return
        if key in ('1', '2', '3'):
            self.level_index = int(key) - 1
            self.reset()
            return
        if key == 'TAB':
            self.focus = (self.focus + 1) % FOCUS_COUNT
            self.cursor = 0
            self.pick = None
            return
        count = (3, 4, 3)[self.focus]
        if key == 'LEFT':
            self.cursor = (self.cursor - 1) % count
        elif key == 'RIGHT':
            self.cursor = (self.cursor + 1) % count
        elif key == 'UP':
            self.opacity = min(2, self.opacity + 1)
        elif key == 'DOWN':
            self.opacity = max(0, self.opacity - 1)
        elif key == 'RETURN':
            self.act('submit')
        elif key == 'Z':
            if self.focus == 0:
                time = (0, 1, 3)[self.cursor]
                if self.pick is None:
                    self.pick = time
                    self.message = 'FIRST PHOTO PICKED / CHOOSE ANOTHER'
                elif self.pick != time:
                    self.act(f'compare:{self.pick}:{time}')
                    self.pick = None
            elif self.focus == 1:
                self.act(f'candidate:{self.cursor}')

    @staticmethod
    def photo(p, x, y, w, h, photo, rain=0, outline=False, background=True):
        if background:
            p.rect(x, y, w, h, 13)
            p.rect(x, y + h - 21, w, 21, 4)
            for i in range(5):
                p.text(x + 12 + i * ((w - 28) // 5), y + h - 14, str(i), 7)
            if rain:
                for dx in range(30, w - 4, 24):
                    p.line(x + dx, y + 6, x + dx - 3, y + 14, 12)
        cx = x + 15 + photo.pos * ((w - 28) // 5)
        cy = y + h - 38
        if outline:
            for dx in range(-11, 13, 4):
                p.pset(cx + dx, cy, 8)
                p.pset(cx + dx, cy + 9, 8)
            p.circb(cx - 7, cy + 13, 3, 8)
            p.circb(cx + 7, cy + 13, 3, 8)
            if photo.roof:
                for dx in range(-13, 15, 4):
                    p.pset(cx + dx, cy - 16, 8)
        else:
            p.rect(cx - 11, cy, 23, 10, 9)
            p.circ(cx - 7, cy + 13, 3, 0)
            p.circ(cx + 7, cy + 13, 3, 0)
            p.rect(cx - 6, cy - 10, 12, 10, 12 if photo.wet else 10)
            if photo.roof:
                p.tri(cx - 14, cy - 13, cx, cy - 22, cx + 14, cy - 13, 3)
                p.line(cx - 11, cy - 13, cx - 11, cy, 3)
                p.line(cx + 11, cy - 13, cx + 11, cy, 3)

    def draw(self, p):
        # 描画は証拠と現在の操作状態のみを読む。判定はsubmit内で行う。
        s, level = self.state, self.level
        known = dict(level.known)
        p.cls(1)
        p.text(12, 10, 'MISSING PHOTO / STAGE ' + str(STAGE), 10)
        p.text(350, 10, 'LEVEL ' + level.id + ' / MISSING TIME 2', 11)
        p.text(12, 27, 'POS = POSITION   ROOF 0/1   WET 0/1   BLUE BOX = WET', 6)
        rain_times = ','.join(str(t) for t, rain in enumerate(level.rain) if rain) or 'NONE'
        p.text(350, 27, 'RAIN TIMES: ' + rain_times, 11)
        rule_text = 'MOVE <= %d / STOP OR RETURN ALLOWED' % MAX_STEP
        if STAGE >= 3:
            rule_text += ' / ROOF CHANGES / RAIN + NO ROOF WETS / WET STAYS'
        p.text(12, 37, rule_text, 6)
        for i, (time, photo) in enumerate(level.known):
            x = 12 + i * 208
            self.photo(p, x, 48, 196, 106, photo, level.rain[time])
            p.text(x + 6, 54, 'TIME ' + str(time) + (' RAIN' if level.rain[time] else ' CLEAR'), 1)
            p.text(x + 4, 163, 'POS %d  ROOF %d  WET %d' % (photo.pos, photo.roof, photo.wet), 7)
            if self.focus == 0 and self.cursor == i:
                p.rectb(x - 2, 46, 200, 110, 10)
            if self.pick == time:
                p.text(x + 132, 137, 'PICKED', 10)
        a, b = s.pair
        p.text(12, 190, 'COMPARE %d -> %d / %d INTERVALS' % (a, b, b - a), 10)
        self.photo(p, 12, 208, 294, 128, known[a], level.rain[a])
        if self.opacity > 0:
            p.dither(self.opacity / 2)
            self.photo(p, 12, 208, 294, 128, known[b], level.rain[b])
            p.dither(1.0)
            self.photo(p, 12, 208, 294, 128, known[a], outline=True, background=False)
        p.text(16, 342, 'OLD = RED DOTS  NEW = SOLID', 6)
        p.text(16, 355, 'OPACITY %d%% / MAX MOVE %d' % (self.opacity * 50, (b - a) * MAX_STEP), 6)
        p.text(328, 190, 'TIME 2 CANDIDATES / KEEP ALL POSSIBLE', 10)
        for i, photo in enumerate(level.candidates):
            x, y = 328 + (i % 2) * 154, 208 + (i // 2) * 82
            chosen = bool(s.selected & (1 << i))
            p.rect(x, y, 146, 74, 2)
            p.rectb(x, y, 146, 74, 10 if self.focus == 1 and self.cursor == i else 11 if chosen else 5)
            p.text(x + 6, y + 6, 'PQRS'[i] + (' KEEP' if chosen else ' OUT'), 11 if chosen else 7)
            p.text(x + 6, y + 23, 'POS %d ROOF %d WET %d' % (photo.pos, photo.roof, photo.wet), 7)
            p.text(x + 6, y + 42, 'CANDIDATE / NOT TRUTH', 5)
        p.text(328, 380, 'RESULT: ' + (s.result.upper() or 'NOT SUBMITTED'), 11)
        p.text(12, 413, 'JUDGMENTS %d / COMPARISONS %d' % (s.tick, len(s.compared)), 6)
        p.text(12, 430, self.message, 11)
        p.text(12, 449, CONTROLS, 7)
        p.text(12, 467, 'R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT', 5)

CONTROLS = 'TAB FOCUS  LEFT/RIGHT SELECT  Z TOGGLE  ENTER SUBMIT'
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'TAB', 'RETURN', 'X', 'R', '1', '2', '3')
DEMO_KEYS = ('TAB', 'RIGHT', 'Z', 'RETURN')

def parse_args(argv=None):
    """QA引数はPyxel importより先に検証する。"""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--frames', type=int)
    parser.add_argument('--screenshot')
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.frames is not None or args.screenshot) and not args.headless_smoke:
        parser.error('QA options require --headless-smoke')
    minimum = max(8, 2 * len(DEMO_KEYS) + 4) if args.demo_checkpoint else 8
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error('--frames must be between %d and 600' % minimum)
    if args.screenshot:
        path = Path(args.screenshot).expanduser().resolve()
        if path.suffix.lower() != '.png' or path.exists() or not path.parent.is_dir():
            parser.error('screenshot must be a NEW .png file in an existing directory')
        args.screenshot = str(path)
    return args


def main(argv=None):
    args = parse_args(argv)
    import json
    import pyxel as p
    app = App()
    p.init(640, 480, title='Missing Photo / lesson ' + str(STAGE), fps=30,
           display_scale=1, headless=args.headless_smoke)
    keys = {name: getattr(p, 'KEY_' + name) for name in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= args.frames:
            if args.screenshot:
                p.screenshot(args.screenshot, scale=1)
            print(json.dumps({'state': asdict(app.state), 'level': app.level.id,
                              'focus': app.focus, 'cursor': app.cursor,
                              'history_length': len(app.timeline.history) if STAGE == 5 else 0},
                             ensure_ascii=True), flush=True)
            p.quit()
            return
        if args.headless_smoke:
            i = p.frame_count // 2
            down = {DEMO_KEYS[i]} if args.demo_checkpoint and p.frame_count % 2 == 0 and i < len(DEMO_KEYS) else set()
        else:
            down = {name for name, code in keys.items() if p.btn(code)}
        app.control(down)
    p.run(update, lambda: app.draw(p))


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

合った答えを、選んだ根拠だけで支えられるか

ルールをビットで表す集合・十分な根拠・失敗を伝える順番。

候補の欄でもう一度 Tab を押すと、根拠の札の欄に移ります。MOVE=1(移動)、RAIN=2(雨と布)、WET=4(濡れの持続)の3種類で、選べるのは2枚までです。Z で切り替え、Enter で候補の集まりと根拠を一緒に提出します。比較は使っても使わなくてもかまいません。証拠を直接読んで候補と根拠を選べば、比較0回でもクリアできます。

候補の集まりが合っていても、選んだ根拠だけで同じ集まりまで絞れるとは限りません。answer(level, 7) は3つのルールすべてで、answer(level, s.reasons) は選んだルールだけで候補を調べます。2つが同じ集まりなら、根拠は十分です。「札の名前を当てるクイズ」でも、画面に出ていない正解の根拠番号と一致するかを見る判定でもありません。

状況 B は、MOVE と RAIN で十分です。MOVE だけなら、位置に問題のない P/Q/S が残ります。RAIN だけなら、濡れは説明できても、移動に問題のある R が残ります。2つを合わせると Q だけになります。WET の札は元のゲームのルールとしてありますが、状況 B の4つの候補を絞る一番少ない組み合わせに、必ず必要とは限りません。状況 A と C は MOVE 1枚で十分です。

判定の順番は、①ありえない写真を残している contradiction → ②ありえる写真を落としている narrow → ③集まりは合っているが根拠が足りない unsupported → ④won です。たとえば状況 B で Q だけを KEEP・根拠なしで出すと unsupported です。候補が P なら、根拠も足りなくても、先に contradiction になります。3枚目の根拠を加えようとして2枚の上限を超えたときは、元の State をそのまま返し、tick も増えません。1枚外してから、別の札を加えられます。

この段階で、元のゲームの状況 A/B/C の候補・物理のルール・根拠の判定がそろいます。意味のある入力による正しい状態の変化は元のゲームと対応していますが、画面とキーの処理は教材用です。提出したあとの英語の結果と、内部のフィードバック feedback は、答えに関わる情報を伝えます。「提出前の draw は answer を呼ばない」というテストは、描画が余計なことをしないかの検査で、画面全体で答えを秘密にできていることの証明ではありません。

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

04_reasons.py / 注目する部分
        elif not s.reasons or answer(level, s.reasons) != correct:
            result = 'unsupported'
            message = '集合は一致。選んだ根拠だけでは、まだ候補が残ります。'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_reasons.py
macOS / Linux
.venv/bin/python 04_reasons.py
画面で見ること起動 → Tab → 右 → Z → Tab → Z → 右 → Z → Enter で、状況 B の Q、MOVE+RAIN、RESULT: WON、JUDGMENTS 4 になります。根拠の欄でさらに 右 → Z と押しても3枚目なので断られ、WET は外れたままです。
  • すべてのルールでの答えと、選んだルールでの答えの集まりを比べる
  • 根拠不足より、写真の矛盾を先に説明する
  • 3枚目を断ったときは、判断に数えない
つまずきやすいところs.reasons == 正解番号 のような決まった比較にしないでください。選んだルールで候補を計算し直して、すべてのルールで計算した集まりと比べます。根拠を1枚も選ばなかった場合は、たまたま集まりが一致しても、証明できたことにはなりません。
少し変えてみよう

ゲームでの操作:状況 B で Q・MOVE だけを提出して UNSUPPORTED、RAIN も加えて WON になることを確かめます。コードの書き換え:REASON_LIMIT = 2 を REASON_LIMIT = 1 に変えて、同じ成功の手順を試してみましょう。RAIN を加えようとすると断られ、MOVE だけの UNSUPPORTED になります。状況 B はどの根拠1枚でも足りないので、この変更のままでは証明できません。確認したら2に戻します。

04_reasons.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(399行)
04_reasons.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段階。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict

MAX_STEP = 1
MOVE, RAIN, WET = 1, 2, 4

@dataclass(frozen=True)
class Photo:
    pos: int
    roof: int
    wet: int

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    rain: tuple
    known: tuple
    candidates: tuple

LEVEL_A = Level("A", "一升ぶんの行き先", (0, 0, 0, 0),
                ((0, Photo(0, 0, 0)), (1, Photo(1, 0, 0)), (3, Photo(3, 0, 0))),
                tuple(Photo(x, 0, 0) for x in (1, 2, 3, 4)))
LEVEL_B = Level("B", "屋根は最後に戻った", (0, 0, 1, 0),
                ((0, Photo(0, 1, 0)), (1, Photo(1, 1, 0)), (3, Photo(3, 1, 1))),
                (Photo(2, 1, 0), Photo(2, 0, 1), Photo(1, 0, 1), Photo(2, 1, 1)))
LEVEL_C = Level("C", "戻ってきた車輪", (0, 0, 0, 0),
                ((0, Photo(1, 1, 0)), (1, Photo(1, 1, 0)), (3, Photo(1, 1, 0))),
                tuple(Photo(x, 1, 0) for x in (0, 1, 2, 3)))
LEVELS = (LEVEL_A, LEVEL_B, LEVEL_C)

def transition(a, b, rain, rules=7):
    if rules & MOVE and abs(a.pos - b.pos) > MAX_STEP:
        return False
    if rules & RAIN and not a.wet and b.wet != int(bool(rain and not b.roof)):
        return False
    if rules & WET and a.wet and not b.wet:
        return False
    return True


def supports(level, candidate, rules=7):
    photos = dict(level.known)
    photos[2] = candidate
    return all(transition(photos[t - 1], photos[t], level.rain[t], rules)
               for t in range(1, 4))


def answer(level, rules=7):
    return sum(1 << i for i, p in enumerate(level.candidates)
               if supports(level, p, rules))


def witness(level, candidate):
    if not supports(level, candidate):
        return None
    return [asdict(dict(level.known).get(t, candidate)) for t in range(4)]


def contradiction(level, candidate):
    photos = dict(level.known)
    photos[2] = candidate
    for t in range(1, 4):
        a, b = photos[t - 1], photos[t]
        if abs(a.pos - b.pos) > MAX_STEP:
            return f'{t-1}から{t}刻: 一間隔で二升以上は動けません。'
        if not a.wet and b.wet != int(bool(level.rain[t] and not b.roof)):
            return f'{t-1}から{t}刻: 乾いた荷物は雨と布の順に確かめよう。'
        if a.wet and not b.wet:
            return f'{t-1}から{t}刻: 濡れた荷物は途中で乾きません。'
    return ''

REASON_LIMIT = 2
REASONS = ("MOVE", "RAIN", "WET")

@dataclass(frozen=True)
class State:
    pair: tuple = (0, 3)
    selected: int = 0
    reasons: int = 0
    compared: tuple = ()
    submissions: int = 0
    result: str = ''
    feedback: str = 'この四枚のうち、可能な写真を全部残そう。'
    tick: int = 0

def apply(state, level, action):
    # 不正な外部入力は無変更。正規の意味入力だけを状態へ反映する。
    if not isinstance(action, str):
        return state
    s = state
    if action.startswith('compare:'):
        try:
            _, a, b = action.split(':')
            a, b = int(a), int(b)
        except (ValueError, TypeError):
            return s
        if a not in (0, 1, 3) or b not in (0, 1, 3) or a == b:
            return s
        pair = tuple(sorted((a, b)))
        return replace(s, pair=pair, compared=s.compared + (pair,),
                       feedback=f'{pair[0]}刻と{pair[1]}刻: {pair[1]-pair[0]}間隔、最大{(pair[1]-pair[0])*MAX_STEP}升。',
                       tick=s.tick + 1)
    if action.startswith('candidate:'):
        try:
            i = int(action.split(':')[1])
        except (ValueError, IndexError):
            return s
        if not 0 <= i < 4:
            return s
        return replace(s, selected=s.selected ^ (1 << i), result='',
                       feedback='残す写真を選択中。Enterで集合を提出。', tick=s.tick + 1)
    if action.startswith('reason:'):
        try:
            i = int(action.split(':')[1])
        except (ValueError, IndexError):
            return s
        if not 0 <= i < 3:
            return s
        reasons = s.reasons ^ (1 << i)
        if reasons.bit_count() > REASON_LIMIT:
            return s
        return replace(s, reasons=reasons, result='',
                       feedback='根拠札は二枚まで。写真を直接読んでも提出できます。', tick=s.tick + 1)
    if action == 'submit':
        correct = answer(level)
        bad = s.selected & ~correct
        if bad:
            result = 'contradiction'
            first_bad = (bad & -bad).bit_length() - 1
            message = contradiction(level, level.candidates[first_bad])
        elif s.selected != correct:
            result = 'narrow'
            message = 'まだ否定できない写真があります。可能なものを全部残そう。'
        elif not s.reasons or answer(level, s.reasons) != correct:
            result = 'unsupported'
            message = '集合は一致。選んだ根拠だけでは、まだ候補が残ります。'
        else:
            result = 'won'
            message = '根拠つきの結論! ' + ('三枚とも可能。写真だけでは一枚に決まりません。' if correct.bit_count() > 1 else 'この四枚のうち、一枚に絞れました。')
        return replace(s, submissions=s.submissions + 1, result=result,
                       feedback=message, tick=s.tick + 1)
    return s

STAGE = 4
DEFAULT_LEVEL = 1
FOCUS_COUNT = 3

class App:
    """キーから意味入力への変換。Pyxelなしでも操作テスト可能。"""
    def __init__(self):
        self.level_index = DEFAULT_LEVEL
        self.wait_release = False
        self.reset()

    @property
    def level(self):
        return LEVELS[self.level_index]

    def reset(self):
        self.state = State()
        self.focus = 0
        self.cursor = 0
        self.pick = None
        self.opacity = 2
        self.message = 'READ THE KNOWN PHOTOS'

    def act(self, action):
        before = self.state
        self.state = apply(self.state, self.level, action)
        if self.state == before:
            self.message = 'NO CHANGE / CHECK LIMITS'
            return False
        if action.startswith('compare:'):
            self.message = 'COMPARISON SAVED / WORLD TIME DID NOT ADVANCE'
        elif action == 'submit':
            self.message = {'won': 'COMPLETE / ALL POSSIBLE CANDIDATES',
                             'contradiction': 'CONTRADICTION / AN IMPOSSIBLE PHOTO IS SELECTED',
                             'narrow': 'TOO NARROW / SOME POSSIBLE PHOTOS ARE MISSING',
                             'unsupported': 'SET MATCHES / REASONS ARE NOT SUFFICIENT'}[self.state.result]
        else:
            self.message = 'SELECTION UPDATED / ENTER TO SUBMIT'
        return True

    def control(self, down):
        down = set(down) & set(KEYS)
        if not down:
            self.wait_release = False
            return
        if self.wait_release:
            return
        self.wait_release = True
        if len(down) != 1:
            self.message = 'RELEASE ALL KEYS'
            return
        key = next(iter(down))
        if key == 'R':
            self.reset()
            return
        if key in ('1', '2', '3'):
            self.level_index = int(key) - 1
            self.reset()
            return
        if key == 'TAB':
            self.focus = (self.focus + 1) % FOCUS_COUNT
            self.cursor = 0
            self.pick = None
            return
        count = (3, 4, 3)[self.focus]
        if key == 'LEFT':
            self.cursor = (self.cursor - 1) % count
        elif key == 'RIGHT':
            self.cursor = (self.cursor + 1) % count
        elif key == 'UP':
            self.opacity = min(2, self.opacity + 1)
        elif key == 'DOWN':
            self.opacity = max(0, self.opacity - 1)
        elif key == 'RETURN':
            self.act('submit')
        elif key == 'Z':
            if self.focus == 0:
                time = (0, 1, 3)[self.cursor]
                if self.pick is None:
                    self.pick = time
                    self.message = 'FIRST PHOTO PICKED / CHOOSE ANOTHER'
                elif self.pick != time:
                    self.act(f'compare:{self.pick}:{time}')
                    self.pick = None
            elif self.focus == 1:
                self.act(f'candidate:{self.cursor}')
            else:
                self.act(f'reason:{self.cursor}')

    @staticmethod
    def photo(p, x, y, w, h, photo, rain=0, outline=False, background=True):
        if background:
            p.rect(x, y, w, h, 13)
            p.rect(x, y + h - 21, w, 21, 4)
            for i in range(5):
                p.text(x + 12 + i * ((w - 28) // 5), y + h - 14, str(i), 7)
            if rain:
                for dx in range(30, w - 4, 24):
                    p.line(x + dx, y + 6, x + dx - 3, y + 14, 12)
        cx = x + 15 + photo.pos * ((w - 28) // 5)
        cy = y + h - 38
        if outline:
            for dx in range(-11, 13, 4):
                p.pset(cx + dx, cy, 8)
                p.pset(cx + dx, cy + 9, 8)
            p.circb(cx - 7, cy + 13, 3, 8)
            p.circb(cx + 7, cy + 13, 3, 8)
            if photo.roof:
                for dx in range(-13, 15, 4):
                    p.pset(cx + dx, cy - 16, 8)
        else:
            p.rect(cx - 11, cy, 23, 10, 9)
            p.circ(cx - 7, cy + 13, 3, 0)
            p.circ(cx + 7, cy + 13, 3, 0)
            p.rect(cx - 6, cy - 10, 12, 10, 12 if photo.wet else 10)
            if photo.roof:
                p.tri(cx - 14, cy - 13, cx, cy - 22, cx + 14, cy - 13, 3)
                p.line(cx - 11, cy - 13, cx - 11, cy, 3)
                p.line(cx + 11, cy - 13, cx + 11, cy, 3)

    def draw(self, p):
        # 描画は証拠と現在の操作状態のみを読む。判定はsubmit内で行う。
        s, level = self.state, self.level
        known = dict(level.known)
        p.cls(1)
        p.text(12, 10, 'MISSING PHOTO / STAGE ' + str(STAGE), 10)
        p.text(350, 10, 'LEVEL ' + level.id + ' / MISSING TIME 2', 11)
        p.text(12, 27, 'POS = POSITION   ROOF 0/1   WET 0/1   BLUE BOX = WET', 6)
        rain_times = ','.join(str(t) for t, rain in enumerate(level.rain) if rain) or 'NONE'
        p.text(350, 27, 'RAIN TIMES: ' + rain_times, 11)
        rule_text = 'MOVE <= %d / STOP OR RETURN ALLOWED' % MAX_STEP
        if STAGE >= 3:
            rule_text += ' / ROOF CHANGES / RAIN + NO ROOF WETS / WET STAYS'
        p.text(12, 37, rule_text, 6)
        for i, (time, photo) in enumerate(level.known):
            x = 12 + i * 208
            self.photo(p, x, 48, 196, 106, photo, level.rain[time])
            p.text(x + 6, 54, 'TIME ' + str(time) + (' RAIN' if level.rain[time] else ' CLEAR'), 1)
            p.text(x + 4, 163, 'POS %d  ROOF %d  WET %d' % (photo.pos, photo.roof, photo.wet), 7)
            if self.focus == 0 and self.cursor == i:
                p.rectb(x - 2, 46, 200, 110, 10)
            if self.pick == time:
                p.text(x + 132, 137, 'PICKED', 10)
        a, b = s.pair
        p.text(12, 190, 'COMPARE %d -> %d / %d INTERVALS' % (a, b, b - a), 10)
        self.photo(p, 12, 208, 294, 128, known[a], level.rain[a])
        if self.opacity > 0:
            p.dither(self.opacity / 2)
            self.photo(p, 12, 208, 294, 128, known[b], level.rain[b])
            p.dither(1.0)
            self.photo(p, 12, 208, 294, 128, known[a], outline=True, background=False)
        p.text(16, 342, 'OLD = RED DOTS  NEW = SOLID', 6)
        p.text(16, 355, 'OPACITY %d%% / MAX MOVE %d' % (self.opacity * 50, (b - a) * MAX_STEP), 6)
        p.text(328, 190, 'TIME 2 CANDIDATES / KEEP ALL POSSIBLE', 10)
        for i, photo in enumerate(level.candidates):
            x, y = 328 + (i % 2) * 154, 208 + (i // 2) * 82
            chosen = bool(s.selected & (1 << i))
            p.rect(x, y, 146, 74, 2)
            p.rectb(x, y, 146, 74, 10 if self.focus == 1 and self.cursor == i else 11 if chosen else 5)
            p.text(x + 6, y + 6, 'PQRS'[i] + (' KEEP' if chosen else ' OUT'), 11 if chosen else 7)
            p.text(x + 6, y + 23, 'POS %d ROOF %d WET %d' % (photo.pos, photo.roof, photo.wet), 7)
            p.text(x + 6, y + 42, 'CANDIDATE / NOT TRUTH', 5)
        p.text(328, 380, 'RESULT: ' + (s.result.upper() or 'NOT SUBMITTED'), 11)
        for i, reason in enumerate(REASONS):
            x = 12 + i * 102
            p.rectb(x, 378, 96, 23, 10 if self.focus == 2 and self.cursor == i else 5)
            p.text(x + 5, 386, ('[X] ' if s.reasons & (1 << i) else '[ ] ') + reason, 11)
        p.text(12, 413, 'JUDGMENTS %d / COMPARISONS %d' % (s.tick, len(s.compared)), 6)
        p.text(12, 430, self.message, 11)
        p.text(12, 449, CONTROLS, 7)
        p.text(12, 467, 'R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT', 5)

CONTROLS = 'TAB FOCUS  LEFT/RIGHT SELECT  Z TOGGLE  ENTER SUBMIT'
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'TAB', 'RETURN', 'X', 'R', '1', '2', '3')
DEMO_KEYS = ('TAB', 'RIGHT', 'Z', 'TAB', 'Z', 'RIGHT', 'Z', 'RETURN')

def parse_args(argv=None):
    """QA引数はPyxel importより先に検証する。"""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--frames', type=int)
    parser.add_argument('--screenshot')
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.frames is not None or args.screenshot) and not args.headless_smoke:
        parser.error('QA options require --headless-smoke')
    minimum = max(8, 2 * len(DEMO_KEYS) + 4) if args.demo_checkpoint else 8
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error('--frames must be between %d and 600' % minimum)
    if args.screenshot:
        path = Path(args.screenshot).expanduser().resolve()
        if path.suffix.lower() != '.png' or path.exists() or not path.parent.is_dir():
            parser.error('screenshot must be a NEW .png file in an existing directory')
        args.screenshot = str(path)
    return args


def main(argv=None):
    args = parse_args(argv)
    import json
    import pyxel as p
    app = App()
    p.init(640, 480, title='Missing Photo / lesson ' + str(STAGE), fps=30,
           display_scale=1, headless=args.headless_smoke)
    keys = {name: getattr(p, 'KEY_' + name) for name in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= args.frames:
            if args.screenshot:
                p.screenshot(args.screenshot, scale=1)
            print(json.dumps({'state': asdict(app.state), 'level': app.level.id,
                              'focus': app.focus, 'cursor': app.cursor,
                              'history_length': len(app.timeline.history) if STAGE == 5 else 0},
                             ensure_ascii=True), flush=True)
            p.quit()
            return
        if args.headless_smoke:
            i = p.frame_count // 2
            down = {DEMO_KEYS[i]} if args.demo_checkpoint and p.frame_count % 2 == 0 and i < len(DEMO_KEYS) else set()
        else:
            down = {name for name, code in keys.items() if p.btn(code)}
        app.control(down)
    p.run(update, lambda: app.draw(p))


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

判断全体を戻し、証拠と表示操作は分けて保つ

書き換えない状態の写し(スナップショット)・128回の判断・Undo の範囲。

X で、直前の有効な判断を1つ取り消します。Timeline には、今の State と、過去の State を並べたタプル history があります。step は、変わる前の状態を履歴の最後に積み、新しい状態を今の値にします。State は frozen で、中の比較の記録もタプルなので、あとの操作で過去の判断が書き換わることはありません。元のゲームと同じ、書き換えない状態の写しを戻す方式です。ファイルに保存して読み直す仕組みは、教材では省いています。

Undo は、selected だけでなく、pair、reasons、compared、submissions、result、feedback、tick を全部元に戻します。成功を取り消せば「成功した」という結果も消え、提出の回数も戻ります。失敗した提出も有効な判断なので、戻すと失敗する前の選び方に戻ります。比較の確定も判断なので、取り消せます。キーを押した回数と、履歴の件数は同じではありません。

左右のカーソル移動、Tab での欄の切り替え、上下での濃さの変更、1枚目の仮の選択は、表示の操作です。これらは Timeline に入りません。X のあとも、今のカーソルの位置や濃さはそのままです。3枚目の根拠を断ったとき、正しくない比較、履歴が空のときの Undo でも、新しい履歴は作りません。R は今の状況について、状態・履歴・表示の選択を全部最初に戻します。1/2/3 は状況を切り替えて、最初に戻します。

履歴は、元のゲームと同じく128回の判断までです。秒数の制限はないので、長く考えても減りません。128件になると、新しい判断をしても何も変わりません。ただし、Undo は上限の検査より先に処理するので、X で1つ戻せば空きが作れます。候補を押す、根拠を押す、比較を確定する、提出する、のどれも1回の判断です。Tab や上下キーは数えません。

元のゲームと同じ意味の入力による正しい流れは、状態の全項目と履歴まで照らし合わせています。ただし、元のゲームにある写真の表示の切り替え、一時停止、タイトル、比較版の切り替え、S キーでの再生記録の書き出し、出来事の記録は省いています。教材では、すべての操作でキーが離されるのを待ち、同時押しを受け付けません。これは、元のゲームの「新しく押されたか」の判定(fresh)と、一部の操作のあとだけ入力を止める仕組み(gate)とは違います。元の app.py を読むときは、「ルールの部分は一致」「入力と表示は違う」と区別してください。

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

05_undo.py / 注目する部分
    after = apply(timeline.state, level, action)
    if after == timeline.state:
        return timeline
    return Timeline(after, timeline.history + (timeline.state,))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること04 の成功の手順で WON にしたあと、X を押します。Q と MOVE+RAIN は残りますが、RESULT は NOT SUBMITTED、submissions 0、tick 3、履歴3件に戻ります。Enter を押すと、同じ WON をもう一度出せます。自動確認は、成功 → X の状態を描きます。
  • 状態全体と履歴を、Undo で戻す
  • 表示の操作や断った操作は、履歴に入れない
  • 128件たまっても、Undo してから再提出できる
つまずきやすいところselected だけを戻すと、成功の表示や根拠、提出回数が過去と食い違います。また、長押ししている間の各フレームや、無効な入力を履歴に積まないでください。上限の判定より前に Undo を処理しないと、履歴がいっぱいのときに戻れなくなります。
少し変えてみよう

ゲームでの操作:成功 → X → Enter で同じ成功、さらに X を4回押して最初の判断の状態に戻ります。表示の欄は根拠のままでよいことも確かめましょう。コードの書き換え:MAX_HISTORY = 128 を MAX_HISTORY = 3 に変えてみましょう。Q、MOVE、RAIN の3回の判断でいっぱいになり、Enter を押しても何も変わりません。X で RAIN を取り消してから Enter を押すと UNSUPPORTED が入り、履歴は3件です。確認したら128に戻します。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(427行)
05_undo.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段階。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict

MAX_STEP = 1
MOVE, RAIN, WET = 1, 2, 4

@dataclass(frozen=True)
class Photo:
    pos: int
    roof: int
    wet: int

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    rain: tuple
    known: tuple
    candidates: tuple

LEVEL_A = Level("A", "一升ぶんの行き先", (0, 0, 0, 0),
                ((0, Photo(0, 0, 0)), (1, Photo(1, 0, 0)), (3, Photo(3, 0, 0))),
                tuple(Photo(x, 0, 0) for x in (1, 2, 3, 4)))
LEVEL_B = Level("B", "屋根は最後に戻った", (0, 0, 1, 0),
                ((0, Photo(0, 1, 0)), (1, Photo(1, 1, 0)), (3, Photo(3, 1, 1))),
                (Photo(2, 1, 0), Photo(2, 0, 1), Photo(1, 0, 1), Photo(2, 1, 1)))
LEVEL_C = Level("C", "戻ってきた車輪", (0, 0, 0, 0),
                ((0, Photo(1, 1, 0)), (1, Photo(1, 1, 0)), (3, Photo(1, 1, 0))),
                tuple(Photo(x, 1, 0) for x in (0, 1, 2, 3)))
LEVELS = (LEVEL_A, LEVEL_B, LEVEL_C)

def transition(a, b, rain, rules=7):
    if rules & MOVE and abs(a.pos - b.pos) > MAX_STEP:
        return False
    if rules & RAIN and not a.wet and b.wet != int(bool(rain and not b.roof)):
        return False
    if rules & WET and a.wet and not b.wet:
        return False
    return True


def supports(level, candidate, rules=7):
    photos = dict(level.known)
    photos[2] = candidate
    return all(transition(photos[t - 1], photos[t], level.rain[t], rules)
               for t in range(1, 4))


def answer(level, rules=7):
    return sum(1 << i for i, p in enumerate(level.candidates)
               if supports(level, p, rules))


def witness(level, candidate):
    if not supports(level, candidate):
        return None
    return [asdict(dict(level.known).get(t, candidate)) for t in range(4)]


def contradiction(level, candidate):
    photos = dict(level.known)
    photos[2] = candidate
    for t in range(1, 4):
        a, b = photos[t - 1], photos[t]
        if abs(a.pos - b.pos) > MAX_STEP:
            return f'{t-1}から{t}刻: 一間隔で二升以上は動けません。'
        if not a.wet and b.wet != int(bool(level.rain[t] and not b.roof)):
            return f'{t-1}から{t}刻: 乾いた荷物は雨と布の順に確かめよう。'
        if a.wet and not b.wet:
            return f'{t-1}から{t}刻: 濡れた荷物は途中で乾きません。'
    return ''

REASON_LIMIT = 2
REASONS = ("MOVE", "RAIN", "WET")

@dataclass(frozen=True)
class State:
    pair: tuple = (0, 3)
    selected: int = 0
    reasons: int = 0
    compared: tuple = ()
    submissions: int = 0
    result: str = ''
    feedback: str = 'この四枚のうち、可能な写真を全部残そう。'
    tick: int = 0

def apply(state, level, action):
    # 不正な外部入力は無変更。正規の意味入力だけを状態へ反映する。
    if not isinstance(action, str):
        return state
    s = state
    if action.startswith('compare:'):
        try:
            _, a, b = action.split(':')
            a, b = int(a), int(b)
        except (ValueError, TypeError):
            return s
        if a not in (0, 1, 3) or b not in (0, 1, 3) or a == b:
            return s
        pair = tuple(sorted((a, b)))
        return replace(s, pair=pair, compared=s.compared + (pair,),
                       feedback=f'{pair[0]}刻と{pair[1]}刻: {pair[1]-pair[0]}間隔、最大{(pair[1]-pair[0])*MAX_STEP}升。',
                       tick=s.tick + 1)
    if action.startswith('candidate:'):
        try:
            i = int(action.split(':')[1])
        except (ValueError, IndexError):
            return s
        if not 0 <= i < 4:
            return s
        return replace(s, selected=s.selected ^ (1 << i), result='',
                       feedback='残す写真を選択中。Enterで集合を提出。', tick=s.tick + 1)
    if action.startswith('reason:'):
        try:
            i = int(action.split(':')[1])
        except (ValueError, IndexError):
            return s
        if not 0 <= i < 3:
            return s
        reasons = s.reasons ^ (1 << i)
        if reasons.bit_count() > REASON_LIMIT:
            return s
        return replace(s, reasons=reasons, result='',
                       feedback='根拠札は二枚まで。写真を直接読んでも提出できます。', tick=s.tick + 1)
    if action == 'submit':
        correct = answer(level)
        bad = s.selected & ~correct
        if bad:
            result = 'contradiction'
            first_bad = (bad & -bad).bit_length() - 1
            message = contradiction(level, level.candidates[first_bad])
        elif s.selected != correct:
            result = 'narrow'
            message = 'まだ否定できない写真があります。可能なものを全部残そう。'
        elif not s.reasons or answer(level, s.reasons) != correct:
            result = 'unsupported'
            message = '集合は一致。選んだ根拠だけでは、まだ候補が残ります。'
        else:
            result = 'won'
            message = '根拠つきの結論! ' + ('三枚とも可能。写真だけでは一枚に決まりません。' if correct.bit_count() > 1 else 'この四枚のうち、一枚に絞れました。')
        return replace(s, submissions=s.submissions + 1, result=result,
                       feedback=message, tick=s.tick + 1)
    return s

MAX_HISTORY = 128

@dataclass(frozen=True)
class Timeline:
    state: State = State()
    history: tuple = ()


def step(timeline, level, action):
    if action == 'undo':
        if timeline.history:
            return Timeline(timeline.history[-1], timeline.history[:-1])
        return timeline
    if len(timeline.history) >= MAX_HISTORY:
        return timeline
    after = apply(timeline.state, level, action)
    if after == timeline.state:
        return timeline
    return Timeline(after, timeline.history + (timeline.state,))

STAGE = 5
DEFAULT_LEVEL = 1
FOCUS_COUNT = 3

class App:
    """キーから意味入力への変換。Pyxelなしでも操作テスト可能。"""
    def __init__(self):
        self.level_index = DEFAULT_LEVEL
        self.wait_release = False
        self.reset()

    @property
    def level(self):
        return LEVELS[self.level_index]

    @property
    def state(self):
        return self.timeline.state

    def reset(self):
        self.timeline = Timeline()
        self.focus = 0
        self.cursor = 0
        self.pick = None
        self.opacity = 2
        self.message = 'READ THE KNOWN PHOTOS'

    def act(self, action):
        before = self.state
        self.timeline = step(self.timeline, self.level, action)
        if self.state == before:
            self.message = 'NO CHANGE / CHECK LIMITS'
            return False
        if action.startswith('compare:'):
            self.message = 'COMPARISON SAVED / WORLD TIME DID NOT ADVANCE'
        elif action == 'submit':
            self.message = {'won': 'COMPLETE / ALL POSSIBLE CANDIDATES',
                             'contradiction': 'CONTRADICTION / AN IMPOSSIBLE PHOTO IS SELECTED',
                             'narrow': 'TOO NARROW / SOME POSSIBLE PHOTOS ARE MISSING',
                             'unsupported': 'SET MATCHES / REASONS ARE NOT SUFFICIENT'}[self.state.result]
        else:
            self.message = 'SELECTION UPDATED / ENTER TO SUBMIT'
        return True

    def control(self, down):
        down = set(down) & set(KEYS)
        if not down:
            self.wait_release = False
            return
        if self.wait_release:
            return
        self.wait_release = True
        if len(down) != 1:
            self.message = 'RELEASE ALL KEYS'
            return
        key = next(iter(down))
        if key == 'R':
            self.reset()
            return
        if key in ('1', '2', '3'):
            self.level_index = int(key) - 1
            self.reset()
            return
        if key == 'X':
            self.timeline = step(self.timeline, self.level, 'undo')
            self.message = 'UNDO / MODEL STATE RESTORED'
            return
        if key == 'TAB':
            self.focus = (self.focus + 1) % FOCUS_COUNT
            self.cursor = 0
            self.pick = None
            return
        count = (3, 4, 3)[self.focus]
        if key == 'LEFT':
            self.cursor = (self.cursor - 1) % count
        elif key == 'RIGHT':
            self.cursor = (self.cursor + 1) % count
        elif key == 'UP':
            self.opacity = min(2, self.opacity + 1)
        elif key == 'DOWN':
            self.opacity = max(0, self.opacity - 1)
        elif key == 'RETURN':
            self.act('submit')
        elif key == 'Z':
            if self.focus == 0:
                time = (0, 1, 3)[self.cursor]
                if self.pick is None:
                    self.pick = time
                    self.message = 'FIRST PHOTO PICKED / CHOOSE ANOTHER'
                elif self.pick != time:
                    self.act(f'compare:{self.pick}:{time}')
                    self.pick = None
            elif self.focus == 1:
                self.act(f'candidate:{self.cursor}')
            else:
                self.act(f'reason:{self.cursor}')

    @staticmethod
    def photo(p, x, y, w, h, photo, rain=0, outline=False, background=True):
        if background:
            p.rect(x, y, w, h, 13)
            p.rect(x, y + h - 21, w, 21, 4)
            for i in range(5):
                p.text(x + 12 + i * ((w - 28) // 5), y + h - 14, str(i), 7)
            if rain:
                for dx in range(30, w - 4, 24):
                    p.line(x + dx, y + 6, x + dx - 3, y + 14, 12)
        cx = x + 15 + photo.pos * ((w - 28) // 5)
        cy = y + h - 38
        if outline:
            for dx in range(-11, 13, 4):
                p.pset(cx + dx, cy, 8)
                p.pset(cx + dx, cy + 9, 8)
            p.circb(cx - 7, cy + 13, 3, 8)
            p.circb(cx + 7, cy + 13, 3, 8)
            if photo.roof:
                for dx in range(-13, 15, 4):
                    p.pset(cx + dx, cy - 16, 8)
        else:
            p.rect(cx - 11, cy, 23, 10, 9)
            p.circ(cx - 7, cy + 13, 3, 0)
            p.circ(cx + 7, cy + 13, 3, 0)
            p.rect(cx - 6, cy - 10, 12, 10, 12 if photo.wet else 10)
            if photo.roof:
                p.tri(cx - 14, cy - 13, cx, cy - 22, cx + 14, cy - 13, 3)
                p.line(cx - 11, cy - 13, cx - 11, cy, 3)
                p.line(cx + 11, cy - 13, cx + 11, cy, 3)

    def draw(self, p):
        # 描画は証拠と現在の操作状態のみを読む。判定はsubmit内で行う。
        s, level = self.state, self.level
        known = dict(level.known)
        p.cls(1)
        p.text(12, 10, 'MISSING PHOTO / STAGE ' + str(STAGE), 10)
        p.text(350, 10, 'LEVEL ' + level.id + ' / MISSING TIME 2', 11)
        p.text(12, 27, 'POS = POSITION   ROOF 0/1   WET 0/1   BLUE BOX = WET', 6)
        rain_times = ','.join(str(t) for t, rain in enumerate(level.rain) if rain) or 'NONE'
        p.text(350, 27, 'RAIN TIMES: ' + rain_times, 11)
        rule_text = 'MOVE <= %d / STOP OR RETURN ALLOWED' % MAX_STEP
        if STAGE >= 3:
            rule_text += ' / ROOF CHANGES / RAIN + NO ROOF WETS / WET STAYS'
        p.text(12, 37, rule_text, 6)
        for i, (time, photo) in enumerate(level.known):
            x = 12 + i * 208
            self.photo(p, x, 48, 196, 106, photo, level.rain[time])
            p.text(x + 6, 54, 'TIME ' + str(time) + (' RAIN' if level.rain[time] else ' CLEAR'), 1)
            p.text(x + 4, 163, 'POS %d  ROOF %d  WET %d' % (photo.pos, photo.roof, photo.wet), 7)
            if self.focus == 0 and self.cursor == i:
                p.rectb(x - 2, 46, 200, 110, 10)
            if self.pick == time:
                p.text(x + 132, 137, 'PICKED', 10)
        a, b = s.pair
        p.text(12, 190, 'COMPARE %d -> %d / %d INTERVALS' % (a, b, b - a), 10)
        self.photo(p, 12, 208, 294, 128, known[a], level.rain[a])
        if self.opacity > 0:
            p.dither(self.opacity / 2)
            self.photo(p, 12, 208, 294, 128, known[b], level.rain[b])
            p.dither(1.0)
            self.photo(p, 12, 208, 294, 128, known[a], outline=True, background=False)
        p.text(16, 342, 'OLD = RED DOTS  NEW = SOLID', 6)
        p.text(16, 355, 'OPACITY %d%% / MAX MOVE %d' % (self.opacity * 50, (b - a) * MAX_STEP), 6)
        p.text(328, 190, 'TIME 2 CANDIDATES / KEEP ALL POSSIBLE', 10)
        for i, photo in enumerate(level.candidates):
            x, y = 328 + (i % 2) * 154, 208 + (i // 2) * 82
            chosen = bool(s.selected & (1 << i))
            p.rect(x, y, 146, 74, 2)
            p.rectb(x, y, 146, 74, 10 if self.focus == 1 and self.cursor == i else 11 if chosen else 5)
            p.text(x + 6, y + 6, 'PQRS'[i] + (' KEEP' if chosen else ' OUT'), 11 if chosen else 7)
            p.text(x + 6, y + 23, 'POS %d ROOF %d WET %d' % (photo.pos, photo.roof, photo.wet), 7)
            p.text(x + 6, y + 42, 'CANDIDATE / NOT TRUTH', 5)
        p.text(328, 380, 'RESULT: ' + (s.result.upper() or 'NOT SUBMITTED'), 11)
        for i, reason in enumerate(REASONS):
            x = 12 + i * 102
            p.rectb(x, 378, 96, 23, 10 if self.focus == 2 and self.cursor == i else 5)
            p.text(x + 5, 386, ('[X] ' if s.reasons & (1 << i) else '[ ] ') + reason, 11)
        p.text(12, 413, 'JUDGMENTS %d / COMPARISONS %d' % (s.tick, len(s.compared)), 6)
        p.text(12, 430, self.message, 11)
        p.text(12, 449, CONTROLS, 7)
        p.text(12, 467, 'R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT', 5)

CONTROLS = 'TAB FOCUS  ARROWS SELECT  Z TOGGLE  ENTER SUBMIT  X UNDO'
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'TAB', 'RETURN', 'X', 'R', '1', '2', '3')
DEMO_KEYS = ('TAB', 'RIGHT', 'Z', 'TAB', 'Z', 'RIGHT', 'Z', 'RETURN', 'X')

def parse_args(argv=None):
    """QA引数はPyxel importより先に検証する。"""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--frames', type=int)
    parser.add_argument('--screenshot')
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.frames is not None or args.screenshot) and not args.headless_smoke:
        parser.error('QA options require --headless-smoke')
    minimum = max(8, 2 * len(DEMO_KEYS) + 4) if args.demo_checkpoint else 8
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error('--frames must be between %d and 600' % minimum)
    if args.screenshot:
        path = Path(args.screenshot).expanduser().resolve()
        if path.suffix.lower() != '.png' or path.exists() or not path.parent.is_dir():
            parser.error('screenshot must be a NEW .png file in an existing directory')
        args.screenshot = str(path)
    return args


def main(argv=None):
    args = parse_args(argv)
    import json
    import pyxel as p
    app = App()
    p.init(640, 480, title='Missing Photo / lesson ' + str(STAGE), fps=30,
           display_scale=1, headless=args.headless_smoke)
    keys = {name: getattr(p, 'KEY_' + name) for name in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= args.frames:
            if args.screenshot:
                p.screenshot(args.screenshot, scale=1)
            print(json.dumps({'state': asdict(app.state), 'level': app.level.id,
                              'focus': app.focus, 'cursor': app.cursor,
                              'history_length': len(app.timeline.history) if STAGE == 5 else 0},
                             ensure_ascii=True), flush=True)
            p.quit()
            return
        if args.headless_smoke:
            i = p.frame_count // 2
            down = {DEMO_KEYS[i]} if args.demo_checkpoint and p.frame_count % 2 == 0 and i < len(DEMO_KEYS) else set()
        else:
            down = {name for name, code in keys.items() if p.btn(code)}
        app.control(down)
    p.run(update, lambda: app.draw(p))


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

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

ルールのまとめ:0/1/3刻のわかっている写真と、2刻の候補 P/Q/R/S。位置は0〜4、1刻ぶんで最大1マス、止まる・戻るも可。布は撮影ごとに着け外しでき、乾いた荷物は、着いた時刻に雨が降っていて布がなければ濡れ、濡れたらもう乾かない。ありえる候補を全部残し、最大2枚の根拠で同じ集まりまで絞れれば成功。答えは A=Q、B=Q、C=P/Q/R。判断は128件まで、Undo あり、時間制限なし。

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

比べる点5ステップの教材元の完成版
段階01 は比較だけ、02 は状況 A の移動と候補の提出、03 は状況 A/B/C の全ルールで根拠は不要、04 で根拠、05 で128件の履歴。状況 A/B/C の全ルール・根拠・履歴が、最初から入っています。
入力・表示英語の図形ラベルで、起動したらすぐ盤面。キーが全部離されるのを待ち、同時押しは受け付けない。濃さの変更と、古い写真の輪郭での比較は残しています。日本語の文字、タイトル、P での説明、Space での注目表示、状況 B の比較版。新しく押されたかの判定(fresh)と、一部の操作のあとだけ入力を止める仕組み(gate)があり、同時押しは分かれ道の順に処理されます。
データ・Undo05 の有効な意味の入力は、元のゲームの State/Timeline すべてと一致します。文字列でない入力を apply に渡しても何も変えない守りを追加。描画の配置、動作確認(QA)用のコマンド、1ファイルだけでの起動を追加しています。model.py と app.py を分けています。出来事の保存、S での再生記録の書き出し、demo/comparison などがあります。
知識の範囲提出前の draw は answer を呼びません。提出の結果は推理に役立つ情報を伝えます。画面全体で答えを秘密にできているとは主張しません。ただ1枚の隠し写真ではなく、証拠とルールから、ありえる候補の集まりを計算します。矛盾の説明(feedback)や結果で、推理の筋道を説明します。

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

  1. model.py の transition / supports / answer:ルールと候補の集まり
  2. model.py の witness / contradiction:ありえる例と、ありえない例
  3. model.py の apply / step:根拠が十分かの判定と、状態の写しによる Undo
  4. app.py の update / draw / photo:入力の制御(gate)、濃さ、輪郭と表示の切り替え
  5. model.py の replay / digest、app.py の export:意味のある入力の記録
自分のゲームに変えるなら

最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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