パズルLESSON 12照準 × 反射パズル原作011

反射便の作り方

一度だけはね返して、届けよう。
まっすぐ飛ぶ動きから、最初にぶつかるものの見つけ方、壁の向き(法線)を使ったはね返りまで。小さな郵便の弾道で、ベクトル(向きと大きさを持つ量)を学びます。

完成版の遊び方角度を決めて封筒を飛ばし、鏡で一度だけはね返らせて、塀の向こうの郵便受けに届けます。

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

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

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

完成版の主な操作:左右で狙う角度、上下で細かく調整、Spaceで発射・回収、Zで前の角度、Rでやり直し、Pで一時停止、Nで次の便

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

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

うまく表示されないとき

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

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

ぶつかる順番と、残りの時間を扱う。

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

1. 押した瞬間・押し続け・確定した状態を分ける
2. 1秒あたりの速度に秒を掛けて、動く量を求める
3. 0〜1の範囲で、ぶつかる時刻を比べる
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Pythonの変数、if・for、関数、タプル・リスト、クラスの self が読めること(dataclass の役割は LESSON.md で補足) / 画面の XY 座標、マイナスの数、小数、四則演算、平方根を使う。px/秒 と 秒 の単位を区別する / 三角関数・内積・区間・二次方程式を扱う、中級寄りの教材です。数式の具体例は示しますが、Python がはじめての人の最初の1本には向きません。
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
← →:狙う角度 / ↑ ↓:0.25度ずつ調整 / Space:1回発射 / R:やり直し / 最後の版のP:一時停止
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_aim.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

照準を1回だけ確定する

押した瞬間・押し続け・確定した状態を分ける。

左右キーは1回の描画につき2度、上下キーは0.25度ずつ、狙う角度を動かします。角度は「度」で保存し、矢印を描くときだけ math.radians でラジアン(計算用の角度の単位)に直します。Space を押した瞬間に、今の角度を shot_angle にうつして、飛んでいる封筒 flight を作ります。flight がある間は、2回目の発射も角度の変更も受け付けません。この段階の封筒は動かず、保存した向きを確かめる練習です。

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

01_aim.py / 注目する部分
if self.flight is not None:
    return False
self.shot_angle = self.angle
self.flight = Flight(self.level.start, status="aimed")

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_aim.py
macOS / Linux
.venv/bin/python 01_aim.py
画面で見ること最初は ANGLE -45.00 です。上キーで -50.75 に合わせて Space を押すと LOCKED(固定)になり、矢印の向きが固定されます。もう一度 Space を押しても変わりません。R キーで -45.00 の、狙っている状態に戻ります。
  • 角度の表示と矢印の向きが連動する
  • Space で一度だけ確定する
  • RとSpaceを同時に押しても、もう一度発射しない
つまずきやすいところSpace を btn で調べると、押しているすべてのフレームが「発射して」という合図になってしまいます。押した瞬間は btnp、角度の長押しは btn を使い、さらに flight があるかどうかでも、2回発射されるのを防ぎます。
少し変えてみよう

最初の角度を0にすると右向き、-90にすると上向きになることを確かめましょう。画面の Y 座標は下に行くほど大きくなるので、上向きがマイナスになる理由も説明してみましょう。

01_aim.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(238行)
01_aim.py / 全文
# 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.
# This teaching edition contains no Noto-derived glyph data.

"""反射便・教材 1/5。単独で起動できる学習用ファイル。
実行: python 01_aim.py
詳しい数式と段階ごとの差分は LESSON.md を参照。
"""
from dataclasses import dataclass, field
import math

FPS = 30
DT = 1 / 120
SPEED = 96.0                 # px / second
RADIUS = 2.0                 # envelope collision radius
EPS = 1e-8
INITIAL_ANGLE = -45.0
SOLUTION_ANGLE = -50.75


@dataclass(frozen=True)
class Wall:
    box: tuple
    mirror: bool = False
    name: str = "wall"


@dataclass(frozen=True)
class Level:
    start: tuple
    target: tuple
    radius: float
    walls: tuple


# 原作の2便目「塀の向こう」。壁の順序も原作と同じ。
LEVEL = Level((32, 96), (160, 96), 6, (
    Wall((0, 0, 192, 16), True, "top"),
    Wall((176, 16, 192, 128), False, "right"),
    Wall((0, 116, 176, 128)),
    Wall((0, 16, 16, 116)),
    Wall((92, 64, 100, 116)),
))


@dataclass
class Flight:
    pos: tuple
    vel: tuple = (0.0, 0.0)
    status: str = "flying"
    bounces: int = 0
    age: float = 0.0
    trace: list = field(default_factory=list)
    used: str = "none"

    def __post_init__(self):
        if not self.trace:
            self.trace = [self.pos]


class World:
    def __init__(self, level=LEVEL):
        self.level = level
        self.reset()

    def reset(self):
        self.angle = INITIAL_ANGLE
        self.shot_angle = None
        self.flight = None
        self.paused = False

    def fire(self):
        if self.flight is not None:
            return False
        self.shot_angle = self.angle
        self.flight = Flight(self.level.start, status="aimed")
        return True

    def control(self, pressed=(), held=()):
        # R と Space が同時でも初期化だけ。押下と長押しを分ける。
        if "R" in pressed:
            self.reset()
            return

        if self.flight is None:
            self.angle += 2 * (("RIGHT" in held) - ("LEFT" in held))
            self.angle += 0.25 * (("DOWN" in held) - ("UP" in held))
            self.angle = (self.angle + 180) % 360 - 180
            if "SPACE" in pressed:
                self.fire()


    def checkpoint(self):
        """QA専用の自動入力・モデル進行。人のプレイ記録ではない。"""
        self.angle = SOLUTION_ANGLE
        self.fire()
        return


STAGE = 1
STAGE_TITLE = 'AIM ONCE'
NOTE = 'SPACE SAVES THE ANGLE. MOVEMENT COMES NEXT.'
MIRROR_LABEL = 'MIRROR: NOT ACTIVE YET'


class App:
    def __init__(self, engine, headless=False, screenshot=None, checkpoint=False):
        self.pyxel = engine
        self.world = World()
        self.headless = headless
        self.screenshot = screenshot
        self.draws = 0
        if checkpoint:
            self.world.checkpoint()

    def update(self):
        p = self.pyxel
        # 12回描画した画面を保存。ゲームクリア検証とは別の起動検証。
        if self.headless and self.draws >= 12:
            if self.screenshot:
                p.screenshot(self.screenshot, scale=2)
            p.quit()
            return
        pressed = {key for key in ("R", "SPACE", "P") if p.btnp(getattr(p, "KEY_" + key))}
        held = {key for key in ("LEFT", "RIGHT", "UP", "DOWN") if p.btn(getattr(p, "KEY_" + key))}
        self.world.control(pressed, held)

    def draw(self):
        p, w = self.pyxel, self.world
        f = w.flight
        p.cls(1)
        p.rect(32, 32, 320, 200, 3)
        for x in range(40, 350, 16):
            for y in range(42, 230, 16):
                p.pset(x, y, 5)
        for wall in w.level.walls:
            x, y, xx, yy = wall.box
            p.rect(x * 2, y * 2, (xx - x) * 2, (yy - y) * 2, 13 if wall.mirror else 4)
            if wall.mirror:
                p.rect(x * 2, yy * 2 - 4, (xx - x) * 2, 4, 7)
                for a in range(int(x * 2), int(xx * 2), 12):
                    p.line(a, yy * 2 - 10, a + 5, yy * 2 - 5, 6)
            else:
                for b in range(int(y * 2) + 4, int(yy * 2), 10):
                    p.line(x * 2, b, xx * 2 - 1, b, 9)
        tx, ty = (value * 2 for value in w.level.target)
        p.rect(tx - 14, ty - 14, 29, 29, 8)
        p.circ(tx, ty, w.level.radius * 2 + 2, 7)
        p.circ(tx, ty, w.level.radius * 2, 0)
        p.text(tx - 12, ty - 26, "TARGET", 7)
        p.text(42, 39, MIRROR_LABEL, 6)
        sx, sy = (value * 2 for value in w.level.start)
        angle = math.radians(w.angle if w.shot_angle is None else w.shot_angle)
        ex, ey = sx + 28 * math.cos(angle), sy + 28 * math.sin(angle)
        p.circ(sx, sy, 9, 5)
        p.line(sx, sy, ex, ey, 10)
        for sign in (-1, 1):
            p.line(ex, ey, ex - 8 * math.cos(angle + sign * 0.45), ey - 8 * math.sin(angle + sign * 0.45), 10)
        p.text(sx - 10, sy + 17, "START", 6)
        if f:
            points = f.trace + [f.pos]
            for a, b in zip(points, points[1:]):
                p.line(a[0] * 2, a[1] * 2, b[0] * 2, b[1] * 2, 6)
            for x, y in f.trace[1:]:
                p.circb(x * 2, y * 2, 4, 10)
            if f.status == "delivered":
                p.circb(tx, ty, 19, 10)
                p.line(tx - 6, ty, tx - 1, ty + 5, 10)
                p.line(tx - 1, ty + 5, tx + 8, ty - 6, 10)
            elif f.status in ("wall", "twice", "outside"):
                x, y = f.pos[0] * 2, f.pos[1] * 2
                p.line(x - 5, y - 5, x + 5, y + 5, 8)
                p.line(x - 5, y + 5, x + 5, y - 5, 8)
        px, py = (f.pos if f and f.status in ("flying", "aimed") else w.level.start)
        p.rect(px * 2 - 5, py * 2 - 3, 11, 7, 10)
        p.rectb(px * 2 - 5, py * 2 - 3, 11, 7, 7)
        p.line(px * 2 - 5, py * 2 - 3, px * 2, py * 2 + 1, 9)
        p.line(px * 2 + 5, py * 2 - 3, px * 2, py * 2 + 1, 9)
        p.rect(0, 0, 384, 20, 1)
        p.text(12, 7, f"REFLECTION MAIL / {STAGE}/5 / {STAGE_TITLE}", 7)
        p.rect(0, 256, 384, 104, 1)
        status = "AIM: ONE SHOT PER TRY" if f is None else {
            "aimed": "LOCKED: DIRECTION SAVED", "flying": "FLYING",
            "delivered": "DELIVERED!", "wall": "STOP: WALL",
            "twice": "STOP: SECOND MIRROR", "outside": "STOP: OUTSIDE / 6 SECONDS",
        }[f.status]
        if w.paused:
            status = "PAUSED"
        p.text(12, 267, status, 10)
        p.text(12, 282, f"ANGLE {w.angle:+07.2f}   TIME {f.age if f else 0:04.2f}s   BOUNCES {f.bounces if f else 0}/1", 7)
        p.text(12, 296, NOTE, 6)
        p.text(12, 314, "LEFT/RIGHT: AIM   UP/DOWN: FINE   SPACE: SEND", 7)
        p.text(12, 328, "R: RETRY   ESC: QUIT" + ("   P: PAUSE" if STAGE == 5 else ""), 6)
        p.text(12, 345, "MODEL: PIXELS / SECONDS / ONE DELIVERY", 5)
        self.draws += 1


