# MIT License # # Copyright (c) 2026 Tidepost 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. # # Exception: assets/glyphs.json contains rasterized glyph data derived from # Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt. # Pyxel is an external dependency under its own MIT license. """ひと息の花火・教材 5/5。原作の仕組みを段階化した小さな独立版。 実行: python 05_result.py """ from dataclasses import dataclass from math import cos, pi, hypot FPS = 60 FUSE = 27 RADIUS = 22 PASSAGE = 720 # 点火前に待てる上限。60 fpsで12秒。 @dataclass(frozen=True) class Spark: x: float y: float = 64 amplitude: float = 0 phase: float = 0 target: bool = False def pos(self, frame): return self.x + self.amplitude * cos(2 * pi * frame / 180 + self.phase), self.y def layout(): return [Spark(40, target=True), Spark(60), Spark(82, amplitude=6, phase=pi), Spark(104), Spark(124, target=True)] class World: def __init__(self): self.nodes = layout() self.frame = 0 self.lit = set() self.used = False self.due = {} self.burst = set() self.events = [] self.done = False self.last_event = 0 @property def won(self): return all(i in self.lit for i, node in enumerate(self.nodes) if node.target) def pos(self, index, frame=None): return self.nodes[index].pos(self.frame if frame is None else frame) def ignite(self, index): if self.used or not 0 <= index < len(self.nodes) or self.done or self.frame >= PASSAGE: return False self.used = True self.lit.add(index) self.due[index] = self.frame + FUSE return True def step(self): if self.done: return self.frame += 1 sources = sorted(i for i, due in self.due.items() if due == self.frame) # 同じ時刻に破裂する花火は、同じ変更前のlitで調べる。 fresh = set() edges = [] for source in sources: x, y = self.pos(source) for target in range(len(self.nodes)): if target in self.lit: continue xx, yy = self.pos(target) if hypot(x - xx, y - yy) <= RADIUS + 1e-9: fresh.add(target) edges.append((source, target)) for source in sources: self.burst.add(source) del self.due[source] for target in sorted(fresh): self.lit.add(target) self.due[target] = self.frame + FUSE if sources: self.events.append({"frame": self.frame, "sources": sources, "new": sorted(fresh), "edges": edges}) self.last_event = self.frame # 全ての予約が消えてから余韻を50フレーム見せる。 if self.used and not self.due and self.frame - self.last_event >= 50: self.done = True if not self.used and self.frame >= PASSAGE: self.done = True def main(headless=False, screenshot=None): import pyxel world = World() selected = 0 paused = False def update(): nonlocal world, selected, paused if headless and pyxel.frame_count >= 12: if screenshot: pyxel.screenshot(screenshot, scale=2) pyxel.quit() return if pyxel.btnp(pyxel.KEY_R): world = World() selected = 0 paused = False return if pyxel.btnp(pyxel.KEY_P): paused = not paused return if paused or world.done: return if not world.used: if pyxel.btnp(pyxel.KEY_LEFT): selected = (selected - 1) % len(world.nodes) if pyxel.btnp(pyxel.KEY_RIGHT): selected = (selected + 1) % len(world.nodes) if pyxel.btnp(pyxel.KEY_SPACE): world.ignite(selected) world.step() def draw(): pyxel.cls(1) pyxel.text(8, 8, "ONE BREATH / STEP 5", 10) pyxel.text(8, 20, f"FRAME {world.frame:03} START {selected + 1}", 7) for i, node in enumerate(world.nodes): x, y = world.pos(i) x += 30 y += 8 color = 10 if i in world.lit else 7 if i in world.burst: color = 14 if node.amplitude: pyxel.line(30 + node.x - node.amplitude, y, 30 + node.x + node.amplitude, y, 5) pyxel.circ(x, y, 3, color) pyxel.text(x - 1, y + 10, str(i + 1), 6) if node.target: pyxel.rectb(x - 6, y - 6, 13, 13, 11) if i == selected and not world.used: pyxel.circb(x, y, 8, 10) if i in world.due: pyxel.text(x - 3, y - 17, str(world.due[i] - world.frame), 10) if not world.used: xx, yy = world.pos(selected, world.frame + FUSE) pyxel.circb(xx + 30, yy + 8, RADIUS, 10) pyxel.text(8, 108, f"LIT {len(world.lit)}/{len(world.nodes)}", 7) pyxel.text(8, 120, "GREEN BOXES = TARGETS", 11) if world.done: pyxel.rect(19, 42, 202, 55, 0) pyxel.text(58, 51, "CLEAR!" if world.won else "TRY ANOTHER TIME", 10) pyxel.text(39, 66, "R: SAME LAYOUT / PHASE", 7) if paused: pyxel.rect(65, 45, 110, 24, 0) pyxel.text(84, 54, "PAUSED / P", 10) pyxel.text(8, 146, "LEFT/RIGHT: SELECT SPACE: FIRE", 6) pyxel.text(8, 158, "R: RESET P: PAUSE ESC: QUIT", 6) pyxel.init(240, 178, title="One Breath - Stage 5", fps=FPS, 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 frames then quit, not a playtest") parser.add_argument("--screenshot", help="PNG path used with --headless-smoke") args = parser.parse_args() main(headless=args.headless_smoke, screenshot=args.screenshot)