アクションLESSON 56空気を使う慣性アクション/固定刻み近似物理原作015

穴あき風船の作り方

ふき出すと、浮く力も変わる。
風船の空気を、進むための噴射と、浮かぶ力の両方に使います。風・ぶつかる処理・空気の補給・ゆっくり着地、を順に加えていく、小さな飛行のゲームを作ります。

完成版の遊び方噴射で空気を使うほど、浮く力が弱くなります。通る道と、空気を補給する場所を選びます。

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

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

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

完成版の主な操作:方向噴射、Space給気接続/離脱、Z失敗後に給気口へ、R再開、P停止、N次航路

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

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

うまく表示されないとき

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

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

1つの資源が、2つの性質に効く動きを作る。

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

1. 長さをそろえた噴射と、決まった時間ずつ進める計算
2. 使う順番と、足りないときは一部だけの噴射
3. 動く前の位置の風と、線分での当たり判定
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・関数・if・for・tuple / dataclass と replace は本文で説明します / 平方根・ベクトルの長さと「1秒あたり」の単位 / Python 3.11以上、Pyxel 2.9.9
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
矢印:押した方向へ噴射(押しっぱなし) / SPACE:04/05で接続/離脱(押した瞬間) / P:全段階で停止/復帰 / R:同じ航路を初期化 / Z:04/05の失敗後に給気口から局所再開 / 1/2/3:03以降の航路選択と初期化 / N:05の成功後に次の航路 / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_thrust.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

位置ではなく、速度を変える

長さをそろえた噴射と、決まった時間ずつ進める計算。

矢印を押し続けると、その方向に噴射します。位置に決まった量を足すやり方とはちがい、まず加速度で速さを変え、その新しい速さで位置を進めます。キーを離しても速さはすぐ0にはならず、空気の抵抗で弱まりながら進みます。

この教材の座標は、右が+x、下が+y です。1回の step は DT=1/120 秒です。frame は step を4回呼び、Pyxel は1秒に30回(30 FPS)描きます。なので、ふつう1回の更新で進むゲームの時間は 1/30 秒です。処理が遅れたときに、本当の時間に追いつく作りではありません。

ななめの入力 (1,1) の長さは √2 なので、math.hypot で求めた長さで割って、長さ1にします。右だけでもななめでも、噴射の強さ90は同じです。押したそれぞれの向きを -1〜1 に制限してから、長さをそろえます。速さの上限55も、向きごとではなく、速さのベクトル全体に使います。

この段階には、空気の消費・浮く力・上下2つの層の風・障害物・空気の補給・着地はありません。横の抵抗には、元のゲームの「風0」のときの式を残しています。練習用の外わくでは、中心の座標を範囲の中に戻し、ふれた方向の速さだけを0にします。これは元のゲームの「動きの途中まで調べる当たり判定」とは別のもので、03 で置きかえます。

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

01_thrust.py / 注目する部分
    ax = THRUST * ux - s.vx / 1.4 - .7 * s.vx
    ay = THRUST * uy - .7 * s.vy
    vx = s.vx + ax * dt; vy = s.vy + ay * dt

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_thrust.py
macOS / Linux
.venv/bin/python 01_thrust.py
画面で見ること右を押すと VX(横の速さ)が増え、離しても右へ進みます。最初の1 step だけなら、VX=0.75、X=24.00625 です。自動の確認では「右30回 → 上20回 → 離す40回」の更新で、T=3.00秒、X 約85.675、Y 約41.051 です。
  • 座標は下向きがプラス
  • 加速度 → 速さ → 位置 の順番
  • ななめの長さを1にそろえる
つまずきやすいところTHRUST を位置に直接足すと、90 px/秒² のつもりが、1回の更新で 90 px も動いてしまいます。速さの更新にも、位置の更新にも DT を掛けます。ななめの入力を割らないと、ななめだけ強くなってしまいます。
少し変えてみよう

THRUST を90から45に変えて、もう一度起動してみましょう。右を押した最初の step で、VX が 0.75 から 0.375 へ半分になります。画面の1回の更新は4 step なので、表示される位置は、最初の step の値そのままにはなりません。確かめたら90に戻します。

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

"""穴あき風船・教材 1/5:噴射と惰性。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
import math, json, hashlib

DT = 1 / 120
RADIUS = 7.
THRUST = 90.
LEAK = .5
BURN = 6.
SPEED_LIMIT = 55.

@dataclass(frozen=True)
class Input:
    x:int=0
    y:int=0
    space:bool=False

@dataclass(frozen=True)
class State:
    x: float = 24.
    y: float = 100.
    vx: float = 0.
    vy: float = 0.
    status: str = 'flying'
    time: float = 0.
    thrust: tuple = (0., 0.)

def initial():
    return State()

def step(s, u=Input(), dt=DT):
    if not math.isfinite(dt) or not 0 < dt <= 1/30:
        raise ValueError('fixed small positive dt required')
    ux = max(-1, min(1, u.x)); uy = max(-1, min(1, u.y))
    mag = math.hypot(ux, uy)
    if mag:
        ux /= mag; uy /= mag
    ax = THRUST * ux - s.vx / 1.4 - .7 * s.vx
    ay = THRUST * uy - .7 * s.vy
    vx = s.vx + ax * dt; vy = s.vy + ay * dt
    speed = math.hypot(vx, vy)
    if speed > SPEED_LIMIT:
        vx *= SPEED_LIMIT / speed; vy *= SPEED_LIMIT / speed
    x = max(7., min(185., s.x + vx * dt))
    y = max(15., min(129., s.y + vy * dt))
    if x in (7., 185.): vx = 0.
    if y in (15., 129.): vy = 0.
    return replace(s, x=x, y=y, vx=vx, vy=vy, time=s.time+dt, thrust=(ux, uy))
def frame(s,u=Input()):
    for i in range(4):s=step(s,u if i==0 else replace(u,space=False))
    return s


STAGE = 1
DEFAULT_LEVEL = 0
DEMO_FRAMES = 90
KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'P', 'R', 'Z', 'N', '1', '2', '3')


class App:
    def __init__(self):
        self.level = DEFAULT_LEVEL
        self.reset()

    def reset(self):
        self.state = initial(self.level) if STAGE >= 3 else initial()
        self.pause = False
        self.demo_tick = 0
        self.route_index = 0

    def control(self, pressed, held):
        # Edges are for commands. Directions are continuously held controls.
        if 'R' in pressed:
            self.reset(); return
        if STAGE >= 3:
            for key in ('1', '2', '3'):
                if key in pressed:
                    self.level = int(key)-1; self.reset(); return
        if 'P' in pressed:
            self.pause = not self.pause; return
        if self.pause:
            return
        if self.state.status == 'retry':
            if STAGE >= 4 and 'Z' in pressed:
                self.state = restore(self.state)
            return
        if self.state.status == 'success':
            if 'N' in pressed:
                self.level = (self.level+1)%3; self.reset()
            return
        u = Input(int('RIGHT' in held)-int('LEFT' in held),
                  int('DOWN' in held)-int('UP' in held),
                  'SPACE' in pressed if STAGE >= 4 else False)
        self.state = frame(self.state, u)

    def demo_input(self):
        """Automatic semantic input, not OS-keyboard or human play evidence."""
        tick = self.demo_tick; self.demo_tick += 1
        if STAGE <= 2:
            return Input(1, 0) if tick < 30 else (Input(0, -1) if tick < 50 else Input())
        if STAGE == 3:
            return Input(1, 0)
        s = self.state
        if STAGE == 4 and s.fills >= 1:
            return Input()
        if s.status != 'flying':
            return Input()
        if s.dock >= 0:
            return Input(space=True) if s.air >= 99.7 else Input()
        if STAGE == 4:
            route = ((45., 94.), (108., 103.))
            target = route[self.route_index]
            if self.route_index == 0 and math.hypot(s.x-target[0], s.y-target[1]) < 10:
                self.route_index = 1; target = route[1]
            if self.route_index == 1 and math.hypot(s.x-target[0], s.y-target[1]) < 11 and math.hypot(s.vx, s.vy) < 14:
                return Input(space=True)
        else:
            target = LEVELS[self.level].goal
        tx = max(-30., min(30., (target[0]-s.x)*1.5))
        ty = max(-30., min(30., (target[1]-s.y)*1.5))
        wind = LEVELS[self.level].wind[0] if s.y < 64 else LEVELS[self.level].wind[1]
        ax = (tx-s.vx)*3-((wind-s.vx)/1.4-.7*s.vx)
        ay = (ty-s.vy)*3-(65-s.air-.7*s.vy)
        return Input(1 if ax > 12 else -1 if ax < -12 else 0,
                     1 if ay > 12 else -1 if ay < -12 else 0)

    def demo_update(self):
        u = self.demo_input()
        held = set()
        if u.x: held.add('RIGHT' if u.x > 0 else 'LEFT')
        if u.y: held.add('DOWN' if u.y > 0 else 'UP')
        pressed = {'SPACE'} if u.space else set()
        self.control(pressed, held)

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(8, 8, '015 LEAKY BALLOON / STAGE '+str(STAGE), 10)
        p.text(8, 22, ('PAUSED' if self.pause else s.status.upper())+'  T '+format(s.time, '.2f')+' s', 11)
        p.text(8, 35, 'X '+format(s.x, '.1f')+' Y '+format(s.y, '.1f')+' SPEED '+format(math.hypot(s.vx,s.vy), '.1f'), 7)
        if STAGE >= 2:
            p.text(210, 22, 'AIR '+format(s.air, '.1f'), 10)
            p.rectb(288, 20, 102, 9, 7); p.rect(289, 21, s.air, 7, 11)
            p.line(354, 18, 354, 30, 8)
            p.text(210, 35, 'USED '+format(s.consumed, '.2f'), 6)
        ox, oy = 32, 48
        p.rect(ox, oy+16, 384, 242, 12)
        p.rect(ox, oy+128, 384, 130, 6)
        for x in range(16, 192, 24): p.line(ox+x*2, oy+16, ox+x*2, oy+258, 13)
        p.line(ox, oy+128, ox+384, oy+128, 7)
        if STAGE >= 3:
            level = LEVELS[s.level]
            p.text(ox+5, oy+19, 'ROUTE '+str(s.level+1)+' / Y64 WIND BOUNDARY', 1)
            for yy, w in ((40, level.wind[0]), (100, level.wind[1])):
                p.text(ox+4, oy+yy*2, 'W '+format(w, '+.0f'), 1)
                if w:
                    d = 1 if w > 0 else -1
                    for xx in (45, 165):
                        x=ox+xx*2; y=oy+yy*2
                        p.line(x-d*14,y,x+d*14,y,5)
                        p.line(x+d*14,y,x+d*8,y-4,5)
                        p.line(x+d*14,y,x+d*8,y+4,5)
            for a,b,c,d in level.walls:
                p.rect(ox+a*2, oy+b*2, (c-a)*2, (d-b)*2, 4)
                p.rectb(ox+a*2, oy+b*2, (c-a)*2, (d-b)*2, 9)
            for a,b,c,d in level.spikes:
                p.rect(ox+a*2,oy+b*2,(c-a)*2,(d-b)*2-8,8)
                for x in range(int(a*2),int(c*2),12):
                    p.tri(ox+x,oy+d*2-8,ox+x+12,oy+d*2-8,ox+x+6,oy+d*2,8)
            for x in range(0,384,12):
                p.line(ox+x,oy+258,ox+x+12,oy+267,13)
                p.line(ox+x,oy+267,ox+x+12,oy+258,13)
        else:
            p.text(ox+5,oy+19,'PRACTICE BOUNDS / NO FAIL',1)
            p.line(ox,oy+258,ox+384,oy+258,5)
        if STAGE >= 4:
            for i,(x,y) in enumerate(level.ports):
                x=ox+x*2; y=oy+y*2
                p.circ(x,y,17,5);p.circb(x,y,24,7)
                p.text(x-2,y+15,str(i),10)
                if s.dock == i: p.line(x,y,x,y+24,10)
            p.text(326,35,'DOCK '+str(s.dock),7)
        if STAGE == 5:
            gx,gy=level.goal; gx=ox+gx*2; gy=oy+gy*2
            p.circb(gx,gy,28,11);p.line(gx+29,gy-20,gx+29,gy+20,4)
            p.tri(gx+29,gy-20,gx+29,gy-7,gx+43,gy-14,11)
            for i,(x,y) in enumerate(level.marks):
                if i not in s.collected:
                    x=ox+x*2;y=oy+y*2;p.tri(x-6,y-3,x+7,y,x-6,y+4,7)
        x=ox+s.x*2; y=oy+s.y*2
        radius=9+(s.air*.045 if STAGE >= 2 else 3.6)
        if s.status != 'retry':
            p.circ(x,y,radius,8);p.circ(x-4,y-4,3,14);p.circb(x,y,14,1)
            p.line(x,y+radius,x,y+22,4);p.rect(x-4,y+21,9,5,9)
            ux,uy=s.thrust
            if ux or uy: p.line(x-ux*15,y-uy*15,x-ux*27,y-uy*27,10)
        else:
            for i in range(8):
                a=i*math.pi/4;p.line(x+math.cos(a)*10,y+math.sin(a)*10,x+math.cos(a)*20,y+math.sin(a)*20,8)
        p.text(8,326,'MODEL: 120 Hz / VIEW: 30 FPS / FIXED RADIUS 7',6)
        if STAGE == 1: message='NORMALIZED JET + INERTIA / NO AIR COST YET'
        elif STAGE == 2: message='LEAK 0.5/s + JET 6/s / AIR < 65: SINK'
        elif STAGE == 3: message='SWEEP WALLS / RED SPIKES + NET = RETRY'
        elif STAGE == 4: message='ATTACH <=12 px AND <=15 px/s / FILL 35/s'
        else: message='LAND '+format(s.landed,'.3f')+'/0.4s  PAPER '+str(len(s.collected))+'/2'
        p.text(8,342,message,10)
        if STAGE >= 3 and s.status == 'retry':
            p.text(8,354,'RETRY: '+s.reason.upper(),8)
        elif STAGE >= 4 and s.dock >= 0:
            p.text(8,354,'REFILLING / SPACE TO DEPART / FILLS '+str(s.fills),11)
        elif STAGE == 5 and s.status == 'success':
            p.text(8,354,'SUCCESS / N NEXT ROUTE / R RESTART',11)
        else:
            p.text(8,354,'ARROWS HOLD: JET / RELEASE: COAST',7)
        p.text(8,369,'ARROWS JET  P PAUSE  R RESET  ESC QUIT',6)
        if STAGE >= 4: p.text(8,383,'SPACE ATTACH/DETACH  Z RETRY  1/2/3 ROUTE',6)
        elif STAGE == 3: p.text(8,383,'1/2/3 SELECT ROUTE / R RESTART AFTER RETRY',6)
        else: p.text(8,383,'SIMULATION SECONDS, NOT WALL-CLOCK TIMING',6)


def parse_args(argv=None):
    """Reject invalid QA flags before importing or initializing Pyxel."""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--frames', type=int)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-output')
    args = parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_output):
        parser.error('QA options require --headless-smoke')
    minimum = DEMO_FRAMES if args.demo_checkpoint else 4
    if args.frames is None: args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error('--frames must be between '+str(minimum)+' and 600')
    for name,suffix in (('screenshot','.png'),('state_output','.json')):
        value = getattr(args,name)
        if value is not None and (not value.strip() or Path(value).suffix.lower() != suffix):
            parser.error('--'+name.replace('_','-')+' requires a '+suffix+' file path')
    return args


def main(argv=None):
    args = parse_args(argv)
    from pathlib import Path
    import pyxel as p
    app = App()
    p.init(448,400,title='015 Leaky Balloon',fps=30,display_scale=2,headless=args.headless_smoke)
    keys = {k:getattr(p,'KEY_'+k) for k in KEYS}
    finished = False
    def update():
        if args.headless_smoke:
            # Initial capture is intentionally frozen at initial().
            if args.demo_checkpoint and p.frame_count < DEMO_FRAMES: app.demo_update()
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if args.headless_smoke and p.frame_count >= args.frames-1 and not finished:
            finished = True
            for name in ('screenshot','state_output'):
                value=getattr(args,name)
                if value:
                    path=Path(value).resolve();path.parent.mkdir(parents=True,exist_ok=True)
                    if name == 'screenshot': p.screenshot(str(path),scale=2)
                    else: path.write_text(json.dumps(asdict(app.state),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            p.quit()
    p.run(update,draw)


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

噴くための空気と、浮くための空気

使う順番と、足りないときは一部だけの噴射。

空気 air は80から始まり、何もしなくても毎秒0.5減ります。方向キーを押している間は、さらに毎秒6を使います。ななめでも追加の消費は6で、向きごとに2回払うことはありません。consumed は、実際に失った空気の合計です。

先にもれる分を引き、その残りから噴射の分を払います。最後に、いつもの半分しか払えないなら、噴射の加速度も半分にします。たとえば空気が (0.5+6×0.5)×DT だけなら、もれる分を引いたあとに、半分の噴射の分が残り、右への速さの増え方は 90×0.5×DT です。空気が0になってもすぐ失敗ではなく、噴射できずに落ちていきます。

上下の加速度に 65-air を足します。下向きがプラスなので、使ったあとの空気が65より多ければ上向き、少なければ下向きの力になります。使う前にちょうど65なら、同じ step のもれで、少し65を下回ります。「空気65なら必ず止まる」はまちがいで、もとの速さ・入力・抵抗も関係します。

これは、元のゲームの小さな画面のための、簡単にした計算です。重力65と浮く力 air を加速度の項として扱い、重さ m や空気の密度・体積は計算しません。空気が減ったときの重さの変化をふくむ運動の式や、本物の気体・飛行を再現する物理の式として説明してはいけません。linked=False は、比べるための「浮く力を65に固定する」基準で、ふつうは True です。

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

02_air.py / 注目する部分
    leak = min(s.air, LEAK * dt)
    remaining = s.air - leak
    spend = min(remaining, BURN * dt) if mag else 0.
    fraction = spend / (BURN * dt) if mag else 0.

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_air.py
macOS / Linux
.venv/bin/python 02_air.py
画面で見ること止まった状態から何も押さないと、80の空気で自然に上がっていきます。何も押さない1回の更新で、空気は 80-0.5/30、右に噴射すると 80-6.5/30 です。自動の確認で3秒後は AIR=68.5、USED=11.5 になります。
  • もれる分を先に払う
  • 残りの空気に応じて、一部だけ噴射する
  • 空気0は、すぐに失敗する条件ではない
つまずきやすいところ空気を0に切り下げるだけで、いつも最大の噴射をあげると、最後の少しの残りで、ただで力が出てしまいます。払えた割合 fraction を、力と、表示用の thrust の両方に掛けます。
少し変えてみよう

LEAK を0.5から1.0に変えてみましょう。何も押さずに30回更新(ゲームの1秒)すると、空気は80から79に減ります。02 では床にふれても失敗にならず、そのあとももれ続けます。確かめたら0.5に戻します。

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

"""穴あき風船・教材 2/5:空気と浮力。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
import math, json, hashlib