def main(headless=False, screenshot=None, checkpoint=False):
    import pyxel

    pyxel.init(384, 360, title=f"Reflection Mail - Stage {STAGE}", fps=FPS, headless=headless)
    app = App(pyxel, headless, screenshot, checkpoint)
    pyxel.run(app.update, app.draw)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="render 12 frames and quit")
    parser.add_argument("--screenshot", help="PNG path, requires --headless-smoke")
    parser.add_argument("--demo-checkpoint", action="store_true", help="synthetic model outcome for QA, not human play")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 02 / 05

速度ベクトルで封筒を動かす

1秒あたりの速度に秒を掛けて、動く量を求める。

速度を (vx, vy) の組で持ち、vx=96*cos(angle)、vy=96*sin(angle) とします。0度なら (96, 0) なので、1/30秒で右へ3.2px進みます。1回の描画のあいだに、1/120秒ずつの更新を4回行います。壁と郵便受けは、まだ見た目だけです。飛んでいる時間が6秒になるか、封筒の中心が画面の外に出ると止まるので、試し撃ちが終わらなくなることはありません。

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

02_velocity.py / 注目する部分
f.pos = (f.pos[0] + f.vel[0] * dt, f.pos[1] + f.vel[1] * dt)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_velocity.py
macOS / Linux
.venv/bin/python 02_velocity.py
画面で見ること-50.75度で Space を押すと右上へ進み、白い鏡を通り抜けて、1.3秒ほどで STOP: OUTSIDE(外に出た)になります。この段階では、これが正しい動きです。R キーで同じ出発点に戻せます。
  • cos と sin から vx と vy を作れる
  • 秒を掛けて、動く量にする
  • 鏡を通り抜ける未完成の状態と、画面外での停止を確かめる
つまずきやすいところ更新のたびに96px進めると、「1秒に96px」ではなく「1回の更新で96px」になってしまいます。位置には必ず vel*dt(速度×時間)を足します。画面は2倍の大きさで描いているので、画面上の長さと、計算に使うピクセルも混同しないようにしましょう。
少し変えてみよう

角度0・dt=1/30 の1回の更新で、x が32から35.2に進むことを計算してみましょう。次に SPEED を48に変えて、同じ時間で動く量が半分になるか確かめましょう。

02_velocity.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(265行)
02_velocity.py / 全文
# 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.
# This teaching edition contains no Noto-derived glyph data.

"""反射便・教材 2/5。単独で起動できる学習用ファイル。
実行: python 02_velocity.py
詳しい数式と段階ごとの差分は LESSON.md を参照。
"""
from dataclasses import dataclass, field
import math

FPS = 30
DT = 1 / 120
SPEED = 96.0                 # px / second
RADIUS = 2.0                 # envelope collision radius
EPS = 1e-8
INITIAL_ANGLE = -45.0
SOLUTION_ANGLE = -50.75


@dataclass(frozen=True)
class Wall:
    box: tuple
    mirror: bool = False
    name: str = "wall"


@dataclass(frozen=True)
class Level:
    start: tuple
    target: tuple
    radius: float
    walls: tuple


# 原作の2便目「塀の向こう」。壁の順序も原作と同じ。
LEVEL = Level((32, 96), (160, 96), 6, (
    Wall((0, 0, 192, 16), True, "top"),
    Wall((176, 16, 192, 128), False, "right"),
    Wall((0, 116, 176, 128)),
    Wall((0, 16, 16, 116)),
    Wall((92, 64, 100, 116)),
))


@dataclass
class Flight:
    pos: tuple
    vel: tuple = (0.0, 0.0)
    status: str = "flying"
    bounces: int = 0
    age: float = 0.0
    trace: list = field(default_factory=list)
    used: str = "none"

    def __post_init__(self):
        if not self.trace:
            self.trace = [self.pos]


def launch(level, angle):
    """angle はラジアン。画面では右が0、下向きが正。"""
    if not math.isfinite(angle):
        raise ValueError("angle must be finite")
    return Flight(level.start, (SPEED * math.cos(angle), SPEED * math.sin(angle)))


def step(level, f, dt=DT):
    if dt < 0 or not math.isfinite(dt):
        raise ValueError("invalid dt")
    if f.status != "flying":
        return f
    f.age += dt
    f.pos = (f.pos[0] + f.vel[0] * dt, f.pos[1] + f.vel[1] * dt)
    if f.status == "flying" and (not 0 <= f.pos[0] <= 192 or not 0 <= f.pos[1] <= 128):
        f.status = "outside"
    if f.status == "flying" and f.age >= 6:
        f.status = "outside"  # 原作も時間切れと場外は同じ状態
    return f


class World:
    def __init__(self, level=LEVEL):
        self.level = level
        self.reset()

    def reset(self):
        self.angle = INITIAL_ANGLE
        self.shot_angle = None
        self.flight = None
        self.paused = False

    def fire(self):
        if self.flight is not None:
            return False
        self.shot_angle = self.angle
        self.flight = launch(self.level, math.radians(self.angle))
        return True

    def control(self, pressed=(), held=()):
        # R と Space が同時でも初期化だけ。押下と長押しを分ける。
        if "R" in pressed:
            self.reset()
            return

        if self.flight is None:
            self.angle += 2 * (("RIGHT" in held) - ("LEFT" in held))
            self.angle += 0.25 * (("DOWN" in held) - ("UP" in held))
            self.angle = (self.angle + 180) % 360 - 180
            if "SPACE" in pressed:
                self.fire()
        if self.flight is not None:
            for _ in range(4):
                step(self.level, self.flight, DT)

    def checkpoint(self):
        """QA専用の自動入力・モデル進行。人のプレイ記録ではない。"""
        self.angle = SOLUTION_ANGLE
        self.fire()
        for _ in range(900):
            if self.flight.status != "flying":
                return
            self.control()
        raise AssertionError("checkpoint did not stop")


STAGE = 2
STAGE_TITLE = 'VELOCITY'
NOTE = 'WALLS ARE VISUAL ONLY IN THIS STEP.'
MIRROR_LABEL = 'MIRROR: NOT ACTIVE YET'


