アクションLESSON 11慣性アクション × 物理シミュレーション原作007

ころがる切手の作り方

すべって、止まって、封筒へ。
「速さ」と「摩擦(すべりにくさ)」を分けて考え、紙の折り目によってすべり方が変わる、小さな操作ゲームを作ります。

完成版の遊び方紙を傾けて切手をすべらせます。折り目に沿う向きはよくすべり、向きによって止まりやすさが違うことを利用して、封筒の上でぴたりと止めます。

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

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

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

完成版の主な操作:方向キーかWASDで紙を傾ける、Pで一時停止、Rでやり直し、Escで面の選択、Nで次の面、Qで終了

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

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

うまく表示されないとき

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

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

速度と位置の単位をそろえる。

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

1. px(ピクセル)と px/s(1秒に何ピクセル動くか)、1回の更新の時間 DT
2. 加速度(px/s²)から速度を、速度から位置を求める
3. 縦横2方向の速度・向きだけを取り出す正規化・だんだん弱まる減速
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数・クラスと self が読める / 座標の x・y、プラスとマイナスの数、四則演算が使える / 辞書とタプル、2乗と平方根を扱える / Pyxel の update と draw、btn と btnp の違いを知っている
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
← → / A D:傾ける(03からは上下・W S も)・P:一時停止と再開・R:最初から・Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_position.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

位置と速度を別の数にする

px(ピクセル)と px/s、1回の更新の時間 DT。

x は今いる場所をピクセル(px)で表し、vx は1秒に動く量を px/s で表します。FPS=60(1秒に60回更新)なら、1回の更新にかかる時間 DT は1/60秒です。速度30px/sに DT を掛けると、1回の更新で動く量は0.5pxになり、これを x に足します。この段階では、左右キーを押しているかどうかで毎回 vx を決めるので、キーを離すとすぐ止まります。y は72pxで固定です。画面の道は2倍の大きさで描いていますが、計算に使う数値は元のゲームと同じピクセルです。端には仮の柵を置いて止め、封筒はまだ位置の目印だけです。

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

01_position.py / 注目する部分
self.vx = clamp(ax, -1, 1) * STEP_SPEED
self.vy = 0.0
self.move_in_rail()

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_position.py
macOS / Linux
.venv/bin/python 01_position.py
画面で見ることR で戻して右キーを1秒ほど押すと、X が24から約54まで増えます。離すと VX は0になります。P で止めて数値を読み、もう一度 P で続けます。同じ確認を自動で行うには --headless-smoke --demo を付けて実行します。
  • 右でXが増え、左で減る
  • 離した瞬間にVXが0になる
  • 「60回の更新×1/60秒=1秒」を説明できる
  • R キーでX=24、TICK=0に戻る
つまずきやすいところx += vx と書くと、1回の更新ごとに30pxも進み、60倍の速さになってしまいます。px/s をピクセルに直すには、秒で表した DT を掛ける必要があります。また、画面を2倍で描いていることを、物理の速度の計算に混ぜないでください。
少し変えてみよう

STEP_SPEED を30から12に変えてみましょう。右を60回の更新ぶん押したとき、X が36px進むと予想して、画面の表示とテストで確かめます。FPS だけを変える場合も、DT=1/FPS の関係は保ちましょう。

01_position.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(231行)
01_position.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.
# 教材にはNoto由来の字形を含めません。上記は原作の通知をそのまま保持。

"""ころがる切手・教材1/5。原作Aの一直線を段階化した独立版。
学習順と数式の前提はLESSON.md。画面はASCII、外部画像・音・字体なし。
"""
from math import ceil, exp, hypot

FPS = 60
DT = 1 / FPS  # 1更新に進むゲーム内秒数。壁時計の実時間ではない。
TILE = 16
RADIUS = 3
START = (24.0, 72.0)
GOAL = (152.0, 72.0)
STEP_SPEED = 30  # 01専用: px/s
ACCEL = 80  # px/s^2
MAX_SPEED = 45  # px/s
PAPER_DRAG = 3.0  # 1/s
PARALLEL_DRAG = 0.6
CROSS_DRAG = 6.0
MAX_SUBSTEP = 0.75  # px
GOAL_HALF = 4  # 中心の許容差、px
GOAL_SPEED = 8  # 必ず「未満」
HOLD_FRAMES = 18


def make_floor():
    """原作Aの9タイル。03以降は中央5枚を横の折り目にする。"""
    return {(tx, 4): "h" if 3 <= tx <= 7 else "paper"
            for tx in range(1, 10)}


def clamp(value, low, high):
    return max(low, min(high, value))


class World:
    def __init__(self):
        self.x, self.y = START
        self.vx = self.vy = 0.0
        self.floor = make_floor()
        self.ticks = 0
        self.paused = False
        self.won = False
        self.hold = 0
        self.contacts = 0
        self.wall_touch = False
        self.input_x = self.input_y = 0

    @property
    def speed(self):
        # 直角三角形: 速さ^2 = vx^2 + vy^2。hypotはその平方根。
        return hypot(self.vx, self.vy)

    @property
    def seconds(self):
        return self.ticks * DT

    @property
    def kind(self):
        return self.floor.get((int(self.x // TILE), int(self.y // TILE)), "paper")

    def move_in_rail(self):
        # 01〜03だけの仮の柵。04で欠けたタイルとの円衝突へ置き換える。
        nx = self.x + self.vx * DT
        ny = self.y + self.vy * DT
        self.x = clamp(nx, TILE + RADIUS, 10 * TILE - RADIUS)
        self.y = clamp(ny, 4 * TILE + RADIUS, 5 * TILE - RADIUS)
        if self.x != nx:
            self.vx = 0.0
        if self.y != ny:
            self.vy = 0.0

    def step(self, ax=0, ay=0):
        if self.paused or self.won:
            return
        self.ticks += 1
        self.input_x, self.input_y = ax, ay
        # 位置px = 速度px/s * 経過秒s。キーを離すとvx=0になる。
        self.vx = clamp(ax, -1, 1) * STEP_SPEED
        self.vy = 0.0
        self.move_in_rail()


STAGE = 1
TITLE = "POSITION / VELOCITY"

def status_lines(world):
    """表示も定数から作る。数値実験で判定とラベルを食い違わせない。"""
    lines = [f"TICK {world.ticks}  TIME {world.seconds:.2f}s  DT {DT:.5f}s",
             f"X {world.x:6.2f}px  Y {world.y:5.2f}px",
             f"VX {world.vx:6.2f}px/s  VY {world.vy:6.2f}px/s  SPEED {world.speed:.2f}px/s"]
    lines.append(f"DIRECT SPEED {STEP_SPEED:g}px/s  NO ACCELERATION")
    return lines


def checkpoint_inputs():
    """再現用の合成入力。人の試遊でもOSキーボード入力でもない。"""
    return [(1, 0)] * 60


class App:
    def __init__(self, pyxel, headless=False, screenshot=None, demo=False):
        self.p = pyxel
        self.world = World()
        self.headless = headless
        self.screenshot = screenshot
        self.demo = demo
        self.actions = checkpoint_inputs() if demo else []
        self.render_frames = 0
        # headlessは初期12更新。demoは合成入力列+結果表示12更新。
        self.limit = len(self.actions) + 12 if demo else 12
        pyxel.init(384, 280, title="Rolling Stamp / " + TITLE,
                   fps=FPS, headless=headless, display_scale=2)
        pyxel.run(self.update, self.draw)

    def update(self):
        p = self.p
        if self.headless and self.render_frames >= self.limit:
            if self.screenshot:
                from pathlib import Path
                path = Path(self.screenshot)
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path), scale=2)
            # snapshotは最後に描いた状態と同じ。quit前に物理更新しない。
            import json
            w = self.world
            print(json.dumps({"stage": STAGE, "synthetic_input": self.demo,
                              "render_frames": self.render_frames,
                              "ticks": w.ticks, "x": w.x, "y": w.y,
                              "vx": w.vx, "vy": w.vy, "won": w.won,
                              "hold": w.hold, "contacts": w.contacts}), flush=True)
            p.quit()
            return
        self.render_frames += 1
        if p.btnp(p.KEY_R):
            self.world = World()
            return
        if p.btnp(p.KEY_P):
            self.world.paused = not self.world.paused
            return
        if self.demo:
            index = self.render_frames - 1
            ax, ay = self.actions[index] if index < len(self.actions) else (0, 0)
        else:
            ax = int(p.btn(p.KEY_RIGHT) or p.btn(p.KEY_D)) - int(p.btn(p.KEY_LEFT) or p.btn(p.KEY_A))
            ay = int(p.btn(p.KEY_DOWN) or p.btn(p.KEY_S)) - int(p.btn(p.KEY_UP) or p.btn(p.KEY_W))
        self.world.step(ax, ay)

    def draw(self):
        p, w = self.p, self.world
        p.cls(1)
        p.text(8, 8, f"007 ROLLING STAMP / {STAGE}: {TITLE}", 10)
        for i, line in enumerate(status_lines(w)):
            p.text(8, 26 + 12 * i, line, 7 if i < 3 else 6)
        p.rect(16, 84, 352, 92, 5)
        p.text(24, 90, "A: STRAIGHT ROAD (DRAWN 2x / UNITS ARE LOGICAL PX)", 7)
        ox, oy, zoom = 16, -10, 2
        for (tx, ty), kind in w.floor.items():
            x, y = ox + zoom * tx * TILE, oy + zoom * ty * TILE
            p.rect(x, y, zoom * TILE, zoom * TILE, 15)
        gx, gy = ox + zoom * GOAL[0], oy + zoom * GOAL[1]
        p.rect(gx - 14, gy - 12, 28, 24, 11 if w.won else 10)
        p.rectb(gx - 14, gy - 12, 28, 24, 4)
        p.line(gx - 13, gy - 11, gx, gy, 4)
        p.line(gx + 13, gy - 11, gx, gy, 4)
        x, y = ox + zoom * w.x, oy + zoom * w.y
        p.line(x, y, x + w.vx * .7, y + w.vy * .7, 8)
        p.circ(x, y, RADIUS * zoom, 12)
        p.pset(x - 2, y - 2, 7)
        p.text(24, 158, "BLUE: STAMP  RED: VELOCITY  YELLOW: ENVELOPE", 7)
        p.text(16, 184, f"RIGHT {FPS} UPDATES = {STEP_SPEED:g}px (NO WALL HIT)", 11)
        tips = [
            "Hold RIGHT: x grows by speed * DT.",
            "Release: velocity becomes zero immediately.",
            "This stage has no inertia or win condition."]
        for i, line in enumerate(tips):
            p.text(16, 202 + 11 * i, line, 7)
        p.text(8, 250, "ARROWS/WASD: TILT  P: PAUSE  R: RESET  ESC: QUIT", 6)
        if self.demo:
            p.text(8, 262, "AUTOMATED INPUT / ACTUAL PYXEL RENDER / NO AUDIO", 8)
        elif STAGE < 3:
            p.text(8, 262, "01/02 USE LEFT/RIGHT ONLY", 6)
        if w.paused:
            p.rect(100, 105, 182, 40, 0)
            p.text(122, 120, "PAUSED / P: RESUME", 10)
        if w.won:
            p.rect(92, 105, 198, 46, 0)
            p.rectb(92, 105, 198, 46, 11)
            p.text(143, 115, "DELIVERED!", 11)
            p.text(115, 132, "R: SAME START / SAME ROAD", 7)


def main():
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="12 updates then quit")
    parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke")
    parser.add_argument("--demo", action="store_true", help="synthetic checkpoint sequence then 12 frames")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    import pyxel  # モデルのテスト時にはウィンドウもPyxel依存も不要。
    App(pyxel, args.headless_smoke, args.screenshot, args.demo)


