パズルLESSON 05タイミング × 連鎖原作010

ひと息の花火の作り方

ひとつの点火が、次の花火へ。
動いている花火に、いつ・どこから火をつける? 「未来に起きる出来事」を予約して処理し、時間差で次々に燃え移る連鎖を作ります。

完成版の遊び方点火できるのは1回だけ。動く花火の位置を見て、どこから・いつ火をつけるかを選びます。

ひと息の花火の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の画面です。この講座では、仕組みがわかりやすいように小さくした「教材版」を、5つのステップで作ります。
PLAY / 試験公開

完成サンプルをブラウザーで遊ぶ

この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。

完成版の主な操作:左右・上下で起点を選ぶ、Spaceで点火、Pで一時停止、Rでやり直し、Nで次の配置、Mで左右反転、Qで終了

ボタンを押すと、ゲームの読み込みが始まります。

画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。

うまく表示されないとき

ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。

同じPyxelアプリをダウンロード(.pyxapp) / 別のタブで開く

未来のイベントを予約する。

いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。

1. 番号と状態を分ける
2. 今の時刻から、出来事が起きる時刻を予約する
3. 距離の判定と集合を使って、同時に起きる出来事を扱う
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・関数の基本
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
← →:起点 / Space:点火 / R:再開 / P:停止
難易度の目安
初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_select.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。

教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。

STEP 01 / 05

起点を選んで一度だけ点火

花火の番号と、状態を分けて持つ。

花火を Spark のリストに並べ、選んでいる花火の番号を selected に保存します。左右キーの btnp で番号を順番に切り替え、Space を押すと点火します。lit は点火した花火の番号の集合、used は最初の点火をもう使ったかを記録する真偽値(True / False)です。この段階では、選んだ花火が黄色になるだけで、燃え移りはまだありません。

下のコードは 01_select.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

01_select.py / 注目する部分
self.used = True
self.lit.add(index)
return True

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 01_select.py
macOS / Linux
.venv/bin/python 01_select.py
画面で見ること左右キーで、黄色い選択の輪が動きます。Space を1回押すと1つだけ黄色になり、2回目を押しても増えません。R キーで、選択も点火も最初に戻ります。
  • 左右で選べる
  • Spaceは1回だけ
  • Rで初期化
つまずきやすいところlit に見た目の色を保存すると、ルールの判定と絵を描く処理が混ざってしまいます。状態には番号の集合を持たせ、色に変えるのは描くときだけにします。
少し変えてみよう

selected の最初の値を0から2に変えて、画面上の番号3から始まる理由を説明してみましょう。

01_select.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(131行)
01_select.py / 全文
# 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.

"""ひと息の花火・教材 1/5。原作の仕組みを段階化した小さな独立版。
実行: python 01_select.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),
            Spark(104), Spark(124, target=True)]


class World:
    def __init__(self):
        self.nodes = layout()
        self.frame = 0
        self.lit = set()
        self.used = False

    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)
        return True

    def step(self):
        self.frame += 1


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 1", 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
            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)
        pyxel.text(8, 112, "ONE SHOT. R TO TRY AGAIN", 7)
        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 1", 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)
STEP 02 / 05

27フレーム後に破裂する

今の時刻から、出来事が起きる時刻を予約する。

この教材は60fps(1秒に60回)で更新します。点火したときの frame(今のフレーム番号)に FUSE=27 を足して、破裂する予定のフレームを due に保存します。step はフレームを1つ進め、予定の時刻になった花火だけを burst(破裂)に移します。27フレームは、60fpsでは0.45秒です。まだ近くの花火には燃え移りません。

下のコードは 02_fuse.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

02_fuse.py / 注目する部分
self.due[index] = self.frame + FUSE

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 02_fuse.py
macOS / Linux
.venv/bin/python 02_fuse.py
画面で見ること点火した花火の上で数字が減っていき、27フレーム後にピンク色に変わります。となりの花火は白いままです。
  • 点火時に予約される
  • 27回目に破裂する
  • まだ連鎖しない
つまずきやすいところ毎フレーム due を frame+27 に書き換えると、予定の時刻がどんどん先に逃げていき、いつまでも破裂しません。予定を決めるのは、点火した瞬間の1回だけです。
少し変えてみよう

まず FUSE=27 のまま、26回目と27回目の違いをテストで確かめましょう。そのあと FUSE を60に変え、59回目と60回目を比べて、ちょうど1秒待つようにしてみましょう。

02_fuse.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(142行)
02_fuse.py / 全文
# 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.

"""ひと息の花火・教材 2/5。原作の仕組みを段階化した小さな独立版。
実行: python 02_fuse.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),
            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()

    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)
        for source in sources:
            self.burst.add(source)
            del self.due[source]


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 2", 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
            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)
        pyxel.text(8, 112, f"{FUSE} FRAMES TO BURST", 7)
        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 2", 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)
STEP 03 / 05

近い花火へ連鎖させる

距離の判定と集合を使って、同時に起きる出来事を扱う。

破裂した花火から距離22以内にある、まだ点火していない花火を fresh に集めます。そのフレームで燃え移る相手を全部調べ終わってから、まとめて lit と due を更新します。同じ相手に2つの花火から燃え移っても、集合なので予約は1つだけです。新しく火がついた花火も、さらに27フレーム待ってから破裂します。

下のコードは 03_chain.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

