# 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。架空の経路パズル。音声出力は省略。""" 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 = 1 @dataclass(frozen=True) class Level: floors: tuple walls: tuple paths: tuple truth: int LEVEL = Level(("wood", "tile", "stone", "wood", "tile", "stone"), (), ("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 class App: def __init__(self): self.reset() def reset(self): self.room = START_ROOM self.candidate = 0 self.gated = False self.message = "READY" 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 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 def tick(self): pass def draw(self, p): p.cls(1) p.text(10, 8, "FOOTSTEP LAB / STAGE 1", 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) p.text(305, 75, "1. PICK A LISTENER", 10) p.text(305, 101, "2. SUM EDGE COSTS", 7) p.text(305, 127, "3. KEEP THE MINIMUM", 7) p.text(10, 249, "ALL EDGES COST 1 IN THIS FIRST LESSON", 7) p.text(10, 277, "WAITING DOES NOT MOVE THE LISTENER", 7) p.text(10, 350, self.message.upper(), 10) p.text(10, 369, "LEFT/RIGHT LISTENER R RESET ESC QUIT", 7) p.text(10, 386, "DISTANCE IS A GRAPH COST, NOT PIXELS", 6) DEMO_EVENTS = {2: 'RIGHT'} KEYS = ('LEFT', 'RIGHT', 'R') CHECKPOINT_FRAME = 16 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())