DT = 1 / 120
RADIUS = 7.
THRUST = 90.
LEAK = .5
BURN = 6.
SPEED_LIMIT = 55.

@dataclass(frozen=True)
class Input:
    x:int=0
    y:int=0
    space:bool=False

@dataclass(frozen=True)
class State:
    x: float = 24.
    y: float = 100.
    vx: float = 0.
    vy: float = 0.
    air: float = 80.
    consumed: float = 0.
    linked: bool = True
    status: str = 'flying'
    time: float = 0.
    thrust: tuple = (0., 0.)

def initial():
    return State()

def step(s, u=Input(), dt=DT):
    if not math.isfinite(dt) or not 0 < dt <= 1/30:
        raise ValueError('fixed small positive dt required')
    ux = max(-1, min(1, u.x)); uy = max(-1, min(1, u.y))
    mag = math.hypot(ux, uy)
    if mag:
        ux /= mag; uy /= mag
    leak = min(s.air, LEAK * dt)
    remaining = s.air - leak
    spend = min(remaining, BURN * dt) if mag else 0.
    fraction = spend / (BURN * dt) if mag else 0.
    air = max(0., remaining - spend)
    buoyancy = air if s.linked else 65.
    ax = THRUST * ux * fraction - s.vx / 1.4 - .7 * s.vx
    ay = THRUST * uy * fraction + 65. - buoyancy - .7 * s.vy
    vx = s.vx + ax * dt; vy = s.vy + ay * dt
    speed = math.hypot(vx, vy)
    if speed > SPEED_LIMIT:
        vx *= SPEED_LIMIT / speed; vy *= SPEED_LIMIT / speed
    x = max(7., min(185., s.x + vx * dt))
    y = max(15., min(129., s.y + vy * dt))
    if x in (7., 185.): vx = 0.
    if y in (15., 129.): vy = 0.
    return replace(s, x=x, y=y, vx=vx, vy=vy, time=s.time+dt, air=air,
                   consumed=s.consumed+leak+spend, thrust=(ux*fraction, uy*fraction))
def frame(s,u=Input()):
    for i in range(4):s=step(s,u if i==0 else replace(u,space=False))
    return s


STAGE = 2
DEFAULT_LEVEL = 0
DEMO_FRAMES = 90
KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'P', 'R', 'Z', 'N', '1', '2', '3')


