# MIT License # # Copyright (c) 2026 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # # Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed # under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt. """沈黙の証人・教材1/5。原作A固定、単独実行。架空の情報ルール。""" from dataclasses import dataclass, asdict, replace # 原作Aの公開候補。選ぶ順番でfactsやtruthは変わらない。 DOCUMENTS = ('map', 'photo', 'clock') NAMES = {'map': '地図', 'photo': '写真', 'clock': '時計'} PLACES = ('近い門', '近い机', '遠い門', '遠い机') SHAPES = ('四角', '丸', '三角') @dataclass(frozen=True) class Fact: place: int shape: int color: str time: int @dataclass(frozen=True) class Level: id: str title: str facts: tuple truth: int LEVEL = Level('A', '返された箱', ( Fact(0, 0, '赤', 0), Fact(1, 1, '赤', 0), Fact(2, 2, '青', 2), Fact(3, 2, '青', 3)), 1) def project(fact, document, detailed): if document == 'map': return ('ABCD'[fact.place] + '・' + PLACES[fact.place] if detailed else ('近い側 A/B' if fact.place < 2 else '遠い側 C/D')) if document == 'photo': return SHAPES[fact.shape] if detailed else fact.color if document == 'clock': return str(fact.time) + '刻' if detailed else ('早い 0/1刻' if fact.time < 2 else '遅い 2/3刻') raise ValueError('unknown document') @dataclass(frozen=True) class State: detailed: bool = True STAGE = 1 STAGE_TITLE = 'PUBLIC POSSIBLE WORLDS' CONTROLS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'A', 'RETURN', 'X', 'R') REPLAY = {'U': 'UP', 'D': 'DOWN', 'L': 'LEFT', 'T': 'RIGHT', 'S': 'SPACE', 'Z': 'Z', 'A': 'A', 'E': 'RETURN', 'X': 'X', 'R': 'R'} class Controller: def __init__(self): self.previous = set() self.gate = False self.reset() def reset(self): self.state = State() self.document = 0 self.candidate = 0 self.notice = 'ready' def apply(self, action): self.state = replace(self.state, detailed=not self.state.detailed) return action def feed(self, down): # 教材は同時押しを拒否。全操作キーが離れるまで次を受け付けない。 down = set(down) & set(CONTROLS) fresh = down - self.previous self.previous = down if self.gate: if not down: self.gate = False return None if not fresh: return None self.gate = True if len(fresh) != 1: self.notice = 'ignored:multiple' return self.notice self.notice = self.handle(next(iter(fresh))) return self.notice def handle(self, key): if key == 'R': self.reset() return 'reset' if key in ('UP', 'DOWN'): self.document = (self.document + (-1 if key == 'UP' else 1)) % 3 return 'document' if key in ('LEFT', 'RIGHT'): self.candidate = (self.candidate + (-1 if key == 'LEFT' else 1)) % 4 return 'candidate' if key == 'SPACE': return self.apply('precision') return 'ignored:key' def snapshot(self): data = {'state': asdict(self.state), 'document': self.document, 'candidate': self.candidate, 'notice': self.notice} return data # 日本語の本文/判定は上に保持。ウィンドウだけ標準フォントで読めるASCII訳。 DISPLAY = { 'A・近い門': 'A / NEAR GATE', 'B・近い机': 'B / NEAR DESK', 'C・遠い門': 'C / FAR GATE', 'D・遠い机': 'D / FAR DESK', '近い側 A/B': 'NEAR A/B', '遠い側 C/D': 'FAR C/D', '四角': 'SQUARE', '丸': 'CIRCLE', '三角': 'TRIANGLE', '赤': 'RED', '青': 'BLUE', '0刻': 'TIME 0', '1刻': 'TIME 1', '2刻': 'TIME 2', '3刻': 'TIME 3', '早い 0/1刻': 'EARLY 0/1', '遅い 2/3刻': 'LATE 2/3', } def wrap(p, x, y, text, width=96, color=7): import textwrap for row, line in enumerate(textwrap.wrap(text, width=width)): p.text(x, y + row * 11, line, color) def draw_shape(p, x, y, fact): color = 8 if fact.color == '赤' else 12 if fact.shape == 0: p.rect(x, y, 12, 12, color) elif fact.shape == 1: p.circ(x + 6, y + 6, 6, color) else: p.tri(x + 6, y, x, y + 12, x + 12, y + 12, color) def draw_board(p, c): state = c.state p.cls(1) p.text(14, 12, 'SILENT WITNESS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10) p.text(14, 29, 'FICTIONAL RULE PUZZLE. Not a model of real witnesses or legal interviews.', 6) p.text(14, 44, 'CASE A / RETURNED BOX. The event stays fixed; only available detail changes.', 6) for i, document in enumerate(DOCUMENTS): x = 14 + i * 216 p.rect(x, 65, 207, 61, 2) p.rectb(x, 65, 207, 61, 10 if c.document == i else 5) p.text(x + 9, 74, document.upper(), 7) p.text(x + 9, 93, 'PREVIEW: DETAIL + CATEGORY', 11) p.text(14, 141, 'PUBLIC POSSIBLE WORLDS / LEFT-RIGHT: SELECT H1-H4', 10) for i, fact in enumerate(LEVEL.facts): y = 158 + i * 36 p.rect(14, y, 632, 32, 0) p.rectb(14, y, 632, 32, 10 if c.candidate == i else 5) draw_shape(p, 35, y + 9, fact) p.text(61, y + 7, 'H' + str(i + 1) + ' ' + DISPLAY[project(fact, 'map', True)], 7) p.text(198, y + 7, DISPLAY[fact.color] + ' ' + DISPLAY[project(fact, 'photo', True)], 7) p.text(317, y + 7, DISPLAY[project(fact, 'clock', True)], 7) p.text(399, y + 7, DISPLAY[project(fact, DOCUMENTS[c.document], state.detailed)], 11) p.text(14, 316, 'TABLE PREVIEW ONLY. These are possibilities, not received answers.', 10) p.text(14, 340, 'UP/DOWN: preview document SPACE: detail/category', 7) p.text(14, 364, 'MODE: ' + ('DETAIL' if state.detailed else 'CATEGORY'), 11) wrap(p, 14, 391, 'Notice: a detailed CLOCK can still leave H1 and H2 together. Both have TIME 0. More precise does not mean unique.') wrap(p, 14, 435, 'MAP detail: A/B/C/D. PHOTO detail: shape, category: color. CLOCK detail: 0/1/2/3, category: early/late.') p.rect(14, 479, 632, 23, 5) p.text(22, 487, 'LAST KEY: ' + c.notice, 7) p.text(14, 513, 'UP/DOWN: DOCUMENT LEFT/RIGHT: CANDIDATE SPACE: PRECISION', 6) p.text(14, 529, 'R: RESET SAME CASE ESC: QUIT TAKE YOUR TIME; NO COUNTDOWN', 6) def main(): import argparse import json from pathlib import Path parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless-replay', metavar='KEYS') parser.add_argument('--screenshot', type=Path) parser.add_argument('--report', type=Path) args = parser.parse_args() # 引数エラーはPyxelのimportより先。モデル試験は標準ライブラリだけで動く。 if args.headless_replay is None and (args.screenshot or args.report): parser.error('output paths require --headless-replay') if args.headless_replay is not None and any(c not in REPLAY for c in args.headless_replay): parser.error('replay keys must use: ' + ''.join(REPLAY)) if args.headless_replay is not None and len(args.headless_replay) > 1000: parser.error('at most 1000 replay keys') args.screenshot = args.screenshot.resolve() if args.screenshot else None args.report = args.report.resolve() if args.report else None import pyxel c = Controller() frames = 0 events = [] sequence = args.headless_replay limit = max(12, len(sequence or '') * 2 + 2) mapping = {name: getattr(pyxel, 'KEY_' + name) for name in CONTROLS} pyxel.init(660, 550, title='069 Silent Witness - Stage ' + str(STAGE), fps=30, display_scale=1, quit_key=pyxel.KEY_NONE, headless=sequence is not None) def update(): nonlocal frames if pyxel.btnp(pyxel.KEY_ESCAPE): pyxel.quit() return if sequence is None: down = {name for name, key in mapping.items() if pyxel.btn(key)} elif frames % 2 == 0 and frames // 2 < len(sequence): down = {REPLAY[sequence[frames // 2]]} else: down = set() event = c.feed(down) if event is not None: events.append({'frame': frames, 'result': event, 'snapshot': c.snapshot()}) frames += 1 def draw(): draw_board(pyxel, c) if sequence is not None and frames >= limit: if args.screenshot: args.screenshot.parent.mkdir(parents=True, exist_ok=True) pyxel.screenshot(str(args.screenshot), scale=1) if args.report: args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text(json.dumps({'stage': STAGE, 'actions': sequence, 'frames': frames, 'events': events, 'snapshot': c.snapshot()}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') pyxel.quit() pyxel.run(update, draw) if __name__ == '__main__': main()