アクションLESSON 66重力反転の探索アクション/固定刻み近似物理原作018

逆さの池の作り方

水面を越えても、勢いは続いていく。
水面を通ると重力の向きが逆になる池を作ります。浅く行ったり来たりしたときの反転の制限(再装填)、岸とのぶつかり、宝を拾って帰ることを、段階ごとに作ります。

完成版の遊び方池の水面を抜けると、重力の向きが逆になります。その勢いを使って、棚の裏側に着地します。

逆さの池の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の2部屋目を、自動で描いた画面です。下の教材は小さな岸から始めて、05で3つの部屋すべてのルールに進みます。
PLAY / 試験公開

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

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

完成版の主な操作:左右キーで移動、Spaceで跳ぶ、Zで戻る、Rでやり直し、Pで一時停止、Nで次の部屋へ

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

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

うまく表示されないとき

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

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

なめらかに変わる位置と速度に、一瞬で切り替わる重力の反転を組み合わせる。

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

1. 画面の座標と、加速度・速度・位置を計算する順番
2. 線分が水面を横切る点と、残りの時間の分だけ進める計算(積分)
3. 状態のフラグ、しきい値(境目の値)、浅い入水をわざと簡単に扱う近似
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・関数・if・for・tuple(タプル)と比較の演算 / dataclassとreplaceは本文で説明します / 速度 px/s(1秒あたりのピクセル数)、加速度 px/s²と線形補間(2つの点の間を割合で求めること) / Python 3.11以上、Pyxel 2.9.9
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右:画面の左右の向きに加速する。離すと抵抗で遅くなる / SPACE:地面に立っているときや浮いているときに、重力と逆の向きへ跳ぶ / P:一時停止と再開。キーを全部離してから、また操作できる / R:同じ部屋を全部最初に戻す / Z:04と05で、直前に水面を横切った step の前へ巻き戻す / 1・2・3:05で部屋を選んで最初から始める / N:05で成功したあと、次の部屋へ / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_motion.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

固定刻みで歩いて跳ぶ

画面の座標と、加速度・速度・位置を計算する順番。

池を作る前に、岸を歩いて跳ぶ小さな実験を作ります。世界は192×128pxで、右が+x、下が+yです。State の x と y は、人物の中心の位置です。vx と vy(速度)の単位は px/s、GRAVITY(重力)の単位は px/s² です。1回の step で進む時間は、DT=1/120秒です。Pyxel の30 FPS(1秒に30回)の update から frame を呼び、frame が step を4回行います。描画の回数を増やしても、draw は状態を変えません。

左右キーを押すと、100×DT を vx に足し、±36の範囲に収めます。離すと、地上では120×DT、空中では12×DT ずつ、速さを0に近づけます。0を通りこして逆向きに走らないように、max と min を使います。左右の入力は、重力の向きに関係なく、画面の左右です。左右を同時に押すと1-1=0なので、何も押していないときと同じ抵抗が働きます。

SPACE は、押した瞬間だけを読む入力です。grounded(地面に立っている)が True なら、vy を -gravity×76 にして跳び、同じ step で重力も加えます。重力が下向き(gravity が1)なら、跳んだときの vy はマイナスです。frame の中の最初の step にだけ jump=True を渡し、残りの3回の step には False を渡します。地面にいない間の SPACE は無視します。

位置を更新するときは、重力を足したあとの速度を使います。このやり方(半陰的 Euler 法)は、正確な放物線の式ではありません。決まった短い時間ごとに進める、数値での近似(だいたいの計算)です。落ちる向きの速度は70までに制限しますが、重力と反対向きに跳ぶ速度76は、この制限で切りません。絶対値(向きを考えない大きさ)を70までにするコードに変えると、跳び方も変わってしまいます。

この段階だけは岸の全体が固く、人物の中心が Y=60 のところが床です。move_practice は、移動したあとの座標を外枠の中に収め、ぶつかった向き(軸)の速度だけを0にします。これは、薄い棚や、いくつも同時にぶつかる場合を扱える、一般的な方法ではありません。水面を通ること、反転、宝、成功の判定はまだありません。P で一時停止、R で最初に戻します。切り替えたあとは、キーを全部離した更新を2回待ちます。

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

01_motion.py / 注目する部分
    vy += s.gravity * GRAVITY * dt
    if vy * s.gravity > MAX_VY: vy = s.gravity * MAX_VY
    x, y, vx, vy, normals = move_practice(s.x, s.y, vx, vy, dt)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_motion.py
macOS / Linux
.venv/bin/python 01_motion.py
画面で見ること右を押したときの最初の1 step は、VX=100/120=0.833333、X=42.006944、Y=60 です。SPACE を押すと、同じ step で VY=-76+80/120 になります。自動の確認では、最初に跳び、右を30回の更新の間押し、そのあと60回の更新の間離します。3秒後は X=101.706111、Y=60、VX=VY=0、PLAYING(遊んでいる途中)です。
  • 座標は人物の中心で、下向きがプラス
  • 加速度から速度を、その速度から位置を求める
  • SPACE は、最初の小さな step(substep)だけに渡す
つまずきやすいところ加速度100を x に直接足したり、DT を速度にだけ掛けたりすると、単位が合わなくなります。また、SPACE を4回の step 全部に渡すと、途中で一瞬だけ着地したときに、同じ1回の入力でもう一度跳んでしまうおそれがあります。
少し変えてみよう

GRAVITY を80から40に変えた場合を、Y=30・VY=0・grounded=False から1 step 進めて比べてみましょう。VY=1/3、Y=30+40/120² になります。test_stage1_gravity_exercise が、定数を一時的に変えて確かめます。ふつうの最初の位置は床の上なので、この落ちる量は画面には出ません。確かめたら80に戻します。

01_motion.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(298行)
01_motion.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
from math import hypot, isfinite
import hashlib, json
DT = 1 / 120
GRAVITY = 80.0
JUMP = 76.0
MAX_VX = 36.0
MAX_VY = 70.0
HX, HY = 3.0, 4.0
WATER = 64.0
EPS = 0.001

@dataclass(frozen=True)
class Level:
    name: str
    portals: tuple
    start: tuple

@dataclass(frozen=True)
class Input:
    move: int = 0
    jump: bool = False

@dataclass(frozen=True)
class State:
    x: float
    y: float
    vx: float = 0.
    vy: float = 0.
    gravity: int = 1
    grounded: bool = True
    time: float = 0.
    status: str = 'playing'

LEVELS = (Level('Practice bank', (), (42., 60.)),)
FLOOR = 60.

def initial(level):
    return State(*level.start)

def move_practice(x, y, vx, vy, dt):
    """Teaching-only endpoint clamp; no shelves and no swept collision."""
    x += vx * dt
    y += vy * dt
    normals = []
    if x < 7: x, vx = 7., 0.; normals.append((1, 0))
    if x > 185: x, vx = 185., 0.; normals.append((-1, 0))
    if y < 12: y, vy = 12., 0.; normals.append((0, 1))
    if y > FLOOR: y, vy = FLOOR, 0.; normals.append((0, -1))
    return x, y, vx, vy, tuple(normals)

def step(level, s, control=Input(), dt=DT):
    if s.status != 'playing': return s, ()
    if not (0 < dt <= 1/30): raise ValueError('Use a bounded fixed step')
    if not all(isfinite(v) for v in (s.x, s.y, s.vx, s.vy)):
        raise ValueError('Nonfinite state')
    move = max(-1, min(1, control.move))
    vx, vy, ground = s.vx, s.vy, s.grounded
    if move:
        vx = max(-MAX_VX, min(MAX_VX, vx + move * 100 * dt))
    else:
        drag = (120 if ground else 12) * dt
        vx = max(0, vx-drag) if vx > 0 else min(0, vx+drag)
    if control.jump and ground:
        vy = -s.gravity * JUMP
        ground = False
    vy += s.gravity * GRAVITY * dt
    if vy * s.gravity > MAX_VY: vy = s.gravity * MAX_VY
    x, y, vx, vy, normals = move_practice(s.x, s.y, vx, vy, dt)
    ground = any(ny == -s.gravity for _, ny in normals)
    return replace(s, x=x, y=y, vx=vx, vy=vy, grounded=ground, time=s.time+dt), ()

def frame(level, s, control=Input()):
    events = []
    for i in range(4):
        s, event = step(level, s, Input(control.move, control.jump and i == 0))
        events.extend(event)
    return s, tuple(events)

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


# The renderer and controls are shared scaffolding; the model above grows by stage.
STAGE = 1
DEMO_FRAMES = 90
KEYS = ('LEFT', 'RIGHT', 'SPACE', 'P', 'R', 'Z', 'N', '1', '2', '3')
DEMO_PLAN = ((14, 0, True), (82, 1, False), (20, 0, False))