class App:
    def __init__(self):
        self.level = DEFAULT_LEVEL
        self.reset()

    def reset(self):
        self.state = initial(self.level) if STAGE >= 3 else initial()
        self.pause = False
        self.demo_tick = 0
        self.route_index = 0

    def control(self, pressed, held):
        # Edges are for commands. Directions are continuously held controls.
        if 'R' in pressed:
            self.reset(); return
        if STAGE >= 3:
            for key in ('1', '2', '3'):
                if key in pressed:
                    self.level = int(key)-1; self.reset(); return
        if 'P' in pressed:
            self.pause = not self.pause; return
        if self.pause:
            return
        if self.state.status == 'retry':
            if STAGE >= 4 and 'Z' in pressed:
                self.state = restore(self.state)
            return
        if self.state.status == 'success':
            if 'N' in pressed:
                self.level = (self.level+1)%3; self.reset()
            return
        u = Input(int('RIGHT' in held)-int('LEFT' in held),
                  int('DOWN' in held)-int('UP' in held),
                  'SPACE' in pressed if STAGE >= 4 else False)
        self.state = frame(self.state, u)

    def demo_input(self):
        """Automatic semantic input, not OS-keyboard or human play evidence."""
        tick = self.demo_tick; self.demo_tick += 1
        if STAGE <= 2:
            return Input(1, 0) if tick < 30 else (Input(0, -1) if tick < 50 else Input())
        if STAGE == 3:
            return Input(1, 0)
        s = self.state
        if STAGE == 4 and s.fills >= 1:
            return Input()
        if s.status != 'flying':
            return Input()
        if s.dock >= 0:
            return Input(space=True) if s.air >= 99.7 else Input()
        if STAGE == 4:
            route = ((45., 94.), (108., 103.))
            target = route[self.route_index]
            if self.route_index == 0 and math.hypot(s.x-target[0], s.y-target[1]) < 10:
                self.route_index = 1; target = route[1]
            if self.route_index == 1 and math.hypot(s.x-target[0], s.y-target[1]) < 11 and math.hypot(s.vx, s.vy) < 14:
                return Input(space=True)
        else:
            target = LEVELS[self.level].goal
        tx = max(-30., min(30., (target[0]-s.x)*1.5))
        ty = max(-30., min(30., (target[1]-s.y)*1.5))
        wind = LEVELS[self.level].wind[0] if s.y < 64 else LEVELS[self.level].wind[1]
        ax = (tx-s.vx)*3-((wind-s.vx)/1.4-.7*s.vx)
        ay = (ty-s.vy)*3-(65-s.air-.7*s.vy)
        return Input(1 if ax > 12 else -1 if ax < -12 else 0,
                     1 if ay > 12 else -1 if ay < -12 else 0)

    def demo_update(self):
        u = self.demo_input()
        held = set()
        if u.x: held.add('RIGHT' if u.x > 0 else 'LEFT')
        if u.y: held.add('DOWN' if u.y > 0 else 'UP')
        pressed = {'SPACE'} if u.space else set()
        self.control(pressed, held)

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(8, 8, '015 LEAKY BALLOON / STAGE '+str(STAGE), 10)
        p.text(8, 22, ('PAUSED' if self.pause else s.status.upper())+'  T '+format(s.time, '.2f')+' s', 11)
        p.text(8, 35, 'X '+format(s.x, '.1f')+' Y '+format(s.y, '.1f')+' SPEED '+format(math.hypot(s.vx,s.vy), '.1f'), 7)
        if STAGE >= 2:
            p.text(210, 22, 'AIR '+format(s.air, '.1f'), 10)
            p.rectb(288, 20, 102, 9, 7); p.rect(289, 21, s.air, 7, 11)
            p.line(354, 18, 354, 30, 8)
            p.text(210, 35, 'USED '+format(s.consumed, '.2f'), 6)
        ox, oy = 32, 48
        p.rect(ox, oy+16, 384, 242, 12)
        p.rect(ox, oy+128, 384, 130, 6)
        for x in range(16, 192, 24): p.line(ox+x*2, oy+16, ox+x*2, oy+258, 13)
        p.line(ox, oy+128, ox+384, oy+128, 7)
        if STAGE >= 3:
            level = LEVELS[s.level]
            p.text(ox+5, oy+19, 'ROUTE '+str(s.level+1)+' / Y64 WIND BOUNDARY', 1)
            for yy, w in ((40, level.wind[0]), (100, level.wind[1])):
                p.text(ox+4, oy+yy*2, 'W '+format(w, '+.0f'), 1)
                if w:
                    d = 1 if w > 0 else -1
                    for xx in (45, 165):
                        x=ox+xx*2; y=oy+yy*2
                        p.line(x-d*14,y,x+d*14,y,5)
                        p.line(x+d*14,y,x+d*8,y-4,5)
                        p.line(x+d*14,y,x+d*8,y+4,5)
            for a,b,c,d in level.walls:
                p.rect(ox+a*2, oy+b*2, (c-a)*2, (d-b)*2, 4)
                p.rectb(ox+a*2, oy+b*2, (c-a)*2, (d-b)*2, 9)
            for a,b,c,d in level.spikes:
                p.rect(ox+a*2,oy+b*2,(c-a)*2,(d-b)*2-8,8)
                for x in range(int(a*2),int(c*2),12):
                    p.tri(ox+x,oy+d*2-8,ox+x+12,oy+d*2-8,ox+x+6,oy+d*2,8)
            for x in range(0,384,12):
                p.line(ox+x,oy+258,ox+x+12,oy+267,13)
                p.line(ox+x,oy+267,ox+x+12,oy+258,13)
        else:
            p.text(ox+5,oy+19,'PRACTICE BOUNDS / NO FAIL',1)
            p.line(ox,oy+258,ox+384,oy+258,5)
        if STAGE >= 4:
            for i,(x,y) in enumerate(level.ports):
                x=ox+x*2; y=oy+y*2
                p.circ(x,y,17,5);p.circb(x,y,24,7)
                p.text(x-2,y+15,str(i),10)
                if s.dock == i: p.line(x,y,x,y+24,10)
            p.text(326,35,'DOCK '+str(s.dock),7)
        if STAGE == 5:
            gx,gy=level.goal; gx=ox+gx*2; gy=oy+gy*2
            p.circb(gx,gy,28,11);p.line(gx+29,gy-20,gx+29,gy+20,4)
            p.tri(gx+29,gy-20,gx+29,gy-7,gx+43,gy-14,11)
            for i,(x,y) in enumerate(level.marks):
                if i not in s.collected:
                    x=ox+x*2;y=oy+y*2;p.tri(x-6,y-3,x+7,y,x-6,y+4,7)
        x=ox+s.x*2; y=oy+s.y*2
        radius=9+(s.air*.045 if STAGE >= 2 else 3.6)
        if s.status != 'retry':
            p.circ(x,y,radius,8);p.circ(x-4,y-4,3,14);p.circb(x,y,14,1)
            p.line(x,y+radius,x,y+22,4);p.rect(x-4,y+21,9,5,9)
            ux,uy=s.thrust
            if ux or uy: p.line(x-ux*15,y-uy*15,x-ux*27,y-uy*27,10)
        else:
            for i in range(8):
                a=i*math.pi/4;p.line(x+math.cos(a)*10,y+math.sin(a)*10,x+math.cos(a)*20,y+math.sin(a)*20,8)
        p.text(8,326,'MODEL: 120 Hz / VIEW: 30 FPS / FIXED RADIUS 7',6)
        if STAGE == 1: message='NORMALIZED JET + INERTIA / NO AIR COST YET'
        elif STAGE == 2: message='LEAK 0.5/s + JET 6/s / AIR < 65: SINK'
        elif STAGE == 3: message='SWEEP WALLS / RED SPIKES + NET = RETRY'
        elif STAGE == 4: message='ATTACH <=12 px AND <=15 px/s / FILL 35/s'
        else: message='LAND '+format(s.landed,'.3f')+'/0.4s  PAPER '+str(len(s.collected))+'/2'
        p.text(8,342,message,10)
        if STAGE >= 3 and s.status == 'retry':
            p.text(8,354,'RETRY: '+s.reason.upper(),8)
        elif STAGE >= 4 and s.dock >= 0:
            p.text(8,354,'REFILLING / SPACE TO DEPART / FILLS '+str(s.fills),11)
        elif STAGE == 5 and s.status == 'success':
            p.text(8,354,'SUCCESS / N NEXT ROUTE / R RESTART',11)
        else:
            p.text(8,354,'ARROWS HOLD: JET / RELEASE: COAST',7)
        p.text(8,369,'ARROWS JET  P PAUSE  R RESET  ESC QUIT',6)
        if STAGE >= 4: p.text(8,383,'SPACE ATTACH/DETACH  Z RETRY  1/2/3 ROUTE',6)
        elif STAGE == 3: p.text(8,383,'1/2/3 SELECT ROUTE / R RESTART AFTER RETRY',6)
        else: p.text(8,383,'SIMULATION SECONDS, NOT WALL-CLOCK TIMING',6)


def parse_args(argv=None):
    """Reject invalid QA flags before importing or initializing Pyxel."""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--frames', type=int)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-output')
    args = parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_output):
        parser.error('QA options require --headless-smoke')
    minimum = DEMO_FRAMES if args.demo_checkpoint else 4
    if args.frames is None: args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error('--frames must be between '+str(minimum)+' and 600')
    for name,suffix in (('screenshot','.png'),('state_output','.json')):
        value = getattr(args,name)
        if value is not None and (not value.strip() or Path(value).suffix.lower() != suffix):
            parser.error('--'+name.replace('_','-')+' requires a '+suffix+' file path')
    return args