class App:
    def __init__(self, engine, headless=False, screenshot=None, checkpoint=False):
        self.pyxel = engine
        self.world = World()
        self.headless = headless
        self.screenshot = screenshot
        self.draws = 0
        if checkpoint:
            self.world.checkpoint()

    def update(self):
        p = self.pyxel
        # 12回描画した画面を保存。ゲームクリア検証とは別の起動検証。
        if self.headless and self.draws >= 12:
            if self.screenshot:
                p.screenshot(self.screenshot, scale=2)
            p.quit()
            return
        pressed = {key for key in ("R", "SPACE", "P") if p.btnp(getattr(p, "KEY_" + key))}
        held = {key for key in ("LEFT", "RIGHT", "UP", "DOWN") if p.btn(getattr(p, "KEY_" + key))}
        self.world.control(pressed, held)

    def draw(self):
        p, w = self.pyxel, self.world
        f = w.flight
        p.cls(1)
        p.rect(32, 32, 320, 200, 3)
        for x in range(40, 350, 16):
            for y in range(42, 230, 16):
                p.pset(x, y, 5)
        for wall in w.level.walls:
            x, y, xx, yy = wall.box
            p.rect(x * 2, y * 2, (xx - x) * 2, (yy - y) * 2, 13 if wall.mirror else 4)
            if wall.mirror:
                p.rect(x * 2, yy * 2 - 4, (xx - x) * 2, 4, 7)
                for a in range(int(x * 2), int(xx * 2), 12):
                    p.line(a, yy * 2 - 10, a + 5, yy * 2 - 5, 6)
            else:
                for b in range(int(y * 2) + 4, int(yy * 2), 10):
                    p.line(x * 2, b, xx * 2 - 1, b, 9)
        tx, ty = (value * 2 for value in w.level.target)
        p.rect(tx - 14, ty - 14, 29, 29, 8)
        p.circ(tx, ty, w.level.radius * 2 + 2, 7)
        p.circ(tx, ty, w.level.radius * 2, 0)
        p.text(tx - 12, ty - 26, "TARGET", 7)
        p.text(42, 39, MIRROR_LABEL, 6)
        sx, sy = (value * 2 for value in w.level.start)
        angle = math.radians(w.angle if w.shot_angle is None else w.shot_angle)
        ex, ey = sx + 28 * math.cos(angle), sy + 28 * math.sin(angle)
        p.circ(sx, sy, 9, 5)
        p.line(sx, sy, ex, ey, 10)
        for sign in (-1, 1):
            p.line(ex, ey, ex - 8 * math.cos(angle + sign * 0.45), ey - 8 * math.sin(angle + sign * 0.45), 10)
        p.text(sx - 10, sy + 17, "START", 6)
        if f:
            points = f.trace + [f.pos]
            for a, b in zip(points, points[1:]):
                p.line(a[0] * 2, a[1] * 2, b[0] * 2, b[1] * 2, 6)
            for x, y in f.trace[1:]:
                p.circb(x * 2, y * 2, 4, 10)
            if f.status == "delivered":
                p.circb(tx, ty, 19, 10)
                p.line(tx - 6, ty, tx - 1, ty + 5, 10)
                p.line(tx - 1, ty + 5, tx + 8, ty - 6, 10)
            elif f.status in ("wall", "twice", "outside"):
                x, y = f.pos[0] * 2, f.pos[1] * 2
                p.line(x - 5, y - 5, x + 5, y + 5, 8)
                p.line(x - 5, y + 5, x + 5, y - 5, 8)
        px, py = (f.pos if f and f.status in ("flying", "aimed") else w.level.start)
        p.rect(px * 2 - 5, py * 2 - 3, 11, 7, 10)
        p.rectb(px * 2 - 5, py * 2 - 3, 11, 7, 7)
        p.line(px * 2 - 5, py * 2 - 3, px * 2, py * 2 + 1, 9)
        p.line(px * 2 + 5, py * 2 - 3, px * 2, py * 2 + 1, 9)
        p.rect(0, 0, 384, 20, 1)
        p.text(12, 7, f"REFLECTION MAIL / {STAGE}/5 / {STAGE_TITLE}", 7)
        p.rect(0, 256, 384, 104, 1)
        status = "AIM: ONE SHOT PER TRY" if f is None else {
            "aimed": "LOCKED: DIRECTION SAVED", "flying": "FLYING",
            "delivered": "DELIVERED!", "wall": "STOP: WALL",
            "twice": "STOP: SECOND MIRROR", "outside": "STOP: OUTSIDE / 6 SECONDS",
        }[f.status]
        if w.paused:
            status = "PAUSED"
        p.text(12, 267, status, 10)
        p.text(12, 282, f"ANGLE {w.angle:+07.2f}   TIME {f.age if f else 0:04.2f}s   BOUNCES {f.bounces if f else 0}/1", 7)
        p.text(12, 296, NOTE, 6)
        p.text(12, 314, "LEFT/RIGHT: AIM   UP/DOWN: FINE   SPACE: SEND", 7)
        p.text(12, 328, "R: RETRY   ESC: QUIT" + ("   P: PAUSE" if STAGE == 5 else ""), 6)
        p.text(12, 345, "MODEL: PIXELS / SECONDS / ONE DELIVERY", 5)
        self.draws += 1


def main(headless=False, screenshot=None, checkpoint=False):
    import pyxel

    pyxel.init(384, 360, title=f"Reflection Mail - Stage {STAGE}", fps=FPS, headless=headless)
    app = App(pyxel, headless, screenshot, checkpoint)
    pyxel.run(app.update, app.draw)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="render 12 frames and quit")
    parser.add_argument("--screenshot", help="PNG path, requires --headless-smoke")
    parser.add_argument("--demo-checkpoint", action="store_true", help="synthetic model outcome for QA, not human play")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 03 / 05

通り道で最初に触れたものを選ぶ

0〜1の範囲で、ぶつかる時刻を比べる。

更新の前と後の位置だけを見るのではなく、p+t*d で表される線分全体を調べます(t は0〜1で、どこまで進んだかを表します)。ray_box は、X と Y それぞれで壁に入る時刻と出る時刻を重ね合わせ、ray_circle は郵便受けに入る時刻を二次方程式で求めます。候補の中でいちばん早い時刻を選び、壁と郵便受けが同時なら壁を優先します。角にぶつかったときは X 方向の法線を使い、壁どうしで同時なら、小数9桁に丸めた時刻→元の壁の番号の順で決めます。この段階では、白い鏡も止まる壁として扱います。

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

03_sweep.py / 注目する部分
wall_hit = min(hits, key=lambda hit: (round(hit[0], 9), hit[1])) if hits else None

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_sweep.py
macOS / Linux
.venv/bin/python 03_sweep.py
画面で見ること-50.75度で発射すると、上の白い鏡の手前、中心が y=18 のところで STOP: WALL(壁で停止)になります。0度でまっすぐ撃つと、まん中の塀の手前 x=90 で止まります。元のゲームの B の郵便受けには、はね返りを作る次の段階まで届きません。
  • うすい壁を飛び越えない
  • この段階では鏡でも止まる
  • 壁と郵便受けを同じ t で比べる
つまずきやすいところ壁を順番に調べて、最初に見つけた壁で止めると、遠い壁が手前の壁を飛び越えて選ばれてしまいます。候補を全部集めてから、ぶつかる時刻で選びます。また、円(郵便受け)の判定だけを先に決めてしまうと、壁の向こうでも成功してしまいます。
少し変えてみよう

テストにある厚さ1pxの壁に注目して、1回で192px動く線分でも x=78 で止まる理由を説明してみましょう。郵便受けにぶつかる時刻と、壁にぶつかる時刻を同じにしたテストも実行してみましょう。

03_sweep.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(333行)
03_sweep.py / 全文
# 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.
# This teaching edition contains no Noto-derived glyph data.

"""反射便・教材 3/5。単独で起動できる学習用ファイル。
実行: python 03_sweep.py
詳しい数式と段階ごとの差分は LESSON.md を参照。
"""
from dataclasses import dataclass, field
import math

FPS = 30
DT = 1 / 120
SPEED = 96.0                 # px / second
RADIUS = 2.0                 # envelope collision radius
EPS = 1e-8
INITIAL_ANGLE = -45.0
SOLUTION_ANGLE = -50.75


@dataclass(frozen=True)
class Wall:
    box: tuple
    mirror: bool = False
    name: str = "wall"


@dataclass(frozen=True)
class Level:
    start: tuple
    target: tuple
    radius: float
    walls: tuple


# 原作の2便目「塀の向こう」。壁の順序も原作と同じ。
LEVEL = Level((32, 96), (160, 96), 6, (
    Wall((0, 0, 192, 16), True, "top"),
    Wall((176, 16, 192, 128), False, "right"),
    Wall((0, 116, 176, 128)),
    Wall((0, 16, 16, 116)),
    Wall((92, 64, 100, 116)),
))