if __name__ == "__main__":
    main()
STEP 02 / 05

慣性を残して逆入力で止まる

加速度(px/s²)から速度を、速度から位置を求める。

加速度は、1秒ごとに速度がどれだけ変わるかを表します。80px/s²なら、1回の更新で速度が80/60px/s増えます。先に vx に ACCEL*ax*DT を足し、その新しい vx を使って位置を動かします。キーを離して ax=0 になっても vx は消えないので、すべり続けます(これが慣性です)。右へ動いている途中で左を押すと、まず減速し、押し続けると速度がマイナスになって左へ戻ります。わかりやすくするため、この段階だけ摩擦を0にし、速度は±45px/sまでに制限しています。元のゲームの減速は、次の段階で加えます。

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

02_inertia.py / 注目する部分
self.vx += ACCEL * clamp(ax, -1, 1) * DT
self.vx = clamp(self.vx, -MAX_SPEED, MAX_SPEED)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_inertia.py
macOS / Linux
.venv/bin/python 02_inertia.py
画面で見ることR から、右を0.5秒ほど押して離し、0.5秒ほど待ちます。離している間も右へ進みます。そのあと左を0.25秒ほど押すと、右へ進みながら VX が小さくなります。さらに左を押すと、VX がマイナスになって戻ってきます。壁に着く前に試しましょう。
  • キーを離しても速度が残る
  • 逆向きに押すと、まず減速してから向きが変わる
  • 「加速度×秒」の単位を説明できる
  • 端では仮の柵で止まる
つまずきやすいところキーを離すたびに vx=0 に戻すと、01と同じになって慣性がなくなります。左を押した瞬間に vx=-45 にするのも、ブレーキではなく瞬間的な方向転換になってしまいます。
少し変えてみよう

ACCEL を40に半分にして、30回の更新のあとの速度が20px/sになるか調べてみましょう。MAX_SPEED は変えずに、速くなるまでにかかる時間だけを比べます。

02_inertia.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(233行)
02_inertia.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.
# 教材にはNoto由来の字形を含めません。上記は原作の通知をそのまま保持。

"""ころがる切手・教材2/5。原作Aの一直線を段階化した独立版。
学習順と数式の前提はLESSON.md。画面はASCII、外部画像・音・字体なし。
"""
from math import ceil, exp, hypot

FPS = 60
DT = 1 / FPS  # 1更新に進むゲーム内秒数。壁時計の実時間ではない。
TILE = 16
RADIUS = 3
START = (24.0, 72.0)
GOAL = (152.0, 72.0)
STEP_SPEED = 30  # 01専用: px/s
ACCEL = 80  # px/s^2
MAX_SPEED = 45  # px/s
PAPER_DRAG = 3.0  # 1/s
PARALLEL_DRAG = 0.6
CROSS_DRAG = 6.0
MAX_SUBSTEP = 0.75  # px
GOAL_HALF = 4  # 中心の許容差、px
GOAL_SPEED = 8  # 必ず「未満」
HOLD_FRAMES = 18


def make_floor():
    """原作Aの9タイル。03以降は中央5枚を横の折り目にする。"""
    return {(tx, 4): "h" if 3 <= tx <= 7 else "paper"
            for tx in range(1, 10)}


def clamp(value, low, high):
    return max(low, min(high, value))


