推理・観察LESSON 27有限制約 × 相対推理パズル原作024

まちがい時計の作り方

正しいかもしれない時計を、全部残す。
時計どうしの時間の差から、成り立つ組み合わせを探します。答えを1つに決めつけず、「正確かもしれない時計」をすべて答える推理ゲームです。

完成版の遊び方時計どうしの時間の差と、ずれの範囲のルールを組み合わせて、正確でありうる時計をすべて選びます。

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

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

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

完成版の主な操作:矢印時計選択、Space候補、Enter提出、X仮置き、Z候補戻す、R再開、P停止

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

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

うまく表示されないとき

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

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

どれか1つの組み合わせで成り立つ候補を残す。

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

1. tuple・番号・限られた範囲・1台だけを変える関数
2. 向きのある差・dict への対応づけ・いくつもの条件
3. itertools.product、全部調べる方法、条件の組み合わせ、割り当て
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上、ターミナルと .venv の基本 / 変数・if・for・関数・tuple・dict の基本 / 集合・dataclass・replace・product は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
01〜04:左右で時計、上下でずれ、03〜04 Nで成立行、04 Xで証拠行。05:矢印で時計、Space候補、Enter提出、X仮置き、Z戻す。共通R初期化・Esc終了。
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_offsets.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

ずれを有限の数で持つ

tuple・番号・限られた範囲・1台だけを変える関数。

時計のずれを、A・B・C・D の順に tuple に入れます。0 は正確、プラスは進んでいる、マイナスは遅れている、という意味です。本当の時刻や、12時間で一周することは使いません。1台がとれる値は -2,-1,0,1,2 の5つなので、全部の組み合わせは 5×5×5×5=625 通りです。この段階は、教材用の編集の実験です。元のゲームにある「差の札」や「正確な時計はちょうど1台」は、まだ判定しません。

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

01_offsets.py / 注目する部分
    values = list(state.values)
    value = values[state.cursor] + delta
    if value not in VALUES:
        return replace(state, reason='boundary')
    values[state.cursor] = value
    return replace(state, values=tuple(values), solution_index=-1, reason='edited')

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_offsets.py
macOS / Linux
.venv/bin/python 01_offsets.py
画面で見ること起動すると、全部の時計が -2 で、A に白い枠があります。上を3回押すと A=+1、右を1回、上を4回押すと B=+2 です。ほかの2台は -2 のままです。さらに上を押すと BOUNDARY(範囲のはし)と出て、+3 にはなりません。
  • 4台のずれを、順番のある tuple で表せる
  • -2 から +2 までの5つの値だけを使う
  • 時計の位置と、ずれの値を分けて説明できる
つまずきやすいところtuple の0番目が時計 A です。値と、時計の位置(番号)をまぜないでください。+2 の次を -2 に戻してしまうと、「ずれの範囲で止める」という教材のルールが変わってしまいます。
少し変えてみよう

上3回 → 右 → 上4回 で [+1,+2,-2,-2] にしてください。上を押し続けても1回分しか変わらないこと、左はしで左を押すと D に移ることも確かめましょう。

01_offsets.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(172行)
01_offsets.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材にNoto字形・フォントは含みません。Pyxel標準ASCIIと図形だけです。

"""まちがい時計・教材 1/5。原作Cを学ぶ独立プログラム。"""
from dataclasses import dataclass, replace

CLOCKS = ('A', 'B', 'C', 'D')
MARGIN = 2
VALUES = tuple(range(-MARGIN, MARGIN + 1))

STAGE = 1
STAGE_TITLE = 'FINITE OFFSETS'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'R')
REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'R': 'R'}
HELP = 'EXPERIMENT: 5 values per clock; 5 x 5 x 5 x 5 = 625 assignments.'

@dataclass(frozen=True)
class State:
    values: tuple = (-2, -2, -2, -2)
    cursor: int = 0
    solution_index: int = -1
    reason: str = 'ready'


def edit(state, delta):
    """範囲の外は拒否。tupleを作り直して古い状態を壊さない。"""
    values = list(state.values)
    value = values[state.cursor] + delta
    if value not in VALUES:
        return replace(state, reason='boundary')
    values[state.cursor] = value
    return replace(state, values=tuple(values), solution_index=-1, reason='edited')