class App:
    def __init__(self):
        self.level = 0
        self.state = initial(LEVELS[self.level])
        self.pause = False
        self.gate = 0
        self.undo_state = None
        self.trail = []
        self.demo_tick = 0

    def reset(self):
        self.state = initial(LEVELS[self.level])
        self.pause = False
        self.undo_state = None
        self.trail = []
        self.gate = 2

    def advance(self, control):
        before = self.state
        self.state, events = frame(LEVELS[self.level], self.state, control)
        for event in events: self.undo_state = event.before
        self.trail.append((self.state.x, self.state.y))
        self.trail = self.trail[-40:]
        if before.status != 'success' and self.state.status == 'success': self.gate = 2

    def control(self, pressed=(), held=()):
        # Two completely released updates prevent held keys leaking after a switch.
        if self.gate:
            self.gate = 2 if set(held).intersection(KEYS) else self.gate-1
            return
        if 'R' in pressed: self.reset(); return
        if STAGE == 5:
            for index in range(3):
                if str(index+1) in pressed:
                    self.level = index; self.reset(); return
        if 'P' in pressed:
            self.pause = not self.pause; self.gate = 2; return
        if self.pause: return
        if STAGE >= 4 and 'Z' in pressed:
            if self.undo_state is not None:
                self.state = self.undo_state; self.undo_state = None; self.trail = []
            self.gate = 2
            return
        if self.state.status == 'success':
            if STAGE == 5 and 'N' in pressed:
                self.level = (self.level+1) % len(LEVELS); self.reset()
            return
        self.advance(Input(int('RIGHT' in held)-int('LEFT' in held), 'SPACE' in pressed))

    def demo_update(self):
        tick = self.demo_tick
        self.demo_tick += 1
        if STAGE == 1:
            control = Input(1 if tick < 30 else 0, tick == 0)
        elif STAGE == 2:
            control = Input(1 if tick < 30 else 0)
        elif STAGE == 3:
            control = Input()
        else:
            control = Input()
            for count, move, jump in DEMO_PLAN:
                if tick < count:
                    control = Input(move, bool(jump and tick == 0)); break
                tick -= count
        held = ('RIGHT',) if control.move > 0 else ('LEFT',) if control.move < 0 else ()
        self.control(('SPACE',) if control.jump else (), held)

    def draw(self, p):
        s, level = self.state, LEVELS[self.level]
        p.cls(1)
        p.text(12, 10, '018 INVERTED POND / STAGE '+str(STAGE), 10)
        p.text(12, 24, ('PAUSED' if self.pause else s.status.upper())+'   TIME '+format(s.time, '.3f')+' s', 11)
        p.text(12, 38, 'X '+format(s.x, '.3f')+'  Y '+format(s.y, '.3f')+'  VX '+format(s.vx, '.2f')+'  VY '+format(s.vy, '.2f'), 7)
        p.text(12, 52, 'GRAVITY '+('DOWN' if s.gravity == 1 else 'UP')+'  CROSSES '+str(getattr(s, 'crossings', 0))+'  GROUNDED '+str(s.grounded), 10)
        if STAGE >= 3:
            p.text(12, 66, 'ARMED '+str(s.armed)+'  FLOATING '+str(s.floating), 6)
        ox, oy = 32, 82
        def xy(x, y): return ox+2*x, oy+2*y
        p.rect(ox, oy, 384, 256, 2)
        p.rect(ox, oy+128, 384, 128, 1)
        for x in range(4, 188, 12):
            p.line(*xy(x, 56), *xy(x+5, 56), 5)
            p.line(*xy(x, 72), *xy(x+5, 72), 5)
        p.text(ox+10, oy+20, 'WORLD 192 x 128 / +Y DOWN', 6)
        for x in range(4, 188):
            opened = STAGE >= 2 and any(a <= x < b for a, b in level.portals)
            if not opened: p.line(*xy(x, 63), *xy(x, 65), 6)
            elif x % 3 == 0: p.line(*xy(x, 64), *xy(x+1, 64), 12)
        p.rect(ox, oy, 8, 256, 5); p.rect(ox+376, oy, 8, 256, 5)
        p.rect(ox, oy, 384, 16, 5); p.rect(ox, oy+240, 384, 16, 5)
        if STAGE >= 4:
            for rock in level.rocks:
                x, y = xy(rock.x1, rock.y1)
                p.rect(x, y, (rock.x2-rock.x1)*2, (rock.y2-rock.y1)*2, 13)
                p.rectb(x, y, (rock.x2-rock.x1)*2, (rock.y2-rock.y1)*2, 7)
            hx, hy = xy(*level.start)
            p.rect(hx-9, hy-22, 18, 22, 4)
            p.tri(hx-13, hy-22, hx, hy-34, hx+13, hy-22, 9)
            if STAGE == 5:
                for name, ix, iy in level.items:
                    x, y = xy(ix, iy)
                    if name in s.collected: p.circb(x, y, 7, 5)
                    elif name == 'moon':
                        p.circ(x, y, 7, 10); p.circ(x+3, y-3, 6, 1)
                    else: p.tri(x-7, y+2, x+6, y-7, x+4, y+5, 11)
        for x, y in self.trail[::3]: p.pset(*xy(x, y), 6)
        x, y = xy(s.x, s.y); g = s.gravity
        p.rect(x-5, y-7, 11, 15, 7)
        p.rect(x-4, y-6*g-1, 9, 4, 12)
        p.pset(x-2, y-4*g, 1); p.pset(x+2, y-4*g, 1)
        p.line(x+14, y-7*g, x+14, y+8*g, 10)
        p.line(x+10, y+4*g, x+14, y+8*g, 10)
        p.line(x+18, y+4*g, x+14, y+8*g, 10)
        if getattr(s, 'floating', False): p.ellib(x-9, y-3, 19, 7, 12)
        messages = {1: 'ACCELERATION -> VELOCITY -> POSITION / FLAT BANK',
                    2: 'CROSS THE LINE: SAME POSITION, NEW GRAVITY',
                    3: 'DASHES = 8 px / FLOAT? SPACE TO JUMP OUT',
                    4: 'SWEPT BANKS + SHELF / Z UNDO LAST CROSS',
                    5: 'ROOM '+str(self.level+1)+' / ITEMS '+str(len(getattr(s, 'collected', ())))+'/'+str(len(getattr(level, 'items', ())))}
        p.text(12, 350, messages[STAGE], 10)
        p.text(12, 364, 'LEFT/RIGHT MOVE  SPACE JUMP  P PAUSE  R RESET', 7)
        p.text(12, 378, '1/2/3 ROOM  Z UNDO  N NEXT  ESC QUIT' if STAGE == 5 else 'MODEL 120 Hz / VIEW 30 FPS / ESC QUIT', 6)
        if s.status == 'success':
            p.rect(108, 176, 232, 38, 1); p.rectb(108, 176, 232, 38, 11)
            p.text(144, 193, 'SUCCESS / N NEXT ROOM', 11)
        elif self.pause:
            p.rect(144, 182, 160, 26, 1); p.text(160, 193, 'PAUSED / P RESUME', 10)


def parse_args(argv=None):
    """All QA arguments are bounded and validated before Pyxel is imported."""
    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='018 Inverted Pond', fps=30, display_scale=2, headless=args.headless_smoke)
    keys = {name: getattr(p, 'KEY_'+name) for name in KEYS}
    finished = False
    def update():
        if args.headless_smoke:
            if args.demo_checkpoint and p.frame_count < DEMO_FRAMES: app.demo_update()
        else:
            app.control({name for name, key in keys.items() if p.btnp(key)},
                        {name for name, key in keys.items() if p.btn(key)})
    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

位置を変換せず重力を反転

線分が水面を横切る点と、残りの時間の分だけ進める計算(積分)。

岸に穴を開けて、Y=64 を通り抜けられる練習用の池に変えます。最初の位置は X=90・Y=40 で、空中です。世界を上下さかさまに描いたり、y を 128-y に置きかえたりはしません。水面を横切ったのと同じ世界の座標から先へ進み、gravity だけを1から-1に、または-1から1に反転します。左右の操作もそのままです。

まず、古い重力で速度を更新し、移動先の候補の x と y を求めます。「前のY < 64 かつ 新しいY >= 64 かつ 重力が下向き」の場合と、その上下が逆の場合だけを、横断(水面を横切ること)とします。前の位置がちょうど水面の上なら、新しい横断とは数えません。こうすると、水面から外へ跳び出す瞬間を、別の入水とまちがえません。

t=(64-前のY)/(候補のY-前のY) は、候補の線分のうち、どの割合のところで水面に着くかを表します。cx は、その割合で求めた(補間した)横断のXです。Event には、横断した点と、その step の直前の State を保存します。ここで y を反対側へ飛ばす必要はありません。残りの時間 rem=dt×(1-t) の分だけ、水面上の点(cx, 64)から、もう一度進め直します(積分し直します)。

速度は、古い重力を dt 全体に加えたものになっています。そこから、残りの時間の分の古い重力を取り消し、新しい重力を加えます。そのため、補正は 2×新しい gravity×GRAVITY×rem です。これは、反転したときに vy=-vy を実行するという意味ではありません。十分な勢いで下向きに入れば、反転したあともまず下向きに進み、新しい上向きの重力でだんだん遅くなります。ここで「勢いを保つ」というのは、位置や速度を急に別の値に置きかえない、というゲームの上での意味です。重さのある水の運動量を計算しているわけではありません。

02は、反転の仕組みだけを見るための、途中の実験です。再装填(armed)と浮遊(floating)はまだありません。そのため、浅く何度も水面を横切ると、反転がくり返されます。これを完成版のルールにするのではなく、次の03で直す問題として観察します。外枠の処理も01の座標の制限のままで、岸や棚はありません。

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

02_crossing.py / 注目する部分
        gravity = -s.gravity
        crossings += 1
        events.append(Event('cross', s, cx, WATER))
        rem = dt * (1-t)
        vy += 2 * gravity * GRAVITY * rem

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_crossing.py
macOS / Linux
.venv/bin/python 02_crossing.py
画面で見ること最初の位置から何も押さなくても、落ちて水に入ります。X=90・Y=63.9・VX=10・VY=60 から1 step 進めると、横断が1回で、重力は上向きになりますが、VY はまだプラス(下向き)です。自動の確認では、3秒後に X=167.569444、Y=41.169289、横断2回で、重力は下向きです。これは決まった入力の並びでの結果で、どんな操作でもこうなるという約束ではありません。
  • 水面を横切っても、座標を鏡のように反転しない
  • 重力の向き(符号)と、速度の向きは別のもの
  • 横切ったあとの残りの時間も使って進める
つまずきやすいところy=128-y にすると位置が飛び、vy=-vy にすると水面ですぐにはね返ります。また、余った時間を捨てると、水面をよく横切る動きのときだけ、進む時間が減ってしまいます。横断した点を求める前に「0で割らないようにする処理」だけを入れても、前のYと新しいYの向きの条件を省いてはいけません。
少し変えてみよう

GRAVITY を40に変えて、X=90・Y=63.9・VX=10・VY=60 から1 step 進めてみましょう。候補の速度は v=60+40×DT、t=0.1/(v×DT) で、結果は VY=v-80×DT×(1-t) となり、まだ下向きです。test_stage2_gravity_exercise が確かめます。反転のたびに速度の符号を直接変えるやり方とはちがうことを確かめたら、80に戻します。

02_crossing.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(319行)
02_crossing.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
from math import hypot, isfinite
import hashlib, json
DT = 1 / 120
GRAVITY = 80.0
JUMP = 76.0
MAX_VX = 36.0
MAX_VY = 70.0
HX, HY = 3.0, 4.0
WATER = 64.0
EPS = 0.001

@dataclass(frozen=True)
class Level:
    name: str
    portals: tuple
    start: tuple

@dataclass(frozen=True)
class Input:
    move: int = 0
    jump: bool = False

@dataclass(frozen=True)
class State:
    x: float
    y: float
    vx: float = 0.
    vy: float = 0.
    gravity: int = 1
    grounded: bool = True
    time: float = 0.
    status: str = 'playing'
    crossings: int = 0

@dataclass(frozen=True)
class Event:
    kind: str
    before: State
    x: float
    y: float

LEVELS = (Level('Open practice pond', ((4, 188),), (90., 40.)),)
FLOOR = 116.

def initial(level):
    return State(*level.start, grounded=False)

def move_practice(x, y, vx, vy, dt):
    """Teaching-only endpoint clamp; no shelves and no swept collision."""
    x += vx * dt
    y += vy * dt
    normals = []
    if x < 7: x, vx = 7., 0.; normals.append((1, 0))
    if x > 185: x, vx = 185., 0.; normals.append((-1, 0))
    if y < 12: y, vy = 12., 0.; normals.append((0, 1))
    if y > FLOOR: y, vy = FLOOR, 0.; normals.append((0, -1))
    return x, y, vx, vy, tuple(normals)

def step(level, s, control=Input(), dt=DT):
    if s.status != 'playing': return s, ()
    if not (0 < dt <= 1/30): raise ValueError('Use a bounded fixed step')
    if not all(isfinite(v) for v in (s.x, s.y, s.vx, s.vy)):
        raise ValueError('Nonfinite state')
    move = max(-1, min(1, control.move))
    vx, vy, ground = s.vx, s.vy, s.grounded
    if move:
        vx = max(-MAX_VX, min(MAX_VX, vx + move * 100 * dt))
    else:
        drag = (120 if ground else 12) * dt
        vx = max(0, vx-drag) if vx > 0 else min(0, vx+drag)
    if control.jump and ground:
        vy = -s.gravity * JUMP
        ground = False
    vy += s.gravity * GRAVITY * dt
    if vy * s.gravity > MAX_VY: vy = s.gravity * MAX_VY
    x, y, vx, vy, normals = move_practice(s.x, s.y, vx, vy, dt)
    gravity, crossings, events = s.gravity, s.crossings, []
    crosses = (s.y < WATER <= y and s.gravity == 1) or (s.y > WATER >= y and s.gravity == -1)
    if crosses:
        t = (WATER-s.y) / (y-s.y)
        cx = s.x + (x-s.x) * t
        gravity = -s.gravity
        crossings += 1
        events.append(Event('cross', s, cx, WATER))
        rem = dt * (1-t)
        vy += 2 * gravity * GRAVITY * rem
        x, y, vx, vy, after = move_practice(cx, WATER, vx, vy, rem)
        normals += after
    ground = any(ny == -gravity for _, ny in normals)
    return replace(s, x=x, y=y, vx=vx, vy=vy, gravity=gravity, grounded=ground,
                   crossings=crossings, time=s.time+dt), tuple(events)