class World:
    def __init__(self):
        self.x, self.y = START
        self.vx = self.vy = 0.0
        self.floor = make_floor()
        self.ticks = 0
        self.paused = False
        self.won = False
        self.hold = 0
        self.contacts = 0
        self.wall_touch = False
        self.input_x = self.input_y = 0

    @property
    def speed(self):
        # 直角三角形: 速さ^2 = vx^2 + vy^2。hypotはその平方根。
        return hypot(self.vx, self.vy)

    @property
    def seconds(self):
        return self.ticks * DT

    @property
    def kind(self):
        return self.floor.get((int(self.x // TILE), int(self.y // TILE)), "paper")

    def move_in_rail(self):
        # 01〜03だけの仮の柵。04で欠けたタイルとの円衝突へ置き換える。
        nx = self.x + self.vx * DT
        ny = self.y + self.vy * DT
        self.x = clamp(nx, TILE + RADIUS, 10 * TILE - RADIUS)
        self.y = clamp(ny, 4 * TILE + RADIUS, 5 * TILE - RADIUS)
        if self.x != nx:
            self.vx = 0.0
        if self.y != ny:
            self.vy = 0.0

    def step(self, ax=0, ay=0):
        if self.paused or self.won:
            return
        self.ticks += 1
        self.input_x, self.input_y = ax, ay
        # 加速度px/s^2 * DT[s] を速度px/sへ加える。
        # キーを離すとax=0なので今の速度が残る(この段階だけ摩擦なし)。
        self.vx += ACCEL * clamp(ax, -1, 1) * DT
        self.vx = clamp(self.vx, -MAX_SPEED, MAX_SPEED)
        self.vy = 0.0
        self.move_in_rail()


STAGE = 2
TITLE = "INERTIA / BRAKING"

def status_lines(world):
    """表示も定数から作る。数値実験で判定とラベルを食い違わせない。"""
    lines = [f"TICK {world.ticks}  TIME {world.seconds:.2f}s  DT {DT:.5f}s",
             f"X {world.x:6.2f}px  Y {world.y:5.2f}px",
             f"VX {world.vx:6.2f}px/s  VY {world.vy:6.2f}px/s  SPEED {world.speed:.2f}px/s"]
    lines.append(f"ACCEL {ACCEL:g}px/s^2  CAP {MAX_SPEED:g}px/s  DRAG 0")
    return lines


def checkpoint_inputs():
    """再現用の合成入力。人の試遊でもOSキーボード入力でもない。"""
    return [(1, 0)] * 30 + [(0, 0)] * 30 + [(-1, 0)] * 15


class App:
    def __init__(self, pyxel, headless=False, screenshot=None, demo=False):
        self.p = pyxel
        self.world = World()
        self.headless = headless
        self.screenshot = screenshot
        self.demo = demo
        self.actions = checkpoint_inputs() if demo else []
        self.render_frames = 0
        # headlessは初期12更新。demoは合成入力列+結果表示12更新。
        self.limit = len(self.actions) + 12 if demo else 12
        pyxel.init(384, 280, title="Rolling Stamp / " + TITLE,
                   fps=FPS, headless=headless, display_scale=2)
        pyxel.run(self.update, self.draw)

    def update(self):
        p = self.p
        if self.headless and self.render_frames >= self.limit:
            if self.screenshot:
                from pathlib import Path
                path = Path(self.screenshot)
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path), scale=2)
            # snapshotは最後に描いた状態と同じ。quit前に物理更新しない。
            import json
            w = self.world
            print(json.dumps({"stage": STAGE, "synthetic_input": self.demo,
                              "render_frames": self.render_frames,
                              "ticks": w.ticks, "x": w.x, "y": w.y,
                              "vx": w.vx, "vy": w.vy, "won": w.won,
                              "hold": w.hold, "contacts": w.contacts}), flush=True)
            p.quit()
            return
        self.render_frames += 1
        if p.btnp(p.KEY_R):
            self.world = World()
            return
        if p.btnp(p.KEY_P):
            self.world.paused = not self.world.paused
            return
        if self.demo:
            index = self.render_frames - 1
            ax, ay = self.actions[index] if index < len(self.actions) else (0, 0)
        else:
            ax = int(p.btn(p.KEY_RIGHT) or p.btn(p.KEY_D)) - int(p.btn(p.KEY_LEFT) or p.btn(p.KEY_A))
            ay = int(p.btn(p.KEY_DOWN) or p.btn(p.KEY_S)) - int(p.btn(p.KEY_UP) or p.btn(p.KEY_W))
        self.world.step(ax, ay)

    def draw(self):
        p, w = self.p, self.world
        p.cls(1)
        p.text(8, 8, f"007 ROLLING STAMP / {STAGE}: {TITLE}", 10)
        for i, line in enumerate(status_lines(w)):
            p.text(8, 26 + 12 * i, line, 7 if i < 3 else 6)
        p.rect(16, 84, 352, 92, 5)
        p.text(24, 90, "A: STRAIGHT ROAD (DRAWN 2x / UNITS ARE LOGICAL PX)", 7)
        ox, oy, zoom = 16, -10, 2
        for (tx, ty), kind in w.floor.items():
            x, y = ox + zoom * tx * TILE, oy + zoom * ty * TILE
            p.rect(x, y, zoom * TILE, zoom * TILE, 15)
        gx, gy = ox + zoom * GOAL[0], oy + zoom * GOAL[1]
        p.rect(gx - 14, gy - 12, 28, 24, 11 if w.won else 10)
        p.rectb(gx - 14, gy - 12, 28, 24, 4)
        p.line(gx - 13, gy - 11, gx, gy, 4)
        p.line(gx + 13, gy - 11, gx, gy, 4)
        x, y = ox + zoom * w.x, oy + zoom * w.y
        p.line(x, y, x + w.vx * .7, y + w.vy * .7, 8)
        p.circ(x, y, RADIUS * zoom, 12)
        p.pset(x - 2, y - 2, 7)
        p.text(24, 158, "BLUE: STAMP  RED: VELOCITY  YELLOW: ENVELOPE", 7)
        p.text(16, 184, f"ACCEL {ACCEL:g}px/s^2  SPEED CAP {MAX_SPEED:g}px/s", 11)
        tips = [
            "Release: keep moving. LEFT: brake, then reverse.",
            "Drag is deliberately zero in this stage.",
            "R resets position, velocity, and the timer."]
        for i, line in enumerate(tips):
            p.text(16, 202 + 11 * i, line, 7)
        p.text(8, 250, "ARROWS/WASD: TILT  P: PAUSE  R: RESET  ESC: QUIT", 6)
        if self.demo:
            p.text(8, 262, "AUTOMATED INPUT / ACTUAL PYXEL RENDER / NO AUDIO", 8)
        elif STAGE < 3:
            p.text(8, 262, "01/02 USE LEFT/RIGHT ONLY", 6)
        if w.paused:
            p.rect(100, 105, 182, 40, 0)
            p.text(122, 120, "PAUSED / P: RESUME", 10)
        if w.won:
            p.rect(92, 105, 198, 46, 0)
            p.rectb(92, 105, 198, 46, 11)
            p.text(143, 115, "DELIVERED!", 11)
            p.text(115, 132, "R: SAME START / SAME ROAD", 7)


def main():
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="12 updates then quit")
    parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke")
    parser.add_argument("--demo", action="store_true", help="synthetic checkpoint sequence then 12 frames")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    import pyxel  # モデルのテスト時にはウィンドウもPyxel依存も不要。
    App(pyxel, args.headless_smoke, args.screenshot, args.demo)


if __name__ == "__main__":
    main()
STEP 03 / 05

折り目の向きで減速を変える

縦横2方向の速度・向きだけを取り出す正規化・だんだん弱まる減速。

ここから上下の動きも扱います。vx と vy は横と縦の速度で、全体の速さは三平方の定理で sqrt(vx²+vy²) と計算します(hypot がこの計算をしてくれます)。ななめ入力 (1,1) の長さは sqrt(2) なので、その長さで両方を割り、ななめのときだけ加速が強くならないようにします(これを正規化といいます)。次に、減速の強さ k(単位は1/s)を使い、r=exp(-k*DT) を「1回の更新のあとに速度が残る割合」とします。exp(z) は自然対数の底 e の z 乗です。キーを押していなければ vx*=r で減らします。押しているときは、一定の加速度 a と、速さに比例する抵抗を同時に扱う式 v_new=v*r+(a/k)*(1-r) を使います。式を導けなくても大丈夫ですが、a/k の単位が px/s になることは確かめておきましょう。普通の紙は縦横とも k=3、横向きの折り目の上では横0.6・縦6です。k が大きいほど早く減速します。詳しい計算例は LESSON.md にあります。

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

03_fold_drag.py / 注目する部分
rx = exp(-kx * DT)
ry = exp(-ky * DT)
self.vx = self.vx * rx + ACCEL * ax / kx * (1 - rx)
self.vy = self.vy * ry + ACCEL * ay / ky * (1 - ry)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_fold_drag.py
macOS / Linux
.venv/bin/python 03_fold_drag.py
画面で見ることR から右へ進むと、X=48で FLOOR が h(横の折り目)になり、DRAG X 0.6/s Y 6/s と表示されます。横の折り目の上で上キーも少し押して離し、VX と VY の減り方を比べましょう。道は細いので、上下は壁に着く前の短い入力で観察します。まだ仮の柵と、封筒の目印だけです。
  • 普通の紙と折り目で表示が切り替わる
  • 横の折り目の上では、横より縦の速度が早く小さくなる
  • ななめ入力の長さを1にそろえる理由がわかる
  • 速度には減速の式、位置には「新しい速度×DT」を使う