class Controller:
    """画面とテストが共用する入力入口。押したら全キーを離す。"""
    def __init__(self):
        self.state = State()
        self.gate = False

    def tick(self, pressed=(), held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return
        if len(pressed) != 1 or len(held) != 1 or pressed != held:
            return
        key = next(iter(pressed))
        self.gate = True
        if key == 'R':
            self.state = State()
        elif key in ('LEFT', 'RIGHT'):
            delta = 1 if key == 'RIGHT' else -1
            self.state = replace(self.state, cursor=(self.state.cursor + delta) % len(CLOCKS))
        elif key in ('UP', 'DOWN'):
            self.state = edit(self.state, 1 if key == 'UP' else -1)

    def snapshot(self):
        return {'values': list(self.state.values), 'focus': CLOCKS[self.state.cursor],
                'solution_index': self.state.solution_index, 'reason': self.state.reason}

def draw_board(pyxel, controller):
    state = controller.state
    pyxel.cls(1)
    pyxel.text(12, 10, 'WRONG CLOCKS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
    pyxel.text(12, 27, 'Original case C: four clocks; integer offsets, NOT a 12-hour cycle.', 6)
    pyxel.rect(8, 42, 464, 36, 2)
    pyxel.text(16, 49, 'OFFSET = shown time minus actual time', 7)
    pyxel.text(16, 64, '0: correct   +: ahead   -: behind    RANGE -2 .. +2', 10)
    for index, clock in enumerate(CLOCKS):
        x = 16 + 116 * index
        pyxel.rect(x, 89, 100, 100, 2)
        pyxel.circ(x + 50, 123, 25, 5)
        pyxel.circ(x + 50, 123, 22, 1)
        pyxel.text(x + 48, 121, clock, 10)
        pyxel.text(x + 30, 158, 'OFFSET ' + f'{state.values[index]:+d}', 7)
        if index == state.cursor:
            pyxel.rectb(x - 2, 87, 104, 104, 7)
    pyxel.text(12, 204, 'ONE ASSIGNMENT: ' + '  '.join(f'{c}={v:+d}' for c, v in zip(CLOCKS, state.values)), 7)
    pyxel.text(12, 230, 'Try changing one clock without changing the other three.', 6)
    pyxel.text(12, 250, 'Bounds are inclusive: -2, -1, 0, +1, +2.', 7)
    pyxel.text(12, 274, 'Teaching experiment: evidence and correct-count checks come next.', 13)
    pyxel.text(12, 365, 'RESULT: ' + state.reason.upper(), 11)
    pyxel.text(12, 383, 'LEFT/RIGHT: clock   UP/DOWN: offset   R: reset   ESC: quit', 7)
    pyxel.text(12, 400, HELP, 6)

def main(headless=False, actions='', screenshot=None, report=None):
    import json
    from pathlib import Path
    # 相対出力先は起動時の作業フォルダー基準。ソースの場所には依存しない。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel
    controller = Controller()
    codes = {key: getattr(pyxel, 'KEY_' + key) for key in CONTROLS}
    frame = 0
    events = []
    last_frame = max(12, len(actions) * 2 + 2)

    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame - 1) // 2
            if frame % 2 and index < len(actions):
                key = REPLAY[actions[index]]
                controller.tick((key,), (key,))
                events.append({'input': actions[index], 'snapshot': controller.snapshot()})
            else:
                controller.tick()
        else:
            controller.tick([key for key, code in codes.items() if pyxel.btnp(code)],
                            [key for key, code in codes.items() if pyxel.btn(code)])

    def draw():
        draw_board(pyxel, controller)
        if headless and frame >= last_frame:
            if screenshot:
                pyxel.screenshot(screenshot, scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Actual Pyxel headless rendering; synthetic controller inputs; silent',
                    'stage': STAGE, 'frames': frame, 'actions': actions,
                    'events': events, 'snapshot': controller.snapshot(),
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(480, 420, title='Wrong Clocks - Step ' + str(STAGE), fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--headless-replay', help='Synthetic inputs: ' + ', '.join(k + '=' + v for k, v in REPLAY.items()))
    parser.add_argument('--screenshot', help='PNG output; headless only')
    parser.add_argument('--report', help='UTF-8 JSON output; headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Outputs require headless mode')
    actions = args.headless_replay or ''
    if any(key not in REPLAY for key in actions):
        parser.error('Unsupported input for this stage')
    main(headless, actions, args.screenshot, args.report)
STEP 02 / 05

矢印を引き算の条件にする

向きのある差・dict への対応づけ・いくつもの条件。

検査の札「B は A より1時間進んでいる」を、「B のずれ − A のずれ = 1」と書きます。EDGES の (source,target,delta) は、「target − source = delta」という順番です。それぞれの札が成り立つか(True/False)を tuple で返し、all で全部の札が成り立つかを調べます。まだ差を確かめるだけで、正確な時計の台数のチェックは次の段階です。

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

02_differences.py / 注目する部分
    offsets = dict(zip(CLOCKS, values))
    return tuple(offsets[target] - offsets[source] == delta
                 for source, target, delta in EDGES)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_differences.py
macOS / Linux
.venv/bin/python 02_differences.py
画面で見ること右 → 上 → 右 → 上2回 → 右 → 上3回 で [-2,-1,0,+1] になります。3枚の札がすべて PASS で、ALL DIFFERENCES も PASS です。D を下に1回変えると、最後の D−C だけが FAIL になります。
  • 「先の時計のずれ − 手前の時計のずれ」で計算する
  • 3枚の札を同時に確かめる
  • 全部を同じだけずらしても、差は変わらない
つまずきやすいところA−B で計算すると、符号が逆になってしまいます。最初の札だけが合っても、残りの札を無視してはいけません。画面の並びと、札の両はしの時計の関係も、分けて考えましょう。
少し変えてみよう

[-2,-1,0,+1] と [-1,0,+1,+2] の差を比べてみてください。全部の時計のずれを1ずつ増やしても、差は変わりません。差だけでは本当の時刻が決まらない理由を説明してみましょう。

02_differences.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(184行)
02_differences.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材にNoto字形・フォントは含みません。Pyxel標準ASCIIと図形だけです。

"""まちがい時計・教材 2/5。原作Cを学ぶ独立プログラム。"""
from dataclasses import dataclass, replace

CLOCKS = ('A', 'B', 'C', 'D')
MARGIN = 2
VALUES = tuple(range(-MARGIN, MARGIN + 1))

# (手前, 矢印の先, 先のずれ - 手前のずれ)
EDGES = (('A', 'B', 1), ('B', 'C', 1), ('C', 'D', 1))

def difference_checks(values):
    offsets = dict(zip(CLOCKS, values))
    return tuple(offsets[target] - offsets[source] == delta
                 for source, target, delta in EDGES)

STAGE = 2
STAGE_TITLE = 'DIFFERENCE CONSTRAINTS'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'R')
REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'R': 'R'}
HELP = 'CHECK ONLY: all three differences must be +1. No zero-count check yet.'

@dataclass(frozen=True)
class State:
    values: tuple = (-2, -2, -2, -2)
    cursor: int = 0
    solution_index: int = -1
    reason: str = 'ready'


def edit(state, delta):
    """範囲の外は拒否。tupleを作り直して古い状態を壊さない。"""
    values = list(state.values)
    value = values[state.cursor] + delta
    if value not in VALUES:
        return replace(state, reason='boundary')
    values[state.cursor] = value
    return replace(state, values=tuple(values), solution_index=-1, reason='edited')

class Controller:
    """画面とテストが共用する入力入口。押したら全キーを離す。"""
    def __init__(self):
        self.state = State()
        self.gate = False

    def tick(self, pressed=(), held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return
        if len(pressed) != 1 or len(held) != 1 or pressed != held:
            return
        key = next(iter(pressed))
        self.gate = True
        if key == 'R':
            self.state = State()
        elif key in ('LEFT', 'RIGHT'):
            delta = 1 if key == 'RIGHT' else -1
            self.state = replace(self.state, cursor=(self.state.cursor + delta) % len(CLOCKS))
        elif key in ('UP', 'DOWN'):
            self.state = edit(self.state, 1 if key == 'UP' else -1)

    def snapshot(self):
        return {'values': list(self.state.values), 'focus': CLOCKS[self.state.cursor],
                'solution_index': self.state.solution_index, 'reason': self.state.reason}

def draw_board(pyxel, controller):
    state = controller.state
    pyxel.cls(1)
    pyxel.text(12, 10, 'WRONG CLOCKS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
    pyxel.text(12, 27, 'Original case C: four clocks; integer offsets, NOT a 12-hour cycle.', 6)
    pyxel.rect(8, 42, 464, 36, 2)
    pyxel.text(16, 49, 'OFFSET = shown time minus actual time', 7)
    pyxel.text(16, 64, '0: correct   +: ahead   -: behind    RANGE -2 .. +2', 10)
    for index, clock in enumerate(CLOCKS):
        x = 16 + 116 * index
        pyxel.rect(x, 89, 100, 100, 2)
        pyxel.circ(x + 50, 123, 25, 5)
        pyxel.circ(x + 50, 123, 22, 1)
        pyxel.text(x + 48, 121, clock, 10)
        pyxel.text(x + 30, 158, 'OFFSET ' + f'{state.values[index]:+d}', 7)
        if index == state.cursor:
            pyxel.rectb(x - 2, 87, 104, 104, 7)
    pyxel.text(12, 204, 'ONE ASSIGNMENT: ' + '  '.join(f'{c}={v:+d}' for c, v in zip(CLOCKS, state.values)), 7)
    checks = difference_checks(state.values)
    for index, (source, target, delta) in enumerate(EDGES):
        actual = state.values[CLOCKS.index(target)] - state.values[CLOCKS.index(source)]
        label = f'{target} - {source} = {delta:+d}   actual {actual:+d}   ' + ('PASS' if checks[index] else 'FAIL')
        pyxel.text(12, 226 + index * 16, label, 11 if checks[index] else 8)
    pyxel.text(12, 288, 'ALL DIFFERENCES: ' + ('PASS' if all(checks) else 'FAIL'), 10)
    pyxel.text(12, 310, 'PASS checks differences only. Full rule arrives in step 3.', 13)
    pyxel.text(12, 365, 'RESULT: ' + state.reason.upper(), 11)
    pyxel.text(12, 383, 'LEFT/RIGHT: clock   UP/DOWN: offset   R: reset   ESC: quit', 7)
    pyxel.text(12, 400, HELP, 6)

def main(headless=False, actions='', screenshot=None, report=None):
    import json
    from pathlib import Path
    # 相対出力先は起動時の作業フォルダー基準。ソースの場所には依存しない。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel
    controller = Controller()
    codes = {key: getattr(pyxel, 'KEY_' + key) for key in CONTROLS}
    frame = 0
    events = []
    last_frame = max(12, len(actions) * 2 + 2)

    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame - 1) // 2
            if frame % 2 and index < len(actions):
                key = REPLAY[actions[index]]
                controller.tick((key,), (key,))
                events.append({'input': actions[index], 'snapshot': controller.snapshot()})
            else:
                controller.tick()
        else:
            controller.tick([key for key, code in codes.items() if pyxel.btnp(code)],
                            [key for key, code in codes.items() if pyxel.btn(code)])

    def draw():
        draw_board(pyxel, controller)
        if headless and frame >= last_frame:
            if screenshot:
                pyxel.screenshot(screenshot, scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Actual Pyxel headless rendering; synthetic controller inputs; silent',
                    'stage': STAGE, 'frames': frame, 'actions': actions,
                    'events': events, 'snapshot': controller.snapshot(),
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(480, 420, title='Wrong Clocks - Step ' + str(STAGE), fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--headless-replay', help='Synthetic inputs: ' + ', '.join(k + '=' + v for k, v in REPLAY.items()))
    parser.add_argument('--screenshot', help='PNG output; headless only')
    parser.add_argument('--report', help='UTF-8 JSON output; headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Outputs require headless mode')
    actions = args.headless_replay or ''
    if any(key not in REPLAY for key in actions):
        parser.error('Unsupported input for this stage')
    main(headless, actions, args.screenshot, args.report)
STEP 03 / 05

625通りから成立する行を残す

itertools.product、全部調べる方法、条件の組み合わせ、割り当て。

4つの数を並べた1行を「割り当て」と呼びます。product は、4台それぞれの5つの値を、もれなく並べた625行を作ります。consistent は「整数か」「範囲の中か」「0がちょうど1つか」「全部の札が成り立つか」を調べます。答えを当てるのではなく全部の行を調べるので、答えを関数の中に書いておく必要はありません。この問題では2行が残ります。N で1行ずつ見比べられ、上下で値を変えて、条件をわざとくずすこともできます。

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

03_assignments.py / 注目する部分
    from itertools import product
    return tuple(values for values in product(VALUES, repeat=len(CLOCKS))
                 if consistent(values))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_assignments.py
macOS / Linux
.venv/bin/python 03_assignments.py
画面で見ることN を1回押すと [-2,-1,0,+1]、2回目は [-1,0,+1,+2]、3回目は最初の行に戻ります。どちらも ZERO COUNT 1(0が1つ)、FULL RULE VALID(全部のルールを満たす)です。成り立つ行が2つあっても、エラーではありません。
  • 割り当ては、全部の時計のずれを同時に決めた1行
  • 625行をもれなく調べる
  • 成り立つ行は1つとは限らない
  • 0はどの行にもちょうど1つ
つまずきやすいところ最初に成り立つ行を見つけた時点で return すると、もう1つの行を見落としてしまいます。0の個数を「1以上」にすると、「何台も同時に正確」という別のルールになってしまいます。
少し変えてみよう

N → 上 で A だけを1増やすと、0は1つのままでも B−A が合わなくなり、INVALID(成り立たない)になることを確かめましょう。N でまた成り立つ行に戻せます。

03_assignments.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(204行)
03_assignments.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材にNoto字形・フォントは含みません。Pyxel標準ASCIIと図形だけです。

"""まちがい時計・教材 3/5。原作Cを学ぶ独立プログラム。"""
from dataclasses import dataclass, replace

CLOCKS = ('A', 'B', 'C', 'D')
MARGIN = 2
VALUES = tuple(range(-MARGIN, MARGIN + 1))

# (手前, 矢印の先, 先のずれ - 手前のずれ)
EDGES = (('A', 'B', 1), ('B', 'C', 1), ('C', 'D', 1))

def difference_checks(values):
    offsets = dict(zip(CLOCKS, values))
    return tuple(offsets[target] - offsets[source] == delta
                 for source, target, delta in EDGES)

def consistent(values):
    """ずれの範囲、正確は1台、すべての差が同時に成り立つ。"""
    return (len(values) == len(CLOCKS)
            and all(type(value) is int and value in VALUES for value in values)
            and values.count(0) == 1
            and all(difference_checks(values)))


def assignments():
    """5通りを4台ぶん掛けた625通り。条件を満たす行だけ残す。"""
    from itertools import product
    return tuple(values for values in product(VALUES, repeat=len(CLOCKS))
                 if consistent(values))

STAGE = 3
STAGE_TITLE = 'CONSISTENT ASSIGNMENTS'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'R', 'N')
REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'R': 'R', 'N': 'N'}
HELP = 'N: next consistent assignment   Correct clocks (=0): exactly ONE'

@dataclass(frozen=True)
class State:
    values: tuple = (-2, -2, -2, -2)
    cursor: int = 0
    solution_index: int = -1
    reason: str = 'ready'


def edit(state, delta):
    """範囲の外は拒否。tupleを作り直して古い状態を壊さない。"""
    values = list(state.values)
    value = values[state.cursor] + delta
    if value not in VALUES:
        return replace(state, reason='boundary')
    values[state.cursor] = value
    return replace(state, values=tuple(values), solution_index=-1, reason='edited')

class Controller:
    """画面とテストが共用する入力入口。押したら全キーを離す。"""
    def __init__(self):
        self.state = State()
        self.gate = False

    def tick(self, pressed=(), held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return
        if len(pressed) != 1 or len(held) != 1 or pressed != held:
            return
        key = next(iter(pressed))
        self.gate = True
        if key == 'R':
            self.state = State()
        elif key in ('LEFT', 'RIGHT'):
            delta = 1 if key == 'RIGHT' else -1
            self.state = replace(self.state, cursor=(self.state.cursor + delta) % len(CLOCKS))
        elif key in ('UP', 'DOWN'):
            self.state = edit(self.state, 1 if key == 'UP' else -1)
        elif key == 'N':
            solutions = assignments()
            index = (self.state.solution_index + 1) % len(solutions)
            self.state = replace(self.state, values=solutions[index],
                                 solution_index=index, reason='consistent_assignment')

    def snapshot(self):
        return {'values': list(self.state.values), 'focus': CLOCKS[self.state.cursor],
                'solution_index': self.state.solution_index, 'reason': self.state.reason}

def draw_board(pyxel, controller):
    state = controller.state
    pyxel.cls(1)
    pyxel.text(12, 10, 'WRONG CLOCKS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
    pyxel.text(12, 27, 'Original case C: four clocks; integer offsets, NOT a 12-hour cycle.', 6)
    pyxel.rect(8, 42, 464, 36, 2)
    pyxel.text(16, 49, 'OFFSET = shown time minus actual time', 7)
    pyxel.text(16, 64, '0: correct   +: ahead   -: behind    RANGE -2 .. +2', 10)
    for index, clock in enumerate(CLOCKS):
        x = 16 + 116 * index
        pyxel.rect(x, 89, 100, 100, 2)
        pyxel.circ(x + 50, 123, 25, 5)
        pyxel.circ(x + 50, 123, 22, 1)
        pyxel.text(x + 48, 121, clock, 10)
        pyxel.text(x + 30, 158, 'OFFSET ' + f'{state.values[index]:+d}', 7)
        if index == state.cursor:
            pyxel.rectb(x - 2, 87, 104, 104, 7)
    pyxel.text(12, 204, 'ONE ASSIGNMENT: ' + '  '.join(f'{c}={v:+d}' for c, v in zip(CLOCKS, state.values)), 7)
    checks = difference_checks(state.values)
    for index, (source, target, delta) in enumerate(EDGES):
        actual = state.values[CLOCKS.index(target)] - state.values[CLOCKS.index(source)]
        label = f'{target} - {source} = {delta:+d}   actual {actual:+d}   ' + ('PASS' if checks[index] else 'FAIL')
        pyxel.text(12, 226 + index * 16, label, 11 if checks[index] else 8)
    pyxel.text(12, 282, 'ZERO COUNT: ' + str(state.values.count(0)) + '   FULL RULE: ' + ('VALID' if consistent(state.values) else 'INVALID'), 10)
    pyxel.text(12, 300, '625 assignments searched / ' + str(len(assignments())) + ' consistent rows', 6)
    pyxel.text(12, 326, 'Each row is one possible world, not four independent guesses.', 7)
    pyxel.text(12, 365, 'RESULT: ' + state.reason.upper(), 11)
    pyxel.text(12, 383, 'LEFT/RIGHT: clock   UP/DOWN: offset   R: reset   ESC: quit', 7)
    pyxel.text(12, 400, HELP, 6)

def main(headless=False, actions='', screenshot=None, report=None):
    import json
    from pathlib import Path
    # 相対出力先は起動時の作業フォルダー基準。ソースの場所には依存しない。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel
    controller = Controller()
    codes = {key: getattr(pyxel, 'KEY_' + key) for key in CONTROLS}
    frame = 0
    events = []
    last_frame = max(12, len(actions) * 2 + 2)

    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame - 1) // 2
            if frame % 2 and index < len(actions):
                key = REPLAY[actions[index]]
                controller.tick((key,), (key,))
                events.append({'input': actions[index], 'snapshot': controller.snapshot()})
            else:
                controller.tick()
        else:
            controller.tick([key for key, code in codes.items() if pyxel.btnp(code)],
                            [key for key, code in codes.items() if pyxel.btn(code)])

    def draw():
        draw_board(pyxel, controller)
        if headless and frame >= last_frame:
            if screenshot:
                pyxel.screenshot(screenshot, scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Actual Pyxel headless rendering; synthetic controller inputs; silent',
                    'stage': STAGE, 'frames': frame, 'actions': actions,
                    'events': events, 'snapshot': controller.snapshot(),
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(480, 420, title='Wrong Clocks - Step ' + str(STAGE), fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--headless-replay', help='Synthetic inputs: ' + ', '.join(k + '=' + v for k, v in REPLAY.items()))
    parser.add_argument('--screenshot', help='PNG output; headless only')
    parser.add_argument('--report', help='UTF-8 JSON output; headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Outputs require headless mode')
    actions = args.headless_replay or ''
    if any(key not in REPLAY for key in actions):
        parser.error('Unsupported input for this stage')
    main(headless, actions, args.screenshot, args.report)
STEP 04 / 05

どれかの行で正確なら候補

「どれかで成り立つ」と「すべてで成り立つ」、集合をまとめる、根拠になる割り当て。

成り立つ行のどれか1行で0になる時計を集めます。1行目では C、2行目では B が0なので、候補の集合は {B,C} です。B と C が同時に0になる行はありません。「成り立つ行すべてで0になる時計」を探す条件とはちがい、そちらの集合は空になります。X は、選んだ時計を0にできる行を表示します。A と D には、その根拠になる行がありません。この段階は、答えを見せてくれる分析用の画面です。

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

04_possible.py / 注目する部分
    return frozenset(clock for values in solutions
                     for clock, value in zip(CLOCKS, values) if value == 0)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_possible.py
macOS / Linux
.venv/bin/python 04_possible.py
画面で見ることPOSSIBLE(ありうる)は B C、CERTAIN(必ず)は (none) です。A で X を押すと NO_WITNESS(根拠なし)。右で B に移って X を押すと [-1,0,+1,+2]、さらに右で C に移って X を押すと [-2,-1,0,+1] です。
  • 「どれかで0」と「どの行でも0」を区別できる
  • B と C は、別々の成り立つ行で正確になる
  • 候補の集合は、重なりを消してまとめる
  • 候補の理由を、具体的な行で示せる
つまずきやすいところ成り立つ行すべての共通部分をとると、答えが空になってしまいます。成り立つ行の数と候補の数も、ふつうは別のものです。根拠なしに、正確な時計を1つに決めないようにしましょう。
少し変えてみよう

R → 右 → 右 → X で、C の根拠になる行を出してください。次に B の根拠の行と比べ、「どの行でも0は1つなのに、候補をまとめると2台になる」理由を、自分の言葉で説明してみましょう。

04_possible.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(225行)
04_possible.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材にNoto字形・フォントは含みません。Pyxel標準ASCIIと図形だけです。

"""まちがい時計・教材 4/5。原作Cを学ぶ独立プログラム。"""
from dataclasses import dataclass, replace

CLOCKS = ('A', 'B', 'C', 'D')
MARGIN = 2
VALUES = tuple(range(-MARGIN, MARGIN + 1))

# (手前, 矢印の先, 先のずれ - 手前のずれ)
EDGES = (('A', 'B', 1), ('B', 'C', 1), ('C', 'D', 1))

def difference_checks(values):
    offsets = dict(zip(CLOCKS, values))
    return tuple(offsets[target] - offsets[source] == delta
                 for source, target, delta in EDGES)

def consistent(values):
    """ずれの範囲、正確は1台、すべての差が同時に成り立つ。"""
    return (len(values) == len(CLOCKS)
            and all(type(value) is int and value in VALUES for value in values)
            and values.count(0) == 1
            and all(difference_checks(values)))


def assignments():
    """5通りを4台ぶん掛けた625通り。条件を満たす行だけ残す。"""
    from itertools import product
    return tuple(values for values in product(VALUES, repeat=len(CLOCKS))
                 if consistent(values))

def possible_correct(solutions):
    """どれか1行で0なら候補。すべての行で0である必要はない。"""
    return frozenset(clock for values in solutions
                     for clock, value in zip(CLOCKS, values) if value == 0)


def witnesses(clock, solutions):
    if clock not in CLOCKS:
        raise ValueError('Unknown clock')
    index = CLOCKS.index(clock)
    return tuple(values for values in solutions if values[index] == 0)

STAGE = 4
STAGE_TITLE = 'POSSIBLE CORRECT CLOCKS'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'R', 'N', 'X')
REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'R': 'R', 'N': 'N', 'X': 'X'}
HELP = 'N: next assignment   X: witness for focused clock (if one exists)'

@dataclass(frozen=True)
class State:
    values: tuple = (-2, -2, -2, -2)
    cursor: int = 0
    solution_index: int = -1
    reason: str = 'ready'


def edit(state, delta):
    """範囲の外は拒否。tupleを作り直して古い状態を壊さない。"""
    values = list(state.values)
    value = values[state.cursor] + delta
    if value not in VALUES:
        return replace(state, reason='boundary')
    values[state.cursor] = value
    return replace(state, values=tuple(values), solution_index=-1, reason='edited')

class Controller:
    """画面とテストが共用する入力入口。押したら全キーを離す。"""
    def __init__(self):
        self.state = State()
        self.gate = False

    def tick(self, pressed=(), held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return
        if len(pressed) != 1 or len(held) != 1 or pressed != held:
            return
        key = next(iter(pressed))
        self.gate = True
        if key == 'R':
            self.state = State()
        elif key in ('LEFT', 'RIGHT'):
            delta = 1 if key == 'RIGHT' else -1
            self.state = replace(self.state, cursor=(self.state.cursor + delta) % len(CLOCKS))
        elif key in ('UP', 'DOWN'):
            self.state = edit(self.state, 1 if key == 'UP' else -1)
        elif key == 'N':
            solutions = assignments()
            index = (self.state.solution_index + 1) % len(solutions)
            self.state = replace(self.state, values=solutions[index],
                                 solution_index=index, reason='consistent_assignment')
        elif key == 'X':
            found = witnesses(CLOCKS[self.state.cursor], assignments())
            if found:
                self.state = replace(self.state, values=found[0], solution_index=-1, reason='witness')
            else:
                self.state = replace(self.state, reason='no_witness')

    def snapshot(self):
        return {'values': list(self.state.values), 'focus': CLOCKS[self.state.cursor],
                'solution_index': self.state.solution_index, 'reason': self.state.reason}

def draw_board(pyxel, controller):
    state = controller.state
    pyxel.cls(1)
    pyxel.text(12, 10, 'WRONG CLOCKS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10)
    pyxel.text(12, 27, 'Original case C: four clocks; integer offsets, NOT a 12-hour cycle.', 6)
    pyxel.rect(8, 42, 464, 36, 2)
    pyxel.text(16, 49, 'OFFSET = shown time minus actual time', 7)
    pyxel.text(16, 64, '0: correct   +: ahead   -: behind    RANGE -2 .. +2', 10)
    for index, clock in enumerate(CLOCKS):
        x = 16 + 116 * index
        pyxel.rect(x, 89, 100, 100, 2)
        pyxel.circ(x + 50, 123, 25, 5)
        pyxel.circ(x + 50, 123, 22, 1)
        pyxel.text(x + 48, 121, clock, 10)
        pyxel.text(x + 30, 158, 'OFFSET ' + f'{state.values[index]:+d}', 7)
        if index == state.cursor:
            pyxel.rectb(x - 2, 87, 104, 104, 7)
    pyxel.text(12, 204, 'ONE ASSIGNMENT: ' + '  '.join(f'{c}={v:+d}' for c, v in zip(CLOCKS, state.values)), 7)
    checks = difference_checks(state.values)
    for index, (source, target, delta) in enumerate(EDGES):
        actual = state.values[CLOCKS.index(target)] - state.values[CLOCKS.index(source)]
        label = f'{target} - {source} = {delta:+d}   actual {actual:+d}   ' + ('PASS' if checks[index] else 'FAIL')
        pyxel.text(12, 226 + index * 16, label, 11 if checks[index] else 8)
    pyxel.text(12, 282, 'ZERO COUNT: ' + str(state.values.count(0)) + '   FULL RULE: ' + ('VALID' if consistent(state.values) else 'INVALID'), 10)
    pyxel.text(12, 300, '625 assignments searched / ' + str(len(assignments())) + ' consistent rows', 6)
    possible = possible_correct(assignments())
    pyxel.text(12, 320, 'POSSIBLE (=0 in ANY row): ' + ' '.join(sorted(possible)), 11)
    certain = [c for i, c in enumerate(CLOCKS) if all(v[i] == 0 for v in assignments())]
    pyxel.text(12, 338, 'CERTAIN (=0 in EVERY row): ' + (' '.join(certain) or '(none)'), 13)
    pyxel.text(12, 365, 'RESULT: ' + state.reason.upper(), 11)
    pyxel.text(12, 383, 'LEFT/RIGHT: clock   UP/DOWN: offset   R: reset   ESC: quit', 7)
    pyxel.text(12, 400, HELP, 6)

def main(headless=False, actions='', screenshot=None, report=None):
    import json
    from pathlib import Path
    # 相対出力先は起動時の作業フォルダー基準。ソースの場所には依存しない。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel
    controller = Controller()
    codes = {key: getattr(pyxel, 'KEY_' + key) for key in CONTROLS}
    frame = 0
    events = []
    last_frame = max(12, len(actions) * 2 + 2)

    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame - 1) // 2
            if frame % 2 and index < len(actions):
                key = REPLAY[actions[index]]
                controller.tick((key,), (key,))
                events.append({'input': actions[index], 'snapshot': controller.snapshot()})
            else:
                controller.tick()
        else:
            controller.tick([key for key, code in codes.items() if pyxel.btnp(code)],
                            [key for key, code in codes.items() if pyxel.btn(code)])

    def draw():
        draw_board(pyxel, controller)
        if headless and frame >= last_frame:
            if screenshot:
                pyxel.screenshot(screenshot, scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Actual Pyxel headless rendering; synthetic controller inputs; silent',
                    'stage': STAGE, 'frames': frame, 'actions': actions,
                    'events': events, 'snapshot': controller.snapshot(),
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(480, 420, title='Wrong Clocks - Step ' + str(STAGE), fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--headless-replay', help='Synthetic inputs: ' + ', '.join(k + '=' + v for k, v in REPLAY.items()))
    parser.add_argument('--screenshot', help='PNG output; headless only')
    parser.add_argument('--report', help='UTF-8 JSON output; headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Outputs require headless mode')
    actions = args.headless_replay or ''
    if any(key not in REPLAY for key in actions):
        parser.error('Unsupported input for this stage')
    main(headless, actions, args.screenshot, args.report)
STEP 05 / 05

候補を選んで過不足なく提出する

次の状態を計算するだけの関数、集合が同じか、仮置きを伝えていく、書きかえない履歴。

元のゲームの状況 C の中身につなげます。矢印で時計を選び、Space で候補に入れる・外すを切りかえ、Enter で「選んだ集合」と answer(答え)が同じかを判定します。余計な時計があれば、成り立たない時計を1台、足りなければ missing(足りない)を示します。X は、その時計を0と仮に置いて、差を順に伝えて計算しますが、選択も履歴も変えません。Z は候補の切りかえだけを戻し、成功したあとも編集に戻れます。元のゲームの中身はそのまま残し、3つの問題の選択や説明のメニューなどは省いています。

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

05_puzzle.py / 注目する部分
        if state.selected==expected:return Result(replace(state,verdict='complete',reason='complete',witness=''),True,'complete')

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_puzzle.py
macOS / Linux
.venv/bin/python 05_puzzle.py
画面で見ること右 → Space → Enter では B だけなので missing です。右 → Space → Enter で B と C になり、COMPLETE(完了)です。Z で B だけの編集中に戻り、Space → Enter でもう一度成功します。成功したあとの Space や Enter は、終わった状態として扱い、中身の状態は変えません。
  • 集合が完全に同じときだけ成功
  • 仮置きと、答えとして選ぶことは別の状態
  • 選択の変更だけを履歴に保存する
  • 成功したあとは、Undo か R で再開できる
つまずきやすいところselected が answer の一部になっているだけでは、成功にしません。B だけでも C だけでも足りません。X の仮置きを答えのチェックにまぜたり、提出を Undo の履歴に入れたりしないでください。
少し変えてみよう

A を選んで Enter を押し、excluded A(A は除外)と出ることを確かめましょう。X で A=0 と仮に置くと D=+3 になり、範囲の外だと読み取れます。R から 右 → Space → 右 → Space → Enter → Z → Space → Enter と進めて、成功・Undo・もう一度成功を通してみましょう。

05_puzzle.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(323行)
05_puzzle.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# 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.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材にNoto字形・フォントは含みません。Pyxel標準ASCIIと図形だけです。

"""まちがい時計・教材5/5。原作Cの完全な候補集合パズル。"""
from dataclasses import dataclass, replace
from itertools import product
import hashlib

@dataclass(frozen=True)
class Edge:
    source: str
    target: str
    delta: int  # offset(target) - offset(source)

@dataclass(frozen=True)
class Level:
    title: str
    clocks: tuple[str, ...]
    edges: tuple[Edge, ...]
    margin: int
    display: tuple[str, ...]

LEVEL = Level('まだ二つある', ('A', 'B', 'C', 'D'),
              (Edge('A', 'B', 1), Edge('B', 'C', 1), Edge('C', 'D', 1)),
              2, ('A', 'B', 'C', 'D'))

@dataclass(frozen=True)
class Hypothesis:
    offsets: tuple[tuple[str,int], ...]
    outside: tuple[str, ...]
    zeros: tuple[str, ...]
    consistent: bool
    @property
    def valid(self): return self.consistent and not self.outside and len(self.zeros)==1


def validate(level):
    assert 3<=len(level.clocks)<=4 and len(set(level.clocks))==len(level.clocks)
    assert set(level.display)==set(level.clocks) and len(level.display)==len(level.clocks)
    assert isinstance(level.margin,int) and 0<=level.margin<=2
    assert 2<=len(level.edges)<=3
    assert all(e.source in level.clocks and e.target in level.clocks and e.source!=e.target and isinstance(e.delta,int) for e in level.edges)
    reached={level.clocks[0]}
    while True:
        new=reached | {e.target for e in level.edges if e.source in reached} | {e.source for e in level.edges if e.target in reached}
        if new==reached:break
        reached=new
    assert reached==set(level.clocks),'Every clock must be connected by visible evidence'
    assert assignments(level),'Inconsistent/empty cases are not accepted'


def assignments(level):
    """Complete bounded oracle; no use of hypothesis propagation or expected answers."""
    solutions=[]
    for values in product(range(-level.margin,level.margin+1),repeat=len(level.clocks)):
        if values.count(0)!=1:continue
        offset=dict(zip(level.clocks,values))
        if all(offset[e.target]-offset[e.source]==e.delta for e in level.edges):
            solutions.append(tuple(zip(level.clocks,values)))
    return tuple(solutions)


def answer(level):
    return frozenset(k for sol in assignments(level) for k,v in sol if v==0)


def hypothesis(level,anchor):
    if anchor not in level.clocks:raise ValueError('Unknown clock')
    values={anchor:0}
    for _ in level.clocks:
        for e in level.edges:
            if e.source in values and e.target not in values:values[e.target]=values[e.source]+e.delta
            if e.target in values and e.source not in values:values[e.source]=values[e.target]-e.delta
    if set(values)!=set(level.clocks):raise ValueError('Disconnected evidence')
    consistent=all(values[e.target]-values[e.source]==e.delta for e in level.edges)
    return Hypothesis(tuple((k,values[k]) for k in level.clocks),tuple(k for k in level.clocks if abs(values[k])>level.margin),tuple(k for k in level.clocks if values[k]==0),consistent)

@dataclass(frozen=True)
class State:
    selected: frozenset[str]=frozenset()
    verdict: str='editing'  # editing, incorrect, complete
    reason: str='ready'
    witness: str=''

@dataclass(frozen=True)
class Timeline:
    state: State=State()
    past: tuple[State,...]=()

@dataclass(frozen=True)
class Result:
    state: State
    accepted: bool
    event: str


def transition(level,state,action):
    kind,clock=action
    if state.verdict=='complete':return Result(state,False,'terminal')
    if kind=='toggle' and clock in level.clocks:
        selected=state.selected ^ frozenset((clock,))
        return Result(State(selected),True,'toggle')
    if kind=='submit' and clock is None:
        expected=answer(level)
        if state.selected==expected:return Result(replace(state,verdict='complete',reason='complete',witness=''),True,'complete')
        extras=state.selected-expected
        # Only one local, checkable counterexample. Do not reveal the entire answer.
        if extras:
            bad=next(k for k in level.clocks if k in extras)
            return Result(replace(state,verdict='incorrect',reason='excluded',witness=bad),True,'incorrect')
        return Result(replace(state,verdict='incorrect',reason='missing',witness=''),True,'incorrect')
    return Result(state,False,'invalid')


def apply(level,timeline,action):
    result=transition(level,timeline.state,action)
    if not result.accepted:return timeline,result
    past=timeline.past+(timeline.state,) if result.event=='toggle' else timeline.past
    return Timeline(result.state,past),result


def undo(timeline):
    if not timeline.past:return timeline
    # Undo candidate choice only; hypothetical inspections never create history.
    return Timeline(replace(timeline.past[-1],verdict='editing',reason='undo',witness=''),timeline.past[:-1])


def state_hash(state):
    return hashlib.sha256(repr((tuple(sorted(state.selected)),state.verdict,state.reason,state.witness)).encode('utf-8')).hexdigest()

validate(LEVEL)

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


class Controller:
    """原作Cの論理状態と、画面用カーソル・仮置き・解放ゲートを分離する。"""
    def __init__(self):
        self.gate = False
        self.reset()

    def reset(self):
        self.timeline = Timeline()
        self.cursor = 0
        self.anchor = None
        self.event = 'ready'

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

    @property
    def focus(self):
        return LEVEL.display[self.cursor]

    def tick(self, pressed=(), held=()):
        pressed = set(pressed) & set(CONTROLS)
        held = set(held) & set(CONTROLS)
        if self.gate:
            if not held:
                self.gate = False
            return
        if len(pressed) != 1 or len(held) != 1 or pressed != held:
            return
        key = next(iter(pressed))
        self.gate = True
        if key == 'R':
            self.reset()
        elif key in ('LEFT', 'UP', 'RIGHT', 'DOWN'):
            delta = 1 if key in ('RIGHT', 'DOWN') else -1
            self.cursor = (self.cursor + delta) % len(LEVEL.display)
            self.event = 'cursor'
        elif key == 'X':
            self.anchor = None if self.anchor == self.focus else self.focus
            self.event = 'hypothesis'
        elif key == 'Z':
            self.timeline = undo(self.timeline)
            self.event = 'undo'
        else:
            action = ('toggle', self.focus) if key == 'SPACE' else ('submit', None)
            self.timeline, result = apply(LEVEL, self.timeline, action)
            self.event = result.event

    def snapshot(self):
        return {'selected': sorted(self.state.selected), 'verdict': self.state.verdict,
                'reason': self.state.reason, 'witness': self.state.witness,
                'focus': self.focus, 'anchor': self.anchor, 'event': self.event,
                'history_depth': len(self.timeline.past), 'hash': state_hash(self.state)}


def draw_board(pyxel, controller):
    state = controller.state
    pyxel.cls(1)
    pyxel.text(12, 10, 'WRONG CLOCKS / STEP 5 / CANDIDATE SET PUZZLE', 10)
    pyxel.text(12, 27, 'Original case C: select EVERY clock that can be correct.', 6)
    pyxel.rect(8, 42, 464, 36, 2)
    pyxel.text(16, 49, 'Integer offsets -2 .. +2. Exactly ONE zero in each assignment.', 7)
    pyxel.text(16, 64, 'Possible means zero in AT LEAST ONE consistent assignment.', 10)
    for index, clock in enumerate(LEVEL.display):
        x = 16 + 116 * index
        pyxel.rect(x, 89, 100, 100, 2)
        pyxel.circ(x + 50, 123, 25, 5)
        pyxel.circ(x + 50, 123, 22, 1)
        pyxel.text(x + 48, 121, clock, 10)
        selected = clock in state.selected
        pyxel.text(x + 24, 158, '[X] POSSIBLE' if selected else '[ ] UNCHECKED', 10 if selected else 13)
        if clock == controller.focus:
            pyxel.rectb(x - 2, 87, 104, 104, 7)
    for index, edge in enumerate(LEVEL.edges):
        pyxel.text(12, 205 + index * 16, f'{edge.target} - {edge.source} = +{edge.delta}   ({edge.target} is ahead of {edge.source})', 7)
    pyxel.rect(8, 260, 464, 65, 0)
    if controller.anchor:
        h = hypothesis(LEVEL, controller.anchor)
        pyxel.text(16, 270, 'HYPOTHESIS: ' + controller.anchor + ' = 0 (does not change selection)', 6)
        pyxel.text(16, 286, '  '.join(f'{c}={v:+d}' for c, v in h.offsets), 7)
        result = ('OUT OF RANGE: ' + ','.join(h.outside)) if h.outside else 'VALID witness for this clock'
        if not h.consistent:
            result = 'INCONSISTENT differences'
        elif len(h.zeros) != 1:
            result = 'INVALID: zero count is not one'
        pyxel.text(16, 305, result, 11 if h.valid else 8)
    else:
        pyxel.text(16, 276, 'X: suppose the focused clock is correct (offset 0).', 6)
        pyxel.text(16, 298, 'Inspect evidence, then use SPACE to choose candidates.', 13)
    messages = {'ready': 'Select all possible clocks; then ENTER.',
                'missing': 'Still missing at least one possible clock.',
                'excluded': 'Excluded clock selected: ' + state.witness + '. Inspect it with X.',
                'complete': 'COMPLETE: exact candidate set! Separate possible worlds.',
                'undo': 'Last candidate toggle restored. Editing again.'}
    pyxel.text(12, 340, messages[state.reason], 10 if state.verdict == 'complete' else 7)
    pyxel.text(12, 358, 'SELECTED: ' + (' '.join(sorted(state.selected)) or '(none)') + '   HISTORY: ' + str(len(controller.timeline.past)), 11)
    pyxel.text(12, 378, 'ARROWS: focus   SPACE: toggle   ENTER: submit   X: hypothesis', 7)
    pyxel.text(12, 396, 'Z: undo (also after success)   R: reset   ESC: quit', 6)

def main(headless=False, actions='', screenshot=None, report=None):
    import json
    from pathlib import Path
    # 相対出力先は起動時の作業フォルダー基準。ソースの場所には依存しない。
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel
    controller = Controller()
    codes = {key: getattr(pyxel, 'KEY_' + key) for key in CONTROLS}
    frame = 0
    events = []
    last_frame = max(12, len(actions) * 2 + 2)

    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame - 1) // 2
            if frame % 2 and index < len(actions):
                key = REPLAY[actions[index]]
                controller.tick((key,), (key,))
                events.append({'input': actions[index], 'snapshot': controller.snapshot()})
            else:
                controller.tick()
        else:
            controller.tick([key for key, code in codes.items() if pyxel.btnp(code)],
                            [key for key, code in codes.items() if pyxel.btn(code)])

    def draw():
        draw_board(pyxel, controller)
        if headless and frame >= last_frame:
            if screenshot:
                pyxel.screenshot(screenshot, scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Actual Pyxel headless rendering; synthetic controller inputs; silent',
                    'stage': STAGE, 'frames': frame, 'actions': actions,
                    'events': events, 'snapshot': controller.snapshot(),
                }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(480, 420, title='Wrong Clocks - Step ' + str(STAGE), fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--headless-replay', help='Synthetic inputs: ' + ', '.join(k + '=' + v for k, v in REPLAY.items()))
    parser.add_argument('--screenshot', help='PNG output; headless only')
    parser.add_argument('--report', help='UTF-8 JSON output; headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Outputs require headless mode')
    actions = args.headless_replay or ''
    if any(key not in REPLAY for key in actions):
        parser.error('Unsupported input for this stage')
    main(headless, actions, args.screenshot, args.report)
AFTER STEP 05

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

時計のずれは整数で、問題ごとの -m〜+m の範囲の中、0 はちょうど1台です。すべての検査の札の差を同時に満たす割り当てのどれかで0になれる時計を、多すぎず少なすぎず選びます。かくされた「本当の時計」や本当の時刻は保存しません。元のゲームは3つの問題があり、C は成り立つ割り当てが2つ、候補は B と C です。X は仮置き、Z は候補の変更の Undo で、成功したあとも戻せます。

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

比べる点5ステップの教材元の完成版
採用する票元のゲームの LEVELS[2]、C「まだ二つある」だけです。最後の 05 のデータと中身の意味を、元のゲームと直接比べています。A「真ん中の時計」、B「離れた札をつなぐ」、C「まだ二つある」の3つの問題があります。
導入段階01 は限られた範囲の編集、02 は差だけの確認、03 はすべての条件での割り当ての一覧、04 は候補を見せる分析用の画面です。05 ではじめて、選んで提出するゲームになります。最初から、すべての条件と候補の選択・提出・仮置き・Undo があります。ずれを手で編集することはありません。
モデルと補助05 には、元のゲームの状況 C の assignments・answer・hypothesis・transition・apply・undo・state_hash をそのまま入れています。01〜04 の上下での編集と、N による全部の答えの表示は、学習用に加えたものです。X で1台ずつ仮置きでき、答える前にすべての候補を自動で一覧表示することはしません。答えを探すプログラム solver.py が別にあります。
入力・画面480×420 の文字(ASCII)と図形です。どの操作も1回押して全部離します。複数のキーは受け付けず、矢印の連続入力もありません。タイトル・説明と一時停止・問題の選択・次の問題・F12・動画は省いています。480×480 の日本語の文字です。複数の矢印は受け付けず、矢印の連続入力と操作の優先順位があります。M/P/N などのメニューと記録があります。
素材・検証範囲Noto の文字は入れておらず、音もありません。実際の Pyxel で画面を出さずに描くテストと、作ったキー入力で確かめています。OS のキー入力・ブラウザー・Windows/macOS の画面・音・人が遊んだ感想は確かめていません。Noto をもとにした文字を入れた、音のない作りです。元のゲームの過去のテストは、この教材の確認の数には入れていません。

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

  1. 元のゲームの LEVELS[0] と LEVELS[1] を加え、display の並びと、札の両はしの時計を区別してみましょう。
  2. validate のつながりのチェックを読み、途切れた札や、矛盾する差を見つける方法を学びましょう。
  3. hypothesis と assignments を別のやり方で作り、それぞれの時計が成り立つ条件を照らし合わせてみましょう。
  4. 元のゲームの app.py の switch・reset・update を読み、説明・問題の選択・キーを離すまで待つ処理を足してみましょう。
  5. 元のゲームの solver.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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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