# 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。架空の経路パズル。音声出力は省略。""" from dataclasses import dataclass, replace import heapq ROOMS = "ABCDEF" EDGES = ("AB", "BC", "DE", "EF", "AD", "BE", "CF") PATH_IDS = "PQRS" START_ROOM = "A" WALL_COST = 3 @dataclass(frozen=True) class Level: floors: tuple walls: tuple paths: tuple truth: int LEVEL = Level(("wood", "tile", "stone", "wood", "tile", "stone"), ("BC", "EF"), ("ABE", "DEB", "CFC", "FCF"), 2) def distances(level, listener, wall_cost=WALL_COST): """Sum edge costs, not geometric distance. Dijkstra's priority queue.""" dist = {room: 99 for room in ROOMS} dist[listener] = 0 queue = [(0, listener)] while queue: distance, room = heapq.heappop(queue) if distance != dist[room]: continue for edge in EDGES: if room not in edge: continue neighbor = edge[1] if edge[0] == room else edge[0] cost = wall_cost if edge in level.walls else 1 new_distance = distance + cost if new_distance < dist[neighbor]: dist[neighbor] = new_distance heapq.heappush(queue, (new_distance, neighbor)) return dist def signature(level, listener, path, wall_cost=WALL_COST): ds = distances(level, listener, wall_cost) return tuple( ("none", 0) if ds[room] >= 4 else (level.floors[ROOMS.index(room)], 2 if ds[room] <= 1 else 1) for room in path ) def sound_text(sound): material, strength = sound return "NONE 0" if strength == 0 else material.upper()+" "+str(strength) @dataclass(frozen=True) class Observation: room: str sounds: tuple @dataclass(frozen=True) class Submission: candidate: int verdict: str @dataclass(frozen=True) class State: observations: tuple = () candidates: tuple = (0, 1, 2, 3) selected_room: str = START_ROOM submissions: tuple = () outcome: str = "running" def initial(): return State() def apply(state, level, action, wall_cost=WALL_COST): if action.startswith("listen:"): room = action.partition(":")[2] if len(room) != 1 or room not in ROOMS: return state, "invalid_room" # Truth produces an observation. Filtering receives only that observation. heard = Observation(room, signature(level, room, level.paths[level.truth], wall_cost)) candidates = tuple(i for i in state.candidates if signature(level, room, level.paths[i], wall_cost) == heard.sounds) return replace(state, observations=state.observations+(heard,), candidates=candidates, selected_room=room, outcome="running"), "heard" if action.startswith("submit:"): try: candidate = int(action.partition(":")[2]) except ValueError: return state, "invalid_candidate" if candidate not in range(len(level.paths)): return state, "invalid_candidate" if candidate == level.truth: verdict = "supported" if len(state.candidates) == 1 else "insufficient" else: verdict = "contradicted" if candidate not in state.candidates else "not_yet_proven" return replace(state, submissions=state.submissions+(Submission(candidate, verdict),), outcome="won" if verdict == "supported" else "running"), verdict return state, "unknown" def forecast(state, level, wall_cost=WALL_COST): """Group possible signatures; never read level.truth.""" rows = [] for room in ROOMS: groups = {} for i in state.candidates: predicted = signature(level, room, level.paths[i], wall_cost) groups.setdefault(predicted, []).append(i) sizes = tuple(sorted(len(group) for group in groups.values())) rows.append({"room": room, "groups": len(groups), "worst_remaining": max(sizes, default=0), "sizes": sizes}) return tuple(rows) def evidence(state, level, candidate, wall_cost=WALL_COST): rows = [] for index, observation in enumerate(state.observations): predicted = signature(level, observation.room, level.paths[candidate], wall_cost) for beat, (guess, heard) in enumerate(zip(predicted, observation.sounds)): if guess != heard: rows.append({"observation": index, "room": observation.room, "beat": beat, "predicted": guess, "heard": heard, "distance": distances(level, observation.room, wall_cost)[level.paths[candidate][beat]]}) return tuple(rows) @dataclass(frozen=True) class Timeline: state: State history: tuple = () def step(timeline, level, action, wall_cost=WALL_COST): if action == "undo": if not timeline.history: return timeline, "no_undo" return Timeline(timeline.history[-1], timeline.history[:-1]), "undo" state, reason = apply(timeline.state, level, action, wall_cost) if state == timeline.state: return timeline, reason return Timeline(state, timeline.history+(timeline.state,)), reason BEAT_FRAMES = 18 class Playback: """Visual clock only. No audio output and no access to evidence state.""" def __init__(self): self.frame = None self.beat = 0 self.paused = False self.events = [] def start(self): self.frame = 0 self.beat = 0 def stop(self): self.frame = None def tick(self): if self.paused or self.frame is None: return if self.frame % BEAT_FRAMES == 0: self.beat = self.frame // BEAT_FRAMES self.events.append((self.frame, self.beat)) self.frame += 1 if self.frame >= BEAT_FRAMES * 3: self.frame = None class App: def __init__(self): self.reset() def reset(self): self.room = START_ROOM self.candidate = 0 self.gated = False self.message = "READY" self.timeline = Timeline(initial()) self.playback = Playback() @property def state(self): return self.timeline.state def control(self, pressed, held): if "R" in pressed: self.reset() self.gated = True return if self.gated: if not pressed and not held: self.gated = False return if len(pressed | held) > 1: self.gated = True self.message = "RELEASE ALL KEYS" return if len(pressed) != 1: return key = next(iter(pressed)) self.gated = True if key == "P": self.playback.paused = not self.playback.paused self.message = "PAUSED" if self.playback.paused else "RESUMED" return if self.playback.paused: return if key in ("LEFT", "RIGHT"): delta = 1 if key == "RIGHT" else -1 self.room = ROOMS[(ROOMS.index(self.room)+delta) % len(ROOMS)] self.message = "LISTENER "+self.room elif key in ("UP", "DOWN"): self.candidate = (self.candidate+(1 if key == "DOWN" else -1)) % 4 self.message = "PATH "+PATH_IDS[self.candidate] elif key == "Z": if self.playback.frame is not None: self.playback.stop() self.message = "STOPPED / SIGNATURE RETAINED" else: self.action("listen:"+self.room) elif key == "V" and self.state.observations: self.playback.start() self.message = "REPLAY / NO NEW OBSERVATION" elif key == "SPACE": self.action("submit:"+str(self.candidate)) elif key == "X": self.action("undo") def tick(self): self.playback.tick() def action(self, action): self.timeline, self.message = step(self.timeline, LEVEL, action) if self.message == "heard": self.playback.start() elif action == "undo" or action.startswith("submit:"): self.playback.stop() if action == "undo" and self.message == "undo": self.room = self.state.selected_room def draw(self, p): p.cls(1) p.text(10, 8, "FOOTSTEP LAB / STAGE 5", 10) p.text(10, 23, "FICTIONAL PUZZLE / VISUAL ONLY - NO AUDIO", 6) ds = distances(LEVEL, self.room) positions = {"A":(45,90), "B":(145,90), "C":(245,90), "D":(45,175), "E":(145,175), "F":(245,175)} for edge in EDGES: x,y = positions[edge[0]] u,v = positions[edge[1]] wall = edge in LEVEL.walls p.line(x, y, u, v, 9 if wall else 6) p.text((x+u)//2+4, (y+v)//2-8, str(WALL_COST if wall else 1), 9 if wall else 6) for room in ROOMS: x,y = positions[room] p.rect(x-30, y-23, 61, 46, 3 if room == self.room else 2) p.rectb(x-30, y-23, 61, 46, 10 if room == self.room else 5) p.text(x-25, y-16, room+" DIST "+str(ds[room]), 10) p.text(x-25, y, LEVEL.floors[ROOMS.index(room)].upper(), 7) if room == self.room: p.text(x-25, y+10, "LISTENER", 11) p.text(10, 213, "DOOR 1 / WALL "+str(WALL_COST)+" / SHORTEST SOUND COST", 6) for i,path in enumerate(LEVEL.paths): y = 58+i*35 p.rect(305, y, 242, 30, 3 if i == self.candidate else 2) p.text(312, y+5, PATH_IDS[i]+" "+" > ".join(path), 10 if i == self.candidate else 7) p.text(312, y+18, "REMAINS" if i in self.state.candidates else "EXCLUDED BY OBSERVATION", 11 if i in self.state.candidates else 13) p.text(10, 232, "DIST 0..1 STRONG 2 / 2..3 WEAK 1 / 4+ NONE 0", 13) p.rect(10, 253, 537, 50, 2) if self.state.observations: observation = self.state.observations[-1] p.text(16, 260, "OBS "+str(len(self.state.observations))+" / ROOM "+observation.room+" / COMPLETE THREE-STEP SIGNATURE", 7) for beat, sound in enumerate(observation.sounds): p.text(16+beat*175, 283, str(beat+1)+": "+sound_text(sound), 10 if beat == self.playback.beat else 7) else: p.text(16, 273, "Z: OBSERVE THE SAME THREE STEPS", 7) clock = "PAUSED" if self.playback.paused else "STOPPED" if self.playback.frame is None else "PLAYING" p.text(10, 315, "VISUAL CLOCK: "+clock+" / EVIDENCE DOES NOT CHANGE WITH TIME", 6) row = next(row for row in forecast(self.state, LEVEL) if row["room"] == self.room) p.text(305, 207, "PREDICT AT "+self.room+": "+str(row["groups"])+" GROUPS", 11) p.text(305, 221, "WORST LEFT "+str(row["worst_remaining"]), 11) mismatches = evidence(self.state, LEVEL, self.candidate) if mismatches: item = mismatches[0] p.text(10, 333, "CONTRADICTION AT "+item["room"]+" BEAT "+str(item["beat"]+1)+": EXPECT "+sound_text(item["predicted"])+" / HEARD "+sound_text(item["heard"]), 9) else: p.text(10, 333, "OUTCOME: "+self.state.outcome.upper()+" / NO RECORDED CONTRADICTION FOR SELECTED PATH", 11) p.text(10, 350, self.message.upper(), 10) p.text(10, 369, "LEFT/RIGHT LISTENER UP/DOWN PATH R RESET ESC QUIT", 7) p.text(10, 386, "Z OBSERVE/STOP V REPLAY P PAUSE SPACE SUBMIT X UNDO", 6) DEMO_EVENTS = {2: 'Z', 6: 'Z', 10: 'RIGHT', 14: 'Z', 72: 'DOWN', 76: 'DOWN', 80: 'SPACE', 84: 'X', 88: 'X', 92: 'RIGHT', 96: 'RIGHT', 100: 'RIGHT', 104: 'RIGHT', 108: 'Z', 164: 'SPACE'} KEYS = ('LEFT', 'RIGHT', 'R', 'UP', 'DOWN', 'Z', 'V', 'P', 'SPACE', 'X') CHECKPOINT_FRAME = 172 def main(args): from pathlib import Path import pyxel as p destination = Path(args.screenshot).resolve() if args.screenshot else None if destination: destination.parent.mkdir(parents=True, exist_ok=True) app = App() p.init(560, 408, title="Footstep Lab", fps=30, display_scale=1, headless=args.headless_smoke) key_ids = {key: getattr(p, "KEY_"+key) for key in KEYS} def update(): if args.headless_smoke and p.frame_count >= args.frames: p.quit() return if args.demo_checkpoint: key = DEMO_EVENTS.get(p.frame_count) pressed = {key} if key else set() app.control(pressed, pressed) else: app.control({k for k,v in key_ids.items() if p.btnp(v)}, {k for k,v in key_ids.items() if p.btn(v)}) app.tick() def draw(): app.draw(p) if destination and p.frame_count == args.frames-1: p.screenshot(str(destination), scale=1) p.run(update, draw) def parse_args(argv=None): # Reject unsupported combinations and unbounded runs before importing Pyxel. import argparse parser = argparse.ArgumentParser(description="Standalone visual footstep lesson") parser.add_argument("--headless-smoke", action="store_true") parser.add_argument("--demo-checkpoint", action="store_true") parser.add_argument("--frames", type=int) parser.add_argument("--screenshot") args = parser.parse_args(argv) if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke: parser.error("--demo-checkpoint, --frames and --screenshot require --headless-smoke") if args.frames is None: args.frames = CHECKPOINT_FRAME if args.demo_checkpoint else 12 if not 2 <= args.frames <= 900: parser.error("--frames must be between 2 and 900") if args.demo_checkpoint and args.frames < CHECKPOINT_FRAME: parser.error("--frames is too short for this checkpoint") if args.screenshot and not args.screenshot.lower().endswith(".png"): parser.error("--screenshot must end in .png") return args if __name__ == "__main__": main(parse_args())