つまずきやすいところ0.6を毎フレームそのまま速度に掛けると、1秒間に60回も強く減速してしまいます。また、x と y に別々に最大値45を決めると、ななめの速さが45を超えてしまいます。全体の速さを計算して、同じ割合で縮めます。
少し変えてみよう

横の折り目の PARALLEL_DRAG を0.6から3に変えて、普通の紙に近いすべり方になるか比べてみましょう。次に make_floor の h を v に変えると、X と Y の減速と、描く線の向きが入れかわることを確かめます。k はプラスの数にしておきます。

03_fold_drag.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(266行)
03_fold_drag.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.
# 教材にはNoto由来の字形を含めません。上記は原作の通知をそのまま保持。

"""ころがる切手・教材3/5。原作Aの一直線を段階化した独立版。
学習順と数式の前提はLESSON.md。画面はASCII、外部画像・音・字体なし。
"""
from math import ceil, exp, hypot

FPS = 60
DT = 1 / FPS  # 1更新に進むゲーム内秒数。壁時計の実時間ではない。
TILE = 16
RADIUS = 3
START = (24.0, 72.0)
GOAL = (152.0, 72.0)
STEP_SPEED = 30  # 01専用: px/s
ACCEL = 80  # px/s^2
MAX_SPEED = 45  # px/s
PAPER_DRAG = 3.0  # 1/s
PARALLEL_DRAG = 0.6
CROSS_DRAG = 6.0
MAX_SUBSTEP = 0.75  # px
GOAL_HALF = 4  # 中心の許容差、px
GOAL_SPEED = 8  # 必ず「未満」
HOLD_FRAMES = 18


def make_floor():
    """原作Aの9タイル。03以降は中央5枚を横の折り目にする。"""
    return {(tx, 4): "h" if 3 <= tx <= 7 else "paper"
            for tx in range(1, 10)}


def clamp(value, low, high):
    return max(low, min(high, value))


class World:
    def __init__(self):
        self.x, self.y = START
        self.vx = self.vy = 0.0
        self.floor = make_floor()
        self.ticks = 0
        self.paused = False
        self.won = False
        self.hold = 0
        self.contacts = 0
        self.wall_touch = False
        self.input_x = self.input_y = 0

    @property
    def speed(self):
        # 直角三角形: 速さ^2 = vx^2 + vy^2。hypotはその平方根。
        return hypot(self.vx, self.vy)

    @property
    def seconds(self):
        return self.ticks * DT

    @property
    def kind(self):
        return self.floor.get((int(self.x // TILE), int(self.y // TILE)), "paper")

    def move_in_rail(self):
        # 01〜03だけの仮の柵。04で欠けたタイルとの円衝突へ置き換える。
        nx = self.x + self.vx * DT
        ny = self.y + self.vy * DT
        self.x = clamp(nx, TILE + RADIUS, 10 * TILE - RADIUS)
        self.y = clamp(ny, 4 * TILE + RADIUS, 5 * TILE - RADIUS)
        if self.x != nx:
            self.vx = 0.0
        if self.y != ny:
            self.vy = 0.0

    def damping(self):
        # 横折り目はxが平行・yが垂直。vは発展実験用の縦折り目。
        if self.kind == "h":
            return PARALLEL_DRAG, CROSS_DRAG
        if self.kind == "v":
            return CROSS_DRAG, PARALLEL_DRAG
        return PAPER_DRAG, PAPER_DRAG

    def accelerate(self, ax, ay):
        # (1,1)の長さはsqrt(2)。長さで割れば斜めでも加速度の強さは1。
        length = hypot(ax, ay)
        if length > 1:
            ax /= length
            ay /= length
        kx, ky = self.damping()
        # expは自然対数の底eのべき乗。r=exp(-k*DT)は1更新後に残る速度の割合。
        # k[1/s]*DT[s]は単位なし。k=3、60Hzならrは約0.9512。
        # dv/dt = a-k*v の一定a,kでの解: v_new=v*r+(a/k)*(1-r)。
        # a/kの単位はpx/s。入力0なら古い速度にrを掛けるだけ。
        # 位置は解析解ではなく更新後の速度*DT。ここも原作に合わせる。
        rx = exp(-kx * DT)
        ry = exp(-ky * DT)
        self.vx = self.vx * rx + ACCEL * ax / kx * (1 - rx)
        self.vy = self.vy * ry + ACCEL * ay / ky * (1 - ry)
        # 大きさだけ上限へ縮める。各軸を別々に切ると斜めが速くなる。
        speed = self.speed
        if speed > MAX_SPEED:
            self.vx *= MAX_SPEED / speed
            self.vy *= MAX_SPEED / speed

    def step(self, ax=0, ay=0):
        if self.paused or self.won:
            return
        self.ticks += 1
        self.input_x, self.input_y = ax, ay
        self.accelerate(ax, ay)
        self.move_in_rail()


STAGE = 3
TITLE = "DIRECTIONAL DRAG"

def status_lines(world):
    """表示も定数から作る。数値実験で判定とラベルを食い違わせない。"""
    lines = [f"TICK {world.ticks}  TIME {world.seconds:.2f}s  DT {DT:.5f}s",
             f"X {world.x:6.2f}px  Y {world.y:5.2f}px",
             f"VX {world.vx:6.2f}px/s  VY {world.vy:6.2f}px/s  SPEED {world.speed:.2f}px/s"]
    kx, ky = world.damping()
    lines.append(f"FLOOR {world.kind}  DRAG X {kx:g}/s Y {ky:g}/s")
    return lines


def checkpoint_inputs():
    """再現用の合成入力。人の試遊でもOSキーボード入力でもない。"""
    return [(1, 0)] * 120 + [(1, -1)] * 6 + [(0, 0)] * 10


class App:
    def __init__(self, pyxel, headless=False, screenshot=None, demo=False):
        self.p = pyxel
        self.world = World()
        self.headless = headless
        self.screenshot = screenshot
        self.demo = demo
        self.actions = checkpoint_inputs() if demo else []
        self.render_frames = 0
        # headlessは初期12更新。demoは合成入力列+結果表示12更新。
        self.limit = len(self.actions) + 12 if demo else 12
        pyxel.init(384, 280, title="Rolling Stamp / " + TITLE,
                   fps=FPS, headless=headless, display_scale=2)
        pyxel.run(self.update, self.draw)

    def update(self):
        p = self.p
        if self.headless and self.render_frames >= self.limit:
            if self.screenshot:
                from pathlib import Path
                path = Path(self.screenshot)
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path), scale=2)
            # snapshotは最後に描いた状態と同じ。quit前に物理更新しない。
            import json
            w = self.world
            print(json.dumps({"stage": STAGE, "synthetic_input": self.demo,
                              "render_frames": self.render_frames,
                              "ticks": w.ticks, "x": w.x, "y": w.y,
                              "vx": w.vx, "vy": w.vy, "won": w.won,
                              "hold": w.hold, "contacts": w.contacts}), flush=True)
            p.quit()
            return
        self.render_frames += 1
        if p.btnp(p.KEY_R):
            self.world = World()
            return
        if p.btnp(p.KEY_P):
            self.world.paused = not self.world.paused
            return
        if self.demo:
            index = self.render_frames - 1
            ax, ay = self.actions[index] if index < len(self.actions) else (0, 0)
        else:
            ax = int(p.btn(p.KEY_RIGHT) or p.btn(p.KEY_D)) - int(p.btn(p.KEY_LEFT) or p.btn(p.KEY_A))
            ay = int(p.btn(p.KEY_DOWN) or p.btn(p.KEY_S)) - int(p.btn(p.KEY_UP) or p.btn(p.KEY_W))
        self.world.step(ax, ay)

    def draw(self):
        p, w = self.p, self.world
        p.cls(1)
        p.text(8, 8, f"007 ROLLING STAMP / {STAGE}: {TITLE}", 10)
        for i, line in enumerate(status_lines(w)):
            p.text(8, 26 + 12 * i, line, 7 if i < 3 else 6)
        p.rect(16, 84, 352, 92, 5)
        p.text(24, 90, "A: STRAIGHT ROAD (DRAWN 2x / UNITS ARE LOGICAL PX)", 7)
        ox, oy, zoom = 16, -10, 2
        for (tx, ty), kind in w.floor.items():
            x, y = ox + zoom * tx * TILE, oy + zoom * ty * TILE
            p.rect(x, y, zoom * TILE, zoom * TILE, 15)
            if kind == "h":
                for dy in (4, 8, 12):
                    p.line(x, y + dy * zoom, x + TILE * zoom - 1, y + dy * zoom, 6)
            if kind == "v":
                for dx in (4, 8, 12):
                    p.line(x + dx * zoom, y, x + dx * zoom, y + TILE * zoom - 1, 6)
        gx, gy = ox + zoom * GOAL[0], oy + zoom * GOAL[1]
        p.rect(gx - 14, gy - 12, 28, 24, 11 if w.won else 10)
        p.rectb(gx - 14, gy - 12, 28, 24, 4)
        p.line(gx - 13, gy - 11, gx, gy, 4)
        p.line(gx + 13, gy - 11, gx, gy, 4)
        x, y = ox + zoom * w.x, oy + zoom * w.y
        p.line(x, y, x + w.vx * .7, y + w.vy * .7, 8)
        p.circ(x, y, RADIUS * zoom, 12)
        p.pset(x - 2, y - 2, 7)
        p.text(24, 158, "BLUE: STAMP  RED: VELOCITY  YELLOW: ENVELOPE", 7)
        p.text(16, 184, f"ACCEL {ACCEL:g}px/s^2  SPEED CAP {MAX_SPEED:g}px/s", 11)
        tips = [
            "Fold h: X slides, Y slows down quickly.",
            "Watch VX/VY and drag while crossing a fold.",
            "Temporary rail clamp; no win condition yet."]
        for i, line in enumerate(tips):
            p.text(16, 202 + 11 * i, line, 7)
        p.text(8, 250, "ARROWS/WASD: TILT  P: PAUSE  R: RESET  ESC: QUIT", 6)
        if self.demo:
            p.text(8, 262, "AUTOMATED INPUT / ACTUAL PYXEL RENDER / NO AUDIO", 8)
        elif STAGE < 3:
            p.text(8, 262, "01/02 USE LEFT/RIGHT ONLY", 6)
        if w.paused:
            p.rect(100, 105, 182, 40, 0)
            p.text(122, 120, "PAUSED / P: RESUME", 10)
        if w.won:
            p.rect(92, 105, 198, 46, 0)
            p.rectb(92, 105, 198, 46, 11)
            p.text(143, 115, "DELIVERED!", 11)
            p.text(115, 132, "R: SAME START / SAME ROAD", 7)


def main():
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="12 updates then quit")
    parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke")
    parser.add_argument("--demo", action="store_true", help="synthetic checkpoint sequence then 12 frames")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    import pyxel  # モデルのテスト時にはウィンドウもPyxel依存も不要。
    App(pyxel, args.headless_smoke, args.screenshot, args.demo)


