完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:矢印時計選択、Space候補、Enter提出、X仮置き、Z候補戻す、R再開、P停止
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
どれか1つの組み合わせで成り立つ候補を残す。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、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終了。
- 難易度の目安
- 中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
ずれを有限の数で持つ
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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
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を展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_offsets.py.venv/bin/python 01_offsets.py- 4台のずれを、順番のある tuple で表せる
- -2 から +2 までの5つの値だけを使う
- 時計の位置と、ずれの値を分けて説明できる
上3回 → 右 → 上4回 で [+1,+2,-2,-2] にしてください。上を押し続けても1回分しか変わらないこと、左はしで左を押すと D に移ることも確かめましょう。
このステップのコード全文(172行)
# 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)矢印を引き算の条件にする
向きのある差・dict への対応づけ・いくつもの条件。
検査の札「B は A より1時間進んでいる」を、「B のずれ − A のずれ = 1」と書きます。EDGES の (source,target,delta) は、「target − source = delta」という順番です。それぞれの札が成り立つか(True/False)を tuple で返し、all で全部の札が成り立つかを調べます。まだ差を確かめるだけで、正確な時計の台数のチェックは次の段階です。
下のコードは 02_differences.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
offsets = dict(zip(CLOCKS, values))
return tuple(offsets[target] - offsets[source] == delta
for source, target, delta in EDGES)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_differences.py.venv/bin/python 02_differences.py[-2,-1,0,+1] になります。3枚の札がすべて PASS で、ALL DIFFERENCES も PASS です。D を下に1回変えると、最後の D−C だけが FAIL になります。- 「先の時計のずれ − 手前の時計のずれ」で計算する
- 3枚の札を同時に確かめる
- 全部を同じだけずらしても、差は変わらない
[-2,-1,0,+1] と [-1,0,+1,+2] の差を比べてみてください。全部の時計のずれを1ずつ増やしても、差は変わりません。差だけでは本当の時刻が決まらない理由を説明してみましょう。
このステップのコード全文(184行)
# 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)625通りから成立する行を残す
itertools.product、全部調べる方法、条件の組み合わせ、割り当て。
4つの数を並べた1行を「割り当て」と呼びます。product は、4台それぞれの5つの値を、もれなく並べた625行を作ります。consistent は「整数か」「範囲の中か」「0がちょうど1つか」「全部の札が成り立つか」を調べます。答えを当てるのではなく全部の行を調べるので、答えを関数の中に書いておく必要はありません。この問題では2行が残ります。N で1行ずつ見比べられ、上下で値を変えて、条件をわざとくずすこともできます。
下のコードは 03_assignments.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
from itertools import product
return tuple(values for values in product(VALUES, repeat=len(CLOCKS))
if consistent(values))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_assignments.py.venv/bin/python 03_assignments.py[-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 でまた成り立つ行に戻せます。
このステップのコード全文(204行)
# 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)どれかの行で正確なら候補
「どれかで成り立つ」と「すべてで成り立つ」、集合をまとめる、根拠になる割り当て。
成り立つ行のどれか1行で0になる時計を集めます。1行目では C、2行目では B が0なので、候補の集合は {B,C} です。B と C が同時に0になる行はありません。「成り立つ行すべてで0になる時計」を探す条件とはちがい、そちらの集合は空になります。X は、選んだ時計を0にできる行を表示します。A と D には、その根拠になる行がありません。この段階は、答えを見せてくれる分析用の画面です。
下のコードは 04_possible.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
return frozenset(clock for values in solutions
for clock, value in zip(CLOCKS, values) if value == 0)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_possible.py.venv/bin/python 04_possible.py[-1,0,+1,+2]、さらに右で C に移って X を押すと [-2,-1,0,+1] です。- 「どれかで0」と「どの行でも0」を区別できる
- B と C は、別々の成り立つ行で正確になる
- 候補の集合は、重なりを消してまとめる
- 候補の理由を、具体的な行で示せる
R → 右 → 右 → X で、C の根拠になる行を出してください。次に B の根拠の行と比べ、「どの行でも0は1つなのに、候補をまとめると2台になる」理由を、自分の言葉で説明してみましょう。
このステップのコード全文(225行)
# 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)候補を選んで過不足なく提出する
次の状態を計算するだけの関数、集合が同じか、仮置きを伝えていく、書きかえない履歴。
元のゲームの状況 C の中身につなげます。矢印で時計を選び、Space で候補に入れる・外すを切りかえ、Enter で「選んだ集合」と answer(答え)が同じかを判定します。余計な時計があれば、成り立たない時計を1台、足りなければ missing(足りない)を示します。X は、その時計を0と仮に置いて、差を順に伝えて計算しますが、選択も履歴も変えません。Z は候補の切りかえだけを戻し、成功したあとも編集に戻れます。元のゲームの中身はそのまま残し、3つの問題の選択や説明のメニューなどは省いています。
下のコードは 05_puzzle.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if state.selected==expected:return Result(replace(state,verdict='complete',reason='complete',witness=''),True,'complete')実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_puzzle.py.venv/bin/python 05_puzzle.pymissing です。右 → 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・もう一度成功を通してみましょう。
このステップのコード全文(323行)
# 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)仕組みがわかったら、元の完成版を読んでみよう。
時計のずれは整数で、問題ごとの -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 をもとにした文字を入れた、音のない作りです。元のゲームの過去のテストは、この教材の確認の数には入れていません。 |
完成版のコードで、次に読むところ
- 元のゲームの
LEVELS[0]とLEVELS[1]を加え、displayの並びと、札の両はしの時計を区別してみましょう。 validateのつながりのチェックを読み、途切れた札や、矛盾する差を見つける方法を学びましょう。hypothesisとassignmentsを別のやり方で作り、それぞれの時計が成り立つ条件を照らし合わせてみましょう。- 元のゲームの
app.pyのswitch・reset・updateを読み、説明・問題の選択・キーを離すまで待つ処理を足してみましょう。 - 元のゲームの
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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