@dataclass
class Flight:
    pos: tuple
    vel: tuple = (0.0, 0.0)
    status: str = "flying"
    bounces: int = 0
    age: float = 0.0
    trace: list = field(default_factory=list)
    used: str = "none"

    def __post_init__(self):
        if not self.trace:
            self.trace = [self.pos]


def launch(level, angle):
    """angle はラジアン。画面では右が0、下向きが正。"""
    if not math.isfinite(angle):
        raise ValueError("angle must be finite")
    return Flight(level.start, (SPEED * math.cos(angle), SPEED * math.sin(angle)))


def ray_box(p, d, box, r=RADIUS):
    """p から p+d への掃引。最初の t と単位法線を返す。"""
    near, far = -math.inf, math.inf
    normal = (0, 0)
    for axis in (0, 1):       # 同時の角接触は X 軸を優先
        lo, hi = box[axis] - r, box[axis + 2] + r
        if abs(d[axis]) < EPS:
            if p[axis] < lo or p[axis] > hi:
                return None
            continue
        a, b = (lo - p[axis]) / d[axis], (hi - p[axis]) / d[axis]
        n = [0, 0]
        n[axis] = -1 if d[axis] > 0 else 1
        if a > b:
            a, b = b, a
        if a > near:
            near, normal = a, tuple(n)
        far = min(far, b)
        if near > far + EPS:
            return None
    if far < 0 or near > 1 or near < -EPS:
        return None
    return max(0, near), normal


def ray_circle(p, d, c, r):
    """中心 p+t*d と受取口の距離が r になる最初の t。"""
    a = d[0] ** 2 + d[1] ** 2
    q = (p[0] - c[0], p[1] - c[1])
    cc = q[0] ** 2 + q[1] ** 2 - r * r
    if cc <= 0:
        return 0.0
    if a < EPS:
        return None
    b = 2 * (q[0] * d[0] + q[1] * d[1])
    discriminant = b * b - 4 * a * cc
    if discriminant < 0:
        return None
    t = (-b - math.sqrt(max(0, discriminant))) / (2 * a)
    return t if 0 <= t <= 1 else None


def first_contact(level, p, d):
    hits = []
    for index, wall in enumerate(level.walls):
        hit = ray_box(p, d, wall.box)
        if hit is not None:
            hits.append((hit[0], index, hit[1], wall))
    # 原作の決定規則: 小数9桁に丸めた時刻、次に壁の番号。
    wall_hit = min(hits, key=lambda hit: (round(hit[0], 9), hit[1])) if hits else None
    target_t = ray_circle(p, d, level.target, level.radius)
    # 同時または EPS 以内なら壁を優先する。
    if target_t is not None and (wall_hit is None or target_t < wall_hit[0] - EPS):
        return (target_t, "target", (0, 0), None)
    if wall_hit is not None:
        t, _, normal, wall = wall_hit
        return (t, "wall", normal, wall)
    return None


def step(level, f, dt=DT):
    if dt < 0 or not math.isfinite(dt):
        raise ValueError("invalid dt")
    if f.status != "flying":
        return f
    f.age += dt
    d = (f.vel[0] * dt, f.vel[1] * dt)
    hit = first_contact(level, f.pos, d)
    if hit is None:
        f.pos = (f.pos[0] + d[0], f.pos[1] + d[1])
    else:
        t, kind, normal, wall = hit
        f.pos = (f.pos[0] + d[0] * t, f.pos[1] + d[1] * t)
        f.trace.append(f.pos)
        f.status = "delivered" if kind == "target" else "wall"
    if f.status == "flying" and (not 0 <= f.pos[0] <= 192 or not 0 <= f.pos[1] <= 128):
        f.status = "outside"
    if f.status == "flying" and f.age >= 6:
        f.status = "outside"  # 原作も時間切れと場外は同じ状態
    return f


class World:
    def __init__(self, level=LEVEL):
        self.level = level
        self.reset()

    def reset(self):
        self.angle = INITIAL_ANGLE
        self.shot_angle = None
        self.flight = None
        self.paused = False

    def fire(self):
        if self.flight is not None:
            return False
        self.shot_angle = self.angle
        self.flight = launch(self.level, math.radians(self.angle))
        return True

    def control(self, pressed=(), held=()):
        # R と Space が同時でも初期化だけ。押下と長押しを分ける。
        if "R" in pressed:
            self.reset()
            return

        if self.flight is None:
            self.angle += 2 * (("RIGHT" in held) - ("LEFT" in held))
            self.angle += 0.25 * (("DOWN" in held) - ("UP" in held))
            self.angle = (self.angle + 180) % 360 - 180
            if "SPACE" in pressed:
                self.fire()
        if self.flight is not None:
            for _ in range(4):
                step(self.level, self.flight, DT)

    def checkpoint(self):
        """QA専用の自動入力・モデル進行。人のプレイ記録ではない。"""
        self.angle = SOLUTION_ANGLE
        self.fire()
        for _ in range(900):
            if self.flight.status != "flying":
                return
            self.control()
        raise AssertionError("checkpoint did not stop")


STAGE = 3
STAGE_TITLE = 'FIRST CONTACT'
NOTE = 'ALL WALLS STOP THE LETTER IN THIS STEP.'
MIRROR_LABEL = 'MIRROR: NOT ACTIVE YET'


class App:
    def __init__(self, engine, headless=False, screenshot=None, checkpoint=False):
        self.pyxel = engine
        self.world = World()
        self.headless = headless
        self.screenshot = screenshot
        self.draws = 0
        if checkpoint:
            self.world.checkpoint()

    def update(self):
        p = self.pyxel
        # 12回描画した画面を保存。ゲームクリア検証とは別の起動検証。
        if self.headless and self.draws >= 12:
            if self.screenshot:
                p.screenshot(self.screenshot, scale=2)
            p.quit()
            return
        pressed = {key for key in ("R", "SPACE", "P") if p.btnp(getattr(p, "KEY_" + key))}
        held = {key for key in ("LEFT", "RIGHT", "UP", "DOWN") if p.btn(getattr(p, "KEY_" + key))}
        self.world.control(pressed, held)

    def draw(self):
        p, w = self.pyxel, self.world
        f = w.flight
        p.cls(1)
        p.rect(32, 32, 320, 200, 3)
        for x in range(40, 350, 16):
            for y in range(42, 230, 16):
                p.pset(x, y, 5)
        for wall in w.level.walls:
            x, y, xx, yy = wall.box
            p.rect(x * 2, y * 2, (xx - x) * 2, (yy - y) * 2, 13 if wall.mirror else 4)
            if wall.mirror:
                p.rect(x * 2, yy * 2 - 4, (xx - x) * 2, 4, 7)
                for a in range(int(x * 2), int(xx * 2), 12):
                    p.line(a, yy * 2 - 10, a + 5, yy * 2 - 5, 6)
            else:
                for b in range(int(y * 2) + 4, int(yy * 2), 10):
                    p.line(x * 2, b, xx * 2 - 1, b, 9)
        tx, ty = (value * 2 for value in w.level.target)
        p.rect(tx - 14, ty - 14, 29, 29, 8)
        p.circ(tx, ty, w.level.radius * 2 + 2, 7)
        p.circ(tx, ty, w.level.radius * 2, 0)
        p.text(tx - 12, ty - 26, "TARGET", 7)
        p.text(42, 39, MIRROR_LABEL, 6)
        sx, sy = (value * 2 for value in w.level.start)
        angle = math.radians(w.angle if w.shot_angle is None else w.shot_angle)
        ex, ey = sx + 28 * math.cos(angle), sy + 28 * math.sin(angle)
        p.circ(sx, sy, 9, 5)
        p.line(sx, sy, ex, ey, 10)
        for sign in (-1, 1):
            p.line(ex, ey, ex - 8 * math.cos(angle + sign * 0.45), ey - 8 * math.sin(angle + sign * 0.45), 10)
        p.text(sx - 10, sy + 17, "START", 6)
        if f:
            points = f.trace + [f.pos]
            for a, b in zip(points, points[1:]):
                p.line(a[0] * 2, a[1] * 2, b[0] * 2, b[1] * 2, 6)
            for x, y in f.trace[1:]:
                p.circb(x * 2, y * 2, 4, 10)
            if f.status == "delivered":
                p.circb(tx, ty, 19, 10)
                p.line(tx - 6, ty, tx - 1, ty + 5, 10)
                p.line(tx - 1, ty + 5, tx + 8, ty - 6, 10)
            elif f.status in ("wall", "twice", "outside"):
                x, y = f.pos[0] * 2, f.pos[1] * 2
                p.line(x - 5, y - 5, x + 5, y + 5, 8)
                p.line(x - 5, y + 5, x + 5, y - 5, 8)
        px, py = (f.pos if f and f.status in ("flying", "aimed") else w.level.start)
        p.rect(px * 2 - 5, py * 2 - 3, 11, 7, 10)
        p.rectb(px * 2 - 5, py * 2 - 3, 11, 7, 7)
        p.line(px * 2 - 5, py * 2 - 3, px * 2, py * 2 + 1, 9)
        p.line(px * 2 + 5, py * 2 - 3, px * 2, py * 2 + 1, 9)
        p.rect(0, 0, 384, 20, 1)
        p.text(12, 7, f"REFLECTION MAIL / {STAGE}/5 / {STAGE_TITLE}", 7)
        p.rect(0, 256, 384, 104, 1)
        status = "AIM: ONE SHOT PER TRY" if f is None else {
            "aimed": "LOCKED: DIRECTION SAVED", "flying": "FLYING",
            "delivered": "DELIVERED!", "wall": "STOP: WALL",
            "twice": "STOP: SECOND MIRROR", "outside": "STOP: OUTSIDE / 6 SECONDS",
        }[f.status]
        if w.paused:
            status = "PAUSED"
        p.text(12, 267, status, 10)
        p.text(12, 282, f"ANGLE {w.angle:+07.2f}   TIME {f.age if f else 0:04.2f}s   BOUNCES {f.bounces if f else 0}/1", 7)
        p.text(12, 296, NOTE, 6)
        p.text(12, 314, "LEFT/RIGHT: AIM   UP/DOWN: FINE   SPACE: SEND", 7)
        p.text(12, 328, "R: RETRY   ESC: QUIT" + ("   P: PAUSE" if STAGE == 5 else ""), 6)
        p.text(12, 345, "MODEL: PIXELS / SECONDS / ONE DELIVERY", 5)
        self.draws += 1


