# 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. """ひと息の花火・教材 4/5。原作の仕組みを段階化した小さな独立版。 実行: python 04_motion.py """ from dataclasses import dataclass from math import cos, pi, hypot FPS = 60 FUSE = 27 RADIUS = 22 @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 = [] 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): return False self.used = True self.lit.add(index) self.due[index] = self.frame + FUSE return True def step(self): 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}) def main(headless=False, screenshot=None): import pyxel world = World() selected = 0 def update(): nonlocal world, selected 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 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 4", 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) pyxel.text(8, 146, "LEFT/RIGHT: SELECT SPACE: FIRE", 6) pyxel.text(8, 158, "R: RESET ESC: QUIT", 6) pyxel.init(240, 178, title="One Breath - Stage 4", 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)