if __name__ == "__main__":
    main()
STEP 04 / 05

円と壁を軸ごとに解決する

いちばん近い点・距離の2乗・細かく分けた当たり判定。

仮の柵を、元のゲームの壁の判定に置きかえます。道は16pxのタイルの辞書で、タイルがない場所は壁です。半径3pxの円(切手)のまわりのタイルを調べ、壁の四角の中で円の中心にいちばん近い点を clamp で求めます。その点までの距離の2乗が、半径の2乗より小さければ重なっています。ぴったり接しているだけなら通します(小数のわずかな誤差 1e-8 は許します)。まず x 方向だけ動かしてみて、壁なら vx だけを0にします。次に y 方向を試し、壁なら vy だけを0にします。こうすると、ななめにぶつかっても、壁に沿った方向の速度は残ります。また、速いときに壁をすり抜けないよう、speed*DT の移動を ceil(切り上げ)で分割し、1回に動く距離を0.75px以下にします。

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

04_collision.py / 注目する部分
nx = self.x + self.vx * DT / count
if self.wall(nx, self.y):
    self.vx = 0.0
    touch = True
else:
    self.x = nx

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_collision.py
macOS / Linux
.venv/bin/python 04_collision.py
画面で見ることR から右上を押すと上の壁に当たり、Y は67あたりで止まっても X は増え続けます。右だけを押して右端まで行くと、X は157以下で止まります。WALL CONTACTS は「壁に触れていない状態から触れた状態になった回数」で、キーを押した回数ではありません。封筒に着いても、まだ成功の表示は出ません。
  • 上の壁ではVYだけが0になる
  • 右端でも円全体が道の内側に残る
  • 1回の更新で動く距離と、分ける回数の単位を説明できる
  • 封筒はまだ目印だけ
つまずきやすいところどちらかの壁に当たるたびに vx と vy を両方0にすると、壁に沿ってすべれなくなります。また、タイルの境目で中心の点だけを調べると、半径3px分だけ壁にめりこんでしまいます。
少し変えてみよう

MAX_SUBSTEP を0.75から0.25に変えて、右上に押したあとの位置を比べてみましょう。安全さは変わりませんが、ぶつかる直前の残りの距離は分け方によって変わるので、まったく同じ座標になるとは限りません。速いときのテストも実行してみましょう。

04_collision.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(290行)
04_collision.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.
# 教材にはNoto由来の字形を含めません。上記は原作の通知をそのまま保持。

"""ころがる切手・教材4/5。原作Aの一直線を段階化した独立版。
学習順と数式の前提はLESSON.md。画面はASCII、外部画像・音・字体なし。
"""
from math import ceil, exp, hypot

FPS = 60
DT = 1 / FPS  # 1更新に進むゲーム内秒数。壁時計の実時間ではない。
TILE = 16
RADIUS = 3
START = (24.0, 72.0)
GOAL = (152.0, 72.0)
STEP_SPEED = 30  # 01専用: px/s
ACCEL = 80  # px/s^2
MAX_SPEED = 45  # px/s
PAPER_DRAG = 3.0  # 1/s
PARALLEL_DRAG = 0.6
CROSS_DRAG = 6.0
MAX_SUBSTEP = 0.75  # px
GOAL_HALF = 4  # 中心の許容差、px
GOAL_SPEED = 8  # 必ず「未満」
HOLD_FRAMES = 18


def make_floor():
    """原作Aの9タイル。03以降は中央5枚を横の折り目にする。"""
    return {(tx, 4): "h" if 3 <= tx <= 7 else "paper"
            for tx in range(1, 10)}


def clamp(value, low, high):
    return max(low, min(high, value))


