完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:左右人物/上下話題、Z確認、Space事実札、Tab行欄、Enter照合、X戻す
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
事実の根拠から、ありえる世界と、確実な結論を分ける。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- 変数・if・for・関数 /
tuple・list・dict、番号(添字)は0から /dataclassとビットで表す集合は本文で説明します - 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- 上下:02以降、開いている話題を選択 / 左右:01は実験札、03以降は結論候補 / Z:02以降、話題を読む / Space:01は公開札、02以降は既読札を配置/除去 / Enter:03以降、結論提出 / 1/2/3:04以降、事件A/B/Cを初期化して選択 / H:04以降、配置済み根拠だけによる候補照合表示 / X:05、最後のモデル判断をUndo / Tab:05のWON後、結末0→1→0を選ぶ / R:同じ事件の状態を初期化。ソースは読み直さない / Esc:終了
- 難易度の目安
- 中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
一つの真相を選ばず、残る世界を全部持つ
組み合わせの全列挙・条件・all と any・数の限られた仮説。
最初は日記の操作を外して、事件 A の中身が見える実験室を作ります。仮説(ありえる出来事の組み合わせ)は、delivered(届けたか)、mover(運んだ人)、dest(行き先)、shelter(雨宿りの場所)の組です。worlds は product ですべての組み合わせ(直積)を作り、「届けていないなら運んだ人も行き先も none、届けたなら両方とも none 以外」という、今回の物語の範囲に合うものだけを残します。出発点は10通りの世界です。乱数や truth という変数で、本当の1つの世界をこっそり持っておく作りではありません。
この段階だけ、「ソラが運んだ」という札と「K で乾かした」という札を、最初から見せておきます。左右キーで選び、Space で根拠に入れる・外すを切り替えます。mask の一番下のビットが「運んだ人」、次のビットが「行き先」の札です。mask >> i & 1 が1なら、その札を使います。fits は1つの世界の値が、札の主張で許される値に入っているかを調べ、lab_remaining は使っているすべての札を満たす世界だけを返します。
結論「ソラが K へ運んだ」が、残った世界のうち1つで成り立つだけなら POSSIBLE, NOT ENTAILED(ありえるが、確実ではない)です。残ったすべての世界で成り立てば ENTAILED(確実)、どの世界でも成り立たなければ IMPOSSIBLE です。Python では空の集まりに対して all([]) が True になるので、先に any で「1つも残っていない」場合を除きます。「矛盾して世界がなくなったから、何でも確実」とは表示しません。
2枚とも使うと、世界は2通り残っていても、結論は確実です。残っているのは、雨宿りの場所が2通りあるからです。すべての値を1つに決めることと、今書きたい1行を証明することは違います。これは今回の限られた範囲での結論で、現実の出来事のすべてや、記憶にない運び直しまで証明するものではありません。画面の英語は、説明のための英数字(ASCII)の表示です。
下のコードは 01_worlds.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
return tuple(w for w in A_W if all(fits(w, c) for i,c in enumerate(LAB_FACTS) if mask >> i & 1))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_worlds.py.venv/bin/python 01_worlds.py- すべての組み合わせから、物語の範囲を決める
- すべての根拠を同時に満たす世界だけを残す
- 「ありえる」「確実」「すべての値が決まる」を区別する
any は「ありえる」、all は「すべての世界で成り立つ」です。結論に関係ない雨宿りの場所が決まっていなくても、「ソラ → K」の結論は成り立ちます。START_MASK = 0 を START_MASK = 2 に1か所変えてみましょう。保存 → Esc で終了 → 同じ 01_worlds.py を起動し直します。最初から K の札だけが USED(使用中)で、4世界・確実ではない状態です。カーソルはソラの札にあるので、Space を押すと2世界・ENTAILED になります。0に戻すときも、保存・終了・起動し直しをします。R は動いているゲームの状態を最初に戻すだけで、コードを読み直しません。
このステップのコード全文(164行)
# MIT License
#
# Copyright (c) 2026
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""すれ違い日記・教材1/5。Python 3.11+、単独実行用。"""
from dataclasses import dataclass, asdict, replace
from itertools import product, combinations
import hashlib, json
def worlds(keys, domains, predicate=lambda w: True):
return tuple((tuple(sorted(dict(zip(keys, values)).items())) for values in product(*domains) if predicate(dict(zip(keys, values)))))
def claim(**kw):
return tuple(((k, (v,)) for k, v in kw.items()))
A_W = worlds(('delivered', 'mover', 'dest', 'shelter'), ((0, 1), ('none', 'mina', 'sora'), ('none', 'R', 'K'), (0, 1)), lambda w: w['delivered'] == 0 and w['mover'] == 'none' and (w['dest'] == 'none') or (w['delivered'] == 1 and w['mover'] != 'none' and (w['dest'] != 'none')))
def fits(world, claims):
w = dict(world)
return all((w[k] in allowed for k, allowed in claims))
STAGE = 1
# Two disclosed facts from A, used as a PUBLIC experiment, not secret answers.
LAB_FACTS = (claim(mover='sora'), claim(dest='K'))
LAB_LABELS = ('SORA MOVED THE PARCEL', 'THE PARCEL WAS DRIED AT K')
START_MASK = 0
def lab_remaining(mask):
return tuple(w for w in A_W if all(fits(w, c) for i,c in enumerate(LAB_FACTS) if mask >> i & 1))
def verdict(options, claims):
matches = [fits(w, claims) for w in options]
if not any(matches):
return 'IMPOSSIBLE'
return 'ENTAILED' if all(matches) else 'POSSIBLE, NOT ENTAILED'
class App:
def __init__(self):
self.reset()
def reset(self):
self.mask = START_MASK
self.cursor = 0
self.gated = False
self.message = 'TRY EACH FACT SEPARATELY, THEN COMBINE THEM.'
def snapshot(self):
return {'mask':self.mask, 'worlds':len(lab_remaining(self.mask)), 'verdict':verdict(lab_remaining(self.mask), claim(mover='sora', dest='K'))}
def control(self, pressed, held):
if self.gated:
if not pressed and not held: self.gated = False
return
if len(pressed | held) > 1:
self.gated = True
self.message = 'CONFLICT: RELEASE ALL KEYS'
return
if len(pressed) != 1: return
key = next(iter(pressed))
self.gated = True
if key == 'R':
self.reset(); self.gated = True
elif key in ('LEFT','RIGHT'):
self.cursor = 1 - self.cursor
elif key == 'SPACE':
self.mask ^= 1 << self.cursor
self.message = 'EVIDENCE CHANGED. NO SINGLE HIDDEN ANSWER IS CHOSEN.'
def draw(self, p):
p.cls(1)
p.text(16, 14, '064 CROSSED DIARIES / 01 FINITE WORLD LAB', 10)
p.text(16, 40, 'A PUBLIC EXPERIMENT: TWO FACTS ARE ALREADY DISCLOSED.', 6)
for i,label in enumerate(LAB_LABELS):
y = 76 + i*34
p.rect(14, y-8, 612, 26, 3 if self.cursor == i else 2)
p.text(24, y, ('[USED] ' if self.mask >> i & 1 else '[ -- ] ')+label, 11 if self.mask >> i & 1 else 7)
options = lab_remaining(self.mask)
p.text(16, 158, 'REMAINING WORLDS: '+str(len(options))+' / '+str(len(A_W)), 10)
p.text(16, 180, 'CLAIM: SORA MOVED THE PARCEL TO K', 7)
p.text(16, 198, verdict(options, claim(mover='sora', dest='K')), 11)
p.text(16, 230, 'EVERY REMAINING WORLD MUST SATISFY A CLAIM TO ENTAIL IT.', 6)
p.text(16, 252, 'MOVER AND DESTINATION IN THE REMAINING HYPOTHESES:', 7)
pairs = sorted({(dict(w)['mover'], dict(w)['dest']) for w in options})
for i,(who,where) in enumerate(pairs):
p.text(24 + (i//4)*300, 276+(i%4)*22, who.upper()+' -> '+where, 7)
p.text(16, 382, 'Different SHELTER values can remain after proof.', 13)
p.text(16, 408, self.message, 10)
p.text(16, 454, 'LEFT/RIGHT SELECT FACT SPACE USE/REMOVE R RESET', 7)
p.text(16, 476, 'ESC QUIT / SAVE CODE, QUIT, THEN RERUN TO SEE EDITS', 6)
def demo_keys():
return ('SPACE', 'RIGHT', 'SPACE')
def main(headless=False, screenshot=None, demo=False, state_json=None):
# Pyxel changes cwd during import/init. Resolve EVERY output first.
from pathlib import Path
if demo and not headless:
raise ValueError('demo requires --headless-smoke')
outputs = []
for value, suffix in ((screenshot, '.png'), (state_json, '.json')):
if value is None:
outputs.append(None)
continue
target = Path(value).resolve()
if not headless or target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
raise ValueError('output needs headless mode, matching suffix, existing parent, and a new file')
outputs.append(str(target))
screenshot, state_json = outputs
import pyxel as p
p.init(640, 500, title='Crossed diaries / lesson '+str(STAGE), fps=30, display_scale=1, headless=headless)
app = App()
sequence = demo_keys() if demo else ()
names = ('UP','DOWN','LEFT','RIGHT','Z','SPACE','RETURN','TAB','H','X','R','1','2','3')
keys = {name: getattr(p, 'KEY_'+name) for name in names}
limit = max(12, len(sequence)*2+6)
def update():
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
if screenshot:
app.draw(p)
p.screen.save(screenshot, 1)
if state_json:
# x mode is also a last-moment overwrite guard.
with open(state_json, 'x', encoding='utf-8') as stream:
json.dump(app.snapshot(), stream, ensure_ascii=False, indent=2)
p.quit()
return
if headless:
index = p.frame_count//2
pressed = {sequence[index]} if p.frame_count%2 == 0 and index < len(sequence) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
p.run(update, lambda: app.draw(p))
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--demo-checkpoint', action='store_true')
parser.add_argument('--screenshot')
parser.add_argument('--state-json')
args = parser.parse_args()
try:
main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json)
except ValueError as error:
parser.error(str(error))開いている、読んだ、根拠に置いたを分ける
dataclass・ビットで表す集合・状態の変化・情報を見せるタイミング。
事件 A の日記に戻ります。元のゲームの6つの Node(話題の札)と仮説の集まりを使い、画面の上下キーで話題を選びます。Z は「読む」、Space は「読んだ札を根拠に置く・外す」操作です。この段階には、結論の提出も Undo もありません。人物ごとに移動する元のゲームの画面を、話題の番号0〜5の一覧に簡単にしています。まだ開いていない話題は LOCKED で、上下キーでは選べません。
State には、opened(開いた)・read(読んだ)・placed(根拠に置いた)を、別々の整数として持ちます。最初の値 opened=23 は 0,1,2,4 が開いている状態、read=3 と placed=3 は 0,1 が読んだ・置いた状態です。2を読むと3が開き、4を読むと5が開きます。開いても本文はまだ読んでおらず、読んでも根拠に置いたことにはなりません。まだ知らないはずの行き先を本文より先に見せないよう、読んでいない話題は場所も「PLACE AND TEXT: NOT YET DISCLOSED」(まだ見せない)で隠します。
remaining(level,evidence) が使うのは、placed のビットが立っている Node.claims(札の主張)だけです。0の「待つ予定だった」と1の「ベンチに何も見えなかった」は最初から読めますが、このルールでは主張(claims)が空です。届けなかったことも、ほかの人の行動も、それだけからは言い切れません。予定や見たことを日記に残すことと、その札で今回の仮説が絞れることは、分けて考えます。
State は frozen=True なので、その場で書き換えずに、replace で次の状態を作ります。同じ札を読み直す、読んでいない札を置く、開いていない話題を読む、では何も変わりません。元のゲームどおり、2以降の事実の札を置くと mirror にも同じビットが立ち、相手の日記の欄に同じ事実を置いた印になります。新しい証拠が2件に増えるわけではありません。入力は必ずキーを全部離して区切り、Z と Space の同時押しは受け付けません。
下のコードは 02_read_place.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
def remaining(level, evidence):
return tuple((w for w in level.worlds if all((fits(w, n.claims) for i, n in enumerate(level.nodes) if evidence >> i & 1))))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_read_place.py.venv/bin/python 02_read_place.py- 開いた(opened)・読んだ(read)・置いた(placed)を区別する
- 本文を確かめてから、根拠に使う
- 予定や「いなかった」という観察と、行動の断定を分ける
read を remaining に渡すと、読んだだけで根拠に使ったことになってしまいます。opened、read、placed は別の集まりです。ミナの予定や、ハルが「いなかった」と見たことから、「ミナは届けなかった」を自動で加えないでください。reset の中の self.state = State() を self.state = State(opened=55) に1か所変え、保存・Esc で終了・同じ 02_read_place.py を起動し直してみましょう。TOPIC 5 は開いていますが、読んでいないので10世界のままです。下を4回 → Z → Space で K の札だけを使うと4世界。上 → Z → Space でソラの札も使うと2世界です。これは質問の道順だけを変えた教材用の改造で、元のゲームの最初の設定ではありません。元に戻すときも、保存・終了・起動し直しをします。
このステップのコード全文(248行)
# MIT License
#
# Copyright (c) 2026
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""すれ違い日記・教材2/5。Python 3.11+、単独実行用。"""
from dataclasses import dataclass, asdict, replace
from itertools import product, combinations
import hashlib, json
@dataclass(frozen=True)
class Node:
person: int
time: int
place: str
topic: str
fact: str
kind: str = 'fact'
claims: tuple = ()
opens: tuple = ()
feeling: str = ''
@dataclass(frozen=True)
class Candidate:
text: str
claims: tuple
action: tuple
provisional: bool = False
@dataclass(frozen=True)
class Level:
id: str
title: str
people: tuple
places: tuple
nodes: tuple
candidates: tuple
worlds: tuple
target: int
interpretation: str
scope: str
def worlds(keys, domains, predicate=lambda w: True):
return tuple((tuple(sorted(dict(zip(keys, values)).items())) for values in product(*domains) if predicate(dict(zip(keys, values)))))
def claim(**kw):
return tuple(((k, (v,)) for k, v in kw.items()))
A_NODES = (Node(0, 0, 'B', '待ち合わせの約束', '0刻: Bでミナを待つと日記に書いた。', 'plan'), Node(0, 2, 'B', '空のベンチ', '2刻: Bに着くと、包みもミナも見えなかった。', 'observation'), Node(1, 1, 'B', '届けた時', '1刻: Bへ赤い包みを置いた。', claims=claim(delivered=1), opens=(3,)), Node(1, 1, 'R', 'その後の雨', 'その後: Rで雨宿りしていた。', claims=claim(shelter=1)), Node(2, 2, 'B', '包みを運んだ時', '2刻: Bから赤い包みを運んだ。行先は次の話題。', claims=claim(mover='sora'), opens=(5,)), Node(2, 2, 'K', '乾かした場所', '2刻: 赤い包みはKにあり、そこで乾かした。', claims=claim(dest='K')))
A_CAND = (Candidate('ミナは来なかった', claim(delivered=0), ('mina', 'absent', 'parcel', '-', 'B', 1)), Candidate('ミナが2刻に包みを運んだ', claim(mover='mina'), ('mina', 'move', 'parcel', 'B', '?', 2)), Candidate('ソラがKへ移した', claim(mover='sora', dest='K'), ('sora', 'move', 'parcel', 'B', 'K', 2)), Candidate('運び先はまだ不明', (), ('unknown', 'hold', 'parcel', 'B', '?', 2), True))
A_W = worlds(('delivered', 'mover', 'dest', 'shelter'), ((0, 1), ('none', 'mina', 'sora'), ('none', 'R', 'K'), (0, 1)), lambda w: w['delivered'] == 0 and w['mover'] == 'none' and (w['dest'] == 'none') or (w['delivered'] == 1 and w['mover'] != 'none' and (w['dest'] != 'none')))
LEVELS = (Level('A', '雨の包み', ('ハル', 'ミナ', 'ソラ'), (('B', 'ベンチ'), ('R', '軒'), ('K', '台所')), A_NODES, A_CAND, A_W, 2, '来なかったのかもしれない。', '2刻、Bからの包み移動'),)
def fits(world, claims):
w = dict(world)
return all((w[k] in allowed for k, allowed in claims))
def remaining(level, evidence):
return tuple((w for w in level.worlds if all((fits(w, n.claims) for i, n in enumerate(level.nodes) if evidence >> i & 1))))
def available(s):
return tuple((i for i in range(6) if s.opened >> i & 1))
@dataclass(frozen=True)
class State:
read: int = 3
opened: int = 23
placed: int = 3
mirror: int = 0
questions: int = 0
feedback: str = ''
tick: int = 0
def apply(s, l, a, variant=False):
if a.startswith('read:'):
i = int(a.split(':')[1])
if not 0 <= i < 6 or not s.opened >> i & 1 or s.read >> i & 1:
return s
n = l.nodes[i]
opened = s.opened | sum((1 << j for j in n.opens))
return replace(s, read=s.read | 1 << i, opened=opened, questions=s.questions + 1, feedback=n.fact, tick=s.tick + 1)
if a.startswith('place:'):
i = int(a.split(':')[1])
if not 0 <= i < 6 or not s.read >> i & 1:
return s
n = l.nodes[i]
if n.kind == 'self_report':
return s
placed = s.placed ^ 1 << i
mirror = s.mirror ^ 1 << i if not variant and i >= 2 else s.mirror
return replace(s, placed=placed, mirror=mirror, feedback='事実札を配置。これは行動の根拠で、内心の断定ではありません。', tick=s.tick + 1)
return s
STAGE = 2
FACT_LINES = ((('Time 0: Haru planned to wait for Mina at B.',), ('Time 2: Haru saw neither Mina nor the parcel at B.',), ('Time 1: Mina put the red parcel at B.',), ('Afterwards: Mina sheltered from rain at R.',), ('Time 2: Sora moved the red parcel from B.', 'The destination is a separate topic.'), ('Time 2: The red parcel was at K and dried there.',)),)
class App:
def __init__(self):
self.level_index = 0
self.reset()
@property
def level(self):
return LEVELS[self.level_index]
def reset(self):
self.state = State()
self.node = 0
self.gated = False
self.show_preview = False
self.message = 'READ AND PLACE ARE TWO SEPARATE DECISIONS.'
def snapshot(self):
return {'level': self.level.id, 'state': asdict(self.state), 'worlds': len(remaining(self.level, self.state.placed))}
def action(self, action):
before = self.state
self.state = apply(self.state, self.level, action)
self.message = 'CHANGED: '+action.upper() if self.state != before else 'NO CHANGE: '+action.upper()
def control(self, pressed, held):
if self.gated:
if not pressed and not held: self.gated = False
return
# Reset and level changes obey the same conflict gate as decisions.
if len(pressed | held) > 1:
self.gated = True
self.message = 'CONFLICT: RELEASE ALL KEYS'
return
if len(pressed) != 1: return
key = next(iter(pressed))
self.gated = True
if key == 'R':
self.reset(); self.gated = True
return
if key in ('UP','DOWN'):
ids = available(self.state)
self.node = ids[(ids.index(self.node)+(-1 if key == 'UP' else 1)) % len(ids)]
elif key == 'Z':
self.action('read:'+str(self.node))
elif key == 'SPACE':
self.action('place:'+str(self.node))
def draw(self, p):
s = self.state
l = self.level
p.cls(1)
p.text(16, 12, '064 CROSSED DIARIES / STAGE '+str(STAGE)+' / CASE '+l.id, 10)
p.text(16, 30, 'QUESTIONS '+str(s.questions)+' / 4 | TICK '+str(s.tick)+' | WORLDS '+str(len(remaining(l,s.placed))), 6)
p.rect(12, 54, 190, 196, 2)
p.rect(212, 54, 416, 196, 2)
for i,n in enumerate(l.nodes):
y = 69+i*28
opened = bool(s.opened >> i & 1)
read = bool(s.read >> i & 1)
used = bool(s.placed >> i & 1)
if i == self.node: p.rect(16, y-5, 182, 22, 3)
label = 'TOPIC '+str(i)+' '+('[USED]' if used else '[READ]' if read else '[ASK]' if opened else '[LOCKED]')
p.text(22, y, label, 11 if used else 7 if opened else 5)
n = l.nodes[self.node]
p.text(224, 67, 'TOPIC '+str(self.node)+' / PERSON '+str(n.person)+' / TIME '+str(n.time), 10)
if s.read >> self.node & 1:
p.text(224, 89, 'PLACE '+n.place+' / '+n.kind.upper(), 6)
for j,line in enumerate(FACT_LINES[self.level_index][self.node]):
p.text(224, 117+j*18, line, 7)
else:
p.text(224, 89, 'PLACE AND TEXT: NOT YET DISCLOSED', 13)
p.text(224, 117, 'Z ASKS ONLY THIS OPEN TOPIC.', 7)
p.text(224, 178, 'READ != PLACED. ONLY PLACED CLAIMS FILTER WORLDS.', 6)
p.text(224, 201, 'MIRRORED FACT SLOTS: '+str(s.mirror), 13)
p.text(224, 222, "A GOAL OR FEELING IS NOT ANOTHER PERSON'S ACTION.", 13)
p.text(16, 278, 'NO CONCLUSIONS YET. TEST WHICH FACTS ACTUALLY CONSTRAIN.', 10)
p.text(16, 310, 'Initial plan and observation carry no world claims here.', 7)
p.text(16, 334, 'Reading a topic can open another topic without placing it.', 7)
p.text(16, 358, 'The missing parcel does not prove Mina never came.', 13)
p.text(16, 434, self.message, 10)
p.text(16, 458, 'UP/DOWN TOPIC Z READ SPACE PLACE/REMOVE R RESET ESC QUIT', 7)
p.text(16, 478, 'READING HAS NO TIMER. R DOES NOT RELOAD CODE.', 6)
def demo_keys():
return ('DOWN', 'DOWN', 'DOWN', 'Z', 'SPACE', 'DOWN', 'Z', 'SPACE')
def main(headless=False, screenshot=None, demo=False, state_json=None):
# Pyxel changes cwd during import/init. Resolve EVERY output first.
from pathlib import Path
if demo and not headless:
raise ValueError('demo requires --headless-smoke')
outputs = []
for value, suffix in ((screenshot, '.png'), (state_json, '.json')):
if value is None:
outputs.append(None)
continue
target = Path(value).resolve()
if not headless or target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
raise ValueError('output needs headless mode, matching suffix, existing parent, and a new file')
outputs.append(str(target))
screenshot, state_json = outputs
import pyxel as p
p.init(640, 500, title='Crossed diaries / lesson '+str(STAGE), fps=30, display_scale=1, headless=headless)
app = App()
sequence = demo_keys() if demo else ()
names = ('UP','DOWN','LEFT','RIGHT','Z','SPACE','RETURN','TAB','H','X','R','1','2','3')
keys = {name: getattr(p, 'KEY_'+name) for name in names}
limit = max(12, len(sequence)*2+6)
def update():
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
if screenshot:
app.draw(p)
p.screen.save(screenshot, 1)
if state_json:
# x mode is also a last-moment overwrite guard.
with open(state_json, 'x', encoding='utf-8') as stream:
json.dump(app.snapshot(), stream, ensure_ascii=False, indent=2)
p.quit()
return
if headless:
index = p.frame_count//2
pressed = {sequence[index]} if p.frame_count%2 == 0 and index < len(sequence) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
p.run(update, lambda: app.draw(p))
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--demo-checkpoint', action='store_true')
parser.add_argument('--screenshot')
parser.add_argument('--state-json')
args = parser.parse_args()
try:
main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json)
except ValueError as error:
parser.error(str(error))保留・矛盾・根拠不足・完成を判定する
「すべての世界で」「どれかの世界で」を確かめる・判定の順番・候補の決まり。
左右キーで4つの1行の候補を選び、Enter で提出できるようにします。選ぶことと提出することは、別の判断です。読む前から提出できますが、当たっていそうな候補を選んでも、根拠を置かなければ完成しません。上下キーはいつも話題を選び、左右キーはいつも候補を選びます。元のゲームの Tab での欄の切り替えは、教材の画面では省いています。
submit は、まず provisional(保留の札か)を見ます。事件 A の「運んだ先はまだわからない」は、言い切ることを控える保留の札なので held(保留)です。次に、残ったどの世界にも候補が合わなければ contradiction(矛盾)。一部の世界では合うけれど、事実の1行として書くには根拠が足りなければ unsupported(根拠不足)です。最後に、書きたい1行が残ったすべての世界で成り立ったときに初めて won です。元のゲームの target は、今回の日記に書く1行を選ぶ番号で、秘密の世界や、まだ知らない事実そのものではありません。
最初から「ソラが K へ運んだ」を選んでも unsupported です。「運んだ人」の札だけでも、行き先がまだ2通りあるので unsupported。2枚を置いて初めて won になります。「ミナは来なかった」は、最初からまちがいとわかっているわけではありませんが、「ミナが届けた」という札を根拠に入れたあとは contradiction です。「まちがいだと示された」状態と、「正しいとも、まちがいとも言えない」状態を、同じ「不正解」にまとめないことが大切です。
元のゲームの counterevidence(反対の証拠)と missing(足りないもの)もこの段階に入り、State.feedback には日本語で、反対の証拠や足りない項目が残ります。ただし、英数字の教材の画面は RESULT の分類だけを表示し、元のゲームの日本語の詳しいメッセージは省いています。H での予測と別の事件は次の段階、Undo と2つの結末は最後の段階です。保留の札の空の主張は、理屈の上ではすべての世界に合いますが、held であって、完成の札ではありません。全部読んだあとでも保留を選べる、という元のゲームの決まりです。
下のコードは 03_entailment.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
def entails(level, evidence, candidate):
options = remaining(level, evidence)
return bool(options) and all((fits(w, level.candidates[candidate].claims) for w in options))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_entailment.py.venv/bin/python 03_entailment.py- 保留と、事実として言い切ることを分ける
- 「まちがいだと示された」と「根拠が足りない」を分ける
- 書きたい1行が、残ったすべての世界で成り立てば完成
any(fits(...)) だけで成功にしないでください。provisional の空の主張を「何にでも当てはまる正解」と扱わないでください。target の番号を選んだだけでも、成功にはなりません。reset の中の self.state = State() を self.state = State(chosen=3) に変えてみましょう。保存・Esc で終了・同じ 03_entailment.py を起動し直します。Enter で HELD、10世界のままです。左 → Enter で「ソラ → K」を選ぶと UNSUPPORTED です。最初に選ばれる候補を変えても、根拠は増えません。元に戻すときも、保存・終了・起動し直しをします。
このステップのコード全文(304行)
# MIT License
#
# Copyright (c) 2026
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""すれ違い日記・教材3/5。Python 3.11+、単独実行用。"""
from dataclasses import dataclass, asdict, replace
from itertools import product, combinations
import hashlib, json
@dataclass(frozen=True)
class Node:
person: int
time: int
place: str
topic: str
fact: str
kind: str = 'fact'
claims: tuple = ()
opens: tuple = ()
feeling: str = ''
@dataclass(frozen=True)
class Candidate:
text: str
claims: tuple
action: tuple
provisional: bool = False
@dataclass(frozen=True)
class Level:
id: str
title: str
people: tuple
places: tuple
nodes: tuple
candidates: tuple
worlds: tuple
target: int
interpretation: str
scope: str
def worlds(keys, domains, predicate=lambda w: True):
return tuple((tuple(sorted(dict(zip(keys, values)).items())) for values in product(*domains) if predicate(dict(zip(keys, values)))))
def claim(**kw):
return tuple(((k, (v,)) for k, v in kw.items()))
A_NODES = (Node(0, 0, 'B', '待ち合わせの約束', '0刻: Bでミナを待つと日記に書いた。', 'plan'), Node(0, 2, 'B', '空のベンチ', '2刻: Bに着くと、包みもミナも見えなかった。', 'observation'), Node(1, 1, 'B', '届けた時', '1刻: Bへ赤い包みを置いた。', claims=claim(delivered=1), opens=(3,)), Node(1, 1, 'R', 'その後の雨', 'その後: Rで雨宿りしていた。', claims=claim(shelter=1)), Node(2, 2, 'B', '包みを運んだ時', '2刻: Bから赤い包みを運んだ。行先は次の話題。', claims=claim(mover='sora'), opens=(5,)), Node(2, 2, 'K', '乾かした場所', '2刻: 赤い包みはKにあり、そこで乾かした。', claims=claim(dest='K')))
A_CAND = (Candidate('ミナは来なかった', claim(delivered=0), ('mina', 'absent', 'parcel', '-', 'B', 1)), Candidate('ミナが2刻に包みを運んだ', claim(mover='mina'), ('mina', 'move', 'parcel', 'B', '?', 2)), Candidate('ソラがKへ移した', claim(mover='sora', dest='K'), ('sora', 'move', 'parcel', 'B', 'K', 2)), Candidate('運び先はまだ不明', (), ('unknown', 'hold', 'parcel', 'B', '?', 2), True))
A_W = worlds(('delivered', 'mover', 'dest', 'shelter'), ((0, 1), ('none', 'mina', 'sora'), ('none', 'R', 'K'), (0, 1)), lambda w: w['delivered'] == 0 and w['mover'] == 'none' and (w['dest'] == 'none') or (w['delivered'] == 1 and w['mover'] != 'none' and (w['dest'] != 'none')))
LEVELS = (Level('A', '雨の包み', ('ハル', 'ミナ', 'ソラ'), (('B', 'ベンチ'), ('R', '軒'), ('K', '台所')), A_NODES, A_CAND, A_W, 2, '来なかったのかもしれない。', '2刻、Bからの包み移動'),)
def fits(world, claims):
w = dict(world)
return all((w[k] in allowed for k, allowed in claims))
def remaining(level, evidence):
return tuple((w for w in level.worlds if all((fits(w, n.claims) for i, n in enumerate(level.nodes) if evidence >> i & 1))))
def available(s):
return tuple((i for i in range(6) if s.opened >> i & 1))
def entails(level, evidence, candidate):
options = remaining(level, evidence)
return bool(options) and all((fits(w, level.candidates[candidate].claims) for w in options))
def counterevidence(l, evidence, candidate):
ids = [i for i in range(6) if evidence >> i & 1]
for size in range(1, len(ids) + 1):
for subset in combinations(ids, size):
mask = sum((1 << i for i in subset))
if not any((fits(w, l.candidates[candidate].claims) for w in remaining(l, mask))):
return '反証: ' + l.nodes[subset[0]].fact
return '初期の予定を確認: ' + l.nodes[0].fact
@dataclass(frozen=True)
class State:
read: int = 3
opened: int = 23
placed: int = 3
mirror: int = 0
chosen: int = 0
questions: int = 0
result: str = ''
ending: int = -1
feedback: str = '事実と、その時の解釈を分けて、空白を埋めよう。'
tick: int = 0
def missing(l, evidence):
options = remaining(l, evidence)
parts = []
for field, vals in l.candidates[l.target].claims:
if any((dict(w)[field] not in vals for w in options)):
parts.append({'mover': '誰が運んだか', 'dest': 'どこへ運ばれたか', 'sign': '集合札の行先', 'practice': '実際に練習した場所', 'art_mover': '絵を運んだ人', 'art': '絵の行先'}[field])
return '未確認: ' + '・'.join(parts) + '。'
def apply(s, l, a, variant=False):
if a.startswith('read:'):
i = int(a.split(':')[1])
if not 0 <= i < 6 or not s.opened >> i & 1 or s.read >> i & 1:
return s
n = l.nodes[i]
opened = s.opened | sum((1 << j for j in n.opens))
return replace(s, read=s.read | 1 << i, opened=opened, questions=s.questions + 1, feedback=n.fact, tick=s.tick + 1)
if a.startswith('place:'):
i = int(a.split(':')[1])
if not 0 <= i < 6 or not s.read >> i & 1:
return s
n = l.nodes[i]
if n.kind == 'self_report':
return s
placed = s.placed ^ 1 << i
mirror = s.mirror ^ 1 << i if not variant and i >= 2 else s.mirror
return replace(s, placed=placed, mirror=mirror, result='', ending=-1, feedback='事実札を配置。これは行動の根拠で、内心の断定ではありません。', tick=s.tick + 1)
if a.startswith('choose:'):
i = int(a.split(':')[1])
if not 0 <= i < 4 or i == s.chosen:
return s
return replace(s, chosen=i, result='', ending=-1, feedback='この行を仮置き。Enterで根拠と照合します。', tick=s.tick + 1)
if a == 'submit':
c = l.candidates[s.chosen]
options = remaining(l, s.placed)
if c.provisional:
result = 'held'
msg = '保留として残しました。行先の根拠があれば、行動の一行へ進めます。'
elif not any((fits(w, c.claims) for w in options)):
result = 'contradiction'
msg = counterevidence(l, s.placed, s.chosen)
elif s.chosen != l.target:
result = 'unsupported'
msg = 'この行は、置いた事実だけでは確かめられていません。'
elif not entails(l, s.placed, s.chosen):
result = 'unsupported'
msg = '行動は一致。' + missing(l, s.placed)
else:
result = 'won'
msg = '根拠つきで日記がつながりました。終わり方はどちらでも完成。'
return replace(s, result=result, feedback=msg, tick=s.tick + 1)
return s
STAGE = 3
FACT_LINES = ((('Time 0: Haru planned to wait for Mina at B.',), ('Time 2: Haru saw neither Mina nor the parcel at B.',), ('Time 1: Mina put the red parcel at B.',), ('Afterwards: Mina sheltered from rain at R.',), ('Time 2: Sora moved the red parcel from B.', 'The destination is a separate topic.'), ('Time 2: The red parcel was at K and dried there.',)),)
CANDIDATE_LABELS = (('Mina did not come', 'Mina moved the parcel at time 2', 'Sora moved the parcel to K', 'Destination still unknown [HOLD]'),)
class App:
def __init__(self):
self.level_index = 0
self.reset()
@property
def level(self):
return LEVELS[self.level_index]
def reset(self):
self.state = State()
self.node = 0
self.gated = False
self.show_preview = False
self.message = 'READ AND PLACE ARE TWO SEPARATE DECISIONS.'
def snapshot(self):
return {'level': self.level.id, 'state': asdict(self.state), 'worlds': len(remaining(self.level, self.state.placed))}
def action(self, action):
before = self.state
self.state = apply(self.state, self.level, action)
self.message = 'CHANGED: '+action.upper() if self.state != before else 'NO CHANGE: '+action.upper()
if action == 'submit':
self.message = 'RESULT: '+self.state.result.upper()
def control(self, pressed, held):
if self.gated:
if not pressed and not held: self.gated = False
return
# Reset and level changes obey the same conflict gate as decisions.
if len(pressed | held) > 1:
self.gated = True
self.message = 'CONFLICT: RELEASE ALL KEYS'
return
if len(pressed) != 1: return
key = next(iter(pressed))
self.gated = True
if key == 'R':
self.reset(); self.gated = True
return
if key in ('UP','DOWN'):
ids = available(self.state)
self.node = ids[(ids.index(self.node)+(-1 if key == 'UP' else 1)) % len(ids)]
elif key == 'Z':
self.action('read:'+str(self.node))
elif key == 'SPACE':
self.action('place:'+str(self.node))
elif key in ('LEFT','RIGHT'):
self.action('choose:'+str((self.state.chosen+(-1 if key == 'LEFT' else 1))%4))
elif key == 'RETURN':
self.action('submit')
def draw(self, p):
s = self.state
l = self.level
p.cls(1)
p.text(16, 12, '064 CROSSED DIARIES / STAGE '+str(STAGE)+' / CASE '+l.id, 10)
p.text(16, 30, 'QUESTIONS '+str(s.questions)+' / 4 | TICK '+str(s.tick)+' | WORLDS '+str(len(remaining(l,s.placed))), 6)
p.rect(12, 54, 190, 196, 2)
p.rect(212, 54, 416, 196, 2)
for i,n in enumerate(l.nodes):
y = 69+i*28
opened = bool(s.opened >> i & 1)
read = bool(s.read >> i & 1)
used = bool(s.placed >> i & 1)
if i == self.node: p.rect(16, y-5, 182, 22, 3)
label = 'TOPIC '+str(i)+' '+('[USED]' if used else '[READ]' if read else '[ASK]' if opened else '[LOCKED]')
p.text(22, y, label, 11 if used else 7 if opened else 5)
n = l.nodes[self.node]
p.text(224, 67, 'TOPIC '+str(self.node)+' / PERSON '+str(n.person)+' / TIME '+str(n.time), 10)
if s.read >> self.node & 1:
p.text(224, 89, 'PLACE '+n.place+' / '+n.kind.upper(), 6)
for j,line in enumerate(FACT_LINES[self.level_index][self.node]):
p.text(224, 117+j*18, line, 7)
else:
p.text(224, 89, 'PLACE AND TEXT: NOT YET DISCLOSED', 13)
p.text(224, 117, 'Z ASKS ONLY THIS OPEN TOPIC.', 7)
p.text(224, 178, 'READ != PLACED. ONLY PLACED CLAIMS FILTER WORLDS.', 6)
p.text(224, 201, 'MIRRORED FACT SLOTS: '+str(s.mirror), 13)
p.text(224, 222, "A GOAL OR FEELING IS NOT ANOTHER PERSON'S ACTION.", 13)
for i,label in enumerate(CANDIDATE_LABELS[self.level_index]):
y = 266+i*25
p.rect(12, y-4, 616, 21, 3 if s.chosen == i else 2)
p.text(22, y, ('> ' if s.chosen == i else ' ')+str(i)+': '+label, 10 if s.chosen == i else 7)
p.text(16, 372, 'RESULT: '+(s.result.upper() or 'NOT SUBMITTED'), 11)
p.text(16, 434, self.message, 10)
p.text(16, 458, 'UP/DOWN TOPIC Z READ SPACE PLACE/REMOVE R RESET ESC QUIT', 7)
p.text(16, 478, 'LEFT/RIGHT CLAIM ENTER SUBMIT', 6)
def demo_keys():
return ('DOWN', 'DOWN', 'DOWN', 'Z', 'SPACE', 'DOWN', 'Z', 'SPACE', 'RIGHT', 'RIGHT', 'RETURN')
def main(headless=False, screenshot=None, demo=False, state_json=None):
# Pyxel changes cwd during import/init. Resolve EVERY output first.
from pathlib import Path
if demo and not headless:
raise ValueError('demo requires --headless-smoke')
outputs = []
for value, suffix in ((screenshot, '.png'), (state_json, '.json')):
if value is None:
outputs.append(None)
continue
target = Path(value).resolve()
if not headless or target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
raise ValueError('output needs headless mode, matching suffix, existing parent, and a new file')
outputs.append(str(target))
screenshot, state_json = outputs
import pyxel as p
p.init(640, 500, title='Crossed diaries / lesson '+str(STAGE), fps=30, display_scale=1, headless=headless)
app = App()
sequence = demo_keys() if demo else ()
names = ('UP','DOWN','LEFT','RIGHT','Z','SPACE','RETURN','TAB','H','X','R','1','2','3')
keys = {name: getattr(p, 'KEY_'+name) for name in names}
limit = max(12, len(sequence)*2+6)
def update():
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
if screenshot:
app.draw(p)
p.screen.save(screenshot, 1)
if state_json:
# x mode is also a last-moment overwrite guard.
with open(state_json, 'x', encoding='utf-8') as stream:
json.dump(app.snapshot(), stream, ensure_ascii=False, indent=2)
p.quit()
return
if headless:
index = p.frame_count//2
pressed = {sequence[index]} if p.frame_count%2 == 0 and index < len(sequence) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
p.run(update, lambda: app.draw(p))
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--demo-checkpoint', action='store_true')
parser.add_argument('--screenshot')
parser.add_argument('--state-json')
args = parser.parse_args()
try:
main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json)
except ValueError as error:
parser.error(str(error))三事件で、本人の説明と他人の行動を混ぜない
見せる情報の境目・本人の説明・置いた根拠だけでの予測。
1/2/3 で、事件 A/B/C の最初の状態に切り替えます。元のゲームどおり、世界の数は A が10、B が24、C が80です。どれも、同じ1回の出来事について比べる、数の限られた仮説で、知らない往復や、追加の運び直しを勝手に足すことはしません。B では、集まりの札の移動と、実際の練習場所が必要です。3刻に説明へ戻ったことだけでは、2刻の集まった場所は証明できません。
事件 C の TOPIC 3 は、フミの「窓辺で作業したかった」という本人の説明です。種類 kind は self_report(本人の説明)、主張 claims は空です。Z で読めますが、Space で根拠に置くことはできません。本人の目的から、「別の人が絵を移した・捨てた」と推理するルールは作りません。灯りを W へ動かした札も、絵が動いたことは証明しません。アオが絵を D から S へ運んだ TOPIC 4 なら、その1件だけで、日記に書く1行を証明できます。
H を押すと、今選んでいる候補が何通りの世界に合うかを、KNOWN-ONLY(わかっていることだけ)として表示します。preview は、remaining(level,placed) と、公開された候補だけを使い、まだ読んでいない Node の主張を先に足すことはしません。これは、この先返ってくる証言の予言でも、次に聞くべき人物を決めるものでもありません。H は、状態・質問の回数・根拠を変えません。読んでいない本文と場所は画面にも出さず、開いていない話題は読めません。
事件 C では、全部読んでも、絵を描いた人と2人の気持ちは決まらず、4世界が残ります。「ナギは描いていない」「2人が隠した」は、確実とも、まちがいとも言えず、unsupported のままです。元のゲームの事実・世界の集まり・候補をそのまま使い、気持ちを当てる秘密の正解は加えていません。この「見せる情報の境目」は、ふつうの画面と関数の入力の設計であり、配ったPythonファイルを読めなくするセキュリティの機能ではありません。
下のコードは 04_private_topics.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
def preview(level, evidence, candidate):
"""Predict logical status from PLACED claims only, never from unopened facts."""
options = remaining(level, evidence)
matching = sum(fits(w, level.candidates[candidate].claims) for w in options)
return (len(options), matching, bool(options) and matching == len(options))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_private_topics.py.venv/bin/python 04_private_topics.py- 本人の説明を、ほかの人の行動の証拠にしない
- 予測には、置いた根拠だけを使う
- まだ決まらない作者や気持ちを、否定に変えない
コードの書き換えはしません。3 → 下を2回 → Z → Space で灯りの札を置きます。下 → Z → Space で本人の説明を読み、置こうとしても NO CHANGE(変化なし)になることを確かめます。Enter は UNSUPPORTED です。下 → Z → Space でアオの札を置き、右 → H → Enter なら WON、4/4 MATCH です。この道順は説明も読んだので質問3回ですが、最初の状態から TOPIC 4 だけを読めば1回で足ります。
このステップのコード全文(359行)
# MIT License
#
# Copyright (c) 2026
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""すれ違い日記・教材4/5。Python 3.11+、単独実行用。"""
from dataclasses import dataclass, asdict, replace
from itertools import product, combinations
import hashlib, json
@dataclass(frozen=True)
class Node:
person: int
time: int
place: str
topic: str
fact: str
kind: str = 'fact'
claims: tuple = ()
opens: tuple = ()
feeling: str = ''
@dataclass(frozen=True)
class Candidate:
text: str
claims: tuple
action: tuple
provisional: bool = False
@dataclass(frozen=True)
class Level:
id: str
title: str
people: tuple
places: tuple
nodes: tuple
candidates: tuple
worlds: tuple
target: int
interpretation: str
scope: str
def worlds(keys, domains, predicate=lambda w: True):
return tuple((tuple(sorted(dict(zip(keys, values)).items())) for values in product(*domains) if predicate(dict(zip(keys, values)))))
def claim(**kw):
return tuple(((k, (v,)) for k, v in kw.items()))
A_NODES = (Node(0, 0, 'B', '待ち合わせの約束', '0刻: Bでミナを待つと日記に書いた。', 'plan'), Node(0, 2, 'B', '空のベンチ', '2刻: Bに着くと、包みもミナも見えなかった。', 'observation'), Node(1, 1, 'B', '届けた時', '1刻: Bへ赤い包みを置いた。', claims=claim(delivered=1), opens=(3,)), Node(1, 1, 'R', 'その後の雨', 'その後: Rで雨宿りしていた。', claims=claim(shelter=1)), Node(2, 2, 'B', '包みを運んだ時', '2刻: Bから赤い包みを運んだ。行先は次の話題。', claims=claim(mover='sora'), opens=(5,)), Node(2, 2, 'K', '乾かした場所', '2刻: 赤い包みはKにあり、そこで乾かした。', claims=claim(dest='K')))
A_CAND = (Candidate('ミナは来なかった', claim(delivered=0), ('mina', 'absent', 'parcel', '-', 'B', 1)), Candidate('ミナが2刻に包みを運んだ', claim(mover='mina'), ('mina', 'move', 'parcel', 'B', '?', 2)), Candidate('ソラがKへ移した', claim(mover='sora', dest='K'), ('sora', 'move', 'parcel', 'B', 'K', 2)), Candidate('運び先はまだ不明', (), ('unknown', 'hold', 'parcel', 'B', '?', 2), True))
A_W = worlds(('delivered', 'mover', 'dest', 'shelter'), ((0, 1), ('none', 'mina', 'sora'), ('none', 'R', 'K'), (0, 1)), lambda w: w['delivered'] == 0 and w['mover'] == 'none' and (w['dest'] == 'none') or (w['delivered'] == 1 and w['mover'] != 'none' and (w['dest'] != 'none')))
B_NODES = (Node(0, 0, 'S', '集合の予定', '0刻: 2刻の鐘でSに集合、と手帳へ書いた。', 'plan'), Node(0, 2, 'S', '鳴らなかった鐘', '2刻: Sで待ったが、鐘は聞こえなかった。', 'observation'), Node(1, 1, 'T', '鐘の修理', '1刻: 修理のため、Tの鐘から縄を外した。', claims=claim(rope=0), opens=(3,)), Node(1, 2, 'L', '練習した場所', '2刻: Lで練習していた。', claims=claim(practice='L')), Node(2, 1, 'S', '集合札の行先', '1刻: 集合札をSからLへ移した。', claims=claim(sign='L'), opens=(5,)), Node(2, 3, 'S', '説明に戻った時', '3刻: Sへ戻り、ユイに説明した。', claims=claim(explain=3)))
B_CAND = (Candidate('皆が練習をやめた', claim(practice='none'), ('all', 'stop', 'practice', 'S', '-', 2)), Candidate('集合場所がLへ移った', claim(sign='L', practice='L'), ('group', 'relocate', 'meeting', 'S', 'L', 2)), Candidate('ユイが時刻を間違えた', claim(wrongtime=1), ('yui', 'mistime', 'meeting', '-', 'S', 2)), Candidate('鐘まで全員待った', claim(practice='wait'), ('all', 'wait', 'bell', 'S', 'S', 2)))
B_W = worlds(('sign', 'practice', 'rope', 'explain', 'wrongtime'), (('S', 'L'), ('none', 'wait', 'L'), (0, 1), (2, 3), (0,)))
C_NODES = (Node(0, 0, 'D', '描く予定', '0刻: Dの灯りを描こう、とメモした。', 'plan'), Node(0, 2, 'D', '空になった机', '2刻: Dに戻ると、灯りも絵も見えなかった。', 'observation'), Node(1, 1, 'D', '灯りを動かした時', '1刻: 灯りをDからWへ動かした。', claims=claim(lamp='W'), opens=(3,)), Node(1, 1, 'W', '窓辺での本人の説明', '本人の説明: 窓辺の作業に使うためだった。', 'self_report', (), (), 'フミは、窓辺で作業したかったと話した。'), Node(2, 1, 'D', '絵を運んだ時', '1刻: 濡れた絵をDからSへ運んだ。', claims=claim(art_mover='ao', art='S'), opens=(5,)), Node(2, 2, 'S', 'その後の絵', '2刻にも、Sに絵が置いてあった。', claims=claim(art='S')))
C_CAND = (Candidate('フミが絵も捨てた', claim(art_mover='fumi', art='trash'), ('fumi', 'discard', 'art', 'D', 'trash', 1)), Candidate('アオが絵をSへ移した', claim(art_mover='ao', art='S'), ('ao', 'move', 'art', 'D', 'S', 1)), Candidate('ナギは絵を描いていない', claim(painted=0), ('nagi', 'absent', 'art', '-', 'D', 0)), Candidate('二人が絵を隠した', claim(intent='both_hide'), ('both', 'hide', 'art', 'D', '?', 1)))
C_W = worlds(('lamp', 'art_mover', 'art', 'painted', 'intent'), (('W', 'S'), ('none', 'ao', 'fumi', 'both'), ('none', 'S', 'W', 'trash'), (0, 1), ('unknown', 'both_hide')), lambda w: w['art_mover'] == 'none' and w['art'] == 'none' or (w['art_mover'] != 'none' and w['art'] != 'none'))
LEVELS = (Level('A', '雨の包み', ('ハル', 'ミナ', 'ソラ'), (('B', 'ベンチ'), ('R', '軒'), ('K', '台所')), A_NODES, A_CAND, A_W, 2, '来なかったのかもしれない。', '2刻、Bからの包み移動'), Level('B', '鳴らなかった合図', ('ユイ', 'レン', 'トワ'), (('T', '塔'), ('S', '広場'), ('L', '練習室')), B_NODES, B_CAND, B_W, 1, 'みんな、練習をやめたのかもしれない。', '2刻の集合の行先'), Level('C', '消えた灯りの絵', ('ナギ', 'フミ', 'アオ'), (('D', '作業机'), ('S', '乾燥棚'), ('W', '窓')), C_NODES, C_CAND, C_W, 1, 'フミが絵も片づけたのかもしれない。', '1刻、Dからの絵の移動'))
def fits(world, claims):
w = dict(world)
return all((w[k] in allowed for k, allowed in claims))
def remaining(level, evidence):
return tuple((w for w in level.worlds if all((fits(w, n.claims) for i, n in enumerate(level.nodes) if evidence >> i & 1))))
def available(s):
return tuple((i for i in range(6) if s.opened >> i & 1))
def entails(level, evidence, candidate):
options = remaining(level, evidence)
return bool(options) and all((fits(w, level.candidates[candidate].claims) for w in options))
def counterevidence(l, evidence, candidate):
ids = [i for i in range(6) if evidence >> i & 1]
for size in range(1, len(ids) + 1):
for subset in combinations(ids, size):
mask = sum((1 << i for i in subset))
if not any((fits(w, l.candidates[candidate].claims) for w in remaining(l, mask))):
return '反証: ' + l.nodes[subset[0]].fact
return '初期の予定を確認: ' + l.nodes[0].fact
@dataclass(frozen=True)
class State:
read: int = 3
opened: int = 23
placed: int = 3
mirror: int = 0
chosen: int = 0
questions: int = 0
result: str = ''
ending: int = -1
feedback: str = '事実と、その時の解釈を分けて、空白を埋めよう。'
tick: int = 0
def missing(l, evidence):
options = remaining(l, evidence)
parts = []
for field, vals in l.candidates[l.target].claims:
if any((dict(w)[field] not in vals for w in options)):
parts.append({'mover': '誰が運んだか', 'dest': 'どこへ運ばれたか', 'sign': '集合札の行先', 'practice': '実際に練習した場所', 'art_mover': '絵を運んだ人', 'art': '絵の行先'}[field])
return '未確認: ' + '・'.join(parts) + '。'
def apply(s, l, a, variant=False):
if a.startswith('read:'):
i = int(a.split(':')[1])
if not 0 <= i < 6 or not s.opened >> i & 1 or s.read >> i & 1:
return s
n = l.nodes[i]
opened = s.opened | sum((1 << j for j in n.opens))
return replace(s, read=s.read | 1 << i, opened=opened, questions=s.questions + 1, feedback=n.fact, tick=s.tick + 1)
if a.startswith('place:'):
i = int(a.split(':')[1])
if not 0 <= i < 6 or not s.read >> i & 1:
return s
n = l.nodes[i]
if n.kind == 'self_report':
return s
placed = s.placed ^ 1 << i
mirror = s.mirror ^ 1 << i if not variant and i >= 2 else s.mirror
return replace(s, placed=placed, mirror=mirror, result='', ending=-1, feedback='事実札を配置。これは行動の根拠で、内心の断定ではありません。', tick=s.tick + 1)
if a.startswith('choose:'):
i = int(a.split(':')[1])
if not 0 <= i < 4 or i == s.chosen:
return s
return replace(s, chosen=i, result='', ending=-1, feedback='この行を仮置き。Enterで根拠と照合します。', tick=s.tick + 1)
if a == 'submit':
c = l.candidates[s.chosen]
options = remaining(l, s.placed)
if c.provisional:
result = 'held'
msg = '保留として残しました。行先の根拠があれば、行動の一行へ進めます。'
elif not any((fits(w, c.claims) for w in options)):
result = 'contradiction'
msg = counterevidence(l, s.placed, s.chosen)
elif s.chosen != l.target:
result = 'unsupported'
msg = 'この行は、置いた事実だけでは確かめられていません。'
elif not entails(l, s.placed, s.chosen):
result = 'unsupported'
msg = '行動は一致。' + missing(l, s.placed)
else:
result = 'won'
msg = '根拠つきで日記がつながりました。終わり方はどちらでも完成。'
return replace(s, result=result, feedback=msg, tick=s.tick + 1)
return s
STAGE = 4
# ASCII translations are UI-only. The original Japanese data stays in LEVELS.
FACT_LINES = (
(('Time 0: Haru planned to wait for Mina at B.',),
('Time 2: Haru saw neither Mina nor the parcel at B.',),
('Time 1: Mina put the red parcel at B.',),
('Afterwards: Mina sheltered from rain at R.',),
('Time 2: Sora moved the red parcel from B.', 'The destination is a separate topic.'),
('Time 2: The red parcel was at K and dried there.',)),
(('Time 0: Yui planned to meet at S at the time-2 bell.',),
('Time 2: Yui waited at S but heard no bell.',),
('Time 1: Ren removed the rope from the bell at T.',),
('Time 2: Ren was practising at L.',),
('Time 1: Towa moved the meeting sign from S to L.',),
('Time 3: Towa returned to S and explained to Yui.',)),
(('Time 0: Nagi planned to draw the lamp at D.',),
('Time 2: Nagi saw neither lamp nor art at D.',),
('Time 1: Fumi moved the lamp from D to W.',),
('Fumi said: I wanted to work by the window.', "SELF-REPORT: not evidence of another person's act."),
('Time 1: Ao moved the wet art from D to S.',),
('Time 2: The art was still on S.',))
)
CANDIDATE_LABELS = (
('Mina did not come', 'Mina moved the parcel at time 2', 'Sora moved the parcel to K', 'Destination still unknown [HOLD]'),
('Everyone stopped practising', 'Meeting moved to L', 'Yui mistook the time', 'Everyone waited for the bell'),
('Fumi also threw away the art', 'Ao moved the art to S', 'Nagi never painted the art', 'Both people hid the art')
)
def preview(level, evidence, candidate):
"""Predict logical status from PLACED claims only, never from unopened facts."""
options = remaining(level, evidence)
matching = sum(fits(w, level.candidates[candidate].claims) for w in options)
return (len(options), matching, bool(options) and matching == len(options))
class App:
def __init__(self):
self.level_index = 0
self.reset()
@property
def level(self):
return LEVELS[self.level_index]
def reset(self):
self.state = State()
self.node = 0
self.gated = False
self.show_preview = False
self.message = 'READ AND PLACE ARE TWO SEPARATE DECISIONS.'
def snapshot(self):
return {'level': self.level.id, 'state': asdict(self.state), 'worlds': len(remaining(self.level, self.state.placed))}
def action(self, action):
before = self.state
self.state = apply(self.state, self.level, action)
self.message = 'CHANGED: '+action.upper() if self.state != before else 'NO CHANGE: '+action.upper()
if action == 'submit':
self.message = 'RESULT: '+self.state.result.upper()
def control(self, pressed, held):
if self.gated:
if not pressed and not held: self.gated = False
return
# Reset and level changes obey the same conflict gate as decisions.
if len(pressed | held) > 1:
self.gated = True
self.message = 'CONFLICT: RELEASE ALL KEYS'
return
if len(pressed) != 1: return
key = next(iter(pressed))
self.gated = True
if key == 'R':
self.reset(); self.gated = True
return
if key in ('1','2','3'):
self.level_index = int(key)-1
self.reset(); self.gated = True
return
if key == 'H':
self.show_preview = not self.show_preview
return
if key in ('UP','DOWN'):
ids = available(self.state)
self.node = ids[(ids.index(self.node)+(-1 if key == 'UP' else 1)) % len(ids)]
elif key == 'Z':
self.action('read:'+str(self.node))
elif key == 'SPACE':
self.action('place:'+str(self.node))
elif key in ('LEFT','RIGHT'):
self.action('choose:'+str((self.state.chosen+(-1 if key == 'LEFT' else 1))%4))
elif key == 'RETURN':
self.action('submit')
def draw(self, p):
s = self.state
l = self.level
p.cls(1)
p.text(16, 12, '064 CROSSED DIARIES / STAGE '+str(STAGE)+' / CASE '+l.id, 10)
p.text(16, 30, 'QUESTIONS '+str(s.questions)+' / 4 | TICK '+str(s.tick)+' | WORLDS '+str(len(remaining(l,s.placed))), 6)
p.rect(12, 54, 190, 196, 2)
p.rect(212, 54, 416, 196, 2)
for i,n in enumerate(l.nodes):
y = 69+i*28
opened = bool(s.opened >> i & 1)
read = bool(s.read >> i & 1)
used = bool(s.placed >> i & 1)
if i == self.node: p.rect(16, y-5, 182, 22, 3)
label = 'TOPIC '+str(i)+' '+('[USED]' if used else '[READ]' if read else '[ASK]' if opened else '[LOCKED]')
p.text(22, y, label, 11 if used else 7 if opened else 5)
n = l.nodes[self.node]
p.text(224, 67, 'TOPIC '+str(self.node)+' / PERSON '+str(n.person)+' / TIME '+str(n.time), 10)
if s.read >> self.node & 1:
p.text(224, 89, 'PLACE '+n.place+' / '+n.kind.upper(), 6)
for j,line in enumerate(FACT_LINES[self.level_index][self.node]):
p.text(224, 117+j*18, line, 7)
else:
p.text(224, 89, 'PLACE AND TEXT: NOT YET DISCLOSED', 13)
p.text(224, 117, 'Z ASKS ONLY THIS OPEN TOPIC.', 7)
p.text(224, 178, 'READ != PLACED. ONLY PLACED CLAIMS FILTER WORLDS.', 6)
p.text(224, 201, 'MIRRORED FACT SLOTS: '+str(s.mirror), 13)
p.text(224, 222, "A GOAL OR FEELING IS NOT ANOTHER PERSON'S ACTION.", 13)
for i,label in enumerate(CANDIDATE_LABELS[self.level_index]):
y = 266+i*25
p.rect(12, y-4, 616, 21, 3 if s.chosen == i else 2)
p.text(22, y, ('> ' if s.chosen == i else ' ')+str(i)+': '+label, 10 if s.chosen == i else 7)
p.text(16, 372, 'RESULT: '+(s.result.upper() or 'NOT SUBMITTED'), 11)
if self.show_preview:
count, matching, entailed = preview(l, s.placed, s.chosen)
p.text(250, 372, 'KNOWN-ONLY: '+str(matching)+' / '+str(count)+' MATCH', 6)
p.text(250, 388, 'ENTAILED' if entailed else 'NOT ENTAILED', 6)
p.text(16, 434, self.message, 10)
p.text(16, 458, 'UP/DOWN TOPIC Z READ SPACE PLACE/REMOVE R RESET ESC QUIT', 7)
p.text(16, 478, 'LEFT/RIGHT CLAIM ENTER SUBMIT 1/2/3 CASE H PREVIEW', 6)
def demo_keys():
return ('DOWN', 'DOWN', 'DOWN', 'Z', 'SPACE', 'DOWN', 'Z', 'SPACE', 'RIGHT', 'RIGHT', 'RETURN', 'H')
def main(headless=False, screenshot=None, demo=False, state_json=None):
# Pyxel changes cwd during import/init. Resolve EVERY output first.
from pathlib import Path
if demo and not headless:
raise ValueError('demo requires --headless-smoke')
outputs = []
for value, suffix in ((screenshot, '.png'), (state_json, '.json')):
if value is None:
outputs.append(None)
continue
target = Path(value).resolve()
if not headless or target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
raise ValueError('output needs headless mode, matching suffix, existing parent, and a new file')
outputs.append(str(target))
screenshot, state_json = outputs
import pyxel as p
p.init(640, 500, title='Crossed diaries / lesson '+str(STAGE), fps=30, display_scale=1, headless=headless)
app = App()
sequence = demo_keys() if demo else ()
names = ('UP','DOWN','LEFT','RIGHT','Z','SPACE','RETURN','TAB','H','X','R','1','2','3')
keys = {name: getattr(p, 'KEY_'+name) for name in names}
limit = max(12, len(sequence)*2+6)
def update():
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
if screenshot:
app.draw(p)
p.screen.save(screenshot, 1)
if state_json:
# x mode is also a last-moment overwrite guard.
with open(state_json, 'x', encoding='utf-8') as stream:
json.dump(app.snapshot(), stream, ensure_ascii=False, indent=2)
p.quit()
return
if headless:
index = p.frame_count//2
pressed = {sequence[index]} if p.frame_count%2 == 0 and index < len(sequence) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
p.run(update, lambda: app.draw(p))
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--demo-checkpoint', action='store_true')
parser.add_argument('--screenshot')
parser.add_argument('--state-json')
args = parser.parse_args()
try:
main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json)
except ValueError as error:
parser.error(str(error))判断を丸ごと戻し、二つの正当な結末を残す
書き換えない状態の履歴・端の場合の操作・ルールと画面を分ける。
最後に、元のゲームの model.py の定義とデータを全部入れます。Timeline は、今の State と、過去の State を並べたタプルです。step は状態が変わる直前の State を保存し、X なら最後の1件を元に戻します。読む、置く、候補を選ぶ、提出、結末を選ぶ、は状態が変われば履歴に入ります。同じ札の読み直しや、無効な操作は入りません。失敗した提出でも、result・feedback・tick が変わるので、1回の判断です。
State に read/opened/placed/mirror/chosen/questions/result/ending/feedback/tick をまとめているので、質問を取り消すと、そのときに開いた話題も閉じます。閉じた話題をカーソルが指していた場合、教材の画面は TOPIC 0 に戻します。H の表示の設定と上下のカーソルは、ルールの履歴には入れません。左右での候補の選択は chosen というルールの状態なので、Undo で戻ります。1回の X を「最後に押したキーを何でも消す」操作だとは考えないでください。
WON のあと Tab を押すと結末0「事実だけ」、もう一度 Tab で結末1「事実と、自分のそのときの気持ち」を選びます。どちらも result は won のままで、正式な完成です。気持ちを残しても、それを新しい行動の証拠にはせず、ほかの人の心の中を作り出すこともしません。教材では、元のゲームの結末の欄と左右+Z の操作を、Tab 1つに簡単にしています。WON のあとも、読む・置き方を変える・X・R を受け付け、根拠の置き方を変えた場合は、result と ending が消えます。
履歴の上限は、元のゲームどおり128回の判断です。上限に達すると新しい判断を止めるので、X で戻すか、R で最初に戻します。digest/replay も最後のファイルにあり、元のゲームのルールの意味のある入力の列と、SHA-256(内容から作る確認用の値)を再現できますが、教材の画面には S での記録の機能や、B での比較モードは付けていません。--state-json は確認用の最後の状態の写しで、replay の形式とは別のものです。元のゲームのルールを完全に入れていることと、元のゲームの画面・音・すべての機能を再現したことは、区別してください。
下のコードは 05_undo_endings.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
def step(t, l, a, variant=False):
if a == 'undo':
return Timeline(t.history[-1], t.history[:-1]) if t.history else t
if len(t.history) >= MAX_HISTORY:
return t
try:
n = apply(t.state, l, a, variant)
except (ValueError, TypeError, IndexError):
return t
return t if n == t.state else Timeline(n, t.history + (t.state,))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_undo_endings.py.venv/bin/python 05_undo_endings.pyState全体をまとめて巻き戻す- 無効な操作と、失敗した提出で、履歴の扱いが違うことを知る
- 2つの結末を、どちらも同じ完成として扱う
tick が変わるので履歴に入ります。1つ目の結末だけを成功として扱ったり、2つ目を劣った結果として扱ったりしないでください。コードの書き換えはしません。上の WON → Tab → Tab → X → X → X と押し、Enter → Tab で「事実だけ」の完成に戻します。次に R → 3 → 下を3回 → Z → Space → 右 → Enter → Tab で、事件 C を質問1回で完成させます。聞いていない話題を残したままでよいこと、FACTS ONLY でも正式な完成であることを確かめてください。コードを改造した場合は、保存・Esc で終了・同じ 05_undo_endings.py を起動し直します。R はコードを読み直す操作ではありません。
このステップのコード全文(414行)
# MIT License
#
# Copyright (c) 2026
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed
# under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt.
"""すれ違い日記・教材5/5。Python 3.11+、単独実行用。"""
from dataclasses import dataclass, asdict, replace
from itertools import product, combinations
import hashlib, json
VERSION = '064-v1'
MAX_HISTORY = 128
@dataclass(frozen=True)
class Node:
person: int
time: int
place: str
topic: str
fact: str
kind: str = 'fact'
claims: tuple = ()
opens: tuple = ()
feeling: str = ''
@dataclass(frozen=True)
class Candidate:
text: str
claims: tuple
action: tuple
provisional: bool = False
@dataclass(frozen=True)
class Level:
id: str
title: str
people: tuple
places: tuple
nodes: tuple
candidates: tuple
worlds: tuple
target: int
interpretation: str
scope: str
def worlds(keys, domains, predicate=lambda w: True):
return tuple((tuple(sorted(dict(zip(keys, values)).items())) for values in product(*domains) if predicate(dict(zip(keys, values)))))
def claim(**kw):
return tuple(((k, (v,)) for k, v in kw.items()))
A_NODES = (Node(0, 0, 'B', '待ち合わせの約束', '0刻: Bでミナを待つと日記に書いた。', 'plan'), Node(0, 2, 'B', '空のベンチ', '2刻: Bに着くと、包みもミナも見えなかった。', 'observation'), Node(1, 1, 'B', '届けた時', '1刻: Bへ赤い包みを置いた。', claims=claim(delivered=1), opens=(3,)), Node(1, 1, 'R', 'その後の雨', 'その後: Rで雨宿りしていた。', claims=claim(shelter=1)), Node(2, 2, 'B', '包みを運んだ時', '2刻: Bから赤い包みを運んだ。行先は次の話題。', claims=claim(mover='sora'), opens=(5,)), Node(2, 2, 'K', '乾かした場所', '2刻: 赤い包みはKにあり、そこで乾かした。', claims=claim(dest='K')))
A_CAND = (Candidate('ミナは来なかった', claim(delivered=0), ('mina', 'absent', 'parcel', '-', 'B', 1)), Candidate('ミナが2刻に包みを運んだ', claim(mover='mina'), ('mina', 'move', 'parcel', 'B', '?', 2)), Candidate('ソラがKへ移した', claim(mover='sora', dest='K'), ('sora', 'move', 'parcel', 'B', 'K', 2)), Candidate('運び先はまだ不明', (), ('unknown', 'hold', 'parcel', 'B', '?', 2), True))
A_W = worlds(('delivered', 'mover', 'dest', 'shelter'), ((0, 1), ('none', 'mina', 'sora'), ('none', 'R', 'K'), (0, 1)), lambda w: w['delivered'] == 0 and w['mover'] == 'none' and (w['dest'] == 'none') or (w['delivered'] == 1 and w['mover'] != 'none' and (w['dest'] != 'none')))
B_NODES = (Node(0, 0, 'S', '集合の予定', '0刻: 2刻の鐘でSに集合、と手帳へ書いた。', 'plan'), Node(0, 2, 'S', '鳴らなかった鐘', '2刻: Sで待ったが、鐘は聞こえなかった。', 'observation'), Node(1, 1, 'T', '鐘の修理', '1刻: 修理のため、Tの鐘から縄を外した。', claims=claim(rope=0), opens=(3,)), Node(1, 2, 'L', '練習した場所', '2刻: Lで練習していた。', claims=claim(practice='L')), Node(2, 1, 'S', '集合札の行先', '1刻: 集合札をSからLへ移した。', claims=claim(sign='L'), opens=(5,)), Node(2, 3, 'S', '説明に戻った時', '3刻: Sへ戻り、ユイに説明した。', claims=claim(explain=3)))
B_CAND = (Candidate('皆が練習をやめた', claim(practice='none'), ('all', 'stop', 'practice', 'S', '-', 2)), Candidate('集合場所がLへ移った', claim(sign='L', practice='L'), ('group', 'relocate', 'meeting', 'S', 'L', 2)), Candidate('ユイが時刻を間違えた', claim(wrongtime=1), ('yui', 'mistime', 'meeting', '-', 'S', 2)), Candidate('鐘まで全員待った', claim(practice='wait'), ('all', 'wait', 'bell', 'S', 'S', 2)))
B_W = worlds(('sign', 'practice', 'rope', 'explain', 'wrongtime'), (('S', 'L'), ('none', 'wait', 'L'), (0, 1), (2, 3), (0,)))
C_NODES = (Node(0, 0, 'D', '描く予定', '0刻: Dの灯りを描こう、とメモした。', 'plan'), Node(0, 2, 'D', '空になった机', '2刻: Dに戻ると、灯りも絵も見えなかった。', 'observation'), Node(1, 1, 'D', '灯りを動かした時', '1刻: 灯りをDからWへ動かした。', claims=claim(lamp='W'), opens=(3,)), Node(1, 1, 'W', '窓辺での本人の説明', '本人の説明: 窓辺の作業に使うためだった。', 'self_report', (), (), 'フミは、窓辺で作業したかったと話した。'), Node(2, 1, 'D', '絵を運んだ時', '1刻: 濡れた絵をDからSへ運んだ。', claims=claim(art_mover='ao', art='S'), opens=(5,)), Node(2, 2, 'S', 'その後の絵', '2刻にも、Sに絵が置いてあった。', claims=claim(art='S')))
C_CAND = (Candidate('フミが絵も捨てた', claim(art_mover='fumi', art='trash'), ('fumi', 'discard', 'art', 'D', 'trash', 1)), Candidate('アオが絵をSへ移した', claim(art_mover='ao', art='S'), ('ao', 'move', 'art', 'D', 'S', 1)), Candidate('ナギは絵を描いていない', claim(painted=0), ('nagi', 'absent', 'art', '-', 'D', 0)), Candidate('二人が絵を隠した', claim(intent='both_hide'), ('both', 'hide', 'art', 'D', '?', 1)))
C_W = worlds(('lamp', 'art_mover', 'art', 'painted', 'intent'), (('W', 'S'), ('none', 'ao', 'fumi', 'both'), ('none', 'S', 'W', 'trash'), (0, 1), ('unknown', 'both_hide')), lambda w: w['art_mover'] == 'none' and w['art'] == 'none' or (w['art_mover'] != 'none' and w['art'] != 'none'))
LEVELS = (Level('A', '雨の包み', ('ハル', 'ミナ', 'ソラ'), (('B', 'ベンチ'), ('R', '軒'), ('K', '台所')), A_NODES, A_CAND, A_W, 2, '来なかったのかもしれない。', '2刻、Bからの包み移動'), Level('B', '鳴らなかった合図', ('ユイ', 'レン', 'トワ'), (('T', '塔'), ('S', '広場'), ('L', '練習室')), B_NODES, B_CAND, B_W, 1, 'みんな、練習をやめたのかもしれない。', '2刻の集合の行先'), Level('C', '消えた灯りの絵', ('ナギ', 'フミ', 'アオ'), (('D', '作業机'), ('S', '乾燥棚'), ('W', '窓')), C_NODES, C_CAND, C_W, 1, 'フミが絵も片づけたのかもしれない。', '1刻、Dからの絵の移動'))
def fits(world, claims):
w = dict(world)
return all((w[k] in allowed for k, allowed in claims))
def remaining(level, evidence):
return tuple((w for w in level.worlds if all((fits(w, n.claims) for i, n in enumerate(level.nodes) if evidence >> i & 1))))
def entails(level, evidence, candidate):
options = remaining(level, evidence)
return bool(options) and all((fits(w, level.candidates[candidate].claims) for w in options))
def counterevidence(l, evidence, candidate):
ids = [i for i in range(6) if evidence >> i & 1]
for size in range(1, len(ids) + 1):
for subset in combinations(ids, size):
mask = sum((1 << i for i in subset))
if not any((fits(w, l.candidates[candidate].claims) for w in remaining(l, mask))):
return '反証: ' + l.nodes[subset[0]].fact
return '初期の予定を確認: ' + l.nodes[0].fact
def available(s):
return tuple((i for i in range(6) if s.opened >> i & 1))
@dataclass(frozen=True)
class State:
read: int = 3
opened: int = 23
placed: int = 3
mirror: int = 0
chosen: int = 0
questions: int = 0
result: str = ''
ending: int = -1
feedback: str = '事実と、その時の解釈を分けて、空白を埋めよう。'
tick: int = 0
@dataclass(frozen=True)
class Timeline:
state: State = State()
history: tuple = ()
def apply(s, l, a, variant=False):
if a.startswith('read:'):
i = int(a.split(':')[1])
if not 0 <= i < 6 or not s.opened >> i & 1 or s.read >> i & 1:
return s
n = l.nodes[i]
opened = s.opened | sum((1 << j for j in n.opens))
return replace(s, read=s.read | 1 << i, opened=opened, questions=s.questions + 1, feedback=n.fact, tick=s.tick + 1)
if a.startswith('place:'):
i = int(a.split(':')[1])
if not 0 <= i < 6 or not s.read >> i & 1:
return s
n = l.nodes[i]
if n.kind == 'self_report':
return s
placed = s.placed ^ 1 << i
mirror = s.mirror ^ 1 << i if not variant and i >= 2 else s.mirror
return replace(s, placed=placed, mirror=mirror, result='', ending=-1, feedback='事実札を配置。これは行動の根拠で、内心の断定ではありません。', tick=s.tick + 1)
if a.startswith('choose:'):
i = int(a.split(':')[1])
if not 0 <= i < 4 or i == s.chosen:
return s
return replace(s, chosen=i, result='', ending=-1, feedback='この行を仮置き。Enterで根拠と照合します。', tick=s.tick + 1)
if a == 'submit':
c = l.candidates[s.chosen]
options = remaining(l, s.placed)
if c.provisional:
result = 'held'
msg = '保留として残しました。行先の根拠があれば、行動の一行へ進めます。'
elif not any((fits(w, c.claims) for w in options)):
result = 'contradiction'
msg = counterevidence(l, s.placed, s.chosen)
elif s.chosen != l.target:
result = 'unsupported'
msg = 'この行は、置いた事実だけでは確かめられていません。'
elif not entails(l, s.placed, s.chosen):
result = 'unsupported'
msg = '行動は一致。' + missing(l, s.placed)
else:
result = 'won'
msg = '根拠つきで日記がつながりました。終わり方はどちらでも完成。'
return replace(s, result=result, feedback=msg, tick=s.tick + 1)
if a.startswith('ending:'):
i = int(a.split(':')[1])
if s.result != 'won' or i not in (0, 1) or s.ending == i:
return s
return replace(s, ending=i, feedback='事実だけを残しました。' if i == 0 else '事実の隣に、自分の「あの時はそう思った」を残しました。', tick=s.tick + 1)
return s
def missing(l, evidence):
options = remaining(l, evidence)
parts = []
for field, vals in l.candidates[l.target].claims:
if any((dict(w)[field] not in vals for w in options)):
parts.append({'mover': '誰が運んだか', 'dest': 'どこへ運ばれたか', 'sign': '集合札の行先', 'practice': '実際に練習した場所', 'art_mover': '絵を運んだ人', 'art': '絵の行先'}[field])
return '未確認: ' + '・'.join(parts) + '。'
def step(t, l, a, variant=False):
if a == 'undo':
return Timeline(t.history[-1], t.history[:-1]) if t.history else t
if len(t.history) >= MAX_HISTORY:
return t
try:
n = apply(t.state, l, a, variant)
except (ValueError, TypeError, IndexError):
return t
return t if n == t.state else Timeline(n, t.history + (t.state,))
def digest(obj):
return hashlib.sha256(json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest()
def replay(l, actions, variant=False):
t = Timeline()
rows = []
for a in actions:
t = step(t, l, a, variant)
rows.append({'action': a, 'tick': t.state.tick, 'state_sha256': digest(asdict(t.state))})
body = {'schema': 1, 'public_id': VERSION + '-' + l.id, 'level': asdict(l), 'settings': {'mirror_diary': not variant}, 'inputs': rows, 'terminal': asdict(t.state)}
return dict(body, sha256=digest(body))
STAGE = 5
# ASCII translations are UI-only. The original Japanese data stays in LEVELS.
FACT_LINES = (
(('Time 0: Haru planned to wait for Mina at B.',),
('Time 2: Haru saw neither Mina nor the parcel at B.',),
('Time 1: Mina put the red parcel at B.',),
('Afterwards: Mina sheltered from rain at R.',),
('Time 2: Sora moved the red parcel from B.', 'The destination is a separate topic.'),
('Time 2: The red parcel was at K and dried there.',)),
(('Time 0: Yui planned to meet at S at the time-2 bell.',),
('Time 2: Yui waited at S but heard no bell.',),
('Time 1: Ren removed the rope from the bell at T.',),
('Time 2: Ren was practising at L.',),
('Time 1: Towa moved the meeting sign from S to L.',),
('Time 3: Towa returned to S and explained to Yui.',)),
(('Time 0: Nagi planned to draw the lamp at D.',),
('Time 2: Nagi saw neither lamp nor art at D.',),
('Time 1: Fumi moved the lamp from D to W.',),
('Fumi said: I wanted to work by the window.', "SELF-REPORT: not evidence of another person's act."),
('Time 1: Ao moved the wet art from D to S.',),
('Time 2: The art was still on S.',))
)
CANDIDATE_LABELS = (
('Mina did not come', 'Mina moved the parcel at time 2', 'Sora moved the parcel to K', 'Destination still unknown [HOLD]'),
('Everyone stopped practising', 'Meeting moved to L', 'Yui mistook the time', 'Everyone waited for the bell'),
('Fumi also threw away the art', 'Ao moved the art to S', 'Nagi never painted the art', 'Both people hid the art')
)
def preview(level, evidence, candidate):
"""Predict logical status from PLACED claims only, never from unopened facts."""
options = remaining(level, evidence)
matching = sum(fits(w, level.candidates[candidate].claims) for w in options)
return (len(options), matching, bool(options) and matching == len(options))
class App:
def __init__(self):
self.level_index = 0
self.reset()
@property
def level(self):
return LEVELS[self.level_index]
@property
def state(self):
return self.timeline.state
def reset(self):
self.timeline = Timeline()
self.node = 0
self.gated = False
self.show_preview = False
self.message = 'READ AND PLACE ARE TWO SEPARATE DECISIONS.'
def snapshot(self):
return {'level': self.level.id, 'state': asdict(self.state), 'worlds': len(remaining(self.level, self.state.placed))}
def action(self, action):
before = self.state
self.timeline = step(self.timeline, self.level, action)
self.message = 'CHANGED: '+action.upper() if self.state != before else 'NO CHANGE: '+action.upper()
if action == 'submit':
self.message = 'RESULT: '+self.state.result.upper()
def control(self, pressed, held):
if self.gated:
if not pressed and not held: self.gated = False
return
# Reset and level changes obey the same conflict gate as decisions.
if len(pressed | held) > 1:
self.gated = True
self.message = 'CONFLICT: RELEASE ALL KEYS'
return
if len(pressed) != 1: return
key = next(iter(pressed))
self.gated = True
if key == 'R':
self.reset(); self.gated = True
return
if key in ('1','2','3'):
self.level_index = int(key)-1
self.reset(); self.gated = True
return
if key == 'H':
self.show_preview = not self.show_preview
return
if key == 'X':
self.action('undo')
if self.node not in available(self.state): self.node = 0
return
if key == 'TAB' and self.state.result == 'won':
self.action('ending:'+str(0 if self.state.ending == -1 else 1-self.state.ending))
return
if key in ('UP','DOWN'):
ids = available(self.state)
self.node = ids[(ids.index(self.node)+(-1 if key == 'UP' else 1)) % len(ids)]
elif key == 'Z':
self.action('read:'+str(self.node))
elif key == 'SPACE':
self.action('place:'+str(self.node))
elif key in ('LEFT','RIGHT'):
self.action('choose:'+str((self.state.chosen+(-1 if key == 'LEFT' else 1))%4))
elif key == 'RETURN':
self.action('submit')
def draw(self, p):
s = self.state
l = self.level
p.cls(1)
p.text(16, 12, '064 CROSSED DIARIES / STAGE '+str(STAGE)+' / CASE '+l.id, 10)
p.text(16, 30, 'QUESTIONS '+str(s.questions)+' / 4 | TICK '+str(s.tick)+' | WORLDS '+str(len(remaining(l,s.placed))), 6)
p.rect(12, 54, 190, 196, 2)
p.rect(212, 54, 416, 196, 2)
for i,n in enumerate(l.nodes):
y = 69+i*28
opened = bool(s.opened >> i & 1)
read = bool(s.read >> i & 1)
used = bool(s.placed >> i & 1)
if i == self.node: p.rect(16, y-5, 182, 22, 3)
label = 'TOPIC '+str(i)+' '+('[USED]' if used else '[READ]' if read else '[ASK]' if opened else '[LOCKED]')
p.text(22, y, label, 11 if used else 7 if opened else 5)
n = l.nodes[self.node]
p.text(224, 67, 'TOPIC '+str(self.node)+' / PERSON '+str(n.person)+' / TIME '+str(n.time), 10)
if s.read >> self.node & 1:
p.text(224, 89, 'PLACE '+n.place+' / '+n.kind.upper(), 6)
for j,line in enumerate(FACT_LINES[self.level_index][self.node]):
p.text(224, 117+j*18, line, 7)
else:
p.text(224, 89, 'PLACE AND TEXT: NOT YET DISCLOSED', 13)
p.text(224, 117, 'Z ASKS ONLY THIS OPEN TOPIC.', 7)
p.text(224, 178, 'READ != PLACED. ONLY PLACED CLAIMS FILTER WORLDS.', 6)
p.text(224, 201, 'MIRRORED FACT SLOTS: '+str(s.mirror), 13)
p.text(224, 222, "A GOAL OR FEELING IS NOT ANOTHER PERSON'S ACTION.", 13)
for i,label in enumerate(CANDIDATE_LABELS[self.level_index]):
y = 266+i*25
p.rect(12, y-4, 616, 21, 3 if s.chosen == i else 2)
p.text(22, y, ('> ' if s.chosen == i else ' ')+str(i)+': '+label, 10 if s.chosen == i else 7)
p.text(16, 372, 'RESULT: '+(s.result.upper() or 'NOT SUBMITTED'), 11)
if self.show_preview:
count, matching, entailed = preview(l, s.placed, s.chosen)
p.text(250, 372, 'KNOWN-ONLY: '+str(matching)+' / '+str(count)+' MATCH', 6)
p.text(250, 388, 'ENTAILED' if entailed else 'NOT ENTAILED', 6)
if s.result == 'won':
ending = ('CHOOSE WITH TAB' if s.ending == -1 else 'FACTS ONLY' if s.ending == 0 else 'FACTS + MY FORMER FEELING')
p.text(16, 410, 'ENDING: '+ending+' / BOTH ARE COMPLETE', 11)
else:
p.text(16, 410, 'UNDO HISTORY: '+str(len(self.timeline.history))+' / 128', 13)
p.text(16, 434, self.message, 10)
p.text(16, 458, 'UP/DOWN TOPIC Z READ SPACE PLACE/REMOVE R RESET ESC QUIT', 7)
p.text(16, 478, 'L/R CLAIM ENTER SUBMIT 1/2/3 CASE H PREVIEW X UNDO TAB END', 6)
def demo_keys():
return ('DOWN', 'DOWN', 'DOWN', 'Z', 'SPACE', 'DOWN', 'Z', 'SPACE', 'RIGHT', 'RIGHT', 'RETURN', 'H', 'TAB', 'TAB')
def main(headless=False, screenshot=None, demo=False, state_json=None):
# Pyxel changes cwd during import/init. Resolve EVERY output first.
from pathlib import Path
if demo and not headless:
raise ValueError('demo requires --headless-smoke')
outputs = []
for value, suffix in ((screenshot, '.png'), (state_json, '.json')):
if value is None:
outputs.append(None)
continue
target = Path(value).resolve()
if not headless or target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
raise ValueError('output needs headless mode, matching suffix, existing parent, and a new file')
outputs.append(str(target))
screenshot, state_json = outputs
import pyxel as p
p.init(640, 500, title='Crossed diaries / lesson '+str(STAGE), fps=30, display_scale=1, headless=headless)
app = App()
sequence = demo_keys() if demo else ()
names = ('UP','DOWN','LEFT','RIGHT','Z','SPACE','RETURN','TAB','H','X','R','1','2','3')
keys = {name: getattr(p, 'KEY_'+name) for name in names}
limit = max(12, len(sequence)*2+6)
def update():
if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
if screenshot:
app.draw(p)
p.screen.save(screenshot, 1)
if state_json:
# x mode is also a last-moment overwrite guard.
with open(state_json, 'x', encoding='utf-8') as stream:
json.dump(app.snapshot(), stream, ensure_ascii=False, indent=2)
p.quit()
return
if headless:
index = p.frame_count//2
pressed = {sequence[index]} if p.frame_count%2 == 0 and index < len(sequence) else set()
app.control(pressed, pressed)
else:
app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
p.run(update, lambda: app.draw(p))
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--headless-smoke', action='store_true')
parser.add_argument('--demo-checkpoint', action='store_true')
parser.add_argument('--screenshot')
parser.add_argument('--state-json')
args = parser.parse_args()
try:
main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json)
except ValueError as error:
parser.error(str(error))仕組みがわかったら、元の完成版を読んでみよう。
ルールのまとめ:事実の札の主張で、A10/B24/C80通りの世界の仮説を絞り、書きたい1行が残ったすべての世界で成り立てば完成。読んでいないことと、根拠に使っていないことを区別します。本人の説明は、ほかの人の行動の証拠にせず、全部に質問することを義務にもしません。「事実だけ」と「自分のそのときの気持ちも残す」の結末は、どちらも完成です。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 段階 | 01 は事件 A の2枚の札を先に見せた実験。02 は読む・置く、03 は事件 A の判定、04 は3つの事件と予測、05 は元のゲームのルール全部。 | 3つの事件・結論・Undo・結末が最初からあります。 |
| 表示と操作 | 英数字(ASCII)、話題の番号の一覧、上下で話題・左右で候補。結末は Tab。ルールの feedback の日本語の詳しい説明は出さず、分類だけを表示。 | Noto フォントの日本語、人物ごとの話題、時刻順の日記、Tab での欄の切り替え、左右+Z で結末。 |
| 補助と省略 | 世界の数と、H でのわかっている根拠との照合を追加。タイトル・一時停止の説明・元のゲームの S での記録・B での比較の切り替え・録画・音は省略。 | 相手の日記の欄との比較、意味のある入力の記録、一時停止の説明などがあります。 |
| 情報境界 | ふつうの画面は、読んでいない本文と場所を出さず、予測は置いた根拠だけで計算します。配ったコードを読めなくするものではありません。 | 予定・観察・そのときの思いこみを分け、本人の説明は別の扱いにしています。 |
完成版のコードで、次に読むところ
- model.py の
worlds/fits/remaining/entails - model.py の
apply:読む・置くの処理と、provisional(保留)の分かれ道 - model.py の
missing/counterevidence:足りないものと、反対の証拠 - model.py の
step/Timeline/replay - app.py の
topics/draw:読んでいない本文と場所を表示しない仕組み - app.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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