def main(argv=None):
    args = parse_args(argv)
    from pathlib import Path
    import pyxel as p
    app = App()
    p.init(448,400,title='015 Leaky Balloon',fps=30,display_scale=2,headless=args.headless_smoke)
    keys = {k:getattr(p,'KEY_'+k) for k in KEYS}
    finished = False
    def update():
        if args.headless_smoke:
            # Initial capture is intentionally frozen at initial().
            if args.demo_checkpoint and p.frame_count < DEMO_FRAMES: app.demo_update()
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if args.headless_smoke and p.frame_count >= args.frames-1 and not finished:
            finished = True
            for name in ('screenshot','state_output'):
                value=getattr(args,name)
                if value:
                    path=Path(value).resolve();path.parent.mkdir(parents=True,exist_ok=True)
                    if name == 'screenshot': p.screenshot(str(path),scale=2)
                    else: path.write_text(json.dumps(asdict(app.state),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            p.quit()
    p.run(update,draw)


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

風の境目と、すり抜けない壁

動く前の位置の風と、線分での当たり判定。

この段階から、元のゲームの3つの航路の地形を使います。最初に選ばれているのは航路2(元のゲームの B)で、補給口にはつながずに、X24・Y36 から飛びます。1/2/3 で航路を選び直せます。上半分 Y<64 と、下半分 Y>=64 で風が変わります。加速度を計算するときの、動く前の位置 s.y を1回だけ使います。動く途中で境目をこえても、その step の風を途中で切りかえません。

1回の step で壁の手前から向こうへ飛ぶと、着いた場所だけのチェックでは、うすい壁をすりぬけてしまいます。sweep_box は、動いた線分が、半径7だけ広げた箱に最初に入る時刻 t(0〜1)と、面の向き(法線)を求めます。move は、いちばん早くふれるところまで動き、面に向かう方向の速さだけを消して、残りの時間で壁に沿ってすべらせます。

元のゲームのとおり、最大4回までふれる処理をして、ふつうの壁からは 0.01 px 押し戻します。これは角を丸めた正確な「円と長方形」の判定ではなく、広げた箱による、安全側の判定です。角の近くでは、見た目の円より早く当たることがあります。速い動きや、どんな形・どんな大きな時間の幅でも正しく計算できる、一般的な当たり判定とは呼べません。

赤い屋根は spike(トゲ)、中心が Y=129 のあみは net で、RETRY(やり直し)になります。空気やゲームの時刻はその失敗の step まで更新し、そのあとは止まります。R で最初から始めます。ふつうの壁は失敗ではなく、すべって進めます。補給と着地は、次の段階で加えます。

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

03_wind_walls.py / 注目する部分
    wind=l.wind[0] if s.y<64 else l.wind[1]
    buoyancy=air if s.linked else 65.
    ax=THRUST*ux*fraction+(wind-s.vx)/1.4-.7*s.vx

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_wind_walls.py
macOS / Linux
.venv/bin/python 03_wind_walls.py
画面で見ること最初の航路2で右を押し続けると、トゲに当たって RETRY: SPIKE です。自動の入力では、X=57、Y 約29.760、T 約1.133秒で止まります。1 を押すと航路1に切りかわり、上の風+10、下の風0を見比べられます。
  • 風は、動く前の高さで決める
  • 線分で、最初にふれるところを選ぶ
  • 面に向かう方向の速さだけを消して、すべらせる
つまずきやすいところ着いたあとの Y で風を読み直すと、境目をこえた step の加速が、元のゲームと変わってしまいます。また、ふれている面からはなれる動きや、面に沿う動きを、新しい衝突として返すと、壁にはりついてしまいます。
少し変えてみよう

LEVELS の最初の航路(Level の初期の値)の上の風10を20に変えて、1 でその航路を選んでみましょう。動く前の Y=63・VX=0 から、何も押さずに1 step 進めると、VX は 20/1.4×DT になります。64以上では、下の風0のままです。test_stage3_wind_exercise が、この変更を一時的に当てて確かめます。確かめたら10に戻します。

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

"""穴あき風船・教材 3/5:二層の風と衝突。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
import math, json, hashlib

DT = 1 / 120
RADIUS = 7.
THRUST = 90.
LEAK = .5
BURN = 6.
SPEED_LIMIT = 55.

@dataclass(frozen=True)
class Level:
    title:str
    start:tuple
    ports:tuple
    goal:tuple
    walls:tuple=()
    spikes:tuple=()
    marks:tuple=()
    wind:tuple=(10.,0.)
LEVELS=(
 Level('ふくらんだまま',(24.,100.),((24.,100.),),(164.,36.),marks=((88.,42.),(140.,25.))),
 Level('低い給気口',(24.,36.),((24.,36.),(108.,103.)),(166.,32.),walls=((72.,40.,96.,76.),),spikes=((64.,8.,120.,24.),),marks=((56.,91.),(144.,60.)),wind=(-12.,8.)),
 Level('満タンなら上がる',(24.,100.),((24.,100.),(92.,100.)),(169.,98.),walls=((104.,8.,148.,76.),(104.,116.,148.,136.)),marks=((123.,96.),(159.,103.)),wind=(0.,0.)),
)
@dataclass(frozen=True)
class Input:
    x:int=0
    y:int=0
    space:bool=False

@dataclass(frozen=True)
class State:
    level: int = 0
    reason: str = ''
    x: float = 24.
    y: float = 100.
    vx: float = 0.
    vy: float = 0.
    air: float = 80.
    consumed: float = 0.
    linked: bool = True
    status: str = 'flying'
    time: float = 0.
    thrust: tuple = (0., 0.)

def initial(level=0, linked=True):
    x, y = LEVELS[level].start
    return State(level=level, x=x, y=y, linked=linked)

def sweep_box(p,d,box,r=RADIUS):
    """Point segment versus inflated AABB. Returns earliest t, outward normal.
    Exact touching while moving away/tangential is not a fresh collision.
    """
    x,y=p;dx,dy=d;a,b,c,e=box;a-=r;b-=r;c+=r;e+=r
    near=-math.inf;far=math.inf;normal=(0.,0.)
    for pos,delta,low,high,n in ((x,dx,a,c,(-1.,0.)),(y,dy,b,e,(0.,-1.))):
        if abs(delta)<1e-12:
            if pos<low or pos>high:return None
            continue
        t1=(low-pos)/delta;t2=(high-pos)/delta;nn=n
        if t1>t2:t1,t2=t2,t1;nn=(-n[0],-n[1])
        if t1>near:near=t1;normal=nn
        far=min(far,t2)
        if near>far:return None
    if far<0 or near< -1e-8 or near>1:return None
    if d[0]*normal[0]+d[1]*normal[1]>=0:return None
    return max(0.,near),normal

def move(s,vx,vy,dt):
    l=LEVELS[s.level];x,y=s.x,s.y;left=dt;path=[(x,y)]
    # Ordinary perimeter. Bottom recovery net is visible, not a timed penalty.
    walls=((-20.,-20.,0.,144.),(192.,-20.,212.,144.),(0.,0.,192.,8.))+l.walls
    for _ in range(4):
        d=(vx*left,vy*left);hits=[]
        for i,box in enumerate(walls):
            h=sweep_box((x,y),d,box)
            if h:hits.append((h[0],0,i,h[1]))
        for i,box in enumerate(l.spikes):
            h=sweep_box((x,y),d,box)
            if h:hits.append((h[0],-1,i,h[1]))
        if vy>0 and y+d[1]>=129:
            hits.append(((129-y)/max(1e-20,d[1]),-2,0,(0.,-1.)))
        if not hits:x+=d[0];y+=d[1];path.append((x,y));break
        t,kind,_,n=min(hits,key=lambda z:(z[0],z[1],z[2]))
        x+=d[0]*t;y+=d[1]*t;path.append((x,y))
        if kind<0:return x,y,0.,0.,'net' if kind==-2 else 'spike',path
        x+=n[0]*.01;y+=n[1]*.01
        vdot=vx*n[0]+vy*n[1];vx-=vdot*n[0];vy-=vdot*n[1]
        left*=1-t
        if left<1e-10:break
    return x,y,vx,vy,'',path

def step(s,u=Input(),dt=DT):
    if not math.isfinite(dt) or not 0<dt<=1/30:raise ValueError('fixed small positive dt required')
    if s.status!='flying':return s
    l=LEVELS[s.level]
    ux=max(-1,min(1,u.x));uy=max(-1,min(1,u.y));mag=math.hypot(ux,uy)
    if mag:ux/=mag;uy/=mag
    leak=min(s.air,LEAK*dt);remaining=s.air-leak
    spend=min(remaining,BURN*dt) if mag else 0.
    fraction=spend/(BURN*dt) if mag else 0.
    air=max(0.,remaining-spend)
    wind=l.wind[0] if s.y<64 else l.wind[1]
    buoyancy=air if s.linked else 65.
    ax=THRUST*ux*fraction+(wind-s.vx)/1.4-.7*s.vx
    ay=THRUST*uy*fraction+65.-buoyancy-.7*s.vy
    vx=s.vx+ax*dt;vy=s.vy+ay*dt
    speed=math.hypot(vx,vy)
    if speed>SPEED_LIMIT:vx*=SPEED_LIMIT/speed;vy*=SPEED_LIMIT/speed
    x,y,vx,vy,reason,path=move(s,vx,vy,dt)
    if reason:return replace(s,x=x,y=y,vx=0.,vy=0.,air=air,status='retry',reason=reason,time=s.time+dt,consumed=s.consumed+leak+spend,thrust=(0.,0.))
    return replace(s,x=x,y=y,vx=vx,vy=vy,air=air,time=s.time+dt,consumed=s.consumed+leak+spend,thrust=(ux*fraction,uy*fraction))

def frame(s,u=Input()):
    for i in range(4):s=step(s,u if i==0 else replace(u,space=False))
    return s


STAGE = 3
DEFAULT_LEVEL = 1
DEMO_FRAMES = 120
KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'P', 'R', 'Z', 'N', '1', '2', '3')


class App:
    def __init__(self):
        self.level = DEFAULT_LEVEL
        self.reset()

    def reset(self):
        self.state = initial(self.level) if STAGE >= 3 else initial()
        self.pause = False
        self.demo_tick = 0
        self.route_index = 0

    def control(self, pressed, held):
        # Edges are for commands. Directions are continuously held controls.
        if 'R' in pressed:
            self.reset(); return
        if STAGE >= 3:
            for key in ('1', '2', '3'):
                if key in pressed:
                    self.level = int(key)-1; self.reset(); return
        if 'P' in pressed:
            self.pause = not self.pause; return
        if self.pause:
            return
        if self.state.status == 'retry':
            if STAGE >= 4 and 'Z' in pressed:
                self.state = restore(self.state)
            return
        if self.state.status == 'success':
            if 'N' in pressed:
                self.level = (self.level+1)%3; self.reset()
            return
        u = Input(int('RIGHT' in held)-int('LEFT' in held),
                  int('DOWN' in held)-int('UP' in held),
                  'SPACE' in pressed if STAGE >= 4 else False)
        self.state = frame(self.state, u)

    def demo_input(self):
        """Automatic semantic input, not OS-keyboard or human play evidence."""
        tick = self.demo_tick; self.demo_tick += 1
        if STAGE <= 2:
            return Input(1, 0) if tick < 30 else (Input(0, -1) if tick < 50 else Input())
        if STAGE == 3:
            return Input(1, 0)
        s = self.state
        if STAGE == 4 and s.fills >= 1:
            return Input()
        if s.status != 'flying':
            return Input()
        if s.dock >= 0:
            return Input(space=True) if s.air >= 99.7 else Input()
        if STAGE == 4:
            route = ((45., 94.), (108., 103.))
            target = route[self.route_index]
            if self.route_index == 0 and math.hypot(s.x-target[0], s.y-target[1]) < 10:
                self.route_index = 1; target = route[1]
            if self.route_index == 1 and math.hypot(s.x-target[0], s.y-target[1]) < 11 and math.hypot(s.vx, s.vy) < 14:
                return Input(space=True)
        else:
            target = LEVELS[self.level].goal
        tx = max(-30., min(30., (target[0]-s.x)*1.5))
        ty = max(-30., min(30., (target[1]-s.y)*1.5))
        wind = LEVELS[self.level].wind[0] if s.y < 64 else LEVELS[self.level].wind[1]
        ax = (tx-s.vx)*3-((wind-s.vx)/1.4-.7*s.vx)
        ay = (ty-s.vy)*3-(65-s.air-.7*s.vy)
        return Input(1 if ax > 12 else -1 if ax < -12 else 0,
                     1 if ay > 12 else -1 if ay < -12 else 0)

    def demo_update(self):
        u = self.demo_input()
        held = set()
        if u.x: held.add('RIGHT' if u.x > 0 else 'LEFT')
        if u.y: held.add('DOWN' if u.y > 0 else 'UP')
        pressed = {'SPACE'} if u.space else set()
        self.control(pressed, held)

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(8, 8, '015 LEAKY BALLOON / STAGE '+str(STAGE), 10)
        p.text(8, 22, ('PAUSED' if self.pause else s.status.upper())+'  T '+format(s.time, '.2f')+' s', 11)
        p.text(8, 35, 'X '+format(s.x, '.1f')+' Y '+format(s.y, '.1f')+' SPEED '+format(math.hypot(s.vx,s.vy), '.1f'), 7)
        if STAGE >= 2:
            p.text(210, 22, 'AIR '+format(s.air, '.1f'), 10)
            p.rectb(288, 20, 102, 9, 7); p.rect(289, 21, s.air, 7, 11)
            p.line(354, 18, 354, 30, 8)
            p.text(210, 35, 'USED '+format(s.consumed, '.2f'), 6)
        ox, oy = 32, 48
        p.rect(ox, oy+16, 384, 242, 12)
        p.rect(ox, oy+128, 384, 130, 6)
        for x in range(16, 192, 24): p.line(ox+x*2, oy+16, ox+x*2, oy+258, 13)
        p.line(ox, oy+128, ox+384, oy+128, 7)
        if STAGE >= 3:
            level = LEVELS[s.level]
            p.text(ox+5, oy+19, 'ROUTE '+str(s.level+1)+' / Y64 WIND BOUNDARY', 1)
            for yy, w in ((40, level.wind[0]), (100, level.wind[1])):
                p.text(ox+4, oy+yy*2, 'W '+format(w, '+.0f'), 1)
                if w:
                    d = 1 if w > 0 else -1
                    for xx in (45, 165):
                        x=ox+xx*2; y=oy+yy*2
                        p.line(x-d*14,y,x+d*14,y,5)
                        p.line(x+d*14,y,x+d*8,y-4,5)
                        p.line(x+d*14,y,x+d*8,y+4,5)
            for a,b,c,d in level.walls:
                p.rect(ox+a*2, oy+b*2, (c-a)*2, (d-b)*2, 4)
                p.rectb(ox+a*2, oy+b*2, (c-a)*2, (d-b)*2, 9)
            for a,b,c,d in level.spikes:
                p.rect(ox+a*2,oy+b*2,(c-a)*2,(d-b)*2-8,8)
                for x in range(int(a*2),int(c*2),12):
                    p.tri(ox+x,oy+d*2-8,ox+x+12,oy+d*2-8,ox+x+6,oy+d*2,8)
            for x in range(0,384,12):
                p.line(ox+x,oy+258,ox+x+12,oy+267,13)
                p.line(ox+x,oy+267,ox+x+12,oy+258,13)
        else:
            p.text(ox+5,oy+19,'PRACTICE BOUNDS / NO FAIL',1)
            p.line(ox,oy+258,ox+384,oy+258,5)
        if STAGE >= 4:
            for i,(x,y) in enumerate(level.ports):
                x=ox+x*2; y=oy+y*2
                p.circ(x,y,17,5);p.circb(x,y,24,7)
                p.text(x-2,y+15,str(i),10)
                if s.dock == i: p.line(x,y,x,y+24,10)
            p.text(326,35,'DOCK '+str(s.dock),7)
        if STAGE == 5:
            gx,gy=level.goal; gx=ox+gx*2; gy=oy+gy*2
            p.circb(gx,gy,28,11);p.line(gx+29,gy-20,gx+29,gy+20,4)
            p.tri(gx+29,gy-20,gx+29,gy-7,gx+43,gy-14,11)
            for i,(x,y) in enumerate(level.marks):
                if i not in s.collected:
                    x=ox+x*2;y=oy+y*2;p.tri(x-6,y-3,x+7,y,x-6,y+4,7)
        x=ox+s.x*2; y=oy+s.y*2
        radius=9+(s.air*.045 if STAGE >= 2 else 3.6)
        if s.status != 'retry':
            p.circ(x,y,radius,8);p.circ(x-4,y-4,3,14);p.circb(x,y,14,1)
            p.line(x,y+radius,x,y+22,4);p.rect(x-4,y+21,9,5,9)
            ux,uy=s.thrust
            if ux or uy: p.line(x-ux*15,y-uy*15,x-ux*27,y-uy*27,10)
        else:
            for i in range(8):
                a=i*math.pi/4;p.line(x+math.cos(a)*10,y+math.sin(a)*10,x+math.cos(a)*20,y+math.sin(a)*20,8)
        p.text(8,326,'MODEL: 120 Hz / VIEW: 30 FPS / FIXED RADIUS 7',6)
        if STAGE == 1: message='NORMALIZED JET + INERTIA / NO AIR COST YET'
        elif STAGE == 2: message='LEAK 0.5/s + JET 6/s / AIR < 65: SINK'
        elif STAGE == 3: message='SWEEP WALLS / RED SPIKES + NET = RETRY'
        elif STAGE == 4: message='ATTACH <=12 px AND <=15 px/s / FILL 35/s'
        else: message='LAND '+format(s.landed,'.3f')+'/0.4s  PAPER '+str(len(s.collected))+'/2'
        p.text(8,342,message,10)
        if STAGE >= 3 and s.status == 'retry':
            p.text(8,354,'RETRY: '+s.reason.upper(),8)
        elif STAGE >= 4 and s.dock >= 0:
            p.text(8,354,'REFILLING / SPACE TO DEPART / FILLS '+str(s.fills),11)
        elif STAGE == 5 and s.status == 'success':
            p.text(8,354,'SUCCESS / N NEXT ROUTE / R RESTART',11)
        else:
            p.text(8,354,'ARROWS HOLD: JET / RELEASE: COAST',7)
        p.text(8,369,'ARROWS JET  P PAUSE  R RESET  ESC QUIT',6)
        if STAGE >= 4: p.text(8,383,'SPACE ATTACH/DETACH  Z RETRY  1/2/3 ROUTE',6)
        elif STAGE == 3: p.text(8,383,'1/2/3 SELECT ROUTE / R RESTART AFTER RETRY',6)
        else: p.text(8,383,'SIMULATION SECONDS, NOT WALL-CLOCK TIMING',6)


def parse_args(argv=None):
    """Reject invalid QA flags before importing or initializing Pyxel."""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--frames', type=int)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-output')
    args = parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_output):
        parser.error('QA options require --headless-smoke')
    minimum = DEMO_FRAMES if args.demo_checkpoint else 4
    if args.frames is None: args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error('--frames must be between '+str(minimum)+' and 600')
    for name,suffix in (('screenshot','.png'),('state_output','.json')):
        value = getattr(args,name)
        if value is not None and (not value.strip() or Path(value).suffix.lower() != suffix):
            parser.error('--'+name.replace('_','-')+' requires a '+suffix+' file path')
    return args


def main(argv=None):
    args = parse_args(argv)
    from pathlib import Path
    import pyxel as p
    app = App()
    p.init(448,400,title='015 Leaky Balloon',fps=30,display_scale=2,headless=args.headless_smoke)
    keys = {k:getattr(p,'KEY_'+k) for k in KEYS}
    finished = False
    def update():
        if args.headless_smoke:
            # Initial capture is intentionally frozen at initial().
            if args.demo_checkpoint and p.frame_count < DEMO_FRAMES: app.demo_update()
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if args.headless_smoke and p.frame_count >= args.frames-1 and not finished:
            finished = True
            for name in ('screenshot','state_output'):
                value=getattr(args,name)
                if value:
                    path=Path(value).resolve();path.parent.mkdir(parents=True,exist_ok=True)
                    if name == 'screenshot': p.screenshot(str(path),scale=2)
                    else: path.write_text(json.dumps(asdict(app.state),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            p.quit()
    p.run(update,draw)


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

近さと速さを両方満たして接続する

押した瞬間の入力と、再開地点(チェックポイント)。

補給口につながった状態で始まるように変えます。つながっている間は位置も速さも動かさず、もれも噴射も止めて、毎秒35ずつ空気を増やします。100で止まり、満タンになっても自動ではなれません。SPACE でいつでもはなれられ、出発したその step から、もれ・噴射・浮く力が働きます。

飛んでいる間の SPACE は、いちばん近い補給口までの距離が12以下、速さが15以下ならつながります。ちょうど12・15も受け付けます。距離だけを見ると、速いまますりぬけながら補給できてしまうので、速さの条件も必要です。つながれなかったときも、時間は止めずに、ふつうの飛行の計算を続けます。

SPACE は「押した瞬間」の入力です。frame の中の4回の step のうち、最初だけ True にし、残りは False にします。4回とも True だと、同じ更新の中で、つながる・はなれるをくり返してしまいます。一方、矢印は押し続けを毎回読みます。左右の同時押し、上下の同時押しは、それぞれ0になります。

つながると checkpoint(再開地点)を更新し、fills(補給の回数)を1増やします。破裂やあみのあとの Z は、その補給口に、速さ0・空気80で戻します。time・consumed・fills はそのまま残すので、この再開は過去に巻き戻す機能ではありません。R は、それらも最初に戻します。この段階には、ゴールでの成功と、自由に拾える紙は、まだ加えていません。

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

04_refill.py / 注目する部分
    if u.space:
        if s.dock>=0:s=replace(s,dock=-1)
        else:
            candidates=[(math.hypot(s.x-x,s.y-y),i) for i,(x,y) in enumerate(l.ports)]
            dist,i=min(candidates)
            if dist<=12 and math.hypot(s.vx,s.vy)<=15:

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_refill.py
macOS / Linux
.venv/bin/python 04_refill.py
画面で見ること最初は DOCK 0 で、AIR が100まで増えます。SPACE で出発します。自動の確認では、航路2ではりの下を進み、補給口1につながって、8秒の時点で X108・Y103・DOCK 1・FILLS 1・AIR 100 です。
  • 距離12と速さ15の、両方の条件
  • SPACE は4回の step の最初だけ
  • Z は記録を残し、R は全部を最初に戻す
つまずきやすいところSPACE に押し続けの btn を使うと、つながる・はなれるが続けて入れかわってしまいます。btnp で押した瞬間をとり、さらに4回の更新の最初にだけ渡します。
少し変えてみよう

R の直後、満タンを待たずに SPACE で出発してみましょう。80で出発でき、1回の更新のあとの空気は 80-0.5/30 です。補給中に矢印を押しても、噴射も消費も起きないことも比べます。

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

"""穴あき風船・教材 4/5:給気口と局所再開。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace, asdict
import math, json, hashlib

DT = 1 / 120
RADIUS = 7.
THRUST = 90.
LEAK = .5
BURN = 6.
SPEED_LIMIT = 55.

@dataclass(frozen=True)
class Level:
    title:str
    start:tuple
    ports:tuple
    goal:tuple
    walls:tuple=()
    spikes:tuple=()
    marks:tuple=()
    wind:tuple=(10.,0.)
LEVELS=(
 Level('ふくらんだまま',(24.,100.),((24.,100.),),(164.,36.),marks=((88.,42.),(140.,25.))),
 Level('低い給気口',(24.,36.),((24.,36.),(108.,103.)),(166.,32.),walls=((72.,40.,96.,76.),),spikes=((64.,8.,120.,24.),),marks=((56.,91.),(144.,60.)),wind=(-12.,8.)),
 Level('満タンなら上がる',(24.,100.),((24.,100.),(92.,100.)),(169.,98.),walls=((104.,8.,148.,76.),(104.,116.,148.,136.)),marks=((123.,96.),(159.,103.)),wind=(0.,0.)),
)
@dataclass(frozen=True)
class Input:
    x:int=0
    y:int=0
    space:bool=False

@dataclass(frozen=True)
class State:
    level:int=0
    x:float=24.
    y:float=100.
    vx:float=0.
    vy:float=0.
    air:float=80.
    dock:int=0
    checkpoint:int=0
    status:str='flying'
    time:float=0.
    consumed:float=0.
    fills:int=0
    reason:str=''
    thrust:tuple=(0.,0.)
    linked:bool=True

def initial(level=0,linked=True):
    x,y=LEVELS[level].start
    return State(level=level,x=x,y=y,linked=linked)

def restore(s):
    x,y=LEVELS[s.level].ports[s.checkpoint]
    return replace(s,x=x,y=y,vx=0.,vy=0.,air=80.,dock=s.checkpoint,status='flying',reason='',thrust=(0.,0.))

def sweep_box(p,d,box,r=RADIUS):
    """Point segment versus inflated AABB. Returns earliest t, outward normal.
    Exact touching while moving away/tangential is not a fresh collision.
    """
    x,y=p;dx,dy=d;a,b,c,e=box;a-=r;b-=r;c+=r;e+=r
    near=-math.inf;far=math.inf;normal=(0.,0.)
    for pos,delta,low,high,n in ((x,dx,a,c,(-1.,0.)),(y,dy,b,e,(0.,-1.))):
        if abs(delta)<1e-12:
            if pos<low or pos>high:return None
            continue
        t1=(low-pos)/delta;t2=(high-pos)/delta;nn=n
        if t1>t2:t1,t2=t2,t1;nn=(-n[0],-n[1])
        if t1>near:near=t1;normal=nn
        far=min(far,t2)
        if near>far:return None
    if far<0 or near< -1e-8 or near>1:return None
    if d[0]*normal[0]+d[1]*normal[1]>=0:return None
    return max(0.,near),normal

def move(s,vx,vy,dt):
    l=LEVELS[s.level];x,y=s.x,s.y;left=dt;path=[(x,y)]
    # Ordinary perimeter. Bottom recovery net is visible, not a timed penalty.
    walls=((-20.,-20.,0.,144.),(192.,-20.,212.,144.),(0.,0.,192.,8.))+l.walls
    for _ in range(4):
        d=(vx*left,vy*left);hits=[]
        for i,box in enumerate(walls):
            h=sweep_box((x,y),d,box)
            if h:hits.append((h[0],0,i,h[1]))
        for i,box in enumerate(l.spikes):
            h=sweep_box((x,y),d,box)
            if h:hits.append((h[0],-1,i,h[1]))
        if vy>0 and y+d[1]>=129:
            hits.append(((129-y)/max(1e-20,d[1]),-2,0,(0.,-1.)))
        if not hits:x+=d[0];y+=d[1];path.append((x,y));break
        t,kind,_,n=min(hits,key=lambda z:(z[0],z[1],z[2]))
        x+=d[0]*t;y+=d[1]*t;path.append((x,y))
        if kind<0:return x,y,0.,0.,'net' if kind==-2 else 'spike',path
        x+=n[0]*.01;y+=n[1]*.01
        vdot=vx*n[0]+vy*n[1];vx-=vdot*n[0];vy-=vdot*n[1]
        left*=1-t
        if left<1e-10:break
    return x,y,vx,vy,'',path

def step(s,u=Input(),dt=DT):
    if not math.isfinite(dt) or not 0<dt<=1/30:raise ValueError('fixed small positive dt required')
    if s.status!='flying':return s
    l=LEVELS[s.level]
    if u.space:
        if s.dock>=0:s=replace(s,dock=-1)
        else:
            candidates=[(math.hypot(s.x-x,s.y-y),i) for i,(x,y) in enumerate(l.ports)]
            dist,i=min(candidates)
            if dist<=12 and math.hypot(s.vx,s.vy)<=15:
                x,y=l.ports[i];s=replace(s,dock=i,checkpoint=i,x=x,y=y,vx=0.,vy=0.,fills=s.fills+1)
    if s.dock>=0:
        return replace(s,air=min(100.,s.air+35*dt),time=s.time+dt,thrust=(0.,0.))
    ux=max(-1,min(1,u.x));uy=max(-1,min(1,u.y));mag=math.hypot(ux,uy)
    if mag:ux/=mag;uy/=mag
    leak=min(s.air,LEAK*dt);remaining=s.air-leak
    spend=min(remaining,BURN*dt) if mag else 0.
    fraction=spend/(BURN*dt) if mag else 0.
    air=max(0.,remaining-spend)
    wind=l.wind[0] if s.y<64 else l.wind[1]
    buoyancy=air if s.linked else 65.
    ax=THRUST*ux*fraction+(wind-s.vx)/1.4-.7*s.vx
    ay=THRUST*uy*fraction+65.-buoyancy-.7*s.vy
    vx=s.vx+ax*dt;vy=s.vy+ay*dt
    speed=math.hypot(vx,vy)
    if speed>SPEED_LIMIT:vx*=SPEED_LIMIT/speed;vy*=SPEED_LIMIT/speed
    x,y,vx,vy,reason,path=move(s,vx,vy,dt)
    if reason:return replace(s,x=x,y=y,vx=0.,vy=0.,air=air,status='retry',reason=reason,time=s.time+dt,consumed=s.consumed+leak+spend,thrust=(0.,0.))
    return replace(s,x=x,y=y,vx=vx,vy=vy,air=air,time=s.time+dt,consumed=s.consumed+leak+spend,thrust=(ux*fraction,uy*fraction))

def frame(s,u=Input()):
    for i in range(4):s=step(s,u if i==0 else replace(u,space=False))
    return s


STAGE = 4
DEFAULT_LEVEL = 1
DEMO_FRAMES = 240
KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'P', 'R', 'Z', 'N', '1', '2', '3')


class App:
    def __init__(self):
        self.level = DEFAULT_LEVEL
        self.reset()

    def reset(self):
        self.state = initial(self.level) if STAGE >= 3 else initial()
        self.pause = False
        self.demo_tick = 0
        self.route_index = 0

    def control(self, pressed, held):
        # Edges are for commands. Directions are continuously held controls.
        if 'R' in pressed:
            self.reset(); return
        if STAGE >= 3:
            for key in ('1', '2', '3'):
                if key in pressed:
                    self.level = int(key)-1; self.reset(); return
        if 'P' in pressed:
            self.pause = not self.pause; return
        if self.pause:
            return
        if self.state.status == 'retry':
            if STAGE >= 4 and 'Z' in pressed:
                self.state = restore(self.state)
            return
        if self.state.status == 'success':
            if 'N' in pressed:
                self.level = (self.level+1)%3; self.reset()
            return
        u = Input(int('RIGHT' in held)-int('LEFT' in held),
                  int('DOWN' in held)-int('UP' in held),
                  'SPACE' in pressed if STAGE >= 4 else False)
        self.state = frame(self.state, u)

    def demo_input(self):
        """Automatic semantic input, not OS-keyboard or human play evidence."""
        tick = self.demo_tick; self.demo_tick += 1
        if STAGE <= 2:
            return Input(1, 0) if tick < 30 else (Input(0, -1) if tick < 50 else Input())
        if STAGE == 3:
            return Input(1, 0)
        s = self.state
        if STAGE == 4 and s.fills >= 1:
            return Input()
        if s.status != 'flying':
            return Input()
        if s.dock >= 0:
            return Input(space=True) if s.air >= 99.7 else Input()
        if STAGE == 4:
            route = ((45., 94.), (108., 103.))
            target = route[self.route_index]
            if self.route_index == 0 and math.hypot(s.x-target[0], s.y-target[1]) < 10:
                self.route_index = 1; target = route[1]
            if self.route_index == 1 and math.hypot(s.x-target[0], s.y-target[1]) < 11 and math.hypot(s.vx, s.vy) < 14:
                return Input(space=True)
        else:
            target = LEVELS[self.level].goal
        tx = max(-30., min(30., (target[0]-s.x)*1.5))
        ty = max(-30., min(30., (target[1]-s.y)*1.5))
        wind = LEVELS[self.level].wind[0] if s.y < 64 else LEVELS[self.level].wind[1]
        ax = (tx-s.vx)*3-((wind-s.vx)/1.4-.7*s.vx)
        ay = (ty-s.vy)*3-(65-s.air-.7*s.vy)
        return Input(1 if ax > 12 else -1 if ax < -12 else 0,
                     1 if ay > 12 else -1 if ay < -12 else 0)

    def demo_update(self):
        u = self.demo_input()
        held = set()
        if u.x: held.add('RIGHT' if u.x > 0 else 'LEFT')
        if u.y: held.add('DOWN' if u.y > 0 else 'UP')
        pressed = {'SPACE'} if u.space else set()
        self.control(pressed, held)

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(8, 8, '015 LEAKY BALLOON / STAGE '+str(STAGE), 10)
        p.text(8, 22, ('PAUSED' if self.pause else s.status.upper())+'  T '+format(s.time, '.2f')+' s', 11)
        p.text(8, 35, 'X '+format(s.x, '.1f')+' Y '+format(s.y, '.1f')+' SPEED '+format(math.hypot(s.vx,s.vy), '.1f'), 7)
        if STAGE >= 2:
            p.text(210, 22, 'AIR '+format(s.air, '.1f'), 10)
            p.rectb(288, 20, 102, 9, 7); p.rect(289, 21, s.air, 7, 11)
            p.line(354, 18, 354, 30, 8)
            p.text(210, 35, 'USED '+format(s.consumed, '.2f'), 6)
        ox, oy = 32, 48
        p.rect(ox, oy+16, 384, 242, 12)
        p.rect(ox, oy+128, 384, 130, 6)
        for x in range(16, 192, 24): p.line(ox+x*2, oy+16, ox+x*2, oy+258, 13)
        p.line(ox, oy+128, ox+384, oy+128, 7)
        if STAGE >= 3:
            level = LEVELS[s.level]
            p.text(ox+5, oy+19, 'ROUTE '+str(s.level+1)+' / Y64 WIND BOUNDARY', 1)
            for yy, w in ((40, level.wind[0]), (100, level.wind[1])):
                p.text(ox+4, oy+yy*2, 'W '+format(w, '+.0f'), 1)
                if w:
                    d = 1 if w > 0 else -1
                    for xx in (45, 165):
                        x=ox+xx*2; y=oy+yy*2
                        p.line(x-d*14,y,x+d*14,y,5)
                        p.line(x+d*14,y,x+d*8,y-4,5)
                        p.line(x+d*14,y,x+d*8,y+4,5)
            for a,b,c,d in level.walls:
                p.rect(ox+a*2, oy+b*2, (c-a)*2, (d-b)*2, 4)
                p.rectb(ox+a*2, oy+b*2, (c-a)*2, (d-b)*2, 9)
            for a,b,c,d in level.spikes:
                p.rect(ox+a*2,oy+b*2,(c-a)*2,(d-b)*2-8,8)
                for x in range(int(a*2),int(c*2),12):
                    p.tri(ox+x,oy+d*2-8,ox+x+12,oy+d*2-8,ox+x+6,oy+d*2,8)
            for x in range(0,384,12):
                p.line(ox+x,oy+258,ox+x+12,oy+267,13)
                p.line(ox+x,oy+267,ox+x+12,oy+258,13)
        else:
            p.text(ox+5,oy+19,'PRACTICE BOUNDS / NO FAIL',1)
            p.line(ox,oy+258,ox+384,oy+258,5)
        if STAGE >= 4:
            for i,(x,y) in enumerate(level.ports):
                x=ox+x*2; y=oy+y*2
                p.circ(x,y,17,5);p.circb(x,y,24,7)
                p.text(x-2,y+15,str(i),10)
                if s.dock == i: p.line(x,y,x,y+24,10)
            p.text(326,35,'DOCK '+str(s.dock),7)
        if STAGE == 5:
            gx,gy=level.goal; gx=ox+gx*2; gy=oy+gy*2
            p.circb(gx,gy,28,11);p.line(gx+29,gy-20,gx+29,gy+20,4)
            p.tri(gx+29,gy-20,gx+29,gy-7,gx+43,gy-14,11)
            for i,(x,y) in enumerate(level.marks):
                if i not in s.collected:
                    x=ox+x*2;y=oy+y*2;p.tri(x-6,y-3,x+7,y,x-6,y+4,7)
        x=ox+s.x*2; y=oy+s.y*2
        radius=9+(s.air*.045 if STAGE >= 2 else 3.6)
        if s.status != 'retry':
            p.circ(x,y,radius,8);p.circ(x-4,y-4,3,14);p.circb(x,y,14,1)
            p.line(x,y+radius,x,y+22,4);p.rect(x-4,y+21,9,5,9)
            ux,uy=s.thrust
            if ux or uy: p.line(x-ux*15,y-uy*15,x-ux*27,y-uy*27,10)
        else:
            for i in range(8):
                a=i*math.pi/4;p.line(x+math.cos(a)*10,y+math.sin(a)*10,x+math.cos(a)*20,y+math.sin(a)*20,8)
        p.text(8,326,'MODEL: 120 Hz / VIEW: 30 FPS / FIXED RADIUS 7',6)
        if STAGE == 1: message='NORMALIZED JET + INERTIA / NO AIR COST YET'
        elif STAGE == 2: message='LEAK 0.5/s + JET 6/s / AIR < 65: SINK'
        elif STAGE == 3: message='SWEEP WALLS / RED SPIKES + NET = RETRY'
        elif STAGE == 4: message='ATTACH <=12 px AND <=15 px/s / FILL 35/s'
        else: message='LAND '+format(s.landed,'.3f')+'/0.4s  PAPER '+str(len(s.collected))+'/2'
        p.text(8,342,message,10)
        if STAGE >= 3 and s.status == 'retry':
            p.text(8,354,'RETRY: '+s.reason.upper(),8)
        elif STAGE >= 4 and s.dock >= 0:
            p.text(8,354,'REFILLING / SPACE TO DEPART / FILLS '+str(s.fills),11)
        elif STAGE == 5 and s.status == 'success':
            p.text(8,354,'SUCCESS / N NEXT ROUTE / R RESTART',11)
        else:
            p.text(8,354,'ARROWS HOLD: JET / RELEASE: COAST',7)
        p.text(8,369,'ARROWS JET  P PAUSE  R RESET  ESC QUIT',6)
        if STAGE >= 4: p.text(8,383,'SPACE ATTACH/DETACH  Z RETRY  1/2/3 ROUTE',6)
        elif STAGE == 3: p.text(8,383,'1/2/3 SELECT ROUTE / R RESTART AFTER RETRY',6)
        else: p.text(8,383,'SIMULATION SECONDS, NOT WALL-CLOCK TIMING',6)


def parse_args(argv=None):
    """Reject invalid QA flags before importing or initializing Pyxel."""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--frames', type=int)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-output')
    args = parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_output):
        parser.error('QA options require --headless-smoke')
    minimum = DEMO_FRAMES if args.demo_checkpoint else 4
    if args.frames is None: args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error('--frames must be between '+str(minimum)+' and 600')
    for name,suffix in (('screenshot','.png'),('state_output','.json')):
        value = getattr(args,name)
        if value is not None and (not value.strip() or Path(value).suffix.lower() != suffix):
            parser.error('--'+name.replace('_','-')+' requires a '+suffix+' file path')
    return args