def frame(level, s, control=Input()):
    events = []
    for i in range(4):
        s, event = step(level, s, Input(control.move, control.jump and i == 0))
        events.extend(event)
    return s, tuple(events)

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


# The renderer and controls are shared scaffolding; the model above grows by stage.
STAGE = 2
DEMO_FRAMES = 90
KEYS = ('LEFT', 'RIGHT', 'SPACE', 'P', 'R', 'Z', 'N', '1', '2', '3')
DEMO_PLAN = ((14, 0, True), (82, 1, False), (20, 0, False))

class App:
    def __init__(self):
        self.level = 0
        self.state = initial(LEVELS[self.level])
        self.pause = False
        self.gate = 0
        self.undo_state = None
        self.trail = []
        self.demo_tick = 0

    def reset(self):
        self.state = initial(LEVELS[self.level])
        self.pause = False
        self.undo_state = None
        self.trail = []
        self.gate = 2

    def advance(self, control):
        before = self.state
        self.state, events = frame(LEVELS[self.level], self.state, control)
        for event in events: self.undo_state = event.before
        self.trail.append((self.state.x, self.state.y))
        self.trail = self.trail[-40:]
        if before.status != 'success' and self.state.status == 'success': self.gate = 2

    def control(self, pressed=(), held=()):
        # Two completely released updates prevent held keys leaking after a switch.
        if self.gate:
            self.gate = 2 if set(held).intersection(KEYS) else self.gate-1
            return
        if 'R' in pressed: self.reset(); return
        if STAGE == 5:
            for index in range(3):
                if str(index+1) in pressed:
                    self.level = index; self.reset(); return
        if 'P' in pressed:
            self.pause = not self.pause; self.gate = 2; return
        if self.pause: return
        if STAGE >= 4 and 'Z' in pressed:
            if self.undo_state is not None:
                self.state = self.undo_state; self.undo_state = None; self.trail = []
            self.gate = 2
            return
        if self.state.status == 'success':
            if STAGE == 5 and 'N' in pressed:
                self.level = (self.level+1) % len(LEVELS); self.reset()
            return
        self.advance(Input(int('RIGHT' in held)-int('LEFT' in held), 'SPACE' in pressed))

    def demo_update(self):
        tick = self.demo_tick
        self.demo_tick += 1
        if STAGE == 1:
            control = Input(1 if tick < 30 else 0, tick == 0)
        elif STAGE == 2:
            control = Input(1 if tick < 30 else 0)
        elif STAGE == 3:
            control = Input()
        else:
            control = Input()
            for count, move, jump in DEMO_PLAN:
                if tick < count:
                    control = Input(move, bool(jump and tick == 0)); break
                tick -= count
        held = ('RIGHT',) if control.move > 0 else ('LEFT',) if control.move < 0 else ()
        self.control(('SPACE',) if control.jump else (), held)

    def draw(self, p):
        s, level = self.state, LEVELS[self.level]
        p.cls(1)
        p.text(12, 10, '018 INVERTED POND / STAGE '+str(STAGE), 10)
        p.text(12, 24, ('PAUSED' if self.pause else s.status.upper())+'   TIME '+format(s.time, '.3f')+' s', 11)
        p.text(12, 38, 'X '+format(s.x, '.3f')+'  Y '+format(s.y, '.3f')+'  VX '+format(s.vx, '.2f')+'  VY '+format(s.vy, '.2f'), 7)
        p.text(12, 52, 'GRAVITY '+('DOWN' if s.gravity == 1 else 'UP')+'  CROSSES '+str(getattr(s, 'crossings', 0))+'  GROUNDED '+str(s.grounded), 10)
        if STAGE >= 3:
            p.text(12, 66, 'ARMED '+str(s.armed)+'  FLOATING '+str(s.floating), 6)
        ox, oy = 32, 82
        def xy(x, y): return ox+2*x, oy+2*y
        p.rect(ox, oy, 384, 256, 2)
        p.rect(ox, oy+128, 384, 128, 1)
        for x in range(4, 188, 12):
            p.line(*xy(x, 56), *xy(x+5, 56), 5)
            p.line(*xy(x, 72), *xy(x+5, 72), 5)
        p.text(ox+10, oy+20, 'WORLD 192 x 128 / +Y DOWN', 6)
        for x in range(4, 188):
            opened = STAGE >= 2 and any(a <= x < b for a, b in level.portals)
            if not opened: p.line(*xy(x, 63), *xy(x, 65), 6)
            elif x % 3 == 0: p.line(*xy(x, 64), *xy(x+1, 64), 12)
        p.rect(ox, oy, 8, 256, 5); p.rect(ox+376, oy, 8, 256, 5)
        p.rect(ox, oy, 384, 16, 5); p.rect(ox, oy+240, 384, 16, 5)
        if STAGE >= 4:
            for rock in level.rocks:
                x, y = xy(rock.x1, rock.y1)
                p.rect(x, y, (rock.x2-rock.x1)*2, (rock.y2-rock.y1)*2, 13)
                p.rectb(x, y, (rock.x2-rock.x1)*2, (rock.y2-rock.y1)*2, 7)
            hx, hy = xy(*level.start)
            p.rect(hx-9, hy-22, 18, 22, 4)
            p.tri(hx-13, hy-22, hx, hy-34, hx+13, hy-22, 9)
            if STAGE == 5:
                for name, ix, iy in level.items:
                    x, y = xy(ix, iy)
                    if name in s.collected: p.circb(x, y, 7, 5)
                    elif name == 'moon':
                        p.circ(x, y, 7, 10); p.circ(x+3, y-3, 6, 1)
                    else: p.tri(x-7, y+2, x+6, y-7, x+4, y+5, 11)
        for x, y in self.trail[::3]: p.pset(*xy(x, y), 6)
        x, y = xy(s.x, s.y); g = s.gravity
        p.rect(x-5, y-7, 11, 15, 7)
        p.rect(x-4, y-6*g-1, 9, 4, 12)
        p.pset(x-2, y-4*g, 1); p.pset(x+2, y-4*g, 1)
        p.line(x+14, y-7*g, x+14, y+8*g, 10)
        p.line(x+10, y+4*g, x+14, y+8*g, 10)
        p.line(x+18, y+4*g, x+14, y+8*g, 10)
        if getattr(s, 'floating', False): p.ellib(x-9, y-3, 19, 7, 12)
        messages = {1: 'ACCELERATION -> VELOCITY -> POSITION / FLAT BANK',
                    2: 'CROSS THE LINE: SAME POSITION, NEW GRAVITY',
                    3: 'DASHES = 8 px / FLOAT? SPACE TO JUMP OUT',
                    4: 'SWEPT BANKS + SHELF / Z UNDO LAST CROSS',
                    5: 'ROOM '+str(self.level+1)+' / ITEMS '+str(len(getattr(s, 'collected', ())))+'/'+str(len(getattr(level, 'items', ())))}
        p.text(12, 350, messages[STAGE], 10)
        p.text(12, 364, 'LEFT/RIGHT MOVE  SPACE JUMP  P PAUSE  R RESET', 7)
        p.text(12, 378, '1/2/3 ROOM  Z UNDO  N NEXT  ESC QUIT' if STAGE == 5 else 'MODEL 120 Hz / VIEW 30 FPS / ESC QUIT', 6)
        if s.status == 'success':
            p.rect(108, 176, 232, 38, 1); p.rectb(108, 176, 232, 38, 11)
            p.text(144, 193, 'SUCCESS / N NEXT ROOM', 11)
        elif self.pause:
            p.rect(144, 182, 160, 26, 1); p.text(160, 193, 'PAUSED / P RESUME', 10)


def parse_args(argv=None):
    """All QA arguments are bounded and validated before Pyxel is imported."""
    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='018 Inverted Pond', fps=30, display_scale=2, headless=args.headless_smoke)
    keys = {name: getattr(p, 'KEY_'+name) for name in KEYS}
    finished = False
    def update():
        if args.headless_smoke:
            if args.demo_checkpoint and p.frame_count < DEMO_FRAMES: app.demo_update()
        else:
            app.control({name for name, key in keys.items() if p.btnp(key)},
                        {name for name, key in keys.items() if p.btn(key)})
    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

一度離れるまで反転を再装填しない

状態のフラグ、しきい値(境目の値)、浅い入水をわざと簡単に扱う近似。

02で見えた細かい行ったり来たりを直すために、armed という「次の反転を許すかどうか」の状態を加えます(再装填)。反転した瞬間は False です。そのあと、step を始めるときの位置が水面から 8-EPS 以上離れると、True に戻ります。EPS=0.001 なので、数字の上では7.999pxですが、小数(浮動小数点)どうしの比較である点も大事です。画面の破線 Y=56 と72は、8pxの目安です。

再装填に使うのは、移動する前の s.y です。移動したあとに初めて8pxを越えた step では、その時点ではまだ False のことがあります。次の step を始めるときにその位置を読み、再装填します。水に入るときに armed=False なら、重力を反転させず、水面で止めます。横断の回数も増やしません。

armed=True で水に入っても、vy²/(2×GRAVITY) < 8 なら、浮いた状態(浮遊)に切り替えます。これは、今の上下の速度から見積もった、進める距離が8px未満かどうかの判定です。位置は横断した点(cx, 64)、vy は0になり、floating と grounded は True になります。これは、横断した直後のふつうの「勢いを保つ」処理とは別のものです。浅い入水を落ち着かせるために、わざと簡単にした近似です。横の速度 vx は、この処理では0にしません。そのあとの入力と、地上の抵抗で変わります。

浮いている間は、SPACE を押さなければ上下には動かず、横の移動と time だけを進めます。SPACE を押すと、grounded なので今の重力と逆向きに跳び、floating を解除します。たとえば、重力が上向きで浮いているなら、下へ跳んで水面から離れます。8pxを越えれば再装填され、戻って水面を横切ると、また反転できます。

この落ち着かせる仕組み(安定化)は、「水面でずっと動けなくする」仕組みではありません。浮いたら SPACE でぬけ出せる操作を、必ずいっしょに用意します。03では最初の Y を60にして、わずか4px落ちるだけの浅い入水を、自然に観察できるようにしました。本物の水の流れ・ねばり・浮く力を計算するものではありません。反転のルールを、遊べる範囲に整えるためのゲームのモデルです。

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

03_rearm_float.py / 注目する部分
    if abs(s.y-WATER) >= 8-EPS: armed = True
    crosses = (s.y < WATER <= y and s.gravity == 1) or (s.y > WATER >= y and s.gravity == -1)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_rearm_float.py
macOS / Linux
.venv/bin/python 03_rearm_float.py
画面で見ること何も操作しないと1回反転し、X=90・Y=64・VY=0、GRAVITY UP、ARMED False、FLOATING True で落ち着きます。3秒後も横断は1回のままで、time だけは3秒まで進みます。SPACE を押すと下へ跳び、9回の更新ほどあとには8pxより離れて、ARMED True になります。
  • 前の位置がしきい値を越えると、再装填する
  • 浅い入水は、vy=0 にする、わざと簡単にした近似
  • SPACE で、浮いた状態からぬけ出す
つまずきやすいところcrossings(横断の回数)が偶数か奇数かだけで再装填の代わりにすると、水面からどれだけ離れたかを調べられません。また、「速度はいつも保たれる」と説明すると、浅い入水で vy を0にする近似と合わなくなります。ふつうの横断、浅い入水、浮いている間、の3つを分けて読みましょう。
少し変えてみよう