def main(headless=False, screenshot=None, checkpoint=False):
    import pyxel

    pyxel.init(384, 360, title=f"Reflection Mail - Stage {STAGE}", fps=FPS, headless=headless)
    app = App(pyxel, headless, screenshot, checkpoint)
    pyxel.run(app.update, app.draw)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="render 12 frames and quit")
    parser.add_argument("--screenshot", help="PNG path, requires --headless-smoke")
    parser.add_argument("--demo-checkpoint", action="store_true", help="synthetic model outcome for QA, not human play")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 04 / 05

法線方向だけを反転する

内積を使って、はね返りに必要な速度の成分を取り出す。

長さ1の法線 n(壁に垂直な向き)について、v・n(内積)は、法線の向きに進む速さを表します。v から 2*(v・n)*n を引くと、法線の向きの成分だけが反対になります。たとえば v=(60,-80) で、上の鏡から内側を向く n=(0,1) なら、内積は-80で、はね返ったあとは (60,80) になります。速さ100は変わりません。白い鏡では一度だけはね返り、2回目に触れると止まります。この段階では、ぶつかったあとの残り時間を捨てているので、はね返る瞬間だけ少し遅れます。

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

04_reflect.py / 注目する部分
dot = v[0] * n[0] + v[1] * n[1]
return (v[0] - 2 * dot * n[0], v[1] - 2 * dot * n[1])

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_reflect.py
macOS / Linux
.venv/bin/python 04_reflect.py
画面で見ること-50.75度で発射すると、白い鏡で一度折れ曲がり、塀の上を越えて DELIVERED(配達完了)になります。BOUNCES(はね返りの回数)は 1/1 です。0度で撃つと、やはり塀で止まります。
  • 白い鏡で一度はね返る
  • はね返る前と後で速さが同じ
  • 残り時間を捨てる簡単な作り方を説明できる
つまずきやすいところvx と vy を両方反対にすると、来た道をそのまま戻ってしまいます。また、法線の長さが1でないと、この式では速さが変わってしまいます。ray_box が返す (±1,0) か (0,±1) をそのまま使います。
少し変えてみよう

(60,-80) を n=(1,0) ではね返した結果を、手で計算してみましょう。remaining_time のテストでは、この段階の y=18.01 と次の段階の y=44.01 を比べて、「速さ」と「進んだ距離」が別の問題であることを確かめましょう。

04_reflect.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(351行)
04_reflect.py / 全文
# 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.
# This teaching edition contains no Noto-derived glyph data.

"""反射便・教材 4/5。単独で起動できる学習用ファイル。
実行: python 04_reflect.py
詳しい数式と段階ごとの差分は LESSON.md を参照。
"""
from dataclasses import dataclass, field
import math

FPS = 30
DT = 1 / 120
SPEED = 96.0                 # px / second
RADIUS = 2.0                 # envelope collision radius
EPS = 1e-8
INITIAL_ANGLE = -45.0
SOLUTION_ANGLE = -50.75


@dataclass(frozen=True)
class Wall:
    box: tuple
    mirror: bool = False
    name: str = "wall"


@dataclass(frozen=True)
class Level:
    start: tuple
    target: tuple
    radius: float
    walls: tuple


# 原作の2便目「塀の向こう」。壁の順序も原作と同じ。
LEVEL = Level((32, 96), (160, 96), 6, (
    Wall((0, 0, 192, 16), True, "top"),
    Wall((176, 16, 192, 128), False, "right"),
    Wall((0, 116, 176, 128)),
    Wall((0, 16, 16, 116)),
    Wall((92, 64, 100, 116)),
))


@dataclass
class Flight:
    pos: tuple
    vel: tuple = (0.0, 0.0)
    status: str = "flying"
    bounces: int = 0
    age: float = 0.0
    trace: list = field(default_factory=list)
    used: str = "none"

    def __post_init__(self):
        if not self.trace:
            self.trace = [self.pos]


def launch(level, angle):
    """angle はラジアン。画面では右が0、下向きが正。"""
    if not math.isfinite(angle):
        raise ValueError("angle must be finite")
    return Flight(level.start, (SPEED * math.cos(angle), SPEED * math.sin(angle)))


def ray_box(p, d, box, r=RADIUS):
    """p から p+d への掃引。最初の t と単位法線を返す。"""
    near, far = -math.inf, math.inf
    normal = (0, 0)
    for axis in (0, 1):       # 同時の角接触は X 軸を優先
        lo, hi = box[axis] - r, box[axis + 2] + r
        if abs(d[axis]) < EPS:
            if p[axis] < lo or p[axis] > hi:
                return None
            continue
        a, b = (lo - p[axis]) / d[axis], (hi - p[axis]) / d[axis]
        n = [0, 0]
        n[axis] = -1 if d[axis] > 0 else 1
        if a > b:
            a, b = b, a
        if a > near:
            near, normal = a, tuple(n)
        far = min(far, b)
        if near > far + EPS:
            return None
    if far < 0 or near > 1 or near < -EPS:
        return None
    return max(0, near), normal


def ray_circle(p, d, c, r):
    """中心 p+t*d と受取口の距離が r になる最初の t。"""
    a = d[0] ** 2 + d[1] ** 2
    q = (p[0] - c[0], p[1] - c[1])
    cc = q[0] ** 2 + q[1] ** 2 - r * r
    if cc <= 0:
        return 0.0
    if a < EPS:
        return None
    b = 2 * (q[0] * d[0] + q[1] * d[1])
    discriminant = b * b - 4 * a * cc
    if discriminant < 0:
        return None
    t = (-b - math.sqrt(max(0, discriminant))) / (2 * a)
    return t if 0 <= t <= 1 else None


def first_contact(level, p, d):
    hits = []
    for index, wall in enumerate(level.walls):
        hit = ray_box(p, d, wall.box)
        if hit is not None:
            hits.append((hit[0], index, hit[1], wall))
    # 原作の決定規則: 小数9桁に丸めた時刻、次に壁の番号。
    wall_hit = min(hits, key=lambda hit: (round(hit[0], 9), hit[1])) if hits else None
    target_t = ray_circle(p, d, level.target, level.radius)
    # 同時または EPS 以内なら壁を優先する。
    if target_t is not None and (wall_hit is None or target_t < wall_hit[0] - EPS):
        return (target_t, "target", (0, 0), None)
    if wall_hit is not None:
        t, _, normal, wall = wall_hit
        return (t, "wall", normal, wall)
    return None


def reflect(v, n):
    """n は長さ1の法線。接線方向は残し、法線方向だけ逆にする。"""
    dot = v[0] * n[0] + v[1] * n[1]
    return (v[0] - 2 * dot * n[0], v[1] - 2 * dot * n[1])