def main(argv=None):
    args = parse_args(argv)
    from pathlib import Path
    import pyxel as p
    app = App()
    p.init(448,400,title='015 Leaky Balloon',fps=30,display_scale=2,headless=args.headless_smoke)
    keys = {k:getattr(p,'KEY_'+k) for k in KEYS}
    finished = False
    def update():
        if args.headless_smoke:
            # Initial capture is intentionally frozen at initial().
            if args.demo_checkpoint and p.frame_count < DEMO_FRAMES: app.demo_update()
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if args.headless_smoke and p.frame_count >= args.frames-1 and not finished:
            finished = True
            for name in ('screenshot','state_output'):
                value=getattr(args,name)
                if value:
                    path=Path(value).resolve();path.parent.mkdir(parents=True,exist_ok=True)
                    if name == 'screenshot': p.screenshot(str(path),scale=2)
                    else: path.write_text(json.dumps(asdict(app.state),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            p.quit()
    p.run(update,draw)


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

ゆっくり輪に入り、連続して留まる

通った道の上で拾う紙と、着地の時間の条件。

元のゲームの model.py の、3つの航路すべて・ふつうのルールがそろいます。ゴールの中心から14以下、動いたあとの速さが18以下なら、landed に dt を足します。両方の条件を続けて0.4秒(ゲームの時間)以上満たすと SUCCESS(成功)です。範囲や速さの条件を一度でも外れた step では、landed を0に戻します。近くを速く通りすぎただけでは成功しません。

判定の順番が大切です。「入力でつながる・はなれる → 空気 → 動く前の位置の風と浮く力 → 速さ → 当たり判定つきの移動 → 自由に拾える紙 → 着地 → 結果」の順です。トゲやあみなら、紙と着地の処理には進まず RETRY です。成功や失敗のあとは、ふつうの step がその State を返し続けます。

紙を拾うのは自由です。move が返す折れ線 path のどれかの線分が、紙の中心から10以内を通ると拾えます。終わりの点だけで調べると、速く動いているときに紙をのがしてしまうので、segment_circle で線分との距離を調べます。一度拾った番号は重ならないようにして、並べた tuple に入れます。Z の再開でも残ります。紙が0枚でも、着地の条件を満たせば成功です。

1/2/3 で元のゲームの A/B/C を選び、成功したら N で次へ進みます。C は低いトンネルで、空気が多いほど上がることが、判断を変えます。満タンがいつも有利とは限りません。ただし「70で出発すれば必ず成功」とは言えず、入力や通る道にもよります。最後のモデルは、関数とクラスの形、36,000の変化、境目の場合、元のゲームの自動操縦(Pilot)の12回の飛行と比べています。これは、すべての状態を証明したものではありません。

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

05_landing.py / 注目する部分
    inside=math.hypot(x-l.goal[0],y-l.goal[1])<=14. and math.hypot(vx,vy)<=18.
    landed=s.landed+dt if inside else 0.

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_landing.py
macOS / Linux
.venv/bin/python 05_landing.py
画面で見ること自動の確認は、航路1で SUCCESS です。時刻 約4.566667秒、AIR 約79.4333、紙 1/2 です。紙を全部拾わなくても成功することが分かります。最後の状態で止まり、N で航路2に進めます。
  • ゆっくり、輪の中に、続けていること
  • 条件から外れたら、いた時間は0に戻る
  • 自由に拾える紙は、成功に必須ではない
つまずきやすいところ範囲の外で landed を0に戻さないと、短く何回も入っただけで、着地の成功になってしまいます。小数を足していった値を landed == 0.4 で比べず、>= で判定します。
少し変えてみよう

ゴールの中で速さの条件を満たす状態に固定したテストで、47 step(47/120秒)では flying、48 step で success になることを確かめます。途中に範囲の外の1 step をはさむと、landed は0に戻ります。さらに test_landing_hold_exercise は、判定の値を0.4から0.6に一時的に変えて、71 step では未成功、72 step で成功になることを確かめます。実際のゲームで試すときも、先に P で止めれば値を読めます。

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

"""穴あき風船・教材 5/5:低速着陸と三つの航路。Python 3.11+ / Pyxel 2.9.9。"""
"""Deterministic 120 Hz balloon approximation, independent of rendering.
Order: edges -> available air -> old-position wind/buoyancy -> velocity ->
swept movement (4 contacts max) -> collectibles/landing -> terminal.
Pixels, seconds, px/s. Circle radius stays 7 regardless of air artwork.
"""
from dataclasses import dataclass, replace, asdict
import math, json, hashlib
DT=1/120
RADIUS=7.
THRUST=90.
LEAK=.5
BURN=6.
SPEED_LIMIT=55.
@dataclass(frozen=True)
class Level:
    title:str
    start:tuple
    ports:tuple
    goal:tuple
    walls:tuple=()
    spikes:tuple=()
    marks:tuple=()
    wind:tuple=(10.,0.)
LEVELS=(
 Level('ふくらんだまま',(24.,100.),((24.,100.),),(164.,36.),marks=((88.,42.),(140.,25.))),
 Level('低い給気口',(24.,36.),((24.,36.),(108.,103.)),(166.,32.),walls=((72.,40.,96.,76.),),spikes=((64.,8.,120.,24.),),marks=((56.,91.),(144.,60.)),wind=(-12.,8.)),
 Level('満タンなら上がる',(24.,100.),((24.,100.),(92.,100.)),(169.,98.),walls=((104.,8.,148.,76.),(104.,116.,148.,136.)),marks=((123.,96.),(159.,103.)),wind=(0.,0.)),
)
@dataclass(frozen=True)
class Input:
    x:int=0
    y:int=0
    space:bool=False
@dataclass(frozen=True)
class State:
    level:int=0
    x:float=24.
    y:float=100.
    vx:float=0.
    vy:float=0.
    air:float=80.
    dock:int=0
    checkpoint:int=0
    status:str='flying'
    time:float=0.
    consumed:float=0.
    fills:int=0
    collected:tuple=()
    landed:float=0.
    reason:str=''
    thrust:tuple=(0.,0.)
    linked:bool=True

def initial(level=0,linked=True):
    x,y=LEVELS[level].start
    return State(level=level,x=x,y=y,linked=linked)

def restore(s):
    x,y=LEVELS[s.level].ports[s.checkpoint]
    return replace(s,x=x,y=y,vx=0.,vy=0.,air=80.,dock=s.checkpoint,status='flying',landed=0.,reason='',thrust=(0.,0.))

def sweep_box(p,d,box,r=RADIUS):
    """Point segment versus inflated AABB. Returns earliest t, outward normal.
    Exact touching while moving away/tangential is not a fresh collision.
    """
    x,y=p;dx,dy=d;a,b,c,e=box;a-=r;b-=r;c+=r;e+=r
    near=-math.inf;far=math.inf;normal=(0.,0.)
    for pos,delta,low,high,n in ((x,dx,a,c,(-1.,0.)),(y,dy,b,e,(0.,-1.))):
        if abs(delta)<1e-12:
            if pos<low or pos>high:return None
            continue
        t1=(low-pos)/delta;t2=(high-pos)/delta;nn=n
        if t1>t2:t1,t2=t2,t1;nn=(-n[0],-n[1])
        if t1>near:near=t1;normal=nn
        far=min(far,t2)
        if near>far:return None
    if far<0 or near< -1e-8 or near>1:return None
    if d[0]*normal[0]+d[1]*normal[1]>=0:return None
    return max(0.,near),normal

def segment_circle(p,q,c,r):
    dx=q[0]-p[0];dy=q[1]-p[1];den=dx*dx+dy*dy
    t=0. if den==0 else max(0.,min(1.,((c[0]-p[0])*dx+(c[1]-p[1])*dy)/den))
    return math.hypot(p[0]+dx*t-c[0],p[1]+dy*t-c[1])<=r

def move(s,vx,vy,dt):
    l=LEVELS[s.level];x,y=s.x,s.y;left=dt;path=[(x,y)]
    # Ordinary perimeter. Bottom recovery net is visible, not a timed penalty.
    walls=((-20.,-20.,0.,144.),(192.,-20.,212.,144.),(0.,0.,192.,8.))+l.walls
    for _ in range(4):
        d=(vx*left,vy*left);hits=[]
        for i,box in enumerate(walls):
            h=sweep_box((x,y),d,box)
            if h:hits.append((h[0],0,i,h[1]))
        for i,box in enumerate(l.spikes):
            h=sweep_box((x,y),d,box)
            if h:hits.append((h[0],-1,i,h[1]))
        if vy>0 and y+d[1]>=129:
            hits.append(((129-y)/max(1e-20,d[1]),-2,0,(0.,-1.)))
        if not hits:x+=d[0];y+=d[1];path.append((x,y));break
        t,kind,_,n=min(hits,key=lambda z:(z[0],z[1],z[2]))
        x+=d[0]*t;y+=d[1]*t;path.append((x,y))
        if kind<0:return x,y,0.,0.,'net' if kind==-2 else 'spike',path
        x+=n[0]*.01;y+=n[1]*.01
        vdot=vx*n[0]+vy*n[1];vx-=vdot*n[0];vy-=vdot*n[1]
        left*=1-t
        if left<1e-10:break
    return x,y,vx,vy,'',path

def step(s,u=Input(),dt=DT):
    if not math.isfinite(dt) or not 0<dt<=1/30:raise ValueError('fixed small positive dt required')
    if s.status!='flying':return s
    l=LEVELS[s.level]
    if u.space:
        if s.dock>=0:s=replace(s,dock=-1)
        else:
            candidates=[(math.hypot(s.x-x,s.y-y),i) for i,(x,y) in enumerate(l.ports)]
            dist,i=min(candidates)
            if dist<=12 and math.hypot(s.vx,s.vy)<=15:
                x,y=l.ports[i];s=replace(s,dock=i,checkpoint=i,x=x,y=y,vx=0.,vy=0.,fills=s.fills+1,landed=0.)
    if s.dock>=0:
        return replace(s,air=min(100.,s.air+35*dt),time=s.time+dt,thrust=(0.,0.))
    ux=max(-1,min(1,u.x));uy=max(-1,min(1,u.y));mag=math.hypot(ux,uy)
    if mag:ux/=mag;uy/=mag
    leak=min(s.air,LEAK*dt);remaining=s.air-leak
    spend=min(remaining,BURN*dt) if mag else 0.
    fraction=spend/(BURN*dt) if mag else 0.
    air=max(0.,remaining-spend)
    wind=l.wind[0] if s.y<64 else l.wind[1]
    buoyancy=air if s.linked else 65.
    ax=THRUST*ux*fraction+(wind-s.vx)/1.4-.7*s.vx
    ay=THRUST*uy*fraction+65.-buoyancy-.7*s.vy
    vx=s.vx+ax*dt;vy=s.vy+ay*dt
    speed=math.hypot(vx,vy)
    if speed>SPEED_LIMIT:vx*=SPEED_LIMIT/speed;vy*=SPEED_LIMIT/speed
    x,y,vx,vy,reason,path=move(s,vx,vy,dt)
    if reason:return replace(s,x=x,y=y,vx=0.,vy=0.,air=air,status='retry',reason=reason,time=s.time+dt,consumed=s.consumed+leak+spend,thrust=(0.,0.))
    marks=list(s.collected)
    for i,c in enumerate(l.marks):
        if i not in marks and any(segment_circle(a,b,c,10.) for a,b in zip(path,path[1:])):marks.append(i)
    inside=math.hypot(x-l.goal[0],y-l.goal[1])<=14. and math.hypot(vx,vy)<=18.
    landed=s.landed+dt if inside else 0.
    return replace(s,x=x,y=y,vx=vx,vy=vy,air=air,time=s.time+dt,consumed=s.consumed+leak+spend,collected=tuple(sorted(marks)),landed=landed,status='success' if landed>=.4 else 'flying',thrust=(ux*fraction,uy*fraction))

def frame(s,u=Input()):
    for i in range(4):s=step(s,u if i==0 else replace(u,space=False))
    return s

def state_hash(s):
    return hashlib.sha256(json.dumps(asdict(s),sort_keys=True,separators=(',',':'),ensure_ascii=False).encode('utf-8')).hexdigest()


STAGE = 5
DEFAULT_LEVEL = 0
DEMO_FRAMES = 210
KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'P', 'R', 'Z', 'N', '1', '2', '3')


class App:
    def __init__(self):
        self.level = DEFAULT_LEVEL
        self.reset()

    def reset(self):
        self.state = initial(self.level) if STAGE >= 3 else initial()
        self.pause = False
        self.demo_tick = 0
        self.route_index = 0

    def control(self, pressed, held):
        # Edges are for commands. Directions are continuously held controls.
        if 'R' in pressed:
            self.reset(); return
        if STAGE >= 3:
            for key in ('1', '2', '3'):
                if key in pressed:
                    self.level = int(key)-1; self.reset(); return
        if 'P' in pressed:
            self.pause = not self.pause; return
        if self.pause:
            return
        if self.state.status == 'retry':
            if STAGE >= 4 and 'Z' in pressed:
                self.state = restore(self.state)
            return
        if self.state.status == 'success':
            if 'N' in pressed:
                self.level = (self.level+1)%3; self.reset()
            return
        u = Input(int('RIGHT' in held)-int('LEFT' in held),
                  int('DOWN' in held)-int('UP' in held),
                  'SPACE' in pressed if STAGE >= 4 else False)
        self.state = frame(self.state, u)

    def demo_input(self):
        """Automatic semantic input, not OS-keyboard or human play evidence."""
        tick = self.demo_tick; self.demo_tick += 1
        if STAGE <= 2:
            return Input(1, 0) if tick < 30 else (Input(0, -1) if tick < 50 else Input())
        if STAGE == 3:
            return Input(1, 0)
        s = self.state
        if STAGE == 4 and s.fills >= 1:
            return Input()
        if s.status != 'flying':
            return Input()
        if s.dock >= 0:
            return Input(space=True) if s.air >= 99.7 else Input()
        if STAGE == 4:
            route = ((45., 94.), (108., 103.))
            target = route[self.route_index]
            if self.route_index == 0 and math.hypot(s.x-target[0], s.y-target[1]) < 10:
                self.route_index = 1; target = route[1]
            if self.route_index == 1 and math.hypot(s.x-target[0], s.y-target[1]) < 11 and math.hypot(s.vx, s.vy) < 14:
                return Input(space=True)
        else:
            target = LEVELS[self.level].goal
        tx = max(-30., min(30., (target[0]-s.x)*1.5))
        ty = max(-30., min(30., (target[1]-s.y)*1.5))
        wind = LEVELS[self.level].wind[0] if s.y < 64 else LEVELS[self.level].wind[1]
        ax = (tx-s.vx)*3-((wind-s.vx)/1.4-.7*s.vx)
        ay = (ty-s.vy)*3-(65-s.air-.7*s.vy)
        return Input(1 if ax > 12 else -1 if ax < -12 else 0,
                     1 if ay > 12 else -1 if ay < -12 else 0)

    def demo_update(self):
        u = self.demo_input()
        held = set()
        if u.x: held.add('RIGHT' if u.x > 0 else 'LEFT')
        if u.y: held.add('DOWN' if u.y > 0 else 'UP')
        pressed = {'SPACE'} if u.space else set()
        self.control(pressed, held)

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(8, 8, '015 LEAKY BALLOON / STAGE '+str(STAGE), 10)
        p.text(8, 22, ('PAUSED' if self.pause else s.status.upper())+'  T '+format(s.time, '.2f')+' s', 11)
        p.text(8, 35, 'X '+format(s.x, '.1f')+' Y '+format(s.y, '.1f')+' SPEED '+format(math.hypot(s.vx,s.vy), '.1f'), 7)
        if STAGE >= 2:
            p.text(210, 22, 'AIR '+format(s.air, '.1f'), 10)
            p.rectb(288, 20, 102, 9, 7); p.rect(289, 21, s.air, 7, 11)
            p.line(354, 18, 354, 30, 8)
            p.text(210, 35, 'USED '+format(s.consumed, '.2f'), 6)
        ox, oy = 32, 48
        p.rect(ox, oy+16, 384, 242, 12)
        p.rect(ox, oy+128, 384, 130, 6)
        for x in range(16, 192, 24): p.line(ox+x*2, oy+16, ox+x*2, oy+258, 13)
        p.line(ox, oy+128, ox+384, oy+128, 7)
        if STAGE >= 3:
            level = LEVELS[s.level]
            p.text(ox+5, oy+19, 'ROUTE '+str(s.level+1)+' / Y64 WIND BOUNDARY', 1)
            for yy, w in ((40, level.wind[0]), (100, level.wind[1])):
                p.text(ox+4, oy+yy*2, 'W '+format(w, '+.0f'), 1)
                if w:
                    d = 1 if w > 0 else -1
                    for xx in (45, 165):
                        x=ox+xx*2; y=oy+yy*2
                        p.line(x-d*14,y,x+d*14,y,5)
                        p.line(x+d*14,y,x+d*8,y-4,5)
                        p.line(x+d*14,y,x+d*8,y+4,5)
            for a,b,c,d in level.walls:
                p.rect(ox+a*2, oy+b*2, (c-a)*2, (d-b)*2, 4)
                p.rectb(ox+a*2, oy+b*2, (c-a)*2, (d-b)*2, 9)
            for a,b,c,d in level.spikes:
                p.rect(ox+a*2,oy+b*2,(c-a)*2,(d-b)*2-8,8)
                for x in range(int(a*2),int(c*2),12):
                    p.tri(ox+x,oy+d*2-8,ox+x+12,oy+d*2-8,ox+x+6,oy+d*2,8)
            for x in range(0,384,12):
                p.line(ox+x,oy+258,ox+x+12,oy+267,13)
                p.line(ox+x,oy+267,ox+x+12,oy+258,13)
        else:
            p.text(ox+5,oy+19,'PRACTICE BOUNDS / NO FAIL',1)
            p.line(ox,oy+258,ox+384,oy+258,5)
        if STAGE >= 4:
            for i,(x,y) in enumerate(level.ports):
                x=ox+x*2; y=oy+y*2
                p.circ(x,y,17,5);p.circb(x,y,24,7)
                p.text(x-2,y+15,str(i),10)
                if s.dock == i: p.line(x,y,x,y+24,10)
            p.text(326,35,'DOCK '+str(s.dock),7)
        if STAGE == 5:
            gx,gy=level.goal; gx=ox+gx*2; gy=oy+gy*2
            p.circb(gx,gy,28,11);p.line(gx+29,gy-20,gx+29,gy+20,4)
            p.tri(gx+29,gy-20,gx+29,gy-7,gx+43,gy-14,11)
            for i,(x,y) in enumerate(level.marks):
                if i not in s.collected:
                    x=ox+x*2;y=oy+y*2;p.tri(x-6,y-3,x+7,y,x-6,y+4,7)
        x=ox+s.x*2; y=oy+s.y*2
        radius=9+(s.air*.045 if STAGE >= 2 else 3.6)
        if s.status != 'retry':
            p.circ(x,y,radius,8);p.circ(x-4,y-4,3,14);p.circb(x,y,14,1)
            p.line(x,y+radius,x,y+22,4);p.rect(x-4,y+21,9,5,9)
            ux,uy=s.thrust
            if ux or uy: p.line(x-ux*15,y-uy*15,x-ux*27,y-uy*27,10)
        else:
            for i in range(8):
                a=i*math.pi/4;p.line(x+math.cos(a)*10,y+math.sin(a)*10,x+math.cos(a)*20,y+math.sin(a)*20,8)
        p.text(8,326,'MODEL: 120 Hz / VIEW: 30 FPS / FIXED RADIUS 7',6)
        if STAGE == 1: message='NORMALIZED JET + INERTIA / NO AIR COST YET'
        elif STAGE == 2: message='LEAK 0.5/s + JET 6/s / AIR < 65: SINK'
        elif STAGE == 3: message='SWEEP WALLS / RED SPIKES + NET = RETRY'
        elif STAGE == 4: message='ATTACH <=12 px AND <=15 px/s / FILL 35/s'
        else: message='LAND '+format(s.landed,'.3f')+'/0.4s  PAPER '+str(len(s.collected))+'/2'
        p.text(8,342,message,10)
        if STAGE >= 3 and s.status == 'retry':
            p.text(8,354,'RETRY: '+s.reason.upper(),8)
        elif STAGE >= 4 and s.dock >= 0:
            p.text(8,354,'REFILLING / SPACE TO DEPART / FILLS '+str(s.fills),11)
        elif STAGE == 5 and s.status == 'success':
            p.text(8,354,'SUCCESS / N NEXT ROUTE / R RESTART',11)
        else:
            p.text(8,354,'ARROWS HOLD: JET / RELEASE: COAST',7)
        p.text(8,369,'ARROWS JET  P PAUSE  R RESET  ESC QUIT',6)
        if STAGE >= 4: p.text(8,383,'SPACE ATTACH/DETACH  Z RETRY  1/2/3 ROUTE',6)
        elif STAGE == 3: p.text(8,383,'1/2/3 SELECT ROUTE / R RESTART AFTER RETRY',6)
        else: p.text(8,383,'SIMULATION SECONDS, NOT WALL-CLOCK TIMING',6)


def parse_args(argv=None):
    """Reject invalid QA flags before importing or initializing Pyxel."""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--frames', type=int)
    parser.add_argument('--screenshot')
    parser.add_argument('--state-output')
    args = parser.parse_args(argv)
    if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_output):
        parser.error('QA options require --headless-smoke')
    minimum = DEMO_FRAMES if args.demo_checkpoint else 4
    if args.frames is None: args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error('--frames must be between '+str(minimum)+' and 600')
    for name,suffix in (('screenshot','.png'),('state_output','.json')):
        value = getattr(args,name)
        if value is not None and (not value.strip() or Path(value).suffix.lower() != suffix):
            parser.error('--'+name.replace('_','-')+' requires a '+suffix+' file path')
    return args