最初の位置から浮いたあと、SPACE を1回だけ押してみましょう。反転の回数は増えずに浮遊が解除され、今の重力と逆向きに跳ぶことを確かめます。test_float_escape_rearms は、上向きと下向きの両方で、最初の1回の更新と、そのあとの8回の更新を調べます。test_stage3_low_energy_cutoff は、√(2×80×8) より1e-6だけ大きい速度と小さい速度を作り、浮遊するかしないかの境目を確かめます。

03_rearm_float.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(341行)
03_rearm_float.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
from math import hypot, isfinite
import hashlib, json
DT = 1 / 120
GRAVITY = 80.0
JUMP = 76.0
MAX_VX = 36.0
MAX_VY = 70.0
HX, HY = 3.0, 4.0
WATER = 64.0
EPS = 0.001

@dataclass(frozen=True)
class Level:
    name: str
    portals: tuple
    start: tuple

@dataclass(frozen=True)
class Input:
    move: int = 0
    jump: bool = False

@dataclass(frozen=True)
class State:
    x: float
    y: float
    vx: float = 0.
    vy: float = 0.
    gravity: int = 1
    grounded: bool = True
    time: float = 0.
    status: str = 'playing'
    crossings: int = 0
    armed: bool = True
    floating: bool = False

@dataclass(frozen=True)
class Event:
    kind: str
    before: State
    x: float
    y: float

LEVELS = (Level('Open practice pond', ((4, 188),), (90., 60.)),)
FLOOR = 116.

def initial(level):
    return State(*level.start, grounded=False)

def move_practice(x, y, vx, vy, dt):
    """Teaching-only endpoint clamp; no shelves and no swept collision."""
    x += vx * dt
    y += vy * dt
    normals = []
    if x < 7: x, vx = 7., 0.; normals.append((1, 0))
    if x > 185: x, vx = 185., 0.; normals.append((-1, 0))
    if y < 12: y, vy = 12., 0.; normals.append((0, 1))
    if y > FLOOR: y, vy = FLOOR, 0.; normals.append((0, -1))
    return x, y, vx, vy, tuple(normals)

def step(level, s, control=Input(), dt=DT):
    if s.status != 'playing': return s, ()
    if not (0 < dt <= 1/30): raise ValueError('Use a bounded fixed step')
    if not all(isfinite(v) for v in (s.x, s.y, s.vx, s.vy)):
        raise ValueError('Nonfinite state')
    move = max(-1, min(1, control.move))
    vx, vy, ground = s.vx, s.vy, s.grounded
    if move:
        vx = max(-MAX_VX, min(MAX_VX, vx + move * 100 * dt))
    else:
        drag = (120 if ground else 12) * dt
        vx = max(0, vx-drag) if vx > 0 else min(0, vx+drag)
    floating = s.floating
    if control.jump and ground:
        vy = -s.gravity * JUMP
        ground = False
        floating = False
    elif floating:
        x, y, vx, _, _ = move_practice(s.x, s.y, vx, 0, dt)
        return replace(s, x=x, vx=vx, vy=0, time=s.time+dt), ()
    vy += s.gravity * GRAVITY * dt
    if vy * s.gravity > MAX_VY: vy = s.gravity * MAX_VY
    x, y, vx, vy, normals = move_practice(s.x, s.y, vx, vy, dt)
    gravity, armed, crossings, events = s.gravity, s.armed, s.crossings, []
    if abs(s.y-WATER) >= 8-EPS: armed = True
    crosses = (s.y < WATER <= y and s.gravity == 1) or (s.y > WATER >= y and s.gravity == -1)
    if crosses:
        t = (WATER-s.y) / (y-s.y)
        cx = s.x + (x-s.x) * t
        if armed:
            gravity = -s.gravity
            crossings += 1
            armed = False
            events.append(Event('cross', s, cx, WATER))
            if vy * vy / (2 * GRAVITY) < 8:
                x, y, vy = cx, WATER, 0
                floating, ground = True, True
            else:
                rem = dt * (1-t)
                vy += 2 * gravity * GRAVITY * rem
                x, y, vx, vy, after = move_practice(cx, WATER, vx, vy, rem)
                normals += after
        else:
            x, y, vy = cx, WATER, 0
            floating, ground = True, True
    elif not armed and ((s.gravity == -1 and s.y >= WATER and y < WATER) or (s.gravity == 1 and s.y <= WATER and y > WATER)):
        t = (WATER-s.y) / (y-s.y) if y != s.y else 0
        x, y, vy = s.x+(x-s.x)*t, WATER, 0
        floating, ground = True, True
    if not floating: ground = any(ny == -gravity for _, ny in normals)
    return replace(s, x=x, y=y, vx=vx, vy=vy, gravity=gravity, grounded=ground,
                   crossings=crossings, armed=armed, floating=floating,
                   time=s.time+dt), tuple(events)

def frame(level, s, control=Input()):
    events = []
    for i in range(4):
        s, event = step(level, s, Input(control.move, control.jump and i == 0))
        events.extend(event)
    return s, tuple(events)

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


# The renderer and controls are shared scaffolding; the model above grows by stage.
STAGE = 3
DEMO_FRAMES = 90
KEYS = ('LEFT', 'RIGHT', 'SPACE', 'P', 'R', 'Z', 'N', '1', '2', '3')
DEMO_PLAN = ((14, 0, True), (82, 1, False), (20, 0, False))

class App:
    def __init__(self):
        self.level = 0
        self.state = initial(LEVELS[self.level])
        self.pause = False
        self.gate = 0
        self.undo_state = None
        self.trail = []
        self.demo_tick = 0

    def reset(self):
        self.state = initial(LEVELS[self.level])
        self.pause = False
        self.undo_state = None
        self.trail = []
        self.gate = 2

    def advance(self, control):
        before = self.state
        self.state, events = frame(LEVELS[self.level], self.state, control)
        for event in events: self.undo_state = event.before
        self.trail.append((self.state.x, self.state.y))
        self.trail = self.trail[-40:]
        if before.status != 'success' and self.state.status == 'success': self.gate = 2

    def control(self, pressed=(), held=()):
        # Two completely released updates prevent held keys leaking after a switch.
        if self.gate:
            self.gate = 2 if set(held).intersection(KEYS) else self.gate-1
            return
        if 'R' in pressed: self.reset(); return
        if STAGE == 5:
            for index in range(3):
                if str(index+1) in pressed:
                    self.level = index; self.reset(); return
        if 'P' in pressed:
            self.pause = not self.pause; self.gate = 2; return
        if self.pause: return
        if STAGE >= 4 and 'Z' in pressed:
            if self.undo_state is not None:
                self.state = self.undo_state; self.undo_state = None; self.trail = []
            self.gate = 2
            return
        if self.state.status == 'success':
            if STAGE == 5 and 'N' in pressed:
                self.level = (self.level+1) % len(LEVELS); self.reset()
            return
        self.advance(Input(int('RIGHT' in held)-int('LEFT' in held), 'SPACE' in pressed))

    def demo_update(self):
        tick = self.demo_tick
        self.demo_tick += 1
        if STAGE == 1:
            control = Input(1 if tick < 30 else 0, tick == 0)
        elif STAGE == 2:
            control = Input(1 if tick < 30 else 0)
        elif STAGE == 3:
            control = Input()
        else:
            control = Input()
            for count, move, jump in DEMO_PLAN:
                if tick < count:
                    control = Input(move, bool(jump and tick == 0)); break
                tick -= count
        held = ('RIGHT',) if control.move > 0 else ('LEFT',) if control.move < 0 else ()
        self.control(('SPACE',) if control.jump else (), held)

    def draw(self, p):
        s, level = self.state, LEVELS[self.level]
        p.cls(1)
        p.text(12, 10, '018 INVERTED POND / STAGE '+str(STAGE), 10)
        p.text(12, 24, ('PAUSED' if self.pause else s.status.upper())+'   TIME '+format(s.time, '.3f')+' s', 11)
        p.text(12, 38, 'X '+format(s.x, '.3f')+'  Y '+format(s.y, '.3f')+'  VX '+format(s.vx, '.2f')+'  VY '+format(s.vy, '.2f'), 7)
        p.text(12, 52, 'GRAVITY '+('DOWN' if s.gravity == 1 else 'UP')+'  CROSSES '+str(getattr(s, 'crossings', 0))+'  GROUNDED '+str(s.grounded), 10)
        if STAGE >= 3:
            p.text(12, 66, 'ARMED '+str(s.armed)+'  FLOATING '+str(s.floating), 6)
        ox, oy = 32, 82
        def xy(x, y): return ox+2*x, oy+2*y
        p.rect(ox, oy, 384, 256, 2)
        p.rect(ox, oy+128, 384, 128, 1)
        for x in range(4, 188, 12):
            p.line(*xy(x, 56), *xy(x+5, 56), 5)
            p.line(*xy(x, 72), *xy(x+5, 72), 5)
        p.text(ox+10, oy+20, 'WORLD 192 x 128 / +Y DOWN', 6)
        for x in range(4, 188):
            opened = STAGE >= 2 and any(a <= x < b for a, b in level.portals)
            if not opened: p.line(*xy(x, 63), *xy(x, 65), 6)
            elif x % 3 == 0: p.line(*xy(x, 64), *xy(x+1, 64), 12)
        p.rect(ox, oy, 8, 256, 5); p.rect(ox+376, oy, 8, 256, 5)
        p.rect(ox, oy, 384, 16, 5); p.rect(ox, oy+240, 384, 16, 5)
        if STAGE >= 4:
            for rock in level.rocks:
                x, y = xy(rock.x1, rock.y1)
                p.rect(x, y, (rock.x2-rock.x1)*2, (rock.y2-rock.y1)*2, 13)
                p.rectb(x, y, (rock.x2-rock.x1)*2, (rock.y2-rock.y1)*2, 7)
            hx, hy = xy(*level.start)
            p.rect(hx-9, hy-22, 18, 22, 4)
            p.tri(hx-13, hy-22, hx, hy-34, hx+13, hy-22, 9)
            if STAGE == 5:
                for name, ix, iy in level.items:
                    x, y = xy(ix, iy)
                    if name in s.collected: p.circb(x, y, 7, 5)
                    elif name == 'moon':
                        p.circ(x, y, 7, 10); p.circ(x+3, y-3, 6, 1)
                    else: p.tri(x-7, y+2, x+6, y-7, x+4, y+5, 11)
        for x, y in self.trail[::3]: p.pset(*xy(x, y), 6)
        x, y = xy(s.x, s.y); g = s.gravity
        p.rect(x-5, y-7, 11, 15, 7)
        p.rect(x-4, y-6*g-1, 9, 4, 12)
        p.pset(x-2, y-4*g, 1); p.pset(x+2, y-4*g, 1)
        p.line(x+14, y-7*g, x+14, y+8*g, 10)
        p.line(x+10, y+4*g, x+14, y+8*g, 10)
        p.line(x+18, y+4*g, x+14, y+8*g, 10)
        if getattr(s, 'floating', False): p.ellib(x-9, y-3, 19, 7, 12)
        messages = {1: 'ACCELERATION -> VELOCITY -> POSITION / FLAT BANK',
                    2: 'CROSS THE LINE: SAME POSITION, NEW GRAVITY',
                    3: 'DASHES = 8 px / FLOAT? SPACE TO JUMP OUT',
                    4: 'SWEPT BANKS + SHELF / Z UNDO LAST CROSS',
                    5: 'ROOM '+str(self.level+1)+' / ITEMS '+str(len(getattr(s, 'collected', ())))+'/'+str(len(getattr(level, 'items', ())))}
        p.text(12, 350, messages[STAGE], 10)
        p.text(12, 364, 'LEFT/RIGHT MOVE  SPACE JUMP  P PAUSE  R RESET', 7)
        p.text(12, 378, '1/2/3 ROOM  Z UNDO  N NEXT  ESC QUIT' if STAGE == 5 else 'MODEL 120 Hz / VIEW 30 FPS / ESC QUIT', 6)
        if s.status == 'success':
            p.rect(108, 176, 232, 38, 1); p.rectb(108, 176, 232, 38, 11)
            p.text(144, 193, 'SUCCESS / N NEXT ROOM', 11)
        elif self.pause:
            p.rect(144, 182, 160, 26, 1); p.text(160, 193, 'PAUSED / P RESUME', 10)