def step(level, f, dt=DT):
    if dt < 0 or not math.isfinite(dt):
        raise ValueError("invalid dt")
    if f.status != "flying":
        return f
    f.age += dt
    d = (f.vel[0] * dt, f.vel[1] * dt)
    hit = first_contact(level, f.pos, d)
    if hit is None:
        f.pos = (f.pos[0] + d[0], f.pos[1] + d[1])
    else:
        t, kind, normal, wall = hit
        f.pos = (f.pos[0] + d[0] * t, f.pos[1] + d[1] * t)
        f.trace.append(f.pos)
        if kind == "target":
            f.status = "delivered"
        elif not wall.mirror:
            f.status = "wall"
        elif f.bounces >= 1:
            f.status = "twice"
        else:
            f.bounces += 1
            f.used = wall.name
            f.vel = reflect(f.vel, normal)
            f.pos = (f.pos[0] + normal[0] * 0.01, f.pos[1] + normal[1] * 0.01)
            # この段階の意図的な簡略化: 接触後の余り時間は捨てる。
            # 第5段階で直す。DT のとき最大0.8pxぶん遅れる。
    if f.status == "flying" and (not 0 <= f.pos[0] <= 192 or not 0 <= f.pos[1] <= 128):
        f.status = "outside"
    if f.status == "flying" and f.age >= 6:
        f.status = "outside"  # 原作も時間切れと場外は同じ状態
    return f


class World:
    def __init__(self, level=LEVEL):
        self.level = level
        self.reset()

    def reset(self):
        self.angle = INITIAL_ANGLE
        self.shot_angle = None
        self.flight = None
        self.paused = False

    def fire(self):
        if self.flight is not None:
            return False
        self.shot_angle = self.angle
        self.flight = launch(self.level, math.radians(self.angle))
        return True

    def control(self, pressed=(), held=()):
        # R と Space が同時でも初期化だけ。押下と長押しを分ける。
        if "R" in pressed:
            self.reset()
            return

        if self.flight is None:
            self.angle += 2 * (("RIGHT" in held) - ("LEFT" in held))
            self.angle += 0.25 * (("DOWN" in held) - ("UP" in held))
            self.angle = (self.angle + 180) % 360 - 180
            if "SPACE" in pressed:
                self.fire()
        if self.flight is not None:
            for _ in range(4):
                step(self.level, self.flight, DT)

    def checkpoint(self):
        """QA専用の自動入力・モデル進行。人のプレイ記録ではない。"""
        self.angle = SOLUTION_ANGLE
        self.fire()
        for _ in range(900):
            if self.flight.status != "flying":
                return
            self.control()
        raise AssertionError("checkpoint did not stop")


STAGE = 4
STAGE_TITLE = 'REFLECTION'
NOTE = 'BOUNCE RESTS UNTIL NEXT TICK IN THIS STEP.'
MIRROR_LABEL = 'WHITE MIRROR'


class App:
    def __init__(self, engine, headless=False, screenshot=None, checkpoint=False):
        self.pyxel = engine
        self.world = World()
        self.headless = headless
        self.screenshot = screenshot
        self.draws = 0
        if checkpoint:
            self.world.checkpoint()

    def update(self):
        p = self.pyxel
        # 12回描画した画面を保存。ゲームクリア検証とは別の起動検証。
        if self.headless and self.draws >= 12:
            if self.screenshot:
                p.screenshot(self.screenshot, scale=2)
            p.quit()
            return
        pressed = {key for key in ("R", "SPACE", "P") if p.btnp(getattr(p, "KEY_" + key))}
        held = {key for key in ("LEFT", "RIGHT", "UP", "DOWN") if p.btn(getattr(p, "KEY_" + key))}
        self.world.control(pressed, held)

    def draw(self):
        p, w = self.pyxel, self.world
        f = w.flight
        p.cls(1)
        p.rect(32, 32, 320, 200, 3)
        for x in range(40, 350, 16):
            for y in range(42, 230, 16):
                p.pset(x, y, 5)
        for wall in w.level.walls:
            x, y, xx, yy = wall.box
            p.rect(x * 2, y * 2, (xx - x) * 2, (yy - y) * 2, 13 if wall.mirror else 4)
            if wall.mirror:
                p.rect(x * 2, yy * 2 - 4, (xx - x) * 2, 4, 7)
                for a in range(int(x * 2), int(xx * 2), 12):
                    p.line(a, yy * 2 - 10, a + 5, yy * 2 - 5, 6)
            else:
                for b in range(int(y * 2) + 4, int(yy * 2), 10):
                    p.line(x * 2, b, xx * 2 - 1, b, 9)
        tx, ty = (value * 2 for value in w.level.target)
        p.rect(tx - 14, ty - 14, 29, 29, 8)
        p.circ(tx, ty, w.level.radius * 2 + 2, 7)
        p.circ(tx, ty, w.level.radius * 2, 0)
        p.text(tx - 12, ty - 26, "TARGET", 7)
        p.text(42, 39, MIRROR_LABEL, 6)
        sx, sy = (value * 2 for value in w.level.start)
        angle = math.radians(w.angle if w.shot_angle is None else w.shot_angle)
        ex, ey = sx + 28 * math.cos(angle), sy + 28 * math.sin(angle)
        p.circ(sx, sy, 9, 5)
        p.line(sx, sy, ex, ey, 10)
        for sign in (-1, 1):
            p.line(ex, ey, ex - 8 * math.cos(angle + sign * 0.45), ey - 8 * math.sin(angle + sign * 0.45), 10)
        p.text(sx - 10, sy + 17, "START", 6)
        if f:
            points = f.trace + [f.pos]
            for a, b in zip(points, points[1:]):
                p.line(a[0] * 2, a[1] * 2, b[0] * 2, b[1] * 2, 6)
            for x, y in f.trace[1:]:
                p.circb(x * 2, y * 2, 4, 10)
            if f.status == "delivered":
                p.circb(tx, ty, 19, 10)
                p.line(tx - 6, ty, tx - 1, ty + 5, 10)
                p.line(tx - 1, ty + 5, tx + 8, ty - 6, 10)
            elif f.status in ("wall", "twice", "outside"):
                x, y = f.pos[0] * 2, f.pos[1] * 2
                p.line(x - 5, y - 5, x + 5, y + 5, 8)
                p.line(x - 5, y + 5, x + 5, y - 5, 8)
        px, py = (f.pos if f and f.status in ("flying", "aimed") else w.level.start)
        p.rect(px * 2 - 5, py * 2 - 3, 11, 7, 10)
        p.rectb(px * 2 - 5, py * 2 - 3, 11, 7, 7)
        p.line(px * 2 - 5, py * 2 - 3, px * 2, py * 2 + 1, 9)
        p.line(px * 2 + 5, py * 2 - 3, px * 2, py * 2 + 1, 9)
        p.rect(0, 0, 384, 20, 1)
        p.text(12, 7, f"REFLECTION MAIL / {STAGE}/5 / {STAGE_TITLE}", 7)
        p.rect(0, 256, 384, 104, 1)
        status = "AIM: ONE SHOT PER TRY" if f is None else {
            "aimed": "LOCKED: DIRECTION SAVED", "flying": "FLYING",
            "delivered": "DELIVERED!", "wall": "STOP: WALL",
            "twice": "STOP: SECOND MIRROR", "outside": "STOP: OUTSIDE / 6 SECONDS",
        }[f.status]
        if w.paused:
            status = "PAUSED"
        p.text(12, 267, status, 10)
        p.text(12, 282, f"ANGLE {w.angle:+07.2f}   TIME {f.age if f else 0:04.2f}s   BOUNCES {f.bounces if f else 0}/1", 7)
        p.text(12, 296, NOTE, 6)
        p.text(12, 314, "LEFT/RIGHT: AIM   UP/DOWN: FINE   SPACE: SEND", 7)
        p.text(12, 328, "R: RETRY   ESC: QUIT" + ("   P: PAUSE" if STAGE == 5 else ""), 6)
        p.text(12, 345, "MODEL: PIXELS / SECONDS / ONE DELIVERY", 5)
        self.draws += 1


def main(headless=False, screenshot=None, checkpoint=False):
    import pyxel

    pyxel.init(384, 360, title=f"Reflection Mail - Stage {STAGE}", fps=FPS, headless=headless)
    app = App(pyxel, headless, screenshot, checkpoint)
    pyxel.run(app.update, app.draw)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="render 12 frames and quit")
    parser.add_argument("--screenshot", help="PNG path, requires --headless-smoke")
    parser.add_argument("--demo-checkpoint", action="store_true", help="synthetic model outcome for QA, not human play")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
STEP 05 / 05

余った時間を使い、有限に終える

はね返ったあとも、同じ更新の残り時間を使う。

