完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:左右・上下で起点を選ぶ、Spaceで点火、Pで一時停止、Rでやり直し、Nで次の配置、Mで左右反転、Qで終了
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
未来のイベントを予約する。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。
- 必要な環境
- Python 3.11以上 / Pyxel 2.9.9
- 知っておくとよいこと
- 変数・if・関数の基本
- 教材に入っているもの
- ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
- 完成した教材の操作
- ← →:起点 / Space:点火 / R:再開 / P:停止
- 難易度の目安
- 初級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
起点を選んで一度だけ点火
花火の番号と、状態を分けて持つ。
花火を Spark のリストに並べ、選んでいる花火の番号を selected に保存します。左右キーの btnp で番号を順番に切り替え、Space を押すと点火します。lit は点火した花火の番号の集合、used は最初の点火をもう使ったかを記録する真偽値(True / False)です。この段階では、選んだ花火が黄色になるだけで、燃え移りはまだありません。
下のコードは 01_select.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
self.used = True
self.lit.add(index)
return True実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_select.py.venv/bin/python 01_select.py- 左右で選べる
- Spaceは1回だけ
- Rで初期化
lit に見た目の色を保存すると、ルールの判定と絵を描く処理が混ざってしまいます。状態には番号の集合を持たせ、色に変えるのは描くときだけにします。selected の最初の値を0から2に変えて、画面上の番号3から始まる理由を説明してみましょう。
このステップのコード全文(131行)
# 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)27フレーム後に破裂する
今の時刻から、出来事が起きる時刻を予約する。
この教材は60fps(1秒に60回)で更新します。点火したときの frame(今のフレーム番号)に FUSE=27 を足して、破裂する予定のフレームを due に保存します。step はフレームを1つ進め、予定の時刻になった花火だけを burst(破裂)に移します。27フレームは、60fpsでは0.45秒です。まだ近くの花火には燃え移りません。
下のコードは 02_fuse.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
self.due[index] = self.frame + FUSE実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_fuse.py.venv/bin/python 02_fuse.py- 点火時に予約される
- 27回目に破裂する
- まだ連鎖しない
due を frame+27 に書き換えると、予定の時刻がどんどん先に逃げていき、いつまでも破裂しません。予定を決めるのは、点火した瞬間の1回だけです。まず FUSE=27 のまま、26回目と27回目の違いをテストで確かめましょう。そのあと FUSE を60に変え、59回目と60回目を比べて、ちょうど1秒待つようにしてみましょう。
このステップのコード全文(142行)
# 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)近い花火へ連鎖させる
距離の判定と集合を使って、同時に起きる出来事を扱う。
破裂した花火から距離22以内にある、まだ点火していない花火を fresh に集めます。そのフレームで燃え移る相手を全部調べ終わってから、まとめて lit と due を更新します。同じ相手に2つの花火から燃え移っても、集合なので予約は1つだけです。新しく火がついた花火も、さらに27フレーム待ってから破裂します。
下のコードは 03_chain.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if hypot(x - xx, y - yy) <= RADIUS + 1e-9:
fresh.add(target)
edges.append((source, target))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_chain.py.venv/bin/python 03_chain.py- 止まっている5個すべてに燃え移る
- 各段で27フレーム待つ
- 同時に燃え移っても予約は1つ
lit を更新すると、調べる順番によって、同時に起きる出来事の結果が変わってしまうことがあります。「燃え移る候補を集める」と「まとめて確定する」を分けます。となりとの距離を22と22.01にして、ちょうど22のときに燃え移るか(境目を含むか)を比べてみましょう。FUSE だけを変えたら、燃え移る範囲と、燃え移る速さのどちらが変わるでしょうか。
このステップのコード全文(162行)
# 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)動く花火のタイミングを読む
判定するその瞬間の位置を使う。
まん中の3番の花火だけを、cos を使って左右に往復させます。燃え移るかの判定には、破裂する瞬間の位置 pos を使い、点火したときの距離は使いません。黄色い大きな輪は、選んだ花火が27フレーム後にいる位置と、半径22の範囲を予告しています。破裂したあとに近づいてきた花火に燃え移ることはありません。
下のコードは 04_motion.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
return self.x + self.amplitude * cos(2 * pi * frame / 180 + self.phase), self.y実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_motion.py.venv/bin/python 04_motion.py- 3番だけ往復する
- Rで動きの位置(位相)も元に戻る
- 点火の時刻で結果が変わる
sources は、予定がちょうど今の花火だけです。amplitude(揺れの幅)を0にして止まっている版に戻し、点火する時刻による違いがなくなることを確かめましょう。次に揺れの幅だけを変えて、同じ FRAME 150 で比べてみましょう。
このステップのコード全文(168行)
# 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)結果と再挑戦を作る
成功の条件と、終わる条件を分ける。
両端の緑の枠が目標です。目標がすべて lit に入れば won(成功)ですが、予約が残っている間は演出が続きます。最後の破裂から50フレーム後に done になり、更新を止めます。点火しなかった場合は、720フレームで終わります。P キーで一時停止し、R キーで同じ配置・同じ動きの位置に戻れます。
下のコードは 05_result.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
if self.used and not self.due and self.frame - self.last_event >= 50:
self.done = True実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_result.py.venv/bin/python 05_result.py- 成功と失敗の両方が終わる
- P中に時間が進まない
- Rで同じ条件へ戻る
won と done を同じ変数にすると、失敗したときに連鎖が終わらなくなったり、最後の演出が途中で止まったりします。「成功したか」と「進行を止めるか」は分けて持ちます。最後の余韻の50フレームを、0と90に変えて見比べてみましょう。成功か失敗かは変わらず、結果を見せるタイミングだけが変わることを説明してみましょう。
このステップのコード全文(198行)
# 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)仕組みがわかったら、元の完成版を読んでみよう。
元の完成版には3つの配置があります。方向キーで起点を選び、Space で1回だけ点火します。27フレーム後に、半径22以内の花火に燃え移ります。R で同じ条件、N で次の配置に進みます。結果画面では、連鎖を1段ずつ再生できます。
ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。
| 比べる点 | 5ステップの教材 | 元の完成版 |
|---|---|---|
| 配置 | B の配置1つだけ | 3つの配置・左右反転や止まった状態との比較 |
| 燃え移り方 | 27フレーム・半径22・まとめて確定 | 同じルール+細かい出来事の記録 |
| 表示 | 英語・図形・音なし | 日本語・予告・結果の1段ずつ再生 |
完成版のコードで、次に読むところ
model.pyのWorld.stepで、sourcesとfreshを分けて同時に処理しているところを確かめましょう。layoutで元のゲームの A・B・C の配置とtargetを読み、見た目ではなく「目標の印」で成功を決めている点を比べましょう。Spark.posとWorld.posで、縦方向の往復・止まった版・左右反転の作り方を読みましょう。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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