def parse_args(argv=None):
    """All QA arguments are bounded and validated before Pyxel is imported."""
    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='018 Inverted Pond', fps=30, display_scale=2, headless=args.headless_smoke)
    keys = {name: getattr(p, 'KEY_'+name) for name in KEYS}
    finished = False
    def update():
        if args.headless_smoke:
            if args.demo_checkpoint and p.frame_count < DEMO_FRAMES: app.demo_update()
        else:
            app.control({name for name, key in keys.items() if p.btnp(key)},
                        {name for name, key in keys.items() if p.btn(key)})
    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

水面より先に、岸と棚を調べる

四角形をふくらませる、線分で調べる(掃引)、ぶつかった面の向き(法線)、巻き戻し。

ここで、元のゲームの最初の部屋を使います。池の開いている部分は X=72〜112、棚は X=116〜148・Y=84〜88 です。岸は Y=64 にある厚さ0の線です。ただし、人物の半分の幅 HX=3・半分の高さ HY=4 の分だけふくらませて扱います。そのため、岸の上では中心が Y≈60、裏側では Y≈68 のところで接します。人物は円ではなく、辺が縦と横にそろった長方形として扱います。

sweep は、移動の線分が、人物の半分の大きさだけ広げた四角形に最初にふれる時刻と、そのときの面の向き(法線)を返します。move_swept は、すべての地形の中から最初にふれるものを選び、その点まで動かして、法線の向きに EPS=0.001px だけ離します。横の壁なら vx だけ、上下の面なら vy だけを0にして、残りの時間を進めます。面に沿って動くことは、新しい衝突としては扱いません。

1回の step で、ふれる処理は最大4回です。同じ時刻の候補がいくつかあるときは、時刻を小数10けたに丸めた値と、地形の並び順で決めます。sweep の中で同時に角に入るときは、X軸を優先します。これは、結果を毎回同じにしやすくするための計算上のルールです。どんなに地形が密集していても完全に正しく動く、と証明するものではありません。

先に、ぶつかりを考えた移動先の候補を作ります。そして、前の位置と候補の位置を結ぶ線分から、横断したかどうかを判定します。岸に止められると、中心は水面に届かず、反転しません。開いている部分の端では、人物の体全体が通れるように、a+HX+EPS < x < b-HX-EPS という、等号をふくまない不等号を使います。描かれた点線の端なら、どこでも通れるわけではありません。

grounded は、ただ vy==0 かどうかでは決めません。重力と反対向きの法線をもつ面にふれたかどうかで決めます。重力が上向きなら、棚の下の面が足場になります。安全に立てた位置は checkpoint に記録し、むりやり画面の外に出たときは、速度0でその岸へ戻します。Z は、直前の横断の Event.before 全体へ戻す、別の機能です。位置・速度・時刻も巻き戻ります。元のゲームと同じく、戻る先は「その描画フレーム全体の前」ではなく、横断した1/120秒の step の直前です。宝と成功は、次の05まで加えません。

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

04_banks_shelves.py / 注目する部分
    # Compute candidate physical motion first so a solid bank wins ties.
    x,y,vx,vy,normals=move_swept(level,s.x,s.y,vx,vy,dt)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_banks_shelves.py
macOS / Linux
.venv/bin/python 04_banks_shelves.py
画面で見ること自動の入力は、最初に跳び、14回の更新を待ってから右へ進みます。91回の更新のあと、X=128.069444・Y=92.001、重力は上向き、接地は True、横断は1回です。棚の下の面に着地しますが、この段階では PLAYING のままです。Z を押すと横断の直前に戻り、キーを全部離してから、また操作できます。
  • 岸とのぶつかりを考えた候補を、先に計算する
  • 面の向き(法線)の方向の速度だけを0にする
  • 接地とは、重力と逆向きの面にふれていること
つまずきやすいところ移動先だけを調べると、薄い棚を通りぬけてしまいます。反対に、ふれたときに vx と vy を両方0にすると、壁に沿った移動も止まってしまいます。vy==0 は、空中のいちばん高いところでも成り立つことがあるので、それだけで着地したと判定してはいけません。
少し変えてみよう

棚の y1 と y2 を、84と88から88と92へ、両方とも4pxずつ下げてみましょう。下から上へ当たるときの中心の Y は 96+EPS です。test_stage4_shelf_exercise が、コピーした Level で確かめます。片方だけを変えると棚の厚さも変わってしまうので、移動させる実験とは区別しましょう。確かめたら元の値に戻します。