remaining(残り時間)を dt から始めます。線分の t の位置でぶつかったら、残りは remaining*(1-t) 秒です。はね返ったあとの速度で新しい線分を作り、最初にぶつかるものを調べ直します。はね返ったあとに法線の向きへ0.01pxだけ離す処理、1回の更新で最大4回までの衝突処理、2回目の鏡で止まる、6秒で飛行を打ち切る、という点を元のゲームに合わせます。DELIVERED や STOP になったら次の更新では動かず、P で一時停止、R ですべて最初に戻せます。

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

05_remaining.py / 注目する部分
remaining *= 1 - t
if remaining < EPS:
    break

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_remaining.py
macOS / Linux
.venv/bin/python 05_remaining.py
画面で見ること-50.75度で発射すると、2秒ほどで DELIVERED、BOUNCES 1/1 になります。最初の -45度から、上キーで23回分調整すると、ぴったり再現できます。飛んでいる途中で P を押すと位置と TIME が止まり、R で角度・飛行・止まった状態を最初に戻せます。
  • 残り時間で、はね返ったあとも動く
  • 成功・壁・2回目の鏡・時間切れのどれでも、きちんと止まる
  • 元のゲームの B と、決めた範囲の動きが一致する
つまずきやすいところremaining=dt*(1-t) と、毎回もとの dt から計算し直すと、2回以上ぶつかったときに時間を使いすぎてしまいます。残っている時間に 1-t を掛けます。はね返ったあとの線分は、古い d を使い回さず、新しい vel から作ります。
少し変えてみよう

出発点 (40,40)、速度 (0,-96)、dt=0.5 で上の鏡にぶつかる場合、22px進んではね返り、残りの26px進むことを計算してみましょう。最後の y=44.01 をテストで確かめ、dt を1/30・1/120・1/240に変えても、届く位置が同じになることを比べましょう。

05_remaining.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(360行)
05_remaining.py / 全文
# 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.
# This teaching edition contains no Noto-derived glyph data.

"""反射便・教材 5/5。単独で起動できる学習用ファイル。
実行: python 05_remaining.py
詳しい数式と段階ごとの差分は LESSON.md を参照。
"""
from dataclasses import dataclass, field
import math

FPS = 30
DT = 1 / 120
SPEED = 96.0                 # px / second
RADIUS = 2.0                 # envelope collision radius
EPS = 1e-8
INITIAL_ANGLE = -45.0
SOLUTION_ANGLE = -50.75


@dataclass(frozen=True)
class Wall:
    box: tuple
    mirror: bool = False
    name: str = "wall"


@dataclass(frozen=True)
class Level:
    start: tuple
    target: tuple
    radius: float
    walls: tuple


# 原作の2便目「塀の向こう」。壁の順序も原作と同じ。
LEVEL = Level((32, 96), (160, 96), 6, (
    Wall((0, 0, 192, 16), True, "top"),
    Wall((176, 16, 192, 128), False, "right"),
    Wall((0, 116, 176, 128)),
    Wall((0, 16, 16, 116)),
    Wall((92, 64, 100, 116)),
))


@dataclass
class Flight:
    pos: tuple
    vel: tuple = (0.0, 0.0)
    status: str = "flying"
    bounces: int = 0
    age: float = 0.0
    trace: list = field(default_factory=list)
    used: str = "none"

    def __post_init__(self):
        if not self.trace:
            self.trace = [self.pos]


def launch(level, angle):
    """angle はラジアン。画面では右が0、下向きが正。"""
    if not math.isfinite(angle):
        raise ValueError("angle must be finite")
    return Flight(level.start, (SPEED * math.cos(angle), SPEED * math.sin(angle)))


def ray_box(p, d, box, r=RADIUS):
    """p から p+d への掃引。最初の t と単位法線を返す。"""
    near, far = -math.inf, math.inf
    normal = (0, 0)
    for axis in (0, 1):       # 同時の角接触は X 軸を優先
        lo, hi = box[axis] - r, box[axis + 2] + r
        if abs(d[axis]) < EPS:
            if p[axis] < lo or p[axis] > hi:
                return None
            continue
        a, b = (lo - p[axis]) / d[axis], (hi - p[axis]) / d[axis]
        n = [0, 0]
        n[axis] = -1 if d[axis] > 0 else 1
        if a > b:
            a, b = b, a
        if a > near:
            near, normal = a, tuple(n)
        far = min(far, b)
        if near > far + EPS:
            return None
    if far < 0 or near > 1 or near < -EPS:
        return None
    return max(0, near), normal


def ray_circle(p, d, c, r):
    """中心 p+t*d と受取口の距離が r になる最初の t。"""
    a = d[0] ** 2 + d[1] ** 2
    q = (p[0] - c[0], p[1] - c[1])
    cc = q[0] ** 2 + q[1] ** 2 - r * r
    if cc <= 0:
        return 0.0
    if a < EPS:
        return None
    b = 2 * (q[0] * d[0] + q[1] * d[1])
    discriminant = b * b - 4 * a * cc
    if discriminant < 0:
        return None
    t = (-b - math.sqrt(max(0, discriminant))) / (2 * a)
    return t if 0 <= t <= 1 else None


def first_contact(level, p, d):
    hits = []
    for index, wall in enumerate(level.walls):
        hit = ray_box(p, d, wall.box)
        if hit is not None:
            hits.append((hit[0], index, hit[1], wall))
    # 原作の決定規則: 小数9桁に丸めた時刻、次に壁の番号。
    wall_hit = min(hits, key=lambda hit: (round(hit[0], 9), hit[1])) if hits else None
    target_t = ray_circle(p, d, level.target, level.radius)
    # 同時または EPS 以内なら壁を優先する。
    if target_t is not None and (wall_hit is None or target_t < wall_hit[0] - EPS):
        return (target_t, "target", (0, 0), None)
    if wall_hit is not None:
        t, _, normal, wall = wall_hit
        return (t, "wall", normal, wall)
    return None


def reflect(v, n):
    """n は長さ1の法線。接線方向は残し、法線方向だけ逆にする。"""
    dot = v[0] * n[0] + v[1] * n[1]
    return (v[0] - 2 * dot * n[0], v[1] - 2 * dot * n[1])


def step(level, f, dt=DT):
    if dt < 0 or not math.isfinite(dt):
        raise ValueError("invalid dt")
    if f.status != "flying":
        return f
    f.age += dt
    remaining = dt
    for _ in range(4):       # 1刻みでの接触解決も有限にする
        d = (f.vel[0] * remaining, f.vel[1] * remaining)
        hit = first_contact(level, f.pos, d)
        if hit is None:
            f.pos = (f.pos[0] + d[0], f.pos[1] + d[1])
            break
        t, kind, normal, wall = hit
        f.pos = (f.pos[0] + d[0] * t, f.pos[1] + d[1] * t)
        f.trace.append(f.pos)
        if kind == "target":
            f.status = "delivered"
            break
        if not wall.mirror:
            f.status = "wall"
            break
        if f.bounces >= 1:
            f.status = "twice"
            break
        f.bounces += 1
        f.used = wall.name
        f.vel = reflect(f.vel, normal)
        f.pos = (f.pos[0] + normal[0] * 0.01, f.pos[1] + normal[1] * 0.01)
        remaining *= 1 - t
        if remaining < EPS:
            break
    if f.status == "flying" and (not 0 <= f.pos[0] <= 192 or not 0 <= f.pos[1] <= 128):
        f.status = "outside"
    if f.status == "flying" and f.age >= 6:
        f.status = "outside"  # 原作も時間切れと場外は同じ状態
    return f


class World:
    def __init__(self, level=LEVEL):
        self.level = level
        self.reset()

    def reset(self):
        self.angle = INITIAL_ANGLE
        self.shot_angle = None
        self.flight = None
        self.paused = False

    def fire(self):
        if self.flight is not None:
            return False
        self.shot_angle = self.angle
        self.flight = launch(self.level, math.radians(self.angle))
        return True

    def control(self, pressed=(), held=()):
        # R と Space が同時でも初期化だけ。押下と長押しを分ける。
        if "R" in pressed:
            self.reset()
            return
        if "P" in pressed:
            self.paused = not self.paused
            return
        if self.paused:
            return
        if self.flight is None:
            self.angle += 2 * (("RIGHT" in held) - ("LEFT" in held))
            self.angle += 0.25 * (("DOWN" in held) - ("UP" in held))
            self.angle = (self.angle + 180) % 360 - 180
            if "SPACE" in pressed:
                self.fire()
        if self.flight is not None:
            for _ in range(4):
                step(self.level, self.flight, DT)

    def checkpoint(self):
        """QA専用の自動入力・モデル進行。人のプレイ記録ではない。"""
        self.angle = SOLUTION_ANGLE
        self.fire()
        for _ in range(900):
            if self.flight.status != "flying":
                return
            self.control()
        raise AssertionError("checkpoint did not stop")


