# MIT License # # Copyright (c) 2026 Pyxel 100 contributors # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # # The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL. # See assets/NOTO-LICENSE.txt. # 教材はNoto字形を同梱せず、ASCIIと図形のみを使います。 """静かな足音・教材 5/5。原作Aの盤面を段階的に作る独立ファイル。""" from dataclasses import dataclass, replace, asdict from collections import deque DIRS = ((0, -1), (0, 1), (-1, 0), (1, 0)) # 上・下・左・右の同距離優先順 ACTIONS = {'U': (0, -1), 'D': (0, 1), 'L': (-1, 0), 'R': (1, 0)} @dataclass(frozen=True) class Level: title: str width: int height: int walls: tuple start: tuple book: tuple exit: tuple guard: tuple facing: tuple patrol: tuple metal: tuple boards: tuple LEVEL = Level( '棚の角', 9, 7, ((4, 2), (4, 3), (4, 4)), (1, 3), (6, 4), (1, 5), (6, 2), (0, 1), ((6, 2), (6, 4)), ((3, 2),), ((3, 1), (4, 1)), ) @dataclass(frozen=True) class Memory: target: tuple | None = None source: str = 'patrol' look: int = 0 patrol_done: int = 0 @dataclass(frozen=True) class Observation: visible: tuple | None heard: tuple | None @dataclass(frozen=True) class Intent: target: tuple | None rotate: bool memory: Memory @dataclass(frozen=True) class State: player: tuple guard: tuple facing: tuple memory: Memory book: bool = False turns: int = 0 status: str = 'play' reason: str = 'ready' sound: tuple | None = None radius: int = 0 heard_path: tuple = () observed: Observation = Observation(None, None) def initial(): return State(LEVEL.start, LEVEL.guard, LEVEL.facing, Memory()) def floor(b, p): return 0 < p[0] < b.width - 1 and 0 < p[1] < b.height - 1 and (p not in b.walls) def neighbors(b, p): return tuple(((p[0] + dx, p[1] + dy) for dx, dy in DIRS if floor(b, (p[0] + dx, p[1] + dy)))) def path(b, a, z): """床だけを幅優先探索。戻り値には出発点と終点も含める。""" if not floor(b, a) or not floor(b, z): return () q = deque([a]) prev = {a: None} while q: p = q.popleft() if p == z: out = [] while p is not None: out.append(p) p = prev[p] return tuple(reversed(out)) for n in neighbors(b, p): if n not in prev: prev[n] = p q.append(n) return () def crosses_box(a, z, w): """線分a→zと棚の閉じた四角の交差。角に触れても遮る。""" lo, hi = (0.0, 1.0) for av, dv, c in zip(a, (z[0] - a[0], z[1] - a[1]), w): if dv == 0: if not c - 0.5 <= av <= c + 0.5: return False else: u, v = ((c - 0.5 - av) / dv, (c + 0.5 - av) / dv) lo = max(lo, min(u, v)) hi = min(hi, max(u, v)) if lo > hi: return False return True def visible(b, g, f, p): """前方4マス・45度以内。音の経路とは違い、直線を棚で遮る。""" if p == g: return True dx, dy = (p[0] - g[0], p[1] - g[1]) front = dx * f[0] + dy * f[1] side = dx * f[1] - dy * f[0] return 1 <= front <= 4 and abs(side) <= front and (not any((crosses_box(g, p, w) for w in b.walls))) def vision(b, g, f): return tuple(((x, y) for y in range(1, b.height - 1) for x in range(1, b.width - 1) if floor(b, (x, y)) and visible(b, g, f, (x, y)))) def sound_radius(b, p, metal_radius=6): return metal_radius if p in b.metal else 3 if p in b.boards else 0 def observe(b, s, player, sound, radius): """この手の観測。s.guardとs.facingは移動する前の値。""" route = path(b, s.guard, sound) if sound is not None and radius else () heard = sound if route and len(route) - 1 <= radius else None return Observation(player if visible(b, s.guard, s.facing, player) else None, heard) def policy(o, m, g, b): # 新しい目 → 新しい耳 → 記憶 → 巡回。隠れた現在位置は引数にない。 if o.visible is not None: return Intent(o.visible, False, Memory(o.visible, 'eye', 2, m.patrol_done)) if o.heard is not None: return Intent(o.heard, False, Memory(o.heard, 'ear', 2, m.patrol_done)) if m.target is not None: if g != m.target: return Intent(m.target, False, m) if m.look > 0: return Intent(None, True, replace(m, look=m.look - 1)) m = Memory(patrol_done=m.patrol_done) done = m.patrol_done nxt = (done + 1) % len(b.patrol) if g == b.patrol[nxt]: done = nxt nxt = (done + 1) % len(b.patrol) return Intent(b.patrol[nxt], False, Memory(patrol_done=done)) def step(b, s, action, metal_radius=6): if s.status != 'play': return s if action not in (*ACTIONS, 'W', 'S'): return s p = s.player if action in ACTIONS: dx, dy = ACTIONS[action] p = (p[0] + dx, p[1] + dy) if not floor(b, p): return s radius = sound_radius(b, p, metal_radius) + (2 if action == 'S' else 0) if action != 'W' else 0 sound = p if radius else None book = s.book or p == b.book if p == s.guard: return replace(s, player=p, book=book, turns=s.turns + 1, status='caught', reason='contact', sound=sound, radius=radius) # この後に見張りを動かす。移動後の再観測はしない。 o = observe(b, s, p, sound, radius) it = policy(o, s.memory, s.guard, b) g = s.guard f = s.facing if it.rotate: f = (-f[1], f[0]) elif it.target is not None: route = path(b, g, it.target) if len(route) > 1: f = (route[1][0] - g[0], route[1][1] - g[1]) g = route[1] mem = it.memory if mem.target is None and g == b.patrol[(mem.patrol_done + 1) % len(b.patrol)]: mem = replace(mem, patrol_done=(mem.patrol_done + 1) % len(b.patrol)) status = 'caught' if p == g else 'won' if book and p == b.exit else 'play' reason = 'contact' if status == 'caught' else 'complete' if status == 'won' else 'eye' if o.visible else 'ear' if o.heard else 'search' if it.memory.target else 'patrol' return State(p, g, f, mem, book, s.turns + 1, status, reason, sound, radius, path(b, s.guard, sound) if o.heard else (), o) def preview(b, s): """新しい観測がないと仮定した次歩。確定した未来ではない。""" it = policy(Observation(None, None), s.memory, s.guard, b) route = path(b, s.guard, it.target) if it.target is not None else () return route[1] if len(route) > 1 else s.guard @dataclass(frozen=True) class Timeline: state: State past: tuple = () def apply(b, t, a, metal_radius=6): n = step(b, t.state, a, metal_radius) return t if n is t.state else Timeline(n, t.past + (t.state,)) def undo(t): return Timeline(t.past[-1], t.past[:-1]) if t.past else t class Game: """Pyxel抜きでも試せる操作窓口。描画は状態を変更しない。""" def __init__(self): self.reset() @property def state(self): return self.timeline.state def reset(self): self.timeline = Timeline(initial()) def act(self, action): self.timeline = apply(LEVEL, self.timeline, action) def back(self): self.timeline = undo(self.timeline) KEY_ACTIONS = {'UP': 'U', 'DOWN': 'D', 'LEFT': 'L', 'RIGHT': 'R', 'SPACE': 'W', 'Z': 'S'} class Controller: """1押下1手。同時押しを棄却し、リセット後は全キーを離す。""" def __init__(self): self.game = Game() self.release_gate = False def tick(self, pressed=(), held=()): pressed, held = set(pressed), set(held) controls = set(KEY_ACTIONS) | {'R', 'X'} if self.release_gate: if not (held & controls): self.release_gate = False return if 'R' in pressed: self.game.reset() self.release_gate = True return if 'X' in pressed: self.game.back() return chosen = [a for key, a in KEY_ACTIONS.items() if key in pressed] held_actions = held & set(KEY_ACTIONS) if len(chosen) == 1 and len(held_actions) == 1: self.game.act(chosen[0]) def main(headless=False, screenshot=None, actions='', frames=12, report=None): import json from pathlib import Path # Pyxel may change cwd to this script's directory at init. Resolve user paths first. screenshot = str(Path(screenshot).resolve()) if screenshot else None report = str(Path(report).resolve()) if report else None import pyxel controller = Controller() events = [] tick = 0 width, height, cell, ox, oy = 320, 302, 27, 38, 39 keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')} reverse_keys = {value: key for key, value in KEY_ACTIONS.items()} reverse_keys.update({'K': 'R', 'X': 'X'}) def update(): nonlocal tick tick += 1 if headless: if tick <= len(actions): # QA専用の合成押下。OSや人が押したキーではない。 key = reverse_keys[actions[tick - 1]] controller.tick((), ()) controller.tick((key,), (key,)) events.append({'tick': tick, 'action': actions[tick - 1], 'state': asdict(controller.game.state)}) else: controller.tick([k for k, v in keys.items() if pyxel.btnp(v)], [k for k, v in keys.items() if pyxel.btn(v)]) def center(p): return ox + p[0] * cell + cell // 2, oy + p[1] * cell + cell // 2 def draw(): s, b = controller.game.state, LEVEL pyxel.cls(1) pyxel.text(8, 8, 'QUIET FOOTSTEPS / STEP 5', 10) pyxel.text(8, 20, f'TURN {s.turns:03} BOOK {int(s.book)} {s.status.upper()}', 7) cone = set(vision(b, s.guard, s.facing)) for y in range(b.height): for x in range(b.width): p = (x, y) cx, cy = center(p) xx, yy = cx - cell // 2, cy - cell // 2 if not floor(b, p): pyxel.rect(xx, yy, cell - 1, cell - 1, 5) continue pyxel.rect(xx, yy, cell - 1, cell - 1, 2 if p in cone else 1) pyxel.rectb(xx, yy, cell - 1, cell - 1, 13) if p in b.metal: pyxel.rect(xx + 5, yy + 5, 16, 16, 13) pyxel.text(xx + 9, yy + 9, 'M', 7) elif p in b.boards: for offset in (7, 13, 19): pyxel.line(xx + 3, yy + offset, xx + 22, yy + offset, 4) if p in s.heard_path: pyxel.circb(cx, cy, 3, 10) if p == b.exit: pyxel.rectb(xx + 5, yy + 4, 16, 19, 11) pyxel.text(cx - 2, cy - 2, 'E', 11) if p == b.book and not s.book: pyxel.rect(xx + 8, yy + 7, 10, 13, 10) if s.memory.target is not None: x, y = center(s.memory.target) pyxel.circb(x, y, 11, 10 if s.memory.source == 'ear' else 8) nxt = preview(b, s) if nxt != s.guard: x, y = center(s.guard) xx, yy = center(nxt) pyxel.line(x, y, xx, yy, 6) pyxel.circb(xx, yy, 4, 6) x, y = center(s.guard) pyxel.rect(x - 5, y - 5, 11, 11, 8) dx, dy = s.facing pyxel.line(x, y, x + dx * 10, y + dy * 10, 7) x, y = center(s.player) pyxel.circ(x, y, 5, 12) if s.book: pyxel.rect(x + 3, y - 5, 4, 5, 10) pyxel.text(8, 232, 'STATE: ' + s.reason.upper(), 10 if s.status == 'won' else 7) pyxel.text(8, 244, f'SOUND {s.radius} CLOTH 0 / BOARD 3 / METAL 6', 6) pyxel.text(8, 256, f'MEM {s.memory.source.upper()} LOOK {s.memory.look} PATROL {s.memory.patrol_done}', 6) pyxel.text(8, 270, 'ARROWS: STEP SPACE: WAIT Z: STOMP', 7) pyxel.text(8, 282, 'R: RESET X: UNDO ESC: QUIT SILENT RENDER', 6) if headless and tick >= frames: if screenshot: pyxel.screenshot(screenshot, scale=2) if report: Path(report).write_text(json.dumps({ 'label': 'Synthetic input; actual Pyxel headless rendering; no audio', 'frames': tick, 'actions': actions, 'events': events, 'final_state': asdict(s), }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') pyxel.quit() pyxel.init(width, height, title='Quiet Footsteps - Stage 5', fps=30, headless=headless) pyxel.run(update, draw) if __name__ == '__main__': import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless-smoke', action='store_true', help='12 rendered frames, then quit') parser.add_argument('--screenshot', help='PNG file path (headless only)') parser.add_argument('--actions', default='', help='Synthetic UDLRWSXK actions during 12-frame smoke') parser.add_argument('--headless-replay', help='Longer synthetic replay, UDLRWSXK; K resets') parser.add_argument('--report', help='UTF-8 JSON report (headless only)') args = parser.parse_args() if args.headless_smoke and args.headless_replay is not None: parser.error('Choose smoke or replay, not both') actions = args.actions if args.headless_replay is None else args.headless_replay if any(a not in 'UDLRWKSX' for a in actions): parser.error('Supported actions in this stage: UDLRWKSX') headless = args.headless_smoke or args.headless_replay is not None if not headless and (args.actions or args.screenshot or args.report): parser.error('actions/screenshot/report require a headless mode') if args.headless_smoke and len(actions) > 12: parser.error('Smoke accepts at most 12 actions; use --headless-replay for longer routes') frames = 12 if args.headless_replay is None else max(12, len(actions) + 2) main(headless, args.screenshot, actions, frames, args.report)