04_banks_shelves.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(408行)
04_banks_shelves.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。"""
"""Continuous position, fixed-step inversion platformer; standard library only.
Units: pixels and seconds. The water changes gravity, never world coordinates.
"""
from dataclasses import dataclass, replace, asdict
from math import inf, hypot, isfinite
import hashlib, json
DT=1/120
GRAVITY=80.0
JUMP=76.0
MAX_VX=36.0
MAX_VY=70.0
HX,HY=3.0,4.0
WATER=64.0
EPS=0.001

@dataclass(frozen=True)
class Rect:
    x1: float; y1: float; x2: float; y2: float
    name: str='rock'
@dataclass(frozen=True)
class Level:
    name: str
    portals: tuple
    rocks: tuple
    start: tuple
    items: tuple
    return_home: bool=True
    home_gravity: int=1

LEVELS=(Level('裏側の月',((72,112),),(Rect(116,84,148,88),),(42,60),(),False),)

@dataclass(frozen=True)
class State:
    x: float; y: float
    vx: float=0.; vy: float=0.
    gravity: int=1
    grounded: bool=True
    armed: bool=True
    floating: bool=False
    collected: tuple=()
    crossings: int=0
    distance: float=0.
    time: float=0.
    status: str='playing'
    checkpoint: tuple=(42.,60.,1)
    falls: int=0
    message: str=''

@dataclass(frozen=True)
class Input:
    move: int=0
    jump: bool=False

@dataclass(frozen=True)
class Event:
    kind: str
    before: State
    x: float
    y: float


def initial(level):
    return State(*level.start,gravity=level.home_gravity,checkpoint=(*level.start,level.home_gravity))

def solid_rects(level):
    banks=[];last=4
    for a,b in level.portals:
        if a>last:banks.append(Rect(last,64,last+(a-last),64,'bank'))
        last=b
    if last<188:banks.append(Rect(last,64,188,64,'bank'))
    # Top and bottom are visible safety stone, not offscreen landing surfaces.
    return tuple(banks)+level.rocks+(Rect(0,0,4,128,'edge'),Rect(188,0,192,128,'edge'),Rect(4,0,188,8,'edge'),Rect(4,120,188,128,'edge'))

def portal_at(level,x):
    return any(a+HX+EPS<x<b-HX-EPS for a,b in level.portals)

def sweep(x,y,dx,dy,rect):
    """Ray vs Minkowski-expanded rectangle. Tangential contacts do not block."""
    lo=(rect.x1-HX,rect.y1-HY);hi=(rect.x2+HX,rect.y2+HY)
    p=(x,y);d=(dx,dy);near=[-inf,-inf];far=[inf,inf];norm=[0,0]
    for axis in (0,1):
        if abs(d[axis])<1e-12:
            if p[axis]<=lo[axis]+1e-8 or p[axis]>=hi[axis]-1e-8:return None
        else:
            t1=(lo[axis]-p[axis])/d[axis];t2=(hi[axis]-p[axis])/d[axis]
            near[axis]=min(t1,t2);far[axis]=max(t1,t2);norm[axis]=-1 if d[axis]>0 else 1
    enter=max(near);leave=min(far)
    if enter>leave+1e-9 or leave<0 or enter<-1e-7 or enter>1+1e-9:return None
    axis=0 if near[0]>=near[1]-1e-9 else 1
    return max(0.,enter),(norm[axis],0) if axis==0 else (0,norm[axis])

def move_swept(level,x,y,vx,vy,dt):
    """Solve earliest static impacts, consume remaining time, at most four contacts."""
    remaining=dt;normals=[]
    for _ in range(4):
        dx,dy=vx*remaining,vy*remaining
        hits=[]
        for i,r in enumerate(solid_rects(level)):
            hit=sweep(x,y,dx,dy,r)
            if hit:hits.append((hit[0],i,hit[1]))
        if not hits:x+=dx;y+=dy;break
        t,_,(nx,ny)=min(hits,key=lambda h:(round(h[0],10),h[1]))
        x+=dx*t+nx*EPS;y+=dy*t+ny*EPS
        remaining*=1-t
        if nx:vx=0.
        if ny:vy=0.
        normals.append((nx,ny))
        if remaining<1e-9 or (vx==0 and vy==0):break
    return x,y,vx,vy,tuple(normals)

def segment_near(a,b,p,r):
    dx,dy=b[0]-a[0],b[1]-a[1];den=dx*dx+dy*dy
    t=max(0,min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/den)) if den else 0
    return hypot(a[0]+dx*t-p[0],a[1]+dy*t-p[1])<=r

def step(level,s,control=Input(),dt=DT,preserve_momentum=True):
    """Input, old-state forces, velocity, earliest swept collision/water, progress.
    Tiny water oscillations settle visibly at the plane. SPACE always escapes.
    """
    if s.status!='playing':return s,()
    if not (0<dt<=1/30):raise ValueError('Use a bounded fixed step')
    if not all(isfinite(v) for v in (s.x,s.y,s.vx,s.vy)):raise ValueError('Nonfinite state')
    move=max(-1,min(1,control.move));vx=s.vx;vy=s.vy;ground=s.grounded
    if move:vx=max(-MAX_VX,min(MAX_VX,vx+move*100*dt))
    else:
        drag=(120 if ground else 12)*dt
        vx=max(0,vx-drag) if vx>0 else min(0,vx+drag)
    floating=s.floating
    if control.jump and ground:
        vy=-s.gravity*JUMP;ground=False;floating=False
    elif floating:
        # The player visibly floats half in the pond. Horizontal motion stops at
        # solid banks, and SPACE jumps into the current side, rearming at 8px.
        nx,ny,nvx,_,normals=move_swept(level,s.x,s.y,vx,0,dt)
        return replace(s,x=nx,vx=nvx,vy=0,time=s.time+dt,distance=s.distance+abs(nx-s.x)),()
    vy+=s.gravity*GRAVITY*dt
    if vy*s.gravity>MAX_VY:vy=s.gravity*MAX_VY
    # Compute candidate physical motion first so a solid bank wins ties.
    x,y,vx,vy,normals=move_swept(level,s.x,s.y,vx,vy,dt)
    gravity=s.gravity;armed=s.armed;events=[];crossings=s.crossings
    if abs(s.y-WATER)>=8-EPS:armed=True
    crosses=(s.y<WATER<=y and s.gravity==1) or (s.y>WATER>=y and s.gravity==-1)
    # At the plane, a jump into the current side is never another crossing.
    if crosses and portal_at(level,s.x+(x-s.x)*(WATER-s.y)/(y-s.y)):
        t=(WATER-s.y)/(y-s.y);cx=s.x+(x-s.x)*t
        if armed:
            gravity=-s.gravity;crossings+=1;armed=False
            events.append(Event('cross',s,cx,WATER))
            if not preserve_momentum:vx=vy=0.
            # A <=8px excursion is resolved as a stable float, no repeated sign
            # change, hidden position warp, or boost. Position stays on the plane.
            if vy*vy/(2*GRAVITY)<8:
                x,y=cx,WATER;vy=0;floating=True;ground=True
            else:
                # Split the old-force velocity update at the water event, then
                # integrate its remainder with the new sign (semi-implicit Euler).
                rem=dt*(1-t);vy+=2*gravity*GRAVITY*rem
                x,y,vx,vy,after=move_swept(level,cx,WATER,vx,vy,rem)
                normals+=after
        else:
            x,y=cx,WATER;vy=0;floating=True;ground=True
    # A shallow return from the new side must not leak through with wrong gravity.
    elif not armed and ((s.gravity==-1 and s.y>=WATER and y<WATER) or (s.gravity==1 and s.y<=WATER and y>WATER)):
        t=(WATER-s.y)/(y-s.y) if y!=s.y else 0
        x=s.x+(x-s.x)*t;y=WATER;vy=0;floating=True;ground=True
    if not floating:ground=any(ny==-gravity for _,ny in normals)
    got=[]  # Collection and completion are added in stage 5.
    checkpoint=s.checkpoint
    if ground and not floating and ((gravity==1 and y<63) or (gravity==-1 and y>65)):
        checkpoint=(x,y,gravity)
    status='playing'

    new=State(x,y,vx,vy,gravity,ground,armed,floating,tuple(got),crossings,s.distance+hypot(x-s.x,y-s.y),s.time+dt,status,checkpoint,s.falls,'float' if floating else '')
    if x<-10 or x>202 or y<-12 or y>140:
        new=replace(new,x=checkpoint[0],y=checkpoint[1],gravity=checkpoint[2],vx=0,vy=0,grounded=True,armed=True,floating=False,falls=s.falls+1,message='岸へ戻った')
    return new,tuple(events)

def frame(level,s,control=Input(),preserve_momentum=True):
    events=[]
    for i in range(4):
        s,e=step(level,s,Input(control.move,control.jump and i==0),preserve_momentum=preserve_momentum);events.extend(e)
    return s,tuple(events)

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

def replay(level,actions,preserve_momentum=True):
    s=initial(level);trace=[]
    for action in actions:
        s,e=frame(level,s,Input(action.get('move',0),action.get('jump',False)),preserve_momentum)
        trace.append(s)
    return s,trace


# The renderer and controls are shared scaffolding; the model above grows by stage.
STAGE = 4
DEMO_FRAMES = 91
KEYS = ('LEFT', 'RIGHT', 'SPACE', 'P', 'R', 'Z', 'N', '1', '2', '3')
DEMO_PLAN = ((14, 0, True), (82, 1, False), (20, 0, False))

class App:
    def __init__(self):
        self.level = 0
        self.state = initial(LEVELS[self.level])
        self.pause = False
        self.gate = 0
        self.undo_state = None
        self.trail = []
        self.demo_tick = 0

    def reset(self):
        self.state = initial(LEVELS[self.level])
        self.pause = False
        self.undo_state = None
        self.trail = []
        self.gate = 2

    def advance(self, control):
        before = self.state
        self.state, events = frame(LEVELS[self.level], self.state, control)
        for event in events: self.undo_state = event.before
        self.trail.append((self.state.x, self.state.y))
        self.trail = self.trail[-40:]
        if before.status != 'success' and self.state.status == 'success': self.gate = 2

    def control(self, pressed=(), held=()):
        # Two completely released updates prevent held keys leaking after a switch.
        if self.gate:
            self.gate = 2 if set(held).intersection(KEYS) else self.gate-1
            return
        if 'R' in pressed: self.reset(); return
        if STAGE == 5:
            for index in range(3):
                if str(index+1) in pressed:
                    self.level = index; self.reset(); return
        if 'P' in pressed:
            self.pause = not self.pause; self.gate = 2; return
        if self.pause: return
        if STAGE >= 4 and 'Z' in pressed:
            if self.undo_state is not None:
                self.state = self.undo_state; self.undo_state = None; self.trail = []
            self.gate = 2
            return
        if self.state.status == 'success':
            if STAGE == 5 and 'N' in pressed:
                self.level = (self.level+1) % len(LEVELS); self.reset()
            return
        self.advance(Input(int('RIGHT' in held)-int('LEFT' in held), 'SPACE' in pressed))

    def demo_update(self):
        tick = self.demo_tick
        self.demo_tick += 1
        if STAGE == 1:
            control = Input(1 if tick < 30 else 0, tick == 0)
        elif STAGE == 2:
            control = Input(1 if tick < 30 else 0)
        elif STAGE == 3:
            control = Input()
        else:
            control = Input()
            for count, move, jump in DEMO_PLAN:
                if tick < count:
                    control = Input(move, bool(jump and tick == 0)); break
                tick -= count
        held = ('RIGHT',) if control.move > 0 else ('LEFT',) if control.move < 0 else ()
        self.control(('SPACE',) if control.jump else (), held)

    def draw(self, p):
        s, level = self.state, LEVELS[self.level]
        p.cls(1)
        p.text(12, 10, '018 INVERTED POND / STAGE '+str(STAGE), 10)
        p.text(12, 24, ('PAUSED' if self.pause else s.status.upper())+'   TIME '+format(s.time, '.3f')+' s', 11)
        p.text(12, 38, 'X '+format(s.x, '.3f')+'  Y '+format(s.y, '.3f')+'  VX '+format(s.vx, '.2f')+'  VY '+format(s.vy, '.2f'), 7)
        p.text(12, 52, 'GRAVITY '+('DOWN' if s.gravity == 1 else 'UP')+'  CROSSES '+str(getattr(s, 'crossings', 0))+'  GROUNDED '+str(s.grounded), 10)
        if STAGE >= 3:
            p.text(12, 66, 'ARMED '+str(s.armed)+'  FLOATING '+str(s.floating), 6)
        ox, oy = 32, 82
        def xy(x, y): return ox+2*x, oy+2*y
        p.rect(ox, oy, 384, 256, 2)
        p.rect(ox, oy+128, 384, 128, 1)
        for x in range(4, 188, 12):
            p.line(*xy(x, 56), *xy(x+5, 56), 5)
            p.line(*xy(x, 72), *xy(x+5, 72), 5)
        p.text(ox+10, oy+20, 'WORLD 192 x 128 / +Y DOWN', 6)
        for x in range(4, 188):
            opened = STAGE >= 2 and any(a <= x < b for a, b in level.portals)
            if not opened: p.line(*xy(x, 63), *xy(x, 65), 6)
            elif x % 3 == 0: p.line(*xy(x, 64), *xy(x+1, 64), 12)
        p.rect(ox, oy, 8, 256, 5); p.rect(ox+376, oy, 8, 256, 5)
        p.rect(ox, oy, 384, 16, 5); p.rect(ox, oy+240, 384, 16, 5)
        if STAGE >= 4:
            for rock in level.rocks:
                x, y = xy(rock.x1, rock.y1)
                p.rect(x, y, (rock.x2-rock.x1)*2, (rock.y2-rock.y1)*2, 13)
                p.rectb(x, y, (rock.x2-rock.x1)*2, (rock.y2-rock.y1)*2, 7)
            hx, hy = xy(*level.start)
            p.rect(hx-9, hy-22, 18, 22, 4)
            p.tri(hx-13, hy-22, hx, hy-34, hx+13, hy-22, 9)
            if STAGE == 5:
                for name, ix, iy in level.items:
                    x, y = xy(ix, iy)
                    if name in s.collected: p.circb(x, y, 7, 5)
                    elif name == 'moon':
                        p.circ(x, y, 7, 10); p.circ(x+3, y-3, 6, 1)
                    else: p.tri(x-7, y+2, x+6, y-7, x+4, y+5, 11)
        for x, y in self.trail[::3]: p.pset(*xy(x, y), 6)
        x, y = xy(s.x, s.y); g = s.gravity
        p.rect(x-5, y-7, 11, 15, 7)
        p.rect(x-4, y-6*g-1, 9, 4, 12)
        p.pset(x-2, y-4*g, 1); p.pset(x+2, y-4*g, 1)
        p.line(x+14, y-7*g, x+14, y+8*g, 10)
        p.line(x+10, y+4*g, x+14, y+8*g, 10)
        p.line(x+18, y+4*g, x+14, y+8*g, 10)
        if getattr(s, 'floating', False): p.ellib(x-9, y-3, 19, 7, 12)
        messages = {1: 'ACCELERATION -> VELOCITY -> POSITION / FLAT BANK',
                    2: 'CROSS THE LINE: SAME POSITION, NEW GRAVITY',
                    3: 'DASHES = 8 px / FLOAT? SPACE TO JUMP OUT',
                    4: 'SWEPT BANKS + SHELF / Z UNDO LAST CROSS',
                    5: 'ROOM '+str(self.level+1)+' / ITEMS '+str(len(getattr(s, 'collected', ())))+'/'+str(len(getattr(level, 'items', ())))}
        p.text(12, 350, messages[STAGE], 10)
        p.text(12, 364, 'LEFT/RIGHT MOVE  SPACE JUMP  P PAUSE  R RESET', 7)
        p.text(12, 378, '1/2/3 ROOM  Z UNDO  N NEXT  ESC QUIT' if STAGE == 5 else 'MODEL 120 Hz / VIEW 30 FPS / ESC QUIT', 6)
        if s.status == 'success':
            p.rect(108, 176, 232, 38, 1); p.rectb(108, 176, 232, 38, 11)
            p.text(144, 193, 'SUCCESS / N NEXT ROOM', 11)
        elif self.pause:
            p.rect(144, 182, 160, 26, 1); p.text(160, 193, 'PAUSED / P RESUME', 10)


def parse_args(argv=None):
    """All QA arguments are bounded and validated before Pyxel is imported."""
    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='018 Inverted Pond', fps=30, display_scale=2, headless=args.headless_smoke)
    keys = {name: getattr(p, 'KEY_'+name) for name in KEYS}
    finished = False
    def update():
        if args.headless_smoke:
            if args.demo_checkpoint and p.frame_count < DEMO_FRAMES: app.demo_update()
        else:
            app.control({name for name, key in keys.items() if p.btnp(key)},
                        {name for name, key in keys.items() if p.btn(key)})
    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

宝を集め、着地して帰還する

進み具合の状態、成功の条件、3つの部屋と、元のゲームとの比較。

元のゲームの model.py にある関数とクラスを全部そのまま1つのファイルに入れ、3つの部屋の地形・宝・帰る条件をそろえます。モデルのふつうのルールは、元のゲームと同じです。画面は教材用の英字(ASCII)の計器の表示なので、画像や日本語の文字(フォント)を読み込む必要はありません。1・2・3で部屋を選び、成功したあとに N で次へ進みます。

移動する前の中心と、最後の中心を結ぶ線分が、宝の中心から7px以内を通れば、宝を取ります。終点だけで調べるより、移動の途中で小さくふれた場合も拾えます。collected は、取った宝の名前を入れた tuple で、同じ名前を二重に増やしません。ただし、ぶつかって折れ曲がった道すじを全部記録するわけではありません。前の位置と最後の位置を結ぶ1本の線分を使う近似である点は変わりません。

成功するには、すべての宝を取り、grounded=True であることが必要です。1部屋目は return_home=False なので、それ以上の場所の指定はありません。考えられている遊び方は、月を取って下の棚の裏に着地することです。でも、「その棚に着いたか」という専用の成功の条件は、コードにはありません。2部屋目と3部屋目では、さらに、重力が最初と同じ向きで、最初の位置との差が X で11未満、Y で5未満である必要があります。<= ではなく < です。

処理の順番は、入力と抵抗→重力→ぶつかりを考えた候補→水面の処理→接地→宝→安全な岸→成功→画面の外からの回復です。ただし、もう浮いていてジャンプもないときは、早めに return して、横の移動・距離・時刻だけを更新します。その step では、宝や成功をもう一度調べません。また、画面の外からの回復より、成功の計算が先です。手で作ったおかしな状態で、結果の優先順位を勝手に読みかえないようにしましょう。ふつうに遊んでいれば、まわりの石の壁が、画面の外へ出るのを防ぎます。

success になった State に step を呼んでも同じ State が返り、シミュレーションの時刻は止まります。成功の画面で SPACE を押しても、次の部屋には進みません。N で次へ、R で全部を最初に戻す、Z で最後の横断の前に戻る、と使い分けます。Z は取った宝と time も戻しますが、R は initial() の状態に戻します。時間制限・残りの人数(残機)・敵・酸素・ふつうのゲームオーバーはありません。

同じフォルダに入っている再現用の入力は、3つの部屋すべてで、元のゲームの最後のハッシュ(状態を短い値にまとめたもの)と一致します。元のゲームとの追加の比較は、使っても使わなくてもよい機能(オプション)です。モデルの関数とクラスの AST(プログラムの組み立てを木の形で表したもの)、18,000回の step 分の同じ入力、境目の224個の状態、別の道すじをふくむ4本の成功する入力を比べます。限られた数の確認なので、すべての状態で正しいという証明でも、人が遊びやすいという証明でもありません。

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

05_collect_return.py / 注目する部分
    status='playing'
    if len(got)==len(level.items) and ground and (not level.return_home or (gravity==level.home_gravity and ground and abs(x-level.start[0])<11 and abs(y-level.start[1])<5)):status='success'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_collect_return.py
macOS / Linux
.venv/bin/python 05_collect_return.py
画面で見ること最初の部屋の自動の確認では、91回の更新で SUCCESS(成功)になります。X=128.069444・Y=92.001、TIME=3.033333秒、月を取っていて、横断1回、重力は上向き、接地は True です。元のゲームの再現用の入力では、2部屋目は307回、3部屋目は319回の更新で成功します。どちらも葉と月を取って、最初の岸に戻ります。
  • すべての宝を取ることと接地が、どの部屋でも必要
  • 2部屋目と3部屋目は、最初と同じ重力と、帰る範囲も必要
  • 成功したあとは、時刻も止まる
つまずきやすいところ宝を全部取っただけで成功にすると、空中でも終わってしまいます。反対に、1部屋目にも「小屋へ帰る」という条件をいつも付けると、元のゲームのルールを変えてしまいます。また、Z で位置だけを戻すと、巻き戻したのに、取った宝や時刻が先の時点のまま残ってしまいます。
少し変えてみよう

1部屋目の return_home を False から True に変えて、同じ91回の更新の入力を流してみましょう。月は取れていても、重力が-1で、出発点から遠いので、PLAYING のままです。test_stage5_return_exercise が、新しい Level を作って確かめます。ゴールを厳しくすると、前の成功する入力をそのまま使えなくなります。その理由を説明してから、False に戻します。

05_collect_return.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(414行)
05_collect_return.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。"""
"""Continuous position, fixed-step inversion platformer; standard library only.
Units: pixels and seconds. The water changes gravity, never world coordinates.
"""
from dataclasses import dataclass, replace, asdict
from math import inf, hypot, isfinite
import hashlib, json
DT=1/120
GRAVITY=80.0
JUMP=76.0
MAX_VX=36.0
MAX_VY=70.0
HX,HY=3.0,4.0
WATER=64.0
EPS=0.001