STAGE = 5
STAGE_TITLE = 'REMAINING TIME'
NOTE = 'SWEEP -> FIRST HIT -> REFLECT -> USE TIME.'
MIRROR_LABEL = 'WHITE MIRROR'


class App:
    def __init__(self, engine, headless=False, screenshot=None, checkpoint=False):
        self.pyxel = engine
        self.world = World()
        self.headless = headless
        self.screenshot = screenshot
        self.draws = 0
        if checkpoint:
            self.world.checkpoint()

    def update(self):
        p = self.pyxel
        # 12回描画した画面を保存。ゲームクリア検証とは別の起動検証。
        if self.headless and self.draws >= 12:
            if self.screenshot:
                p.screenshot(self.screenshot, scale=2)
            p.quit()
            return
        pressed = {key for key in ("R", "SPACE", "P") if p.btnp(getattr(p, "KEY_" + key))}
        held = {key for key in ("LEFT", "RIGHT", "UP", "DOWN") if p.btn(getattr(p, "KEY_" + key))}
        self.world.control(pressed, held)

    def draw(self):
        p, w = self.pyxel, self.world
        f = w.flight
        p.cls(1)
        p.rect(32, 32, 320, 200, 3)
        for x in range(40, 350, 16):
            for y in range(42, 230, 16):
                p.pset(x, y, 5)
        for wall in w.level.walls:
            x, y, xx, yy = wall.box
            p.rect(x * 2, y * 2, (xx - x) * 2, (yy - y) * 2, 13 if wall.mirror else 4)
            if wall.mirror:
                p.rect(x * 2, yy * 2 - 4, (xx - x) * 2, 4, 7)
                for a in range(int(x * 2), int(xx * 2), 12):
                    p.line(a, yy * 2 - 10, a + 5, yy * 2 - 5, 6)
            else:
                for b in range(int(y * 2) + 4, int(yy * 2), 10):
                    p.line(x * 2, b, xx * 2 - 1, b, 9)
        tx, ty = (value * 2 for value in w.level.target)
        p.rect(tx - 14, ty - 14, 29, 29, 8)
        p.circ(tx, ty, w.level.radius * 2 + 2, 7)
        p.circ(tx, ty, w.level.radius * 2, 0)
        p.text(tx - 12, ty - 26, "TARGET", 7)
        p.text(42, 39, MIRROR_LABEL, 6)
        sx, sy = (value * 2 for value in w.level.start)
        angle = math.radians(w.angle if w.shot_angle is None else w.shot_angle)
        ex, ey = sx + 28 * math.cos(angle), sy + 28 * math.sin(angle)
        p.circ(sx, sy, 9, 5)
        p.line(sx, sy, ex, ey, 10)
        for sign in (-1, 1):
            p.line(ex, ey, ex - 8 * math.cos(angle + sign * 0.45), ey - 8 * math.sin(angle + sign * 0.45), 10)
        p.text(sx - 10, sy + 17, "START", 6)
        if f:
            points = f.trace + [f.pos]
            for a, b in zip(points, points[1:]):
                p.line(a[0] * 2, a[1] * 2, b[0] * 2, b[1] * 2, 6)
            for x, y in f.trace[1:]:
                p.circb(x * 2, y * 2, 4, 10)
            if f.status == "delivered":
                p.circb(tx, ty, 19, 10)
                p.line(tx - 6, ty, tx - 1, ty + 5, 10)
                p.line(tx - 1, ty + 5, tx + 8, ty - 6, 10)
            elif f.status in ("wall", "twice", "outside"):
                x, y = f.pos[0] * 2, f.pos[1] * 2
                p.line(x - 5, y - 5, x + 5, y + 5, 8)
                p.line(x - 5, y + 5, x + 5, y - 5, 8)
        px, py = (f.pos if f and f.status in ("flying", "aimed") else w.level.start)
        p.rect(px * 2 - 5, py * 2 - 3, 11, 7, 10)
        p.rectb(px * 2 - 5, py * 2 - 3, 11, 7, 7)
        p.line(px * 2 - 5, py * 2 - 3, px * 2, py * 2 + 1, 9)
        p.line(px * 2 + 5, py * 2 - 3, px * 2, py * 2 + 1, 9)
        p.rect(0, 0, 384, 20, 1)
        p.text(12, 7, f"REFLECTION MAIL / {STAGE}/5 / {STAGE_TITLE}", 7)
        p.rect(0, 256, 384, 104, 1)
        status = "AIM: ONE SHOT PER TRY" if f is None else {
            "aimed": "LOCKED: DIRECTION SAVED", "flying": "FLYING",
            "delivered": "DELIVERED!", "wall": "STOP: WALL",
            "twice": "STOP: SECOND MIRROR", "outside": "STOP: OUTSIDE / 6 SECONDS",
        }[f.status]
        if w.paused:
            status = "PAUSED"
        p.text(12, 267, status, 10)
        p.text(12, 282, f"ANGLE {w.angle:+07.2f}   TIME {f.age if f else 0:04.2f}s   BOUNCES {f.bounces if f else 0}/1", 7)
        p.text(12, 296, NOTE, 6)
        p.text(12, 314, "LEFT/RIGHT: AIM   UP/DOWN: FINE   SPACE: SEND", 7)
        p.text(12, 328, "R: RETRY   ESC: QUIT" + ("   P: PAUSE" if STAGE == 5 else ""), 6)
        p.text(12, 345, "MODEL: PIXELS / SECONDS / ONE DELIVERY", 5)
        self.draws += 1


def main(headless=False, screenshot=None, checkpoint=False):
    import pyxel

    pyxel.init(384, 360, title=f"Reflection Mail - Stage {STAGE}", fps=FPS, headless=headless)
    app = App(pyxel, headless, screenshot, checkpoint)
    pyxel.run(app.update, app.draw)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="render 12 frames and quit")
    parser.add_argument("--screenshot", help="PNG path, requires --headless-smoke")
    parser.add_argument("--demo-checkpoint", action="store_true", help="synthetic model outcome for QA, not human play")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    main(args.headless_smoke, args.screenshot, args.demo_checkpoint)
AFTER STEP 05

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

元のゲームには全部で3つの便があります。角度を決めて、半径2px・速さ96px/秒の封筒を送り、白い鏡では1回だけはね返れます。れんがの壁や、2回目に鏡に触れたときは止まり、郵便受けに届けば成功です。1回の描画(30fps)につき1/120秒の更新を4回行い、最初にぶつかるものを優先して、はね返ったあとの残り時間も処理します。

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

比べる点5ステップの教材元の完成版
面と届く場所元のゲームのB「塀の向こう」の1面。最後の版は-50.75度で配達できる。A・B・Cの3便。最初はまっすぐ、次は上の鏡、最後は横の鏡。
入力と試し撃ち1回撃つごとにRでやり直す。飛んでいる間は角度を固定。細かい調整は1回の描画で0.25度。Pは最後の版だけ。飛んでいる間も角度を変えられる。Spaceで回収し、次に押すと再発射。Zで前の角度、Nで次の便。細かい調整は1回の描画で0.4度。
段階ごとの仕組み01は向きだけ、02はぶつからない、03は鏡でも止まる、04ははね返ったあとの残り時間を捨てる。05で元のゲームのBの飛び方と同じになる。最初から、線分での判定・1回のはね返り・残り時間・きちんと止まる仕組みがある。
衝突と近似05は、壁の番号順・同時なら壁を優先・X方向の法線を優先・四角を半径分広げる・0.01px離す・1回の更新で4回まで・6秒で打ち切り、を守る。同じルール。四角の角では、最大0.83pxほど早めに止まる、安全側の近似。
表示と記録英字(ASCII)・図形・音なし。今の試し撃ちの軌跡だけを表示。12回の描画を自動で撮る仕組みがある。日本語の文字、前回の軌跡の表示切り替え、試し撃ちの回数、便の選択画面、制作用の記録。

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

  1. 元のゲームの model.py の step を読み、first_contact に分けた候補集めと同じ順番になっていることを確かめましょう。
  2. 元のゲームの model.py の LEVELS と borders で、A と C の壁の配置と、郵便受けの半径5・7を読み、B の半径6との違いを比べましょう。
  3. 元のゲームの model.py の ray_box で near と far を、ray_circle で二次方程式の係数を、この教材の数値の例に置きかえて追ってみましょう。
  4. 元のゲームの app.py の fire と update で、回収・前の角度・次の便・一時停止の切り替わりを読みましょう。
自分のゲームに変えるなら

最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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