def main(argv=None):
    args = parse_args(argv)
    from pathlib import Path
    import pyxel as p
    app = App()
    p.init(448,400,title='015 Leaky Balloon',fps=30,display_scale=2,headless=args.headless_smoke)
    keys = {k:getattr(p,'KEY_'+k) for k in KEYS}
    finished = False
    def update():
        if args.headless_smoke:
            # Initial capture is intentionally frozen at initial().
            if args.demo_checkpoint and p.frame_count < DEMO_FRAMES: app.demo_update()
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)})
    def draw():
        nonlocal finished
        app.draw(p)
        if args.headless_smoke and p.frame_count >= args.frames-1 and not finished:
            finished = True
            for name in ('screenshot','state_output'):
                value=getattr(args,name)
                if value:
                    path=Path(value).resolve();path.parent.mkdir(parents=True,exist_ok=True)
                    if name == 'screenshot': p.screenshot(str(path),scale=2)
                    else: path.write_text(json.dumps(asdict(app.state),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            p.quit()
    p.run(update,draw)


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

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

1秒に120回の、簡単にした計算です。空気のもれは0.5/秒、噴射6/秒、噴射の加速度90。重力65と、使ったあとの空気による浮く力、速さの上限55、半径7だけ広げた箱による当たり判定です。距離12・速さ15以下で補給口につながり、35/秒で補給します。ゴールから距離14・速さ18以下を0.4秒続ければ成功です。紙は自由に拾えます。

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

比べる点5ステップの教材元の完成版
段階01 は空気なし、02 は空気と浮く力、03 は風と当たり判定、04 は補給、05 で元のゲームの3つの航路すべてのモデルです。練習用の外わくは 01/02 だけです。最初から、すべてのルールを使う3つの航路です。
画面と操作文字(ASCII)で表示し、すべての段階で P/R があります。03 以降は 1/2/3 で直接選びます。元のゲームより大きい、補助の表示があります。日本語の文字、表紙で左右に選ぶ、F12 で保存、専用の比較の画面があります。
検証用機能画面を出さない、限られたテスト用の設定で、画像と状態の JSON を出します。中には linked の比較用のモデルも残しています。自動操縦(Pilot)、再現の記録、比較のコマンド、GIF の出力があります。
物理の解釈元のゲームのとおり、重さの変化を計算しない、簡単にした加速度の計算です。広げた箱の角や、時間の幅の境目での限界をはっきり書いています。本物の気体や飛行を再現するものではありません。

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

  1. model.py: step の接続→空気→力→衝突→紙→着陸
  2. model.py: sweep_box / move の接触順序と4回上限
  3. model.py: restore / frame の局所再開とSPACEの立ち上がり
  4. pilot.py: steer / Pilot の速度制御と航路
  5. experiment.py: open_loop_a と比較実験(限定条件の入力監査)
自分のゲームに変えるなら

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

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