class World:
    def __init__(self):
        self.x, self.y = START
        self.vx = self.vy = 0.0
        self.floor = make_floor()
        self.ticks = 0
        self.paused = False
        self.won = False
        self.hold = 0
        self.contacts = 0
        self.wall_touch = False
        self.input_x = self.input_y = 0

    @property
    def speed(self):
        # 直角三角形: 速さ^2 = vx^2 + vy^2。hypotはその平方根。
        return hypot(self.vx, self.vy)

    @property
    def seconds(self):
        return self.ticks * DT

    @property
    def kind(self):
        return self.floor.get((int(self.x // TILE), int(self.y // TILE)), "paper")

    def damping(self):
        # 横折り目はxが平行・yが垂直。vは発展実験用の縦折り目。
        if self.kind == "h":
            return PARALLEL_DRAG, CROSS_DRAG
        if self.kind == "v":
            return CROSS_DRAG, PARALLEL_DRAG
        return PAPER_DRAG, PAPER_DRAG

    def accelerate(self, ax, ay):
        # (1,1)の長さはsqrt(2)。長さで割れば斜めでも加速度の強さは1。
        length = hypot(ax, ay)
        if length > 1:
            ax /= length
            ay /= length
        kx, ky = self.damping()
        # expは自然対数の底eのべき乗。r=exp(-k*DT)は1更新後に残る速度の割合。
        # k[1/s]*DT[s]は単位なし。k=3、60Hzならrは約0.9512。
        # dv/dt = a-k*v の一定a,kでの解: v_new=v*r+(a/k)*(1-r)。
        # a/kの単位はpx/s。入力0なら古い速度にrを掛けるだけ。
        # 位置は解析解ではなく更新後の速度*DT。ここも原作に合わせる。
        rx = exp(-kx * DT)
        ry = exp(-ky * DT)
        self.vx = self.vx * rx + ACCEL * ax / kx * (1 - rx)
        self.vy = self.vy * ry + ACCEL * ay / ky * (1 - ry)
        # 大きさだけ上限へ縮める。各軸を別々に切ると斜めが速くなる。
        speed = self.speed
        if speed > MAX_SPEED:
            self.vx *= MAX_SPEED / speed
            self.vy *= MAX_SPEED / speed

    def wall(self, x, y):
        # 円を囲むタイルだけを走査。floorにないタイルは壁。
        for ty in range(int((y - RADIUS) // TILE), int((y + RADIUS) // TILE) + 1):
            for tx in range(int((x - RADIUS) // TILE), int((x + RADIUS) // TILE) + 1):
                if (tx, ty) in self.floor:
                    continue
                # 四角内で円の中心に最も近い点。平方距離で円との重なりを調べる。
                qx = clamp(x, tx * TILE, (tx + 1) * TILE)
                qy = clamp(y, ty * TILE, (ty + 1) * TILE)
                if (qx - x) ** 2 + (qy - y) ** 2 < RADIUS ** 2 - 1e-8:
                    return True
        return False

    def move_with_walls(self):
        # 1回の移動距離はspeed*DT [px]。ceilで切り上げ0.75px以下に分割。
        count = max(1, ceil(self.speed * DT / MAX_SUBSTEP))
        touch = False
        for _ in range(count):
            nx = self.x + self.vx * DT / count
            if self.wall(nx, self.y):
                self.vx = 0.0
                touch = True
            else:
                self.x = nx
            ny = self.y + self.vy * DT / count
            if self.wall(self.x, ny):
                self.vy = 0.0
                touch = True
            else:
                self.y = ny
        # 非接触から接触へ変わった回数を数える。壁に沿う速度は残す。
        if touch and not self.wall_touch:
            self.contacts += 1
        self.wall_touch = touch

    def step(self, ax=0, ay=0):
        if self.paused or self.won:
            return
        self.ticks += 1
        self.input_x, self.input_y = ax, ay
        self.accelerate(ax, ay)
        self.move_with_walls()


STAGE = 4
TITLE = "CIRCLE / WALL"

def status_lines(world):
    """表示も定数から作る。数値実験で判定とラベルを食い違わせない。"""
    lines = [f"TICK {world.ticks}  TIME {world.seconds:.2f}s  DT {DT:.5f}s",
             f"X {world.x:6.2f}px  Y {world.y:5.2f}px",
             f"VX {world.vx:6.2f}px/s  VY {world.vy:6.2f}px/s  SPEED {world.speed:.2f}px/s"]
    kx, ky = world.damping()
    lines.append(f"FLOOR {world.kind}  DRAG X {kx:g}/s Y {ky:g}/s")
    return lines


def checkpoint_inputs():
    """再現用の合成入力。人の試遊でもOSキーボード入力でもない。"""
    return [(1, -1)] * 120


class App:
    def __init__(self, pyxel, headless=False, screenshot=None, demo=False):
        self.p = pyxel
        self.world = World()
        self.headless = headless
        self.screenshot = screenshot
        self.demo = demo
        self.actions = checkpoint_inputs() if demo else []
        self.render_frames = 0
        # headlessは初期12更新。demoは合成入力列+結果表示12更新。
        self.limit = len(self.actions) + 12 if demo else 12
        pyxel.init(384, 280, title="Rolling Stamp / " + TITLE,
                   fps=FPS, headless=headless, display_scale=2)
        pyxel.run(self.update, self.draw)

    def update(self):
        p = self.p
        if self.headless and self.render_frames >= self.limit:
            if self.screenshot:
                from pathlib import Path
                path = Path(self.screenshot)
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path), scale=2)
            # snapshotは最後に描いた状態と同じ。quit前に物理更新しない。
            import json
            w = self.world
            print(json.dumps({"stage": STAGE, "synthetic_input": self.demo,
                              "render_frames": self.render_frames,
                              "ticks": w.ticks, "x": w.x, "y": w.y,
                              "vx": w.vx, "vy": w.vy, "won": w.won,
                              "hold": w.hold, "contacts": w.contacts}), flush=True)
            p.quit()
            return
        self.render_frames += 1
        if p.btnp(p.KEY_R):
            self.world = World()
            return
        if p.btnp(p.KEY_P):
            self.world.paused = not self.world.paused
            return
        if self.demo:
            index = self.render_frames - 1
            ax, ay = self.actions[index] if index < len(self.actions) else (0, 0)
        else:
            ax = int(p.btn(p.KEY_RIGHT) or p.btn(p.KEY_D)) - int(p.btn(p.KEY_LEFT) or p.btn(p.KEY_A))
            ay = int(p.btn(p.KEY_DOWN) or p.btn(p.KEY_S)) - int(p.btn(p.KEY_UP) or p.btn(p.KEY_W))
        self.world.step(ax, ay)

    def draw(self):
        p, w = self.p, self.world
        p.cls(1)
        p.text(8, 8, f"007 ROLLING STAMP / {STAGE}: {TITLE}", 10)
        for i, line in enumerate(status_lines(w)):
            p.text(8, 26 + 12 * i, line, 7 if i < 3 else 6)
        p.rect(16, 84, 352, 92, 5)
        p.text(24, 90, "A: STRAIGHT ROAD (DRAWN 2x / UNITS ARE LOGICAL PX)", 7)
        ox, oy, zoom = 16, -10, 2
        for (tx, ty), kind in w.floor.items():
            x, y = ox + zoom * tx * TILE, oy + zoom * ty * TILE
            p.rect(x, y, zoom * TILE, zoom * TILE, 15)
            if kind == "h":
                for dy in (4, 8, 12):
                    p.line(x, y + dy * zoom, x + TILE * zoom - 1, y + dy * zoom, 6)
            if kind == "v":
                for dx in (4, 8, 12):
                    p.line(x + dx * zoom, y, x + dx * zoom, y + TILE * zoom - 1, 6)
        gx, gy = ox + zoom * GOAL[0], oy + zoom * GOAL[1]
        p.rect(gx - 14, gy - 12, 28, 24, 11 if w.won else 10)
        p.rectb(gx - 14, gy - 12, 28, 24, 4)
        p.line(gx - 13, gy - 11, gx, gy, 4)
        p.line(gx + 13, gy - 11, gx, gy, 4)
        x, y = ox + zoom * w.x, oy + zoom * w.y
        p.line(x, y, x + w.vx * .7, y + w.vy * .7, 8)
        p.circ(x, y, RADIUS * zoom, 12)
        p.pset(x - 2, y - 2, 7)
        p.text(24, 158, "BLUE: STAMP  RED: VELOCITY  YELLOW: ENVELOPE", 7)
        p.text(16, 184, f"WALL CONTACTS {w.contacts}  RADIUS {RADIUS:g}px  STEP <= {MAX_SUBSTEP:g}px", 11)
        tips = [
            "UP+RIGHT: hit the top, keep moving along it.",
            "Only the blocked velocity component becomes 0.",
            "Envelope is a marker; delivery comes in 05."]
        for i, line in enumerate(tips):
            p.text(16, 202 + 11 * i, line, 7)
        p.text(8, 250, "ARROWS/WASD: TILT  P: PAUSE  R: RESET  ESC: QUIT", 6)
        if self.demo:
            p.text(8, 262, "AUTOMATED INPUT / ACTUAL PYXEL RENDER / NO AUDIO", 8)
        elif STAGE < 3:
            p.text(8, 262, "01/02 USE LEFT/RIGHT ONLY", 6)
        if w.paused:
            p.rect(100, 105, 182, 40, 0)
            p.text(122, 120, "PAUSED / P: RESUME", 10)
        if w.won:
            p.rect(92, 105, 198, 46, 0)
            p.rectb(92, 105, 198, 46, 11)
            p.text(143, 115, "DELIVERED!", 11)
            p.text(115, 132, "R: SAME START / SAME ROAD", 7)


def main():
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="12 updates then quit")
    parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke")
    parser.add_argument("--demo", action="store_true", help="synthetic checkpoint sequence then 12 frames")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    import pyxel  # モデルのテスト時にはウィンドウもPyxel依存も不要。
    App(pyxel, args.headless_smoke, args.screenshot, args.demo)


if __name__ == "__main__":
    main()
STEP 05 / 05

ゆっくり留まると封が閉じる

位置と速さの組み合わせ条件・続いた回数のカウント・終わりの状態。

封筒の中心 (152,72) から、x も y も±4px以内にいて、しかも速さが8px/s未満なら、hold を1増やします。これは切手の中心で判定していて、切手の絵全体が封筒に入ったかどうかではありません。18回の更新のあいだ続けて条件を満たすと won(成功)になり、位置も時間も止まります。位置はちょうど±4pxも含み、速さはちょうど8では成功になりません。条件から外れたら hold=0 に戻します。P は一時停止、R は新しい World に置きかえて、成功・速度・経過時間・壁に触れた回数も最初に戻します。最後の版の物理と判定は、元のゲームの A と同じです。

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

05_delivery.py / 注目する部分
if self.qualifies():
    self.hold += 1
else:
    self.hold = 0
if self.hold >= HOLD_FRAMES:
    self.won = True

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_delivery.py
macOS / Linux
.venv/bin/python 05_delivery.py
画面で見ること確実な練習の手順:右を押して、右の壁で止まるまで進みます(最初の設定では4秒ほど)。左を0.2秒ほど短く押して離し、封筒の中でゆっくり止まるのを待ちます。HOLD が18になると DELIVERED!(配達完了)です。R でやり直し、今度は壁に当てずに手前でブレーキをかけてみましょう。--demo を付けると「右を240回→左を12回→入力なしで120回」の更新を再現し、273回目で成功します。
  • 速く通り過ぎただけでは成功しない
  • 条件から外れるとHOLDが0に戻る
  • 18回続けて条件を満たすと成功し、時間が止まる
  • 成功したあとも、R キーで同じ最初の状態に戻る
つまずきやすいところhold を一度増やしたら減らさないようにすると、とびとびに封筒に触れるだけで成功してしまいます。速さを調べずに位置だけを見ると、速く通り過ぎても配達できてしまいます。
少し変えてみよう

HOLD_FRAMES を18から30に変えて、止まっている必要がある時間が0.3秒から0.5秒に変わることを確かめましょう。GOAL_SPEED を4に下げた場合も、画面に出る条件と判定が一緒に変わるか確かめましょう。

05_delivery.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(310行)
05_delivery.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.
# 教材にはNoto由来の字形を含めません。上記は原作の通知をそのまま保持。

"""ころがる切手・教材5/5。原作Aの一直線を段階化した独立版。
学習順と数式の前提はLESSON.md。画面はASCII、外部画像・音・字体なし。
"""
from math import ceil, exp, hypot

FPS = 60
DT = 1 / FPS  # 1更新に進むゲーム内秒数。壁時計の実時間ではない。
TILE = 16
RADIUS = 3
START = (24.0, 72.0)
GOAL = (152.0, 72.0)
STEP_SPEED = 30  # 01専用: px/s
ACCEL = 80  # px/s^2
MAX_SPEED = 45  # px/s
PAPER_DRAG = 3.0  # 1/s
PARALLEL_DRAG = 0.6
CROSS_DRAG = 6.0
MAX_SUBSTEP = 0.75  # px
GOAL_HALF = 4  # 中心の許容差、px
GOAL_SPEED = 8  # 必ず「未満」
HOLD_FRAMES = 18


def make_floor():
    """原作Aの9タイル。03以降は中央5枚を横の折り目にする。"""
    return {(tx, 4): "h" if 3 <= tx <= 7 else "paper"
            for tx in range(1, 10)}


def clamp(value, low, high):
    return max(low, min(high, value))


class World:
    def __init__(self):
        self.x, self.y = START
        self.vx = self.vy = 0.0
        self.floor = make_floor()
        self.ticks = 0
        self.paused = False
        self.won = False
        self.hold = 0
        self.contacts = 0
        self.wall_touch = False
        self.input_x = self.input_y = 0

    @property
    def speed(self):
        # 直角三角形: 速さ^2 = vx^2 + vy^2。hypotはその平方根。
        return hypot(self.vx, self.vy)

    @property
    def seconds(self):
        return self.ticks * DT

    @property
    def kind(self):
        return self.floor.get((int(self.x // TILE), int(self.y // TILE)), "paper")

    def damping(self):
        # 横折り目はxが平行・yが垂直。vは発展実験用の縦折り目。
        if self.kind == "h":
            return PARALLEL_DRAG, CROSS_DRAG
        if self.kind == "v":
            return CROSS_DRAG, PARALLEL_DRAG
        return PAPER_DRAG, PAPER_DRAG

    def accelerate(self, ax, ay):
        # (1,1)の長さはsqrt(2)。長さで割れば斜めでも加速度の強さは1。
        length = hypot(ax, ay)
        if length > 1:
            ax /= length
            ay /= length
        kx, ky = self.damping()
        # expは自然対数の底eのべき乗。r=exp(-k*DT)は1更新後に残る速度の割合。
        # k[1/s]*DT[s]は単位なし。k=3、60Hzならrは約0.9512。
        # dv/dt = a-k*v の一定a,kでの解: v_new=v*r+(a/k)*(1-r)。
        # a/kの単位はpx/s。入力0なら古い速度にrを掛けるだけ。
        # 位置は解析解ではなく更新後の速度*DT。ここも原作に合わせる。
        rx = exp(-kx * DT)
        ry = exp(-ky * DT)
        self.vx = self.vx * rx + ACCEL * ax / kx * (1 - rx)
        self.vy = self.vy * ry + ACCEL * ay / ky * (1 - ry)
        # 大きさだけ上限へ縮める。各軸を別々に切ると斜めが速くなる。
        speed = self.speed
        if speed > MAX_SPEED:
            self.vx *= MAX_SPEED / speed
            self.vy *= MAX_SPEED / speed

    def wall(self, x, y):
        # 円を囲むタイルだけを走査。floorにないタイルは壁。
        for ty in range(int((y - RADIUS) // TILE), int((y + RADIUS) // TILE) + 1):
            for tx in range(int((x - RADIUS) // TILE), int((x + RADIUS) // TILE) + 1):
                if (tx, ty) in self.floor:
                    continue
                # 四角内で円の中心に最も近い点。平方距離で円との重なりを調べる。
                qx = clamp(x, tx * TILE, (tx + 1) * TILE)
                qy = clamp(y, ty * TILE, (ty + 1) * TILE)
                if (qx - x) ** 2 + (qy - y) ** 2 < RADIUS ** 2 - 1e-8:
                    return True
        return False

    def move_with_walls(self):
        # 1回の移動距離はspeed*DT [px]。ceilで切り上げ0.75px以下に分割。
        count = max(1, ceil(self.speed * DT / MAX_SUBSTEP))
        touch = False
        for _ in range(count):
            nx = self.x + self.vx * DT / count
            if self.wall(nx, self.y):
                self.vx = 0.0
                touch = True
            else:
                self.x = nx
            ny = self.y + self.vy * DT / count
            if self.wall(self.x, ny):
                self.vy = 0.0
                touch = True
            else:
                self.y = ny
        # 非接触から接触へ変わった回数を数える。壁に沿う速度は残す。
        if touch and not self.wall_touch:
            self.contacts += 1
        self.wall_touch = touch

    def qualifies(self):
        gx, gy = GOAL
        return (abs(self.x - gx) <= GOAL_HALF
                and abs(self.y - gy) <= GOAL_HALF
                and self.speed < GOAL_SPEED)

    def check_goal(self):
        # 累計ではなく連続。1更新でも外れれば0へ戻す。
        if self.qualifies():
            self.hold += 1
        else:
            self.hold = 0
        if self.hold >= HOLD_FRAMES:
            self.won = True

    def step(self, ax=0, ay=0):
        if self.paused or self.won:
            return
        self.ticks += 1
        self.input_x, self.input_y = ax, ay
        self.accelerate(ax, ay)
        self.move_with_walls()
        self.check_goal()


STAGE = 5
TITLE = "SLOW DELIVERY"

def status_lines(world):
    """表示も定数から作る。数値実験で判定とラベルを食い違わせない。"""
    lines = [f"TICK {world.ticks}  TIME {world.seconds:.2f}s  DT {DT:.5f}s",
             f"X {world.x:6.2f}px  Y {world.y:5.2f}px",
             f"VX {world.vx:6.2f}px/s  VY {world.vy:6.2f}px/s  SPEED {world.speed:.2f}px/s"]
    kx, ky = world.damping()
    lines.append(f"FLOOR {world.kind}  DRAG X {kx:g}/s Y {ky:g}/s")
    return lines


def checkpoint_inputs():
    """再現用の合成入力。人の試遊でもOSキーボード入力でもない。"""
    # 人もできる3手順: 右240更新で右壁、左12更新、離して120更新。
    # スクリプトは位置や速度を代入せず、通常と同じstepへ方向だけを渡す。
    return [(1, 0)] * 240 + [(-1, 0)] * 12 + [(0, 0)] * 120


class App:
    def __init__(self, pyxel, headless=False, screenshot=None, demo=False):
        self.p = pyxel
        self.world = World()
        self.headless = headless
        self.screenshot = screenshot
        self.demo = demo
        self.actions = checkpoint_inputs() if demo else []
        self.render_frames = 0
        # headlessは初期12更新。demoは合成入力列+結果表示12更新。
        self.limit = len(self.actions) + 12 if demo else 12
        pyxel.init(384, 280, title="Rolling Stamp / " + TITLE,
                   fps=FPS, headless=headless, display_scale=2)
        pyxel.run(self.update, self.draw)

    def update(self):
        p = self.p
        if self.headless and self.render_frames >= self.limit:
            if self.screenshot:
                from pathlib import Path
                path = Path(self.screenshot)
                path.parent.mkdir(parents=True, exist_ok=True)
                p.screenshot(str(path), scale=2)
            # snapshotは最後に描いた状態と同じ。quit前に物理更新しない。
            import json
            w = self.world
            print(json.dumps({"stage": STAGE, "synthetic_input": self.demo,
                              "render_frames": self.render_frames,
                              "ticks": w.ticks, "x": w.x, "y": w.y,
                              "vx": w.vx, "vy": w.vy, "won": w.won,
                              "hold": w.hold, "contacts": w.contacts}), flush=True)
            p.quit()
            return
        self.render_frames += 1
        if p.btnp(p.KEY_R):
            self.world = World()
            return
        if p.btnp(p.KEY_P):
            self.world.paused = not self.world.paused
            return
        if self.demo:
            index = self.render_frames - 1
            ax, ay = self.actions[index] if index < len(self.actions) else (0, 0)
        else:
            ax = int(p.btn(p.KEY_RIGHT) or p.btn(p.KEY_D)) - int(p.btn(p.KEY_LEFT) or p.btn(p.KEY_A))
            ay = int(p.btn(p.KEY_DOWN) or p.btn(p.KEY_S)) - int(p.btn(p.KEY_UP) or p.btn(p.KEY_W))
        self.world.step(ax, ay)

    def draw(self):
        p, w = self.p, self.world
        p.cls(1)
        p.text(8, 8, f"007 ROLLING STAMP / {STAGE}: {TITLE}", 10)
        for i, line in enumerate(status_lines(w)):
            p.text(8, 26 + 12 * i, line, 7 if i < 3 else 6)
        p.rect(16, 84, 352, 92, 5)
        p.text(24, 90, "A: STRAIGHT ROAD (DRAWN 2x / UNITS ARE LOGICAL PX)", 7)
        ox, oy, zoom = 16, -10, 2
        for (tx, ty), kind in w.floor.items():
            x, y = ox + zoom * tx * TILE, oy + zoom * ty * TILE
            p.rect(x, y, zoom * TILE, zoom * TILE, 15)
            if kind == "h":
                for dy in (4, 8, 12):
                    p.line(x, y + dy * zoom, x + TILE * zoom - 1, y + dy * zoom, 6)
            if kind == "v":
                for dx in (4, 8, 12):
                    p.line(x + dx * zoom, y, x + dx * zoom, y + TILE * zoom - 1, 6)
        gx, gy = ox + zoom * GOAL[0], oy + zoom * GOAL[1]
        p.rect(gx - 14, gy - 12, 28, 24, 11 if w.won else 10)
        p.rectb(gx - 14, gy - 12, 28, 24, 4)
        p.line(gx - 13, gy - 11, gx, gy, 4)
        p.line(gx + 13, gy - 11, gx, gy, 4)
        x, y = ox + zoom * w.x, oy + zoom * w.y
        p.line(x, y, x + w.vx * .7, y + w.vy * .7, 8)
        p.circ(x, y, RADIUS * zoom, 12)
        p.pset(x - 2, y - 2, 7)
        p.text(24, 158, "BLUE: STAMP  RED: VELOCITY  YELLOW: ENVELOPE", 7)
        p.rect(16, 184, 180, 5, 5)
        p.rect(16, 184, 180 * min(w.hold, HOLD_FRAMES) / HOLD_FRAMES, 5, 11)
        p.text(204, 184, f"HOLD {w.hold}/{HOLD_FRAMES}  WALL {w.contacts}", 11)
        tips = [
            f"Stay within +/-{GOAL_HALF:g}px; speed < {GOAL_SPEED:g}px/s.",
            f"Keep it for {HOLD_FRAMES} updates ({HOLD_FRAMES * DT:.2f}s).",
            "Brake before the envelope; R to try again."]
        for i, line in enumerate(tips):
            p.text(16, 202 + 11 * i, line, 7)
        p.text(8, 250, "ARROWS/WASD: TILT  P: PAUSE  R: RESET  ESC: QUIT", 6)
        if self.demo:
            p.text(8, 262, "AUTOMATED INPUT / ACTUAL PYXEL RENDER / NO AUDIO", 8)
        elif STAGE < 3:
            p.text(8, 262, "01/02 USE LEFT/RIGHT ONLY", 6)
        if w.paused:
            p.rect(100, 105, 182, 40, 0)
            p.text(122, 120, "PAUSED / P: RESUME", 10)
        if w.won:
            p.rect(92, 105, 198, 46, 0)
            p.rectb(92, 105, 198, 46, 11)
            p.text(143, 115, "DELIVERED!", 11)
            p.text(115, 132, "R: SAME START / SAME ROAD", 7)


def main():
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true", help="12 updates then quit")
    parser.add_argument("--screenshot", help="PNG path; use with --headless-smoke")
    parser.add_argument("--demo", action="store_true", help="synthetic checkpoint sequence then 12 frames")
    args = parser.parse_args()
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    import pyxel  # モデルのテスト時にはウィンドウもPyxel依存も不要。
    App(pyxel, args.headless_smoke, args.screenshot, args.demo)


if __name__ == "__main__":
    main()
AFTER STEP 05

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

元のゲームには3つの面があります。方向キーか WASD で紙を傾け、折り目に沿った向きはすべりやすくなっています。切手の中心が封筒の±4px以内で、速さが8px/s未満の状態が18回の更新続くと成功です。B と C には穴が、B には低い速さで乗ると記録される台座があります。制限時間はありません。

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

比べる点5ステップの教材元の完成版
入っている面A「止まる封筒」の1面。5つの段階すべてで、まっすぐな1本道を使う。A・B・Cの3面、面の選択と次の面。
段階ごとの物理01は速度を直接決める、02は摩擦なし、03までは仮の柵。04で元のゲームの壁の判定、05で元のゲームの配達判定をそろえる。すべての面で、向きによる減速・円の当たり判定・配達判定を最初から使う。
最後の段階で同じになるルールAの配置、1秒に60回の更新、加速度80・最大速度45、減速3/0.6/6、半径3・0.75pxずつの分割、±4px・速さ8未満・18回の更新、をそのまま使う。Aと同じ値・同じ計算の順番。BとCには穴・台座・曲がり角もある。
表示と操作英字(ASCII)・図形・音なし。数値と自動チェック用の確認点、P・R・Escの操作。道を2倍で描く。日本語の文字・タイトル・面の選択・Nで次の面・基準版との比較・デモの実行。

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

  1. model.py の Game.step を、05の step・accelerate・move_with_walls・check_goal と見比べて、元のゲームの A の計算の順番を確かめましょう。
  2. model.py の level(1) と level(2) で、曲がり角・穴・縦の折り目を足した配置を読みましょう。
  3. Game.over_hole と checkpoint で、黒い12pxの四角に中心が入ったときの落下と、台座に戻る仕組みを調べましょう。
  4. app.py の、タイトル・面の選択・Nで次の面・plain での比較を読みましょう。
自分のゲームに変えるなら

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

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