@dataclass(frozen=True)
class Rect:
    x1: float; y1: float; x2: float; y2: float
    name: str='rock'
@dataclass(frozen=True)
class Level:
    name: str
    portals: tuple
    rocks: tuple
    start: tuple
    items: tuple
    return_home: bool=True
    home_gravity: int=1

LEVELS=(
 Level('裏側の月',((72,112),),(Rect(116,84,148,88),),(42,60),(('moon',128,95),),False),
 Level('頭の上は道',((48,80),(136,168)),(Rect(92,36,120,40),Rect(92,84,128,88)),(30,60),(('leaf',106,30),('moon',106,95))),
 Level('同じ形、違う着地',((56,104),(144,176)),(Rect(104,36,132,40),Rect(108,80,132,84),Rect(144,88,180,92)),(34,60),(('leaf',118,30),('moon',120,91))),
)

@dataclass(frozen=True)
class State:
    x: float; y: float
    vx: float=0.; vy: float=0.
    gravity: int=1
    grounded: bool=True
    armed: bool=True
    floating: bool=False
    collected: tuple=()
    crossings: int=0
    distance: float=0.
    time: float=0.
    status: str='playing'
    checkpoint: tuple=(42.,60.,1)
    falls: int=0
    message: str=''

@dataclass(frozen=True)
class Input:
    move: int=0
    jump: bool=False

@dataclass(frozen=True)
class Event:
    kind: str
    before: State
    x: float
    y: float


def initial(level):
    return State(*level.start,gravity=level.home_gravity,checkpoint=(*level.start,level.home_gravity))

def solid_rects(level):
    banks=[];last=4
    for a,b in level.portals:
        if a>last:banks.append(Rect(last,64,last+(a-last),64,'bank'))
        last=b
    if last<188:banks.append(Rect(last,64,188,64,'bank'))
    # Top and bottom are visible safety stone, not offscreen landing surfaces.
    return tuple(banks)+level.rocks+(Rect(0,0,4,128,'edge'),Rect(188,0,192,128,'edge'),Rect(4,0,188,8,'edge'),Rect(4,120,188,128,'edge'))

def portal_at(level,x):
    return any(a+HX+EPS<x<b-HX-EPS for a,b in level.portals)

def sweep(x,y,dx,dy,rect):
    """Ray vs Minkowski-expanded rectangle. Tangential contacts do not block."""
    lo=(rect.x1-HX,rect.y1-HY);hi=(rect.x2+HX,rect.y2+HY)
    p=(x,y);d=(dx,dy);near=[-inf,-inf];far=[inf,inf];norm=[0,0]
    for axis in (0,1):
        if abs(d[axis])<1e-12:
            if p[axis]<=lo[axis]+1e-8 or p[axis]>=hi[axis]-1e-8:return None
        else:
            t1=(lo[axis]-p[axis])/d[axis];t2=(hi[axis]-p[axis])/d[axis]
            near[axis]=min(t1,t2);far[axis]=max(t1,t2);norm[axis]=-1 if d[axis]>0 else 1
    enter=max(near);leave=min(far)
    if enter>leave+1e-9 or leave<0 or enter<-1e-7 or enter>1+1e-9:return None
    axis=0 if near[0]>=near[1]-1e-9 else 1
    return max(0.,enter),(norm[axis],0) if axis==0 else (0,norm[axis])

def move_swept(level,x,y,vx,vy,dt):
    """Solve earliest static impacts, consume remaining time, at most four contacts."""
    remaining=dt;normals=[]
    for _ in range(4):
        dx,dy=vx*remaining,vy*remaining
        hits=[]
        for i,r in enumerate(solid_rects(level)):
            hit=sweep(x,y,dx,dy,r)
            if hit:hits.append((hit[0],i,hit[1]))
        if not hits:x+=dx;y+=dy;break
        t,_,(nx,ny)=min(hits,key=lambda h:(round(h[0],10),h[1]))
        x+=dx*t+nx*EPS;y+=dy*t+ny*EPS
        remaining*=1-t
        if nx:vx=0.
        if ny:vy=0.
        normals.append((nx,ny))
        if remaining<1e-9 or (vx==0 and vy==0):break
    return x,y,vx,vy,tuple(normals)

def segment_near(a,b,p,r):
    dx,dy=b[0]-a[0],b[1]-a[1];den=dx*dx+dy*dy
    t=max(0,min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/den)) if den else 0
    return hypot(a[0]+dx*t-p[0],a[1]+dy*t-p[1])<=r

def step(level,s,control=Input(),dt=DT,preserve_momentum=True):
    """Input, old-state forces, velocity, earliest swept collision/water, progress.
    Tiny water oscillations settle visibly at the plane. SPACE always escapes.
    """
    if s.status!='playing':return s,()
    if not (0<dt<=1/30):raise ValueError('Use a bounded fixed step')
    if not all(isfinite(v) for v in (s.x,s.y,s.vx,s.vy)):raise ValueError('Nonfinite state')
    move=max(-1,min(1,control.move));vx=s.vx;vy=s.vy;ground=s.grounded
    if move:vx=max(-MAX_VX,min(MAX_VX,vx+move*100*dt))
    else:
        drag=(120 if ground else 12)*dt
        vx=max(0,vx-drag) if vx>0 else min(0,vx+drag)
    floating=s.floating
    if control.jump and ground:
        vy=-s.gravity*JUMP;ground=False;floating=False
    elif floating:
        # The player visibly floats half in the pond. Horizontal motion stops at
        # solid banks, and SPACE jumps into the current side, rearming at 8px.
        nx,ny,nvx,_,normals=move_swept(level,s.x,s.y,vx,0,dt)
        return replace(s,x=nx,vx=nvx,vy=0,time=s.time+dt,distance=s.distance+abs(nx-s.x)),()
    vy+=s.gravity*GRAVITY*dt
    if vy*s.gravity>MAX_VY:vy=s.gravity*MAX_VY
    # Compute candidate physical motion first so a solid bank wins ties.
    x,y,vx,vy,normals=move_swept(level,s.x,s.y,vx,vy,dt)
    gravity=s.gravity;armed=s.armed;events=[];crossings=s.crossings
    if abs(s.y-WATER)>=8-EPS:armed=True
    crosses=(s.y<WATER<=y and s.gravity==1) or (s.y>WATER>=y and s.gravity==-1)
    # At the plane, a jump into the current side is never another crossing.
    if crosses and portal_at(level,s.x+(x-s.x)*(WATER-s.y)/(y-s.y)):
        t=(WATER-s.y)/(y-s.y);cx=s.x+(x-s.x)*t
        if armed:
            gravity=-s.gravity;crossings+=1;armed=False
            events.append(Event('cross',s,cx,WATER))
            if not preserve_momentum:vx=vy=0.
            # A <=8px excursion is resolved as a stable float, no repeated sign
            # change, hidden position warp, or boost. Position stays on the plane.
            if vy*vy/(2*GRAVITY)<8:
                x,y=cx,WATER;vy=0;floating=True;ground=True
            else:
                # Split the old-force velocity update at the water event, then
                # integrate its remainder with the new sign (semi-implicit Euler).
                rem=dt*(1-t);vy+=2*gravity*GRAVITY*rem
                x,y,vx,vy,after=move_swept(level,cx,WATER,vx,vy,rem)
                normals+=after
        else:
            x,y=cx,WATER;vy=0;floating=True;ground=True
    # A shallow return from the new side must not leak through with wrong gravity.
    elif not armed and ((s.gravity==-1 and s.y>=WATER and y<WATER) or (s.gravity==1 and s.y<=WATER and y>WATER)):
        t=(WATER-s.y)/(y-s.y) if y!=s.y else 0
        x=s.x+(x-s.x)*t;y=WATER;vy=0;floating=True;ground=True
    if not floating:ground=any(ny==-gravity for _,ny in normals)
    got=list(s.collected)
    for name,ix,iy in level.items:
        if name not in got and segment_near((s.x,s.y),(x,y),(ix,iy),7):got.append(name)
    checkpoint=s.checkpoint
    if ground and not floating and ((gravity==1 and y<63) or (gravity==-1 and y>65)):
        checkpoint=(x,y,gravity)
    status='playing'
    if len(got)==len(level.items) and ground and (not level.return_home or (gravity==level.home_gravity and ground and abs(x-level.start[0])<11 and abs(y-level.start[1])<5)):status='success'
    new=State(x,y,vx,vy,gravity,ground,armed,floating,tuple(got),crossings,s.distance+hypot(x-s.x,y-s.y),s.time+dt,status,checkpoint,s.falls,'float' if floating else '')
    if x<-10 or x>202 or y<-12 or y>140:
        new=replace(new,x=checkpoint[0],y=checkpoint[1],gravity=checkpoint[2],vx=0,vy=0,grounded=True,armed=True,floating=False,falls=s.falls+1,message='岸へ戻った')
    return new,tuple(events)