03_chain.py / 注目する部分
if hypot(x - xx, y - yy) <= RADIUS + 1e-9:
    fresh.add(target)
    edges.append((source, target))

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_chain.py
macOS / Linux
.venv/bin/python 03_chain.py
画面で見ること1番から点火すると、右へ順番に燃え移り、LIT が 5/5 になります。1回の更新で全部が破裂するのではなく、1段ずつ伝わっていきます。
  • 止まっている5個すべてに燃え移る
  • 各段で27フレーム待つ
  • 同時に燃え移っても予約は1つ
つまずきやすいところ調べている途中で lit を更新すると、調べる順番によって、同時に起きる出来事の結果が変わってしまうことがあります。「燃え移る候補を集める」と「まとめて確定する」を分けます。
少し変えてみよう

となりとの距離を22と22.01にして、ちょうど22のときに燃え移るか(境目を含むか)を比べてみましょう。FUSE だけを変えたら、燃え移る範囲と、燃え移る速さのどちらが変わるでしょうか。

03_chain.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(162行)
03_chain.py / 全文
# 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.

"""ひと息の花火・教材 3/5。原作の仕組みを段階化した小さな独立版。
実行: python 03_chain.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),
            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 3", 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
            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)
        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 3", 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)
STEP 04 / 05

動く花火のタイミングを読む

判定するその瞬間の位置を使う。

まん中の3番の花火だけを、cos を使って左右に往復させます。燃え移るかの判定には、破裂する瞬間の位置 pos を使い、点火したときの距離は使いません。黄色い大きな輪は、選んだ花火が27フレーム後にいる位置と、半径22の範囲を予告しています。破裂したあとに近づいてきた花火に燃え移ることはありません。

下のコードは 04_motion.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

04_motion.py / 注目する部分
return self.x + self.amplitude * cos(2 * pi * frame / 180 + self.phase), self.y

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 04_motion.py
macOS / Linux
.venv/bin/python 04_motion.py
画面で見ること3番が左右に動きます。1番を FRAME 0 あたりで点火した場合と、R で戻して FRAME 150 あたりで点火した場合を比べると、後のほうは両端の目標まで届きます。
  • 3番だけ往復する
  • Rで動きの位置(位相)も元に戻る
  • 点火の時刻で結果が変わる
つまずきやすいところ毎フレーム、過去に破裂したすべての位置で判定すると、あとから輪に入ってきた花火にも燃え移ってしまいます。燃え移る元になる sources は、予定がちょうど今の花火だけです。
少し変えてみよう

amplitude(揺れの幅)を0にして止まっている版に戻し、点火する時刻による違いがなくなることを確かめましょう。次に揺れの幅だけを変えて、同じ FRAME 150 で比べてみましょう。

04_motion.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(168行)
04_motion.py / 全文
# 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)
STEP 05 / 05

結果と再挑戦を作る

成功の条件と、終わる条件を分ける。

両端の緑の枠が目標です。目標がすべて lit に入れば won(成功)ですが、予約が残っている間は演出が続きます。最後の破裂から50フレーム後に done になり、更新を止めます。点火しなかった場合は、720フレームで終わります。P キーで一時停止し、R キーで同じ配置・同じ動きの位置に戻れます。

下のコードは 05_result.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

05_result.py / 注目する部分
if self.used and not self.due and self.frame - self.last_event >= 50:
    self.done = True

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 05_result.py
macOS / Linux
.venv/bin/python 05_result.py
画面で見ること1番を選んだまま FRAME 150 あたりで Space を押すと、両端まで届いて CLEAR になります。早く点火しすぎて届かなければ TRY ANOTHER TIME(別のタイミングで試そう)です。結果が出ている間は時間が止まり、R キーで再開できます。
  • 成功と失敗の両方が終わる
  • P中に時間が進まない
  • Rで同じ条件へ戻る
つまずきやすいところwon と done を同じ変数にすると、失敗したときに連鎖が終わらなくなったり、最後の演出が途中で止まったりします。「成功したか」と「進行を止めるか」は分けて持ちます。
少し変えてみよう

最後の余韻の50フレームを、0と90に変えて見比べてみましょう。成功か失敗かは変わらず、結果を見せるタイミングだけが変わることを説明してみましょう。

05_result.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(198行)
05_result.py / 全文
# 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)
AFTER STEP 05

仕組みがわかったら、元の完成版を読んでみよう。

元の完成版には3つの配置があります。方向キーで起点を選び、Space で1回だけ点火します。27フレーム後に、半径22以内の花火に燃え移ります。R で同じ条件、N で次の配置に進みます。結果画面では、連鎖を1段ずつ再生できます。

ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。

比べる点5ステップの教材元の完成版
配置B の配置1つだけ3つの配置・左右反転や止まった状態との比較
燃え移り方27フレーム・半径22・まとめて確定同じルール+細かい出来事の記録
表示英語・図形・音なし日本語・予告・結果の1段ずつ再生

完成版のコードで、次に読むところ

  1. model.py の World.step で、sources と fresh を分けて同時に処理しているところを確かめましょう。
  2. layout で元のゲームの A・B・C の配置と target を読み、見た目ではなく「目標の印」で成功を決めている点を比べましょう。
  3. Spark.pos と World.pos で、縦方向の往復・止まった版・左右反転の作り方を読みましょう。
  4. app.py で、出来事の記録を結果の再生表示に使っているところを読みましょう。
自分のゲームに変えるなら

最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。

教材をダウンロードする

無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について

検証について

どこまで動作を確認したかを見る

2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版は、ルールのテストで確認しています。

この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る