def frame(level,s,control=Input(),preserve_momentum=True):
    events=[]
    for i in range(4):
        s,e=step(level,s,Input(control.move,control.jump and i==0),preserve_momentum=preserve_momentum);events.extend(e)
    return s,tuple(events)

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

def replay(level,actions,preserve_momentum=True):
    s=initial(level);trace=[]
    for action in actions:
        s,e=frame(level,s,Input(action.get('move',0),action.get('jump',False)),preserve_momentum)
        trace.append(s)
    return s,trace


# The renderer and controls are shared scaffolding; the model above grows by stage.
STAGE = 5
DEMO_FRAMES = 91
KEYS = ('LEFT', 'RIGHT', 'SPACE', 'P', 'R', 'Z', 'N', '1', '2', '3')
DEMO_PLAN = ((14, 0, True), (82, 1, False), (20, 0, False))

class App:
    def __init__(self):
        self.level = 0
        self.state = initial(LEVELS[self.level])
        self.pause = False
        self.gate = 0
        self.undo_state = None
        self.trail = []
        self.demo_tick = 0

    def reset(self):
        self.state = initial(LEVELS[self.level])
        self.pause = False
        self.undo_state = None
        self.trail = []
        self.gate = 2

    def advance(self, control):
        before = self.state
        self.state, events = frame(LEVELS[self.level], self.state, control)
        for event in events: self.undo_state = event.before
        self.trail.append((self.state.x, self.state.y))
        self.trail = self.trail[-40:]
        if before.status != 'success' and self.state.status == 'success': self.gate = 2

    def control(self, pressed=(), held=()):
        # Two completely released updates prevent held keys leaking after a switch.
        if self.gate:
            self.gate = 2 if set(held).intersection(KEYS) else self.gate-1
            return
        if 'R' in pressed: self.reset(); return
        if STAGE == 5:
            for index in range(3):
                if str(index+1) in pressed:
                    self.level = index; self.reset(); return
        if 'P' in pressed:
            self.pause = not self.pause; self.gate = 2; return
        if self.pause: return
        if STAGE >= 4 and 'Z' in pressed:
            if self.undo_state is not None:
                self.state = self.undo_state; self.undo_state = None; self.trail = []
            self.gate = 2
            return
        if self.state.status == 'success':
            if STAGE == 5 and 'N' in pressed:
                self.level = (self.level+1) % len(LEVELS); self.reset()
            return
        self.advance(Input(int('RIGHT' in held)-int('LEFT' in held), 'SPACE' in pressed))

    def demo_update(self):
        tick = self.demo_tick
        self.demo_tick += 1
        if STAGE == 1:
            control = Input(1 if tick < 30 else 0, tick == 0)
        elif STAGE == 2:
            control = Input(1 if tick < 30 else 0)
        elif STAGE == 3:
            control = Input()
        else:
            control = Input()
            for count, move, jump in DEMO_PLAN:
                if tick < count:
                    control = Input(move, bool(jump and tick == 0)); break
                tick -= count
        held = ('RIGHT',) if control.move > 0 else ('LEFT',) if control.move < 0 else ()
        self.control(('SPACE',) if control.jump else (), held)

    def draw(self, p):
        s, level = self.state, LEVELS[self.level]
        p.cls(1)
        p.text(12, 10, '018 INVERTED POND / STAGE '+str(STAGE), 10)
        p.text(12, 24, ('PAUSED' if self.pause else s.status.upper())+'   TIME '+format(s.time, '.3f')+' s', 11)
        p.text(12, 38, 'X '+format(s.x, '.3f')+'  Y '+format(s.y, '.3f')+'  VX '+format(s.vx, '.2f')+'  VY '+format(s.vy, '.2f'), 7)
        p.text(12, 52, 'GRAVITY '+('DOWN' if s.gravity == 1 else 'UP')+'  CROSSES '+str(getattr(s, 'crossings', 0))+'  GROUNDED '+str(s.grounded), 10)
        if STAGE >= 3:
            p.text(12, 66, 'ARMED '+str(s.armed)+'  FLOATING '+str(s.floating), 6)
        ox, oy = 32, 82
        def xy(x, y): return ox+2*x, oy+2*y
        p.rect(ox, oy, 384, 256, 2)
        p.rect(ox, oy+128, 384, 128, 1)
        for x in range(4, 188, 12):
            p.line(*xy(x, 56), *xy(x+5, 56), 5)
            p.line(*xy(x, 72), *xy(x+5, 72), 5)
        p.text(ox+10, oy+20, 'WORLD 192 x 128 / +Y DOWN', 6)
        for x in range(4, 188):
            opened = STAGE >= 2 and any(a <= x < b for a, b in level.portals)
            if not opened: p.line(*xy(x, 63), *xy(x, 65), 6)
            elif x % 3 == 0: p.line(*xy(x, 64), *xy(x+1, 64), 12)
        p.rect(ox, oy, 8, 256, 5); p.rect(ox+376, oy, 8, 256, 5)
        p.rect(ox, oy, 384, 16, 5); p.rect(ox, oy+240, 384, 16, 5)
        if STAGE >= 4:
            for rock in level.rocks:
                x, y = xy(rock.x1, rock.y1)
                p.rect(x, y, (rock.x2-rock.x1)*2, (rock.y2-rock.y1)*2, 13)
                p.rectb(x, y, (rock.x2-rock.x1)*2, (rock.y2-rock.y1)*2, 7)
            hx, hy = xy(*level.start)
            p.rect(hx-9, hy-22, 18, 22, 4)
            p.tri(hx-13, hy-22, hx, hy-34, hx+13, hy-22, 9)
            if STAGE == 5:
                for name, ix, iy in level.items:
                    x, y = xy(ix, iy)
                    if name in s.collected: p.circb(x, y, 7, 5)
                    elif name == 'moon':
                        p.circ(x, y, 7, 10); p.circ(x+3, y-3, 6, 1)
                    else: p.tri(x-7, y+2, x+6, y-7, x+4, y+5, 11)
        for x, y in self.trail[::3]: p.pset(*xy(x, y), 6)
        x, y = xy(s.x, s.y); g = s.gravity
        p.rect(x-5, y-7, 11, 15, 7)
        p.rect(x-4, y-6*g-1, 9, 4, 12)
        p.pset(x-2, y-4*g, 1); p.pset(x+2, y-4*g, 1)
        p.line(x+14, y-7*g, x+14, y+8*g, 10)
        p.line(x+10, y+4*g, x+14, y+8*g, 10)
        p.line(x+18, y+4*g, x+14, y+8*g, 10)
        if getattr(s, 'floating', False): p.ellib(x-9, y-3, 19, 7, 12)
        messages = {1: 'ACCELERATION -> VELOCITY -> POSITION / FLAT BANK',
                    2: 'CROSS THE LINE: SAME POSITION, NEW GRAVITY',
                    3: 'DASHES = 8 px / FLOAT? SPACE TO JUMP OUT',
                    4: 'SWEPT BANKS + SHELF / Z UNDO LAST CROSS',
                    5: 'ROOM '+str(self.level+1)+' / ITEMS '+str(len(getattr(s, 'collected', ())))+'/'+str(len(getattr(level, 'items', ())))}
        p.text(12, 350, messages[STAGE], 10)
        p.text(12, 364, 'LEFT/RIGHT MOVE  SPACE JUMP  P PAUSE  R RESET', 7)
        p.text(12, 378, '1/2/3 ROOM  Z UNDO  N NEXT  ESC QUIT' if STAGE == 5 else 'MODEL 120 Hz / VIEW 30 FPS / ESC QUIT', 6)
        if s.status == 'success':
            p.rect(108, 176, 232, 38, 1); p.rectb(108, 176, 232, 38, 11)
            p.text(144, 193, 'SUCCESS / N NEXT ROOM', 11)
        elif self.pause:
            p.rect(144, 182, 160, 26, 1); p.text(160, 193, 'PAUSED / P RESUME', 10)


def parse_args(argv=None):
    """All QA arguments are bounded and validated before Pyxel is imported."""
    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='018 Inverted Pond', fps=30, display_scale=2, headless=args.headless_smoke)
    keys = {name: getattr(p, 'KEY_'+name) for name in KEYS}
    finished = False
    def update():
        if args.headless_smoke:
            if args.demo_checkpoint and p.frame_count < DEMO_FRAMES: app.demo_update()
        else:
            app.control({name for name, key in keys.items() if p.btnp(key)},
                        {name for name, key in keys.items() if p.btn(key)})
    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回(120Hz)の step を、1回の描画につき4回行います。横の加速度は100、抵抗は地上で120・空中で12、横の速さの上限は36です。跳ぶ速さは76、重力は80、重力の向きに落ちる速さの上限は70です。水面は Y64 で、ふつうは位置と速度をなめらかにつないだまま重力を反転します。前の位置と水面の距離が 8-EPS 以上になると再装填します。上下の速度²/(2G) < 8 で水に入ったときは、水面で止まります。すべての宝を取って接地すると成功で、部屋2と3では帰ることも必要です。

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

比べる点5ステップの教材元の完成版
段階01は全体が岸、02は開いた池と再装填のない反転、03は落ち着かせる仕組み(安定化)、04は1部屋目のぶつかり、05で元のゲームの3部屋すべてのモデル。最初から、3部屋すべての物理と、進み方のルール。
画面と操作英字(ASCII)の計器、8pxの補助線、表紙はない。05では1・2・3で部屋を直接選ぶ。日本語のドット文字(bitmap)、表紙で左右に選ぶ、F12で保存、比較の表示。
教材の近似01〜03は、終点を範囲の中に押しこむだけ(クランプ)。02は安定化の前の実験なので、浅い行ったり来たりがくり返される。03は最初の Y を60にして、浮遊を体験する。人物の大きさの分ふくらませた四角形に対して線分で調べ(掃引)、再装填と浮遊をいつも使う。
検証用機能読み込むときは標準ライブラリだけを使う。範囲をチェックしてある QA(品質確認)用の引数と、PNG・JSON の出力。再現用の専用ファイル、solver(答えを自動で探すプログラム)、比較の実験、GIF の出力。

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

  1. model.py の step(衝突→水面→接地→宝→成功→回復の順番)
  2. model.py の sweep・move_swept(ふれる順番と、4回までの上限)
  3. model.py の portal_at・segment_near(境目のきびしい条件)
  4. app.py の advance・update(横断の Undo と、キーを離すまで待つ仕組み)
  5. solver.py の expand・generate(3部屋すべての再現用の入力)
自分のゲームに変えるなら

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

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