スポーツ・レースLESSON 41シュート・遅延観測と逃走AI原作016

やわらかいゴールの作り方

逃げるゴールが見ているのは、少し前の球です。
ボタンを押して力を溜め、離して射つシュートと、遅れて届いた情報(観測)から動く向きを決めるゴールを作ります。位置・速度・観測した時刻を、分けて扱います。

完成版の遊び方ゴールは、近づいてくる球から少し遅れて逃げます。そのゴールをねらって、射つ場所・角度・力を選んで球を射ちます。

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

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

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

完成版の主な操作:左右キーで場所を選ぶ・ねらいを動かす、上下キーでねらいを細かく調整、Spaceで決定・長押しで力を溜める・離して射つ、Zで場所選びに戻る、Rでやり直し、Pで一時停止

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

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

うまく表示されないとき

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

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

ゴールが判断に使う情報が、いつの時点のものかをそろえる。

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

1. 押した瞬間と押し続け、場面の切り替え、時間の単位
2. 通り道全体の当たり判定、丸い角、はね返り、ぶつかる時刻の順番
3. 位置の履歴、18tickの遅れ、観測と方針、先読みしないこと
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.10以上の書き方を使います。動作は Python 3.12.14 と Pyxel 2.9.9 で確かめています / 変数、if、for、関数、リスト、角度と座標 / dataclass、線分掃引(球が通った線に沿って調べる当たり判定)、状態遷移(場面の切り替え)は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右:02以降の場所選びは、押した瞬間に1つ動く。ねらい(照準)は、押し続けると入力フレームごとに±2度動く / 上下:押し続けると照準が±0.4度動く。上がマイナス / SPACE:選んだものの決定・もう一度試し射ち。押した瞬間に溜め始め、押し続けると力が増え、離すと発射 / Z:02以降、照準や結果の画面から場所選びへ戻る / R:本編を、同じ面Cの最初の状態に戻す / P:05の本編だけ、一時停止と再開。溜めていた力は取り消す / Esc:Pyxelを終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_charge.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

押す・溜める・離すを別々の出来事にする

押した瞬間と押し続け、場面の切り替え、時間の単位。

最初は、中央から球がまっすぐ飛ぶだけです。SPACE を押した瞬間(新押下)に、力を20にします。そのあと押し続けている間(保持)に、力を増やします。キーを離したフレームで球を作ります。球の速さは、力20で50px/s(1秒に50ピクセル)、力100で120px/sです。角度は下向きがプラスで、±75度まで動かせます。重力はありません。球を作った次の入力フレームから、1/120秒ぶんの物理の計算(物理tick)を4回ずつ進めます。壁・ゴール・場所選び・AI(ゴールの自動の動き)は、まだありません。

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

01_charge.py / 注目する部分
        if 'SPACE' in h:
            s.power = min(100., s.power + 80 / 21)
        else:
            s.flight = launch(LEVEL, s.origin, s.angle, s.power)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_charge.py
macOS / Linux
.venv/bin/python 01_charge.py
画面で見ることSPACE を押した直後の力は20です。そのまま21入力フレーム(0.7秒)押し続けると、100になります。押したままの間は発射しません。短く押して離すと、球の速さは50px/sです。発射したフレームでは、球は位置(24,64)にあり、物理tickは0です。次の入力フレームで4tick進み、x=25.666666…になります。
  • 押した瞬間・押し続け・離した瞬間を区別できる
  • 1秒あたりの速度(px/s)に、1tickの時間 DT を掛けられる
  • 発射するフレームと、球が動き始めるフレームを区別できる
  • 教材1には、得点も壁でのはね返りもないと分かる
つまずきやすいところSPACE を押している間ずっと球を作ると、連射になってしまいます。逆に、btnp(押した瞬間だけ反応する関数)だけで溜めると、力は一度しか増えません。px/s の速度をそのまま位置に足すと、120倍の速さで動いてしまいます。「押したフレームの次から21回増える」と数えましょう。
少し変えてみよう

力20・角度0度で射った球が、物理120tickで進む距離を予想しましょう(答えは50px)。次に、押し続ける回数を21回から20回にすると、力はいくつになるか計算します(96.190476…)。test_03 と test_04 で確かめ、押し続けても発射しない理由を説明してみましょう。

01_charge.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(346行)
01_charge.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.
# 教材画面はASCIIとオリジナル図形のみ。Noto字形データは使いません。

"""やわらかいゴール・教材1/5。説明と簡略化の範囲はLESSON.md。"""
from dataclasses import dataclass, field, asdict
import math
import json
import hashlib

# 位置px、速度px/s、角度degree。入力30Hz、物理120Hz。重力は加えない。
DT = 1 / 120
EPS = 1e-9
RADIUS = 3.0
ORIGINS = (32., 64., 96.)

@dataclass(frozen=True)
class Level:
    title: str
    goal: float
    direction: int
    speed: float
    blocks: tuple = ()

# 原作Cを一つ選ぶ。上向きに逃げ、下側に小さな衝立がある。
LEVEL = Level('待ってから戻る', 88., -1, 22., ((136., 82., 148., 94.),))

@dataclass
class Flight:
    x: float
    y: float
    vx: float
    vy: float
    status: str = 'flying'
    ticks: int = 0
    trail: list = field(default_factory=list)
    @property
    def pos(self):
        return self.x, self.y


def launch(level, origin, angle, power, moving=False, record=True):
    if origin not in (0, 1, 2) or not all(math.isfinite(v) for v in (angle, power)):
        raise ValueError('invalid launch')
    if not -75 <= angle <= 75 or not 20 <= power <= 100:
        raise ValueError('launch outside UI range')
    speed = 50 + (power - 20) * 70 / 80
    a = math.radians(angle)
    f = Flight(24., ORIGINS[origin], speed * math.cos(a), speed * math.sin(a))
    f.trail = [f.pos]
    return f


def physics(level, f):
    if f.status != 'flying':
        return f
    f.x += f.vx * DT
    f.y += f.vy * DT
    f.ticks += 1
    if f.ticks % 4 == 0:
        f.trail.append(f.pos)
    # 教材1だけの回収条件。まだ壁も得点もない。
    if not 0 <= f.x <= 190 or not 0 <= f.y <= 128 or f.ticks >= 720:
        f.status = 'collected'
    return f


@dataclass(frozen=True)
class Input:
    pressed: tuple = ()
    held: tuple = ()

@dataclass
class Game:
    origin: int = 1
    angle: float = 0.
    mode: str = 'aim'
    power: float = 20.
    charging: bool = False
    require_release: bool = True
    attempts: int = 0
    flight: Flight | None = None


def fresh():
    return Game()


def frame(s, i=Input()):
    p, h = set(i.pressed), set(i.held)
    if 'R' in p:
        return fresh()
    if s.require_release:
        if 'SPACE' not in h:
            s.require_release = False
        space = False
    else:
        space = 'SPACE' in p
    if s.mode == 'flying':
        for _ in range(4):
            physics(LEVEL, s.flight)
        if s.flight.status != 'flying':
            s.mode = 'result'
        return s
    if s.mode == 'result':
        if space:
            s.flight = None
            s.mode = 'aim'
            s.power = 20.
            s.require_release = True
        return s
    s.angle = max(-75., min(75., s.angle + 2 * ('RIGHT' in h) - 2 * ('LEFT' in h)
                            + .4 * ('DOWN' in h) - .4 * ('UP' in h)))
    if space:
        s.charging = True
        s.power = 20.
        return s
    if s.charging:
        if 'SPACE' in h:
            s.power = min(100., s.power + 80 / 21)
        else:
            s.flight = launch(LEVEL, s.origin, s.angle, s.power)
            s.attempts += 1
            s.charging = False
            s.mode = 'flying'
    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 = 1
STAGE_TITLE = 'CHARGE + RELEASE'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'R')

class Controller:
    def __init__(self):
        self.state = fresh()

    def tick(self, pressed=(), held=()):
        pressed = tuple(k for k in pressed if k in CONTROLS)
        held = tuple(k for k in held if k in CONTROLS)
        self.state = frame(self.state, Input(pressed, held))

    def snapshot(self):
        return {'state': asdict(self.state), 'hash': state_hash(self.state)}


def draw_board(pyxel, controller):
    s = controller.state
    f = s.flight
    g = getattr(f, 'g', LEVEL.goal)
    pyxel.cls(1)
    pyxel.text(12, 10, f'FLEEING GOAL 016 / STEP {STAGE}', 10)
    pyxel.text(12, 24, STAGE_TITLE, 7)
    ox, oy, scale = 10, 44, 2
    def point(x, y):
        return ox+x*scale, oy+y*scale
    def line(a, b, color):
        pyxel.line(*point(*a), *point(*b), color)
    pyxel.rect(26, 68, 352, 208, 0)
    for x in range(16, 184, 12):
        line((x, 12), (x, 116), 1)
    if STAGE >= 2:
        line((8, 12), (176, 12), 7)
        line((8, 116), (176, 116), 7)
        line((8, 12), (8, 116), 13)
        for x,y,z,w in LEVEL.blocks:
            pyxel.rect(*point(x,y), (z-x)*2, (w-y)*2, 4)
            pyxel.rectb(*point(x,y), (z-x)*2, (w-y)*2, 9)
        line((176, 12), (176, g-12), 7)
        line((176, g+12), (176, 116), 7)
        line((176, g-12), (187, g-12), 7)
        line((176, g+12), (187, g+12), 7)
        line((187, g-12), (187, g+12), 6)
        line((191, 28), (191, 100), 5)
    if STAGE >= 3:
        for y in range(12,116,5):
            line((88,y),(88,y+2),10)
        pyxel.text(194, 72, 'SENSE x > 88', 10)
        if f and f.sensed:
            x,y=f.sensed
            pyxel.circb(*point(x,y),8,10)
            line((x,y),(176,g),5)
        if f:
            direction=f.direction
            line((193,g),(193,g+direction*8),10)
            line((193,g+direction*8),(191,g+direction*5),10)
            line((193,g+direction*8),(195,g+direction*5),10)
    if getattr(s,'previous',None):
        for a,b in zip(s.previous.trail,s.previous.trail[1:]):line(a,b,5)
    if f:
        for a,b in zip(f.trail,f.trail[1:]):line(a,b,6)
        pyxel.circ(*point(f.x,f.y),6,10 if f.status=='scored' else 7)
        pyxel.circb(*point(f.x,f.y),6,0)
    else:
        for k,y in enumerate(ORIGINS):
            if STAGE==1 and k!=1:continue
            pyxel.circb(*point(24,y),9,10 if k==s.origin else 5)
        x,y=24,ORIGINS[s.origin]
        a=math.radians(s.angle)
        line((x,y),(x+20*math.cos(a),y+20*math.sin(a)),10)
        pyxel.circ(*point(x,y),6,10 if s.charging else 7)
    lines=[('MODE',s.mode.upper(),10),('ANGLE',f'{s.angle:+.1f} deg',7),
           ('POWER',f'{s.power:.2f}',10),('ATTEMPTS',str(s.attempts),7),
           ('PHYSICS TICK',str(f.ticks if f else 0),6)]
    if f:lines += [('BALL',f.status.upper(),7)]
    if STAGE>=2:lines += [('GOAL Y',f'{g:.3f}',7),('BOUNCES',str(f.bounces if f else 0),6)]
    if STAGE>=3:lines += [('DELAY','18 ticks = 0.15 s',10),('SENSE',str(bool(f and f.sensing)),7),
                          ('DIRECTION',str(f.direction if f else LEVEL.direction),10)]
    if STAGE>=4:lines += [('GOAL SPEED',f'{f.gv if f else 0:.3f} px/s',6),
                          ('MEMORY',str(f.memory_ticks if f else 0),6)]
    for n,(label,value,color) in enumerate(lines):
        pyxel.text(410,48+n*16,label,13)
        pyxel.text(497,48+n*16,value,color)
    pyxel.rectb(30,298,346,12,6)
    pyxel.rect(32,300,(s.power-20)/80*342,8,10)
    pyxel.text(12, 322, 'ARROWS: aim / fine adjust   SPACE: press, hold, release   R: reset', 7)
    pyxel.text(12, 337, 'One UI frame = 4 physics ticks. Speed is px/s. No gravity.', 6)
    notes={1:'LAB: charge + straight motion. No walls, goal, selection, or pause.',
           2:'LAB: static original C. Swept collisions + reflection; no sensor / AI.',
           3:'LAB: old-ball observation + policy arrow. Goal action is still disabled.',
           4:'Original C physics. Bottom +28 deg, power 20: try a slow wall return.',
           5:'Original C + title/pause. P: pause  Z: select again. R beats P.'}
    pyxel.text(12,353,notes[STAGE],10)
    pyxel.text(12,369,'SELECT: LEFT/RIGHT then SPACE; release SPACE before starting charge.' if STAGE>=2
               else 'After reset/retry: release SPACE first. Short tap gives power 20.',7)
    pyxel.text(12,385,'WHITE: solid posts/walls  BROWN: block  YELLOW RING: old observed ball' if STAGE>=2
               else 'Hold 21 more UI frames after the new press for maximum power.',13)
    if getattr(controller,'title',False):
        pyxel.rect(50,113,322,110,1);pyxel.rectb(50,113,322,110,10)
        pyxel.text(72,132,'FLEEING GOAL / ORIGINAL CASE C',10)
        pyxel.text(72,152,'Can a slow ball beat a fleeing goal?',7)
        pyxel.text(72,173,'The goal sees only an old ball position.',6)
        pyxel.text(72,200,'SPACE: start, then release once',10)
    elif getattr(s,'paused',False):
        pyxel.rect(70,150,292,42,1);pyxel.rectb(70,150,292,42,10)
        pyxel.text(88,165,'PAUSED: physics + AI + history frozen',10)
    elif s.mode in ('result','won'):
        pyxel.rect(42,154,334,43,1);pyxel.rectb(42,154,334,43,10)
        pyxel.text(56,165,'SCORED! / R: reset same case' if s.mode=='won' else 'RESULT: '+f.status.upper(),10)
        if s.mode!='won':pyxel.text(56,182,'SPACE: retry aim / Z: select position' if STAGE>=2 else 'SPACE: retry aim',7)


def demo_keys(controller, frame_number):
    """技術QA用の一定入力。AIへの未来情報ではなくプレイヤーの操作列。"""
    # 05は最初の2フレームでタイトルを抜ける。他は直接開始。
    if STAGE==5:
        if frame_number==1:return ('SPACE',)
        frame_number-=2
    if STAGE==1:
        if 2<=frame_number<=23:return ('SPACE',)
        return ()
    # 下を選択し、28度を14入力フレームで作り、最弱で放す。
    if frame_number==2:return ('RIGHT',)
    if frame_number==4:return ('SPACE',)
    if 6<=frame_number<=19:return ('RIGHT',)
    if frame_number==21:return ('SPACE',)
    return ()


def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None):
    from pathlib import Path
    import pyxel  # モデルのimportと標準ライブラリテストには不要。
    controller=Controller()
    codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    frame_number=0
    old_held=set()
    changes=[]
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    report=str(Path(report).resolve()) if report else None
    def update():
        nonlocal frame_number,old_held
        frame_number+=1
        if headless:
            if replay is not None:
                held=set(replay[frame_number-1]) if frame_number<=len(replay) else set()
            else:
                held=set(demo_keys(controller,frame_number) if demo else ())
            pressed=held-old_held
            old_held=held
        else:
            held={name for name,code in codes.items() if pyxel.btn(code)}
            pressed={name for name,code in codes.items() if pyxel.btnp(code)}
        old_mode=controller.state.mode
        controller.tick(pressed,held)
        if pressed or controller.state.mode!=old_mode:
            changes.append({'frame':frame_number,'pressed':sorted(pressed),'held':sorted(held),
                            'mode':controller.state.mode,'hash':state_hash(controller.state)})
    def draw():
        draw_board(pyxel,controller)
        if headless and frame_number>=frames:
            if screenshot:pyxel.screenshot(screenshot,scale=1)
            if report:
                Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic controller inputs; silent; not human play',
                    'stage':STAGE,'requested_frames':frames,'actual_frames':frame_number,'demo':demo,
                    'changes':changes,'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(630,406,title=f'Fleeing Goal / Step {STAGE}',fps=30,headless=headless)
    pyxel.run(update,draw)


if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless',action='store_true')
    parser.add_argument('--frames',type=int,default=180)
    parser.add_argument('--demo',action='store_true')
    parser.add_argument('--replay-json',help='UTF-8 JSON list of held-key lists, one per UI frame')
    parser.add_argument('--screenshot',help='PNG output; headless only; relative to current directory')
    parser.add_argument('--report',help='UTF-8 JSON output; headless only')
    args=parser.parse_args()
    if not 12<=args.frames<=100000:parser.error('Use 12 through 100000 frames')
    if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):
        parser.error('QA input/output options require --headless')
    if args.demo and args.replay_json:parser.error('Choose demo or replay, not both')
    replay=None
    if args.replay_json:
        try:replay=json.loads(Path(args.replay_json).read_text(encoding='utf-8'))
        except (OSError,ValueError) as error:parser.error(str(error))
        if not isinstance(replay,list) or any(not isinstance(keys,list) or any(not isinstance(k,str) or k not in CONTROLS for k in keys) for keys in replay):
            parser.error('Replay must be a list of lists of supported key names')
    main(args.headless,args.frames,args.demo,replay,args.screenshot,args.report)
STEP 02 / 05

球が通った線分から最初の接触を選ぶ

通り道全体の当たり判定(掃引判定)、丸い角、はね返り、ぶつかる時刻の順番。

元のゲームの面Cにある場所選び、上下の壁、衝立(ついたて)、止まったままのゴールを加えます。ぶつかる相手の四角形を球の半径3だけ広げ、角には円を置きます。球の中心が動いた線分(移動線分)をそこへ当てて、いちばん先にぶつかるもの(最初の接触)だけを選びます。上下の壁に当たったら、縦の速度の向きを反対にして、残った時間のぶんも進めます。得点の線は x=179 です。同じ時刻に衝立や支柱(ゴールの柱)にも当たるときは、得点よりもそちらを優先します。ゴールは y=88 に止めたままにします。これは教材用の比較のための形です。

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

02_collision.py / 注目する部分
        t,_,reason=min(hits,key=lambda h:(round(h[0],10),h[1],h[2]))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_collision.py
macOS / Linux
.venv/bin/python 02_collision.py
画面で見ること下の場所を選び、+28度・力20で蹴ります。球は下の壁で一度はね返り、止まっている支柱に物理tick406で当たります。はね返っても、球の速さは変わりません。ゴールの入り口(開口)の中心から9px離れた線(接線)の上を通ると、半径3の球が支柱にふれるので失敗になります。
  • ぶつかる時刻 t が、0〜1の割合で表されていると分かる
  • 丸い角の円と、四角形の役割を説明できる
  • はね返ったあとの残り時間を捨てない
  • 同時にぶつかったときは、得点を後回しにする
つまずきやすいところ動いたあとの点だけを調べると、速い球が衝立をすり抜けてしまいます。四角形全体をただ3px広げるだけだと、角が四角いままになります。先に x>=179 で得点にすると、同じ時刻のほかの接触を見落とします。
少し変えてみよう

test_17 の高さの差9を、8.999と9にして、止まった開口を通りぬける場合と、支柱にふれる場合を比べます。test_19 では、衝立への最初の接触と得点が同じ時刻です。なぜ block(衝立に当たった結果)が優先されるのかを、hits の2番目の要素を読んで説明しましょう。

02_collision.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(405行)
02_collision.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.
# 教材画面はASCIIとオリジナル図形のみ。Noto字形データは使いません。

"""やわらかいゴール・教材2/5。説明と簡略化の範囲はLESSON.md。"""
from dataclasses import dataclass, field, asdict
import math
import json
import hashlib

# 位置px、速度px/s、角度degree。入力30Hz、物理120Hz。重力は加えない。
DT = 1 / 120
EPS = 1e-9
RADIUS = 3.0
ORIGINS = (32., 64., 96.)

@dataclass(frozen=True)
class Level:
    title: str
    goal: float
    direction: int
    speed: float
    blocks: tuple = ()

# 原作Cを一つ選ぶ。上向きに逃げ、下側に小さな衝立がある。
LEVEL = Level('待ってから戻る', 88., -1, 22., ((136., 82., 148., 94.),))

def ray_box(p,d,b):
    lo=0.;hi=1.
    for axis in (0,1):
        if abs(d[axis])<EPS:
            if not b[axis]-EPS<=p[axis]<=b[axis+2]+EPS:return None
        else:
            a=(b[axis]-p[axis])/d[axis];z=(b[axis+2]-p[axis])/d[axis]
            lo=max(lo,min(a,z));hi=min(hi,max(a,z))
            if lo>hi+EPS:return None
    return max(0.,lo) if lo<=1+EPS and hi>=0 else None

def ray_circle(p,d,c,r=RADIUS):
    q=(p[0]-c[0],p[1]-c[1]);a=d[0]*d[0]+d[1]*d[1]
    cc=q[0]*q[0]+q[1]*q[1]-r*r
    if cc<=EPS:return 0.
    if a<EPS:return None
    b=2*(q[0]*d[0]+q[1]*d[1]);disc=b*b-4*a*cc
    if disc<0:return None
    t=(-b-math.sqrt(max(0,disc)))/(2*a)
    return t if -EPS<=t<=1+EPS else None

def circle_box(p,d,b):
    x,y,z,w=b
    hits=[ray_box(p,d,(x-RADIUS,y,z+RADIUS,w)),ray_box(p,d,(x,y-RADIUS,z,w+RADIUS))]
    hits += [ray_circle(p,d,c) for c in ((x,y),(x,w),(z,y),(z,w))]
    return min((h for h in hits if h is not None),default=None)

def gate_hit(p,d,g,dg):
    """Moving posts, treated as capsules. y is transformed into gate-relative space."""
    q=(p[0],p[1]-g);v=(d[0],d[1]-dg)
    hits=[ray_box(q,v,(173.,-1000.,179.,-12.)),ray_box(q,v,(173.,12.,179.,1000.)),
          ray_circle(q,v,(176.,-12.)),ray_circle(q,v,(176.,12.))]
    return min((h for h in hits if h is not None),default=None)

@dataclass
class Flight:
    x:float
    y:float
    vx:float
    vy:float
    g:float
    direction:int
    gv:float=0.
    status:str='flying'
    ticks:int=0
    bounces:int=0
    trail:list=field(default_factory=list)
    goal_trail:list=field(default_factory=list)
    first_contact_goal:float|None=None
    moving:bool=True
    record:bool=True
    @property
    def age(self):return self.ticks*DT
    @property
    def pos(self):return self.x,self.y

def launch(level,origin,angle,power,moving=True,record=True):
    if origin not in (0,1,2) or not all(math.isfinite(v) for v in (angle,power)):raise ValueError('invalid launch')
    if not -75<=angle<=75 or not 20<=power<=100:raise ValueError('launch outside UI range')
    speed=50+(power-20)*70/80;a=math.radians(angle)
    f=Flight(24.,ORIGINS[origin],speed*math.cos(a),speed*math.sin(a),level.goal,level.direction,moving=moving,record=record)
    if record:f.trail=[f.pos];f.goal_trail=[f.g]
    return f

def physics(level, f):
    if f.status != 'flying':
        return f
    oldg, dg = f.g, 0.  # 教材2/3は静止ゴール。
    remain=1.;elapsed=0.
    for _ in range(4):
        d=(f.vx*DT*remain,f.vy*DT*remain);p=f.pos
        hits=[]
        for i,b in enumerate(level.blocks):
            t=circle_box(p,d,b)
            if t is not None:hits.append((t,0,'block'))
        h=gate_hit(p,d,oldg+dg*elapsed,dg*remain)
        if h is not None:hits.append((h,0,'post'))
        for boundary,axis,sense,name in ((15.,1,-1,'top'),(113.,1,1,'bottom'),(11.,0,-1,'left'),(179.,0,1,'goal')):
            if d[axis]*sense>EPS:
                t=(boundary-p[axis])/d[axis]
                if -EPS<=t<=1+EPS:hits.append((max(0.,t),1 if name=='goal' else 0,name))
        if not hits:
            f.x+=d[0];f.y+=d[1];break
        t,_,reason=min(hits,key=lambda h:(round(h[0],10),h[1],h[2]))
        f.x+=d[0]*t;f.y+=d[1]*t;elapsed+=remain*t;remain*=1-t
        if f.record:f.trail.append(f.pos)
        if reason in ('top','bottom'):
            f.vy=-f.vy;f.bounces+=1;f.y+=.01 if reason=='top' else -.01
            if remain<EPS:break
        else:
            f.status='scored' if reason=='goal' else reason
            f.first_contact_goal=oldg+dg*elapsed;f.g=f.first_contact_goal
            break
    f.ticks+=1
    if f.record and (f.ticks%4==0 or f.status!='flying'):
        f.trail.append(f.pos);f.goal_trail.append(f.g)
    if f.status=='flying' and f.ticks>=720:f.status='timeout'
    return f

def simulate(level,origin,angle,power,moving=True,record=False):
    f=launch(level,origin,angle,power,moving,record)
    while f.status=='flying':physics(level,f)
    return f

@dataclass(frozen=True)
class Input:
    pressed:tuple=()
    held:tuple=()
@dataclass
class Game:
    level:int=2
    origin:int=1
    angle:float=0.
    mode:str='select'
    paused:bool=False
    power:float=20.
    charging:bool=False
    require_release:bool=True
    attempts:int=0
    flight:Flight|None=None
    previous:Flight|None=None
    moving:bool=True

def fresh(level=2,moving=True):return Game(level=level,moving=moving)
def frame(s,i=Input()):
    p=set(i.pressed);h=set(i.held)
    if 'R' in p:return fresh(s.level,s.moving)
    if s.require_release:
        if 'SPACE' not in h:s.require_release=False
        space=False
    else:space='SPACE' in p
    if s.mode=='won':
        return s
    if s.mode=='select':
        if 'LEFT' in p:s.origin=(s.origin-1)%3
        if 'RIGHT' in p:s.origin=(s.origin+1)%3
        if space:s.mode='aim';s.require_release=True
        return s
    if s.mode=='flying':
        for _ in range(4):physics(LEVEL,s.flight)
        if s.flight.status!='flying':s.mode='won' if s.flight.status=='scored' else 'result'
        return s
    if 'Z' in p:
        s.previous=s.flight;s.flight=None;s.mode='select';s.charging=False;s.power=20.;s.require_release=True
        return s
    if s.mode=='result':
        if space:
            s.previous=s.flight;s.flight=None;s.mode='aim';s.power=20.;s.require_release=True
        return s
    s.angle=max(-75.,min(75.,s.angle+2*('RIGHT'in h)-2*('LEFT'in h)+.4*('DOWN'in h)-.4*('UP'in h)))
    if space:
        s.charging=True;s.power=20.
        return s
    if s.charging:
        if 'SPACE' in h:s.power=min(100.,s.power+80/21)
        else:
            s.flight=launch(LEVEL,s.origin,s.angle,s.power,s.moving)
            s.attempts+=1;s.charging=False;s.mode='flying'
    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 = 2
STAGE_TITLE = 'SWEPT CONTACT + WALL BOUNCE'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'R', 'Z')

class Controller:
    def __init__(self):
        self.state = fresh()

    def tick(self, pressed=(), held=()):
        pressed = tuple(k for k in pressed if k in CONTROLS)
        held = tuple(k for k in held if k in CONTROLS)
        self.state = frame(self.state, Input(pressed, held))

    def snapshot(self):
        return {'state': asdict(self.state), 'hash': state_hash(self.state)}


def draw_board(pyxel, controller):
    s = controller.state
    f = s.flight
    g = getattr(f, 'g', LEVEL.goal)
    pyxel.cls(1)
    pyxel.text(12, 10, f'FLEEING GOAL 016 / STEP {STAGE}', 10)
    pyxel.text(12, 24, STAGE_TITLE, 7)
    ox, oy, scale = 10, 44, 2
    def point(x, y):
        return ox+x*scale, oy+y*scale
    def line(a, b, color):
        pyxel.line(*point(*a), *point(*b), color)
    pyxel.rect(26, 68, 352, 208, 0)
    for x in range(16, 184, 12):
        line((x, 12), (x, 116), 1)
    if STAGE >= 2:
        line((8, 12), (176, 12), 7)
        line((8, 116), (176, 116), 7)
        line((8, 12), (8, 116), 13)
        for x,y,z,w in LEVEL.blocks:
            pyxel.rect(*point(x,y), (z-x)*2, (w-y)*2, 4)
            pyxel.rectb(*point(x,y), (z-x)*2, (w-y)*2, 9)
        line((176, 12), (176, g-12), 7)
        line((176, g+12), (176, 116), 7)
        line((176, g-12), (187, g-12), 7)
        line((176, g+12), (187, g+12), 7)
        line((187, g-12), (187, g+12), 6)
        line((191, 28), (191, 100), 5)
    if STAGE >= 3:
        for y in range(12,116,5):
            line((88,y),(88,y+2),10)
        pyxel.text(194, 72, 'SENSE x > 88', 10)
        if f and f.sensed:
            x,y=f.sensed
            pyxel.circb(*point(x,y),8,10)
            line((x,y),(176,g),5)
        if f:
            direction=f.direction
            line((193,g),(193,g+direction*8),10)
            line((193,g+direction*8),(191,g+direction*5),10)
            line((193,g+direction*8),(195,g+direction*5),10)
    if getattr(s,'previous',None):
        for a,b in zip(s.previous.trail,s.previous.trail[1:]):line(a,b,5)
    if f:
        for a,b in zip(f.trail,f.trail[1:]):line(a,b,6)
        pyxel.circ(*point(f.x,f.y),6,10 if f.status=='scored' else 7)
        pyxel.circb(*point(f.x,f.y),6,0)
    else:
        for k,y in enumerate(ORIGINS):
            if STAGE==1 and k!=1:continue
            pyxel.circb(*point(24,y),9,10 if k==s.origin else 5)
        x,y=24,ORIGINS[s.origin]
        a=math.radians(s.angle)
        line((x,y),(x+20*math.cos(a),y+20*math.sin(a)),10)
        pyxel.circ(*point(x,y),6,10 if s.charging else 7)
    lines=[('MODE',s.mode.upper(),10),('ANGLE',f'{s.angle:+.1f} deg',7),
           ('POWER',f'{s.power:.2f}',10),('ATTEMPTS',str(s.attempts),7),
           ('PHYSICS TICK',str(f.ticks if f else 0),6)]
    if f:lines += [('BALL',f.status.upper(),7)]
    if STAGE>=2:lines += [('GOAL Y',f'{g:.3f}',7),('BOUNCES',str(f.bounces if f else 0),6)]
    if STAGE>=3:lines += [('DELAY','18 ticks = 0.15 s',10),('SENSE',str(bool(f and f.sensing)),7),
                          ('DIRECTION',str(f.direction if f else LEVEL.direction),10)]
    if STAGE>=4:lines += [('GOAL SPEED',f'{f.gv if f else 0:.3f} px/s',6),
                          ('MEMORY',str(f.memory_ticks if f else 0),6)]
    for n,(label,value,color) in enumerate(lines):
        pyxel.text(410,48+n*16,label,13)
        pyxel.text(497,48+n*16,value,color)
    pyxel.rectb(30,298,346,12,6)
    pyxel.rect(32,300,(s.power-20)/80*342,8,10)
    pyxel.text(12, 322, 'ARROWS: aim / fine adjust   SPACE: press, hold, release   R: reset', 7)
    pyxel.text(12, 337, 'One UI frame = 4 physics ticks. Speed is px/s. No gravity.', 6)
    notes={1:'LAB: charge + straight motion. No walls, goal, selection, or pause.',
           2:'LAB: static original C. Swept collisions + reflection; no sensor / AI.',
           3:'LAB: old-ball observation + policy arrow. Goal action is still disabled.',
           4:'Original C physics. Bottom +28 deg, power 20: try a slow wall return.',
           5:'Original C + title/pause. P: pause  Z: select again. R beats P.'}
    pyxel.text(12,353,notes[STAGE],10)
    pyxel.text(12,369,'SELECT: LEFT/RIGHT then SPACE; release SPACE before starting charge.' if STAGE>=2
               else 'After reset/retry: release SPACE first. Short tap gives power 20.',7)
    pyxel.text(12,385,'WHITE: solid posts/walls  BROWN: block  YELLOW RING: old observed ball' if STAGE>=2
               else 'Hold 21 more UI frames after the new press for maximum power.',13)
    if getattr(controller,'title',False):
        pyxel.rect(50,113,322,110,1);pyxel.rectb(50,113,322,110,10)
        pyxel.text(72,132,'FLEEING GOAL / ORIGINAL CASE C',10)
        pyxel.text(72,152,'Can a slow ball beat a fleeing goal?',7)
        pyxel.text(72,173,'The goal sees only an old ball position.',6)
        pyxel.text(72,200,'SPACE: start, then release once',10)
    elif getattr(s,'paused',False):
        pyxel.rect(70,150,292,42,1);pyxel.rectb(70,150,292,42,10)
        pyxel.text(88,165,'PAUSED: physics + AI + history frozen',10)
    elif s.mode in ('result','won'):
        pyxel.rect(42,154,334,43,1);pyxel.rectb(42,154,334,43,10)
        pyxel.text(56,165,'SCORED! / R: reset same case' if s.mode=='won' else 'RESULT: '+f.status.upper(),10)
        if s.mode!='won':pyxel.text(56,182,'SPACE: retry aim / Z: select position' if STAGE>=2 else 'SPACE: retry aim',7)


def demo_keys(controller, frame_number):
    """技術QA用の一定入力。AIへの未来情報ではなくプレイヤーの操作列。"""
    # 05は最初の2フレームでタイトルを抜ける。他は直接開始。
    if STAGE==5:
        if frame_number==1:return ('SPACE',)
        frame_number-=2
    if STAGE==1:
        if 2<=frame_number<=23:return ('SPACE',)
        return ()
    # 下を選択し、28度を14入力フレームで作り、最弱で放す。
    if frame_number==2:return ('RIGHT',)
    if frame_number==4:return ('SPACE',)
    if 6<=frame_number<=19:return ('RIGHT',)
    if frame_number==21:return ('SPACE',)
    return ()


def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None):
    from pathlib import Path
    import pyxel  # モデルのimportと標準ライブラリテストには不要。
    controller=Controller()
    codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    frame_number=0
    old_held=set()
    changes=[]
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    report=str(Path(report).resolve()) if report else None
    def update():
        nonlocal frame_number,old_held
        frame_number+=1
        if headless:
            if replay is not None:
                held=set(replay[frame_number-1]) if frame_number<=len(replay) else set()
            else:
                held=set(demo_keys(controller,frame_number) if demo else ())
            pressed=held-old_held
            old_held=held
        else:
            held={name for name,code in codes.items() if pyxel.btn(code)}
            pressed={name for name,code in codes.items() if pyxel.btnp(code)}
        old_mode=controller.state.mode
        controller.tick(pressed,held)
        if pressed or controller.state.mode!=old_mode:
            changes.append({'frame':frame_number,'pressed':sorted(pressed),'held':sorted(held),
                            'mode':controller.state.mode,'hash':state_hash(controller.state)})
    def draw():
        draw_board(pyxel,controller)
        if headless and frame_number>=frames:
            if screenshot:pyxel.screenshot(screenshot,scale=1)
            if report:
                Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic controller inputs; silent; not human play',
                    'stage':STAGE,'requested_frames':frames,'actual_frames':frame_number,'demo':demo,
                    'changes':changes,'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(630,406,title=f'Fleeing Goal / Step {STAGE}',fps=30,headless=headless)
    pyxel.run(update,draw)


if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless',action='store_true')
    parser.add_argument('--frames',type=int,default=180)
    parser.add_argument('--demo',action='store_true')
    parser.add_argument('--replay-json',help='UTF-8 JSON list of held-key lists, one per UI frame')
    parser.add_argument('--screenshot',help='PNG output; headless only; relative to current directory')
    parser.add_argument('--report',help='UTF-8 JSON output; headless only')
    args=parser.parse_args()
    if not 12<=args.frames<=100000:parser.error('Use 12 through 100000 frames')
    if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):
        parser.error('QA input/output options require --headless')
    if args.demo and args.replay_json:parser.error('Choose demo or replay, not both')
    replay=None
    if args.replay_json:
        try:replay=json.loads(Path(args.replay_json).read_text(encoding='utf-8'))
        except (OSError,ValueError) as error:parser.error(str(error))
        if not isinstance(replay,list) or any(not isinstance(keys,list) or any(not isinstance(k,str) or k not in CONTROLS for k in keys) for keys in replay):
            parser.error('Replay must be a list of lists of supported key names')
    main(args.headless,args.frames,args.demo,replay,args.screenshot,args.report)
STEP 03 / 05

古い球を見る処理と逃げる判断を切り離す

位置の履歴、18tickの遅れ、観測と方針、先読みしないこと。

ゴールはまだ動かしません。そのかわりに、反応帯(ゴールが球に反応する範囲)、黄色で描く古い球、ゴールが動きたい向き(意図)を示す矢印を加えます。observe(観測する関数)は、更新を始めたときの球が x>88 かどうかで、観測できるかを決めます。観測できるときは、19個ある位置の履歴の先頭を読みます。これは、その時点から18物理tick前、つまり0.15秒前の球の位置です。policy(向きを決める関数)が受け取るのは、古い球の高さ・ゴール自身の高さ・前の向きだけです。今の球の高さや速度、プレイヤーの入力は受け取りません。

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

03_observation.py / 注目する部分
    diff = observation.delayed[1] - observation.goal_y
    if abs(diff) > 2:
        return -1 if diff > 0 else 1
    return previous_direction

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_observation.py
macOS / Linux
.venv/bin/python 03_observation.py
画面で見ること球が x=88 にいる更新では、まだ観測しません。88を越えた次の更新から、黄色い輪(古い球)が出ます。古い球がゴールの枠より上にあれば、向きは下向きの+1、下にあれば上向きの-1になります。差がちょうど±2pxのときは、前の向きをそのまま保ちます。矢印が変わっても、枠は y=88 のまま動きません。壁で一度はね返す、02と同じ弱いショットは、02と同じく tick406 で post(支柱に当たる)になります。
  • 今の球の x は反応帯の判定だけに使い、球の y は古い観測のものだけを使う、と区別できる
  • 履歴が19個なのに、遅れが18tickになる理由を説明できる
  • policy の中で、球の速度やプレイヤーの入力を読まない
  • ちょうど±2pxも含む「反応しない幅」(不感帯)では、前の向きを保てる
つまずきやすいところ「履歴が19個だから19tickの遅れ」と数えるのはまちがいです。更新の前には、今の位置も履歴の最後に入っています。そのため、先頭との差は18です。黄色い輪だけを遅らせて、判断には f.y(今の球の高さ)を使うと、画面に見えるものとAIが知っていることが合わなくなります。
少し変えてみよう

test_24 で、今の球の y と速度を大きく変えてみます。履歴が同じなら、観測と方針(決める向き)は変わらないことを確かめましょう。次に test_25 で、差が2のときと2.000001のときの境目を調べます。このAIの公平さは、「わざと弱くする」ことではありません。「与えた情報だけで判断する」ことです。そのちがいを説明してみましょう。

03_observation.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(449行)
03_observation.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.
# 教材画面はASCIIとオリジナル図形のみ。Noto字形データは使いません。

"""やわらかいゴール・教材3/5。説明と簡略化の範囲はLESSON.md。"""
from dataclasses import dataclass, field, asdict
import math
import json
import hashlib

# 位置px、速度px/s、角度degree。入力30Hz、物理120Hz。重力は加えない。
DT = 1 / 120
EPS = 1e-9
RADIUS = 3.0
ORIGINS = (32., 64., 96.)

@dataclass(frozen=True)
class Level:
    title: str
    goal: float
    direction: int
    speed: float
    blocks: tuple = ()

# 原作Cを一つ選ぶ。上向きに逃げ、下側に小さな衝立がある。
LEVEL = Level('待ってから戻る', 88., -1, 22., ((136., 82., 148., 94.),))

def ray_box(p,d,b):
    lo=0.;hi=1.
    for axis in (0,1):
        if abs(d[axis])<EPS:
            if not b[axis]-EPS<=p[axis]<=b[axis+2]+EPS:return None
        else:
            a=(b[axis]-p[axis])/d[axis];z=(b[axis+2]-p[axis])/d[axis]
            lo=max(lo,min(a,z));hi=min(hi,max(a,z))
            if lo>hi+EPS:return None
    return max(0.,lo) if lo<=1+EPS and hi>=0 else None

def ray_circle(p,d,c,r=RADIUS):
    q=(p[0]-c[0],p[1]-c[1]);a=d[0]*d[0]+d[1]*d[1]
    cc=q[0]*q[0]+q[1]*q[1]-r*r
    if cc<=EPS:return 0.
    if a<EPS:return None
    b=2*(q[0]*d[0]+q[1]*d[1]);disc=b*b-4*a*cc
    if disc<0:return None
    t=(-b-math.sqrt(max(0,disc)))/(2*a)
    return t if -EPS<=t<=1+EPS else None

def circle_box(p,d,b):
    x,y,z,w=b
    hits=[ray_box(p,d,(x-RADIUS,y,z+RADIUS,w)),ray_box(p,d,(x,y-RADIUS,z,w+RADIUS))]
    hits += [ray_circle(p,d,c) for c in ((x,y),(x,w),(z,y),(z,w))]
    return min((h for h in hits if h is not None),default=None)

def gate_hit(p,d,g,dg):
    """Moving posts, treated as capsules. y is transformed into gate-relative space."""
    q=(p[0],p[1]-g);v=(d[0],d[1]-dg)
    hits=[ray_box(q,v,(173.,-1000.,179.,-12.)),ray_box(q,v,(173.,12.,179.,1000.)),
          ray_circle(q,v,(176.,-12.)),ray_circle(q,v,(176.,12.))]
    return min((h for h in hits if h is not None),default=None)

@dataclass
class Flight:
    x:float
    y:float
    vx:float
    vy:float
    g:float
    direction:int
    gv:float=0.
    status:str='flying'
    ticks:int=0
    bounces:int=0
    trail:list=field(default_factory=list)
    goal_trail:list=field(default_factory=list)
    history:list=field(default_factory=list)
    sensed:tuple|None=None
    memory_ticks:int=0
    sensing:bool=False
    first_contact_goal:float|None=None
    moving:bool=True
    record:bool=True
    @property
    def age(self):return self.ticks*DT
    @property
    def pos(self):return self.x,self.y

def launch(level,origin,angle,power,moving=True,record=True):
    if origin not in (0,1,2) or not all(math.isfinite(v) for v in (angle,power)):raise ValueError('invalid launch')
    if not -75<=angle<=75 or not 20<=power<=100:raise ValueError('launch outside UI range')
    speed=50+(power-20)*70/80;a=math.radians(angle)
    f=Flight(24.,ORIGINS[origin],speed*math.cos(a),speed*math.sin(a),level.goal,level.direction,moving=moving,record=record)
    f.history=[(24.,ORIGINS[origin])]*19
    if record:f.trail=[f.pos];f.goal_trail=[f.g]
    return f

@dataclass(frozen=True)
class Observation:
    active: bool
    delayed: tuple | None
    goal_y: float


def observe(f):
    """今のxは反応帯への入場だけに使う。高さの情報は履歴だけ。"""
    active = f.x > 88
    return Observation(active, f.history[0] if active else None, f.g)


def policy(observation, previous_direction):
    """観測と以前の向きだけで判断。速度・現在の球y・入力は受け取らない。"""
    if not observation.active:
        return previous_direction
    diff = observation.delayed[1] - observation.goal_y
    if abs(diff) > 2:
        return -1 if diff > 0 else 1
    return previous_direction


def remember(f, observation):
    # 減算前の値を保存。30だったら、その後30tickは惰性の目標速度を保つ。
    coasting = f.memory_ticks > 0
    f.sensing = observation.active
    if observation.active:
        f.sensed = observation.delayed
        f.direction = policy(observation, f.direction)
        f.memory_ticks = 30
    elif f.memory_ticks > 0:
        f.memory_ticks -= 1
    return coasting


def physics(level, f):
    if f.status != 'flying':
        return f
    observation = observe(f)
    coasting = remember(f, observation)
    oldg, dg = f.g, 0.  # 教材2/3は静止ゴール。
    remain=1.;elapsed=0.
    for _ in range(4):
        d=(f.vx*DT*remain,f.vy*DT*remain);p=f.pos
        hits=[]
        for i,b in enumerate(level.blocks):
            t=circle_box(p,d,b)
            if t is not None:hits.append((t,0,'block'))
        h=gate_hit(p,d,oldg+dg*elapsed,dg*remain)
        if h is not None:hits.append((h,0,'post'))
        for boundary,axis,sense,name in ((15.,1,-1,'top'),(113.,1,1,'bottom'),(11.,0,-1,'left'),(179.,0,1,'goal')):
            if d[axis]*sense>EPS:
                t=(boundary-p[axis])/d[axis]
                if -EPS<=t<=1+EPS:hits.append((max(0.,t),1 if name=='goal' else 0,name))
        if not hits:
            f.x+=d[0];f.y+=d[1];break
        t,_,reason=min(hits,key=lambda h:(round(h[0],10),h[1],h[2]))
        f.x+=d[0]*t;f.y+=d[1]*t;elapsed+=remain*t;remain*=1-t
        if f.record:f.trail.append(f.pos)
        if reason in ('top','bottom'):
            f.vy=-f.vy;f.bounces+=1;f.y+=.01 if reason=='top' else -.01
            if remain<EPS:break
        else:
            f.status='scored' if reason=='goal' else reason
            f.first_contact_goal=oldg+dg*elapsed;f.g=f.first_contact_goal
            break
    f.ticks+=1
    f.history.append(f.pos);f.history.pop(0)
    if f.record and (f.ticks%4==0 or f.status!='flying'):
        f.trail.append(f.pos);f.goal_trail.append(f.g)
    if f.status=='flying' and f.ticks>=720:f.status='timeout'
    return f

def simulate(level,origin,angle,power,moving=True,record=False):
    f=launch(level,origin,angle,power,moving,record)
    while f.status=='flying':physics(level,f)
    return f

@dataclass(frozen=True)
class Input:
    pressed:tuple=()
    held:tuple=()
@dataclass
class Game:
    level:int=2
    origin:int=1
    angle:float=0.
    mode:str='select'
    paused:bool=False
    power:float=20.
    charging:bool=False
    require_release:bool=True
    attempts:int=0
    flight:Flight|None=None
    previous:Flight|None=None
    moving:bool=True

def fresh(level=2,moving=True):return Game(level=level,moving=moving)
def frame(s,i=Input()):
    p=set(i.pressed);h=set(i.held)
    if 'R' in p:return fresh(s.level,s.moving)
    if s.require_release:
        if 'SPACE' not in h:s.require_release=False
        space=False
    else:space='SPACE' in p
    if s.mode=='won':
        return s
    if s.mode=='select':
        if 'LEFT' in p:s.origin=(s.origin-1)%3
        if 'RIGHT' in p:s.origin=(s.origin+1)%3
        if space:s.mode='aim';s.require_release=True
        return s
    if s.mode=='flying':
        for _ in range(4):physics(LEVEL,s.flight)
        if s.flight.status!='flying':s.mode='won' if s.flight.status=='scored' else 'result'
        return s
    if 'Z' in p:
        s.previous=s.flight;s.flight=None;s.mode='select';s.charging=False;s.power=20.;s.require_release=True
        return s
    if s.mode=='result':
        if space:
            s.previous=s.flight;s.flight=None;s.mode='aim';s.power=20.;s.require_release=True
        return s
    s.angle=max(-75.,min(75.,s.angle+2*('RIGHT'in h)-2*('LEFT'in h)+.4*('DOWN'in h)-.4*('UP'in h)))
    if space:
        s.charging=True;s.power=20.
        return s
    if s.charging:
        if 'SPACE' in h:s.power=min(100.,s.power+80/21)
        else:
            s.flight=launch(LEVEL,s.origin,s.angle,s.power,s.moving)
            s.attempts+=1;s.charging=False;s.mode='flying'
    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 = 3
STAGE_TITLE = 'OBSERVE OLD BALL / POLICY'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'R', 'Z')

class Controller:
    def __init__(self):
        self.state = fresh()

    def tick(self, pressed=(), held=()):
        pressed = tuple(k for k in pressed if k in CONTROLS)
        held = tuple(k for k in held if k in CONTROLS)
        self.state = frame(self.state, Input(pressed, held))

    def snapshot(self):
        return {'state': asdict(self.state), 'hash': state_hash(self.state)}


def draw_board(pyxel, controller):
    s = controller.state
    f = s.flight
    g = getattr(f, 'g', LEVEL.goal)
    pyxel.cls(1)
    pyxel.text(12, 10, f'FLEEING GOAL 016 / STEP {STAGE}', 10)
    pyxel.text(12, 24, STAGE_TITLE, 7)
    ox, oy, scale = 10, 44, 2
    def point(x, y):
        return ox+x*scale, oy+y*scale
    def line(a, b, color):
        pyxel.line(*point(*a), *point(*b), color)
    pyxel.rect(26, 68, 352, 208, 0)
    for x in range(16, 184, 12):
        line((x, 12), (x, 116), 1)
    if STAGE >= 2:
        line((8, 12), (176, 12), 7)
        line((8, 116), (176, 116), 7)
        line((8, 12), (8, 116), 13)
        for x,y,z,w in LEVEL.blocks:
            pyxel.rect(*point(x,y), (z-x)*2, (w-y)*2, 4)
            pyxel.rectb(*point(x,y), (z-x)*2, (w-y)*2, 9)
        line((176, 12), (176, g-12), 7)
        line((176, g+12), (176, 116), 7)
        line((176, g-12), (187, g-12), 7)
        line((176, g+12), (187, g+12), 7)
        line((187, g-12), (187, g+12), 6)
        line((191, 28), (191, 100), 5)
    if STAGE >= 3:
        for y in range(12,116,5):
            line((88,y),(88,y+2),10)
        pyxel.text(194, 72, 'SENSE x > 88', 10)
        if f and f.sensed:
            x,y=f.sensed
            pyxel.circb(*point(x,y),8,10)
            line((x,y),(176,g),5)
        if f:
            direction=f.direction
            line((193,g),(193,g+direction*8),10)
            line((193,g+direction*8),(191,g+direction*5),10)
            line((193,g+direction*8),(195,g+direction*5),10)
    if getattr(s,'previous',None):
        for a,b in zip(s.previous.trail,s.previous.trail[1:]):line(a,b,5)
    if f:
        for a,b in zip(f.trail,f.trail[1:]):line(a,b,6)
        pyxel.circ(*point(f.x,f.y),6,10 if f.status=='scored' else 7)
        pyxel.circb(*point(f.x,f.y),6,0)
    else:
        for k,y in enumerate(ORIGINS):
            if STAGE==1 and k!=1:continue
            pyxel.circb(*point(24,y),9,10 if k==s.origin else 5)
        x,y=24,ORIGINS[s.origin]
        a=math.radians(s.angle)
        line((x,y),(x+20*math.cos(a),y+20*math.sin(a)),10)
        pyxel.circ(*point(x,y),6,10 if s.charging else 7)
    lines=[('MODE',s.mode.upper(),10),('ANGLE',f'{s.angle:+.1f} deg',7),
           ('POWER',f'{s.power:.2f}',10),('ATTEMPTS',str(s.attempts),7),
           ('PHYSICS TICK',str(f.ticks if f else 0),6)]
    if f:lines += [('BALL',f.status.upper(),7)]
    if STAGE>=2:lines += [('GOAL Y',f'{g:.3f}',7),('BOUNCES',str(f.bounces if f else 0),6)]
    if STAGE>=3:lines += [('DELAY','18 ticks = 0.15 s',10),('SENSE',str(bool(f and f.sensing)),7),
                          ('DIRECTION',str(f.direction if f else LEVEL.direction),10)]
    if STAGE>=4:lines += [('GOAL SPEED',f'{f.gv if f else 0:.3f} px/s',6),
                          ('MEMORY',str(f.memory_ticks if f else 0),6)]
    for n,(label,value,color) in enumerate(lines):
        pyxel.text(410,48+n*16,label,13)
        pyxel.text(497,48+n*16,value,color)
    pyxel.rectb(30,298,346,12,6)
    pyxel.rect(32,300,(s.power-20)/80*342,8,10)
    pyxel.text(12, 322, 'ARROWS: aim / fine adjust   SPACE: press, hold, release   R: reset', 7)
    pyxel.text(12, 337, 'One UI frame = 4 physics ticks. Speed is px/s. No gravity.', 6)
    notes={1:'LAB: charge + straight motion. No walls, goal, selection, or pause.',
           2:'LAB: static original C. Swept collisions + reflection; no sensor / AI.',
           3:'LAB: old-ball observation + policy arrow. Goal action is still disabled.',
           4:'Original C physics. Bottom +28 deg, power 20: try a slow wall return.',
           5:'Original C + title/pause. P: pause  Z: select again. R beats P.'}
    pyxel.text(12,353,notes[STAGE],10)
    pyxel.text(12,369,'SELECT: LEFT/RIGHT then SPACE; release SPACE before starting charge.' if STAGE>=2
               else 'After reset/retry: release SPACE first. Short tap gives power 20.',7)
    pyxel.text(12,385,'WHITE: solid posts/walls  BROWN: block  YELLOW RING: old observed ball' if STAGE>=2
               else 'Hold 21 more UI frames after the new press for maximum power.',13)
    if getattr(controller,'title',False):
        pyxel.rect(50,113,322,110,1);pyxel.rectb(50,113,322,110,10)
        pyxel.text(72,132,'FLEEING GOAL / ORIGINAL CASE C',10)
        pyxel.text(72,152,'Can a slow ball beat a fleeing goal?',7)
        pyxel.text(72,173,'The goal sees only an old ball position.',6)
        pyxel.text(72,200,'SPACE: start, then release once',10)
    elif getattr(s,'paused',False):
        pyxel.rect(70,150,292,42,1);pyxel.rectb(70,150,292,42,10)
        pyxel.text(88,165,'PAUSED: physics + AI + history frozen',10)
    elif s.mode in ('result','won'):
        pyxel.rect(42,154,334,43,1);pyxel.rectb(42,154,334,43,10)
        pyxel.text(56,165,'SCORED! / R: reset same case' if s.mode=='won' else 'RESULT: '+f.status.upper(),10)
        if s.mode!='won':pyxel.text(56,182,'SPACE: retry aim / Z: select position' if STAGE>=2 else 'SPACE: retry aim',7)


def demo_keys(controller, frame_number):
    """技術QA用の一定入力。AIへの未来情報ではなくプレイヤーの操作列。"""
    # 05は最初の2フレームでタイトルを抜ける。他は直接開始。
    if STAGE==5:
        if frame_number==1:return ('SPACE',)
        frame_number-=2
    if STAGE==1:
        if 2<=frame_number<=23:return ('SPACE',)
        return ()
    # 下を選択し、28度を14入力フレームで作り、最弱で放す。
    if frame_number==2:return ('RIGHT',)
    if frame_number==4:return ('SPACE',)
    if 6<=frame_number<=19:return ('RIGHT',)
    if frame_number==21:return ('SPACE',)
    return ()


def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None):
    from pathlib import Path
    import pyxel  # モデルのimportと標準ライブラリテストには不要。
    controller=Controller()
    codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    frame_number=0
    old_held=set()
    changes=[]
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    report=str(Path(report).resolve()) if report else None
    def update():
        nonlocal frame_number,old_held
        frame_number+=1
        if headless:
            if replay is not None:
                held=set(replay[frame_number-1]) if frame_number<=len(replay) else set()
            else:
                held=set(demo_keys(controller,frame_number) if demo else ())
            pressed=held-old_held
            old_held=held
        else:
            held={name for name,code in codes.items() if pyxel.btn(code)}
            pressed={name for name,code in codes.items() if pyxel.btnp(code)}
        old_mode=controller.state.mode
        controller.tick(pressed,held)
        if pressed or controller.state.mode!=old_mode:
            changes.append({'frame':frame_number,'pressed':sorted(pressed),'held':sorted(held),
                            'mode':controller.state.mode,'hash':state_hash(controller.state)})
    def draw():
        draw_board(pyxel,controller)
        if headless and frame_number>=frames:
            if screenshot:pyxel.screenshot(screenshot,scale=1)
            if report:
                Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic controller inputs; silent; not human play',
                    'stage':STAGE,'requested_frames':frames,'actual_frames':frame_number,'demo':demo,
                    'changes':changes,'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(630,406,title=f'Fleeing Goal / Step {STAGE}',fps=30,headless=headless)
    pyxel.run(update,draw)


if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless',action='store_true')
    parser.add_argument('--frames',type=int,default=180)
    parser.add_argument('--demo',action='store_true')
    parser.add_argument('--replay-json',help='UTF-8 JSON list of held-key lists, one per UI frame')
    parser.add_argument('--screenshot',help='PNG output; headless only; relative to current directory')
    parser.add_argument('--report',help='UTF-8 JSON output; headless only')
    args=parser.parse_args()
    if not 12<=args.frames<=100000:parser.error('Use 12 through 100000 frames')
    if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):
        parser.error('QA input/output options require --headless')
    if args.demo and args.replay_json:parser.error('Choose demo or replay, not both')
    replay=None
    if args.replay_json:
        try:replay=json.loads(Path(args.replay_json).read_text(encoding='utf-8'))
        except (OSError,ValueError) as error:parser.error(str(error))
        if not isinstance(replay,list) or any(not isinstance(keys,list) or any(not isinstance(k,str) or k not in CONTROLS for k in keys) for keys in replay):
            parser.error('Replay must be a list of lists of supported key names')
    main(args.headless,args.frames,args.demo,replay,args.screenshot,args.report)
STEP 04 / 05

決めた向きを加速度付きの行動へ変える

観測・方針・行動、慣性(すぐには止まれない動き)、動く支柱との相対的な当たり判定。

act_goal(ゴールを動かす関数)を加えて、元のゲームの面Cの物理の仕組みを完成させます。目標の速度は「向き×22px/s」です。加速度は90px/s²なので、1物理tickで変えられる速度は最大0.75px/sです。球が反応帯を出ると、ゴールは最後の向きを30tickのあいだ保ち、そのあとで減速します。支柱との衝突は、球の動きから枠の動きを引いた相対座標(枠から見た球の動き)で判定します。

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

04_flee.py / 注目する部分
    dv = max(-90 * DT, min(90 * DT, desired - f.gv))
    f.gv += dv
    f.g += f.gv * DT

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_flee.py
macOS / Linux
.venv/bin/python 04_flee.py
画面で見ること下の場所から+28度・力20で射つと、一度はね返って、物理tick422で scored(得点)になります。最後のゴールの高さは約45.2566です。同じ角度で力100にすると、tick169で post(支柱に当たる)になります。上の場所から+32度・力100でも成功します。遅い球は、ゴールの枠に逃げる時間を与えます。そのため、球を速くすればいつもうまくいくとは限りません。
  • 観測・判断・行動が、別々の関数になっている
  • 加速度の単位と、1tickでの速度の変化を計算できる
  • 30tickの惰性(向きを保って動き続けること)を、減らす前の値で判定できる
  • 元のゲームの面Cで、弱い球が成功する理由を説明できる
つまずきやすいところ毎tick、いきなり目標の速度にすると、加速にかかる時間がなくなってしまいます。memory_ticks(向きを覚えている残りのtick数)を減らしたあとの値だけで動くかどうかを決めると、惰性が29tickになってしまいます。動いたあとの枠だけで衝突を調べると、途中での接触を見落とします。
少し変えてみよう

test_29 で、memory_ticks=30 から30回は速度22、31回目で21.25になる理由を追ってみましょう。弱い球と速い球を射ち分けて、黄色い過去の球と、ゴールの動きの時間のずれを説明してみましょう。

04_flee.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(463行)
04_flee.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.
# 教材画面はASCIIとオリジナル図形のみ。Noto字形データは使いません。

"""やわらかいゴール・教材4/5。説明と簡略化の範囲はLESSON.md。"""
from dataclasses import dataclass, field, asdict
import math
import json
import hashlib

# 位置px、速度px/s、角度degree。入力30Hz、物理120Hz。重力は加えない。
DT = 1 / 120
EPS = 1e-9
RADIUS = 3.0
ORIGINS = (32., 64., 96.)

@dataclass(frozen=True)
class Level:
    title: str
    goal: float
    direction: int
    speed: float
    blocks: tuple = ()

# 原作Cを一つ選ぶ。上向きに逃げ、下側に小さな衝立がある。
LEVEL = Level('待ってから戻る', 88., -1, 22., ((136., 82., 148., 94.),))

def ray_box(p,d,b):
    lo=0.;hi=1.
    for axis in (0,1):
        if abs(d[axis])<EPS:
            if not b[axis]-EPS<=p[axis]<=b[axis+2]+EPS:return None
        else:
            a=(b[axis]-p[axis])/d[axis];z=(b[axis+2]-p[axis])/d[axis]
            lo=max(lo,min(a,z));hi=min(hi,max(a,z))
            if lo>hi+EPS:return None
    return max(0.,lo) if lo<=1+EPS and hi>=0 else None

def ray_circle(p,d,c,r=RADIUS):
    q=(p[0]-c[0],p[1]-c[1]);a=d[0]*d[0]+d[1]*d[1]
    cc=q[0]*q[0]+q[1]*q[1]-r*r
    if cc<=EPS:return 0.
    if a<EPS:return None
    b=2*(q[0]*d[0]+q[1]*d[1]);disc=b*b-4*a*cc
    if disc<0:return None
    t=(-b-math.sqrt(max(0,disc)))/(2*a)
    return t if -EPS<=t<=1+EPS else None

def circle_box(p,d,b):
    x,y,z,w=b
    hits=[ray_box(p,d,(x-RADIUS,y,z+RADIUS,w)),ray_box(p,d,(x,y-RADIUS,z,w+RADIUS))]
    hits += [ray_circle(p,d,c) for c in ((x,y),(x,w),(z,y),(z,w))]
    return min((h for h in hits if h is not None),default=None)

def gate_hit(p,d,g,dg):
    """Moving posts, treated as capsules. y is transformed into gate-relative space."""
    q=(p[0],p[1]-g);v=(d[0],d[1]-dg)
    hits=[ray_box(q,v,(173.,-1000.,179.,-12.)),ray_box(q,v,(173.,12.,179.,1000.)),
          ray_circle(q,v,(176.,-12.)),ray_circle(q,v,(176.,12.))]
    return min((h for h in hits if h is not None),default=None)

@dataclass
class Flight:
    x:float
    y:float
    vx:float
    vy:float
    g:float
    direction:int
    gv:float=0.
    status:str='flying'
    ticks:int=0
    bounces:int=0
    trail:list=field(default_factory=list)
    goal_trail:list=field(default_factory=list)
    history:list=field(default_factory=list)
    sensed:tuple|None=None
    memory_ticks:int=0
    sensing:bool=False
    first_contact_goal:float|None=None
    moving:bool=True
    record:bool=True
    @property
    def age(self):return self.ticks*DT
    @property
    def pos(self):return self.x,self.y

def launch(level,origin,angle,power,moving=True,record=True):
    if origin not in (0,1,2) or not all(math.isfinite(v) for v in (angle,power)):raise ValueError('invalid launch')
    if not -75<=angle<=75 or not 20<=power<=100:raise ValueError('launch outside UI range')
    speed=50+(power-20)*70/80;a=math.radians(angle)
    f=Flight(24.,ORIGINS[origin],speed*math.cos(a),speed*math.sin(a),level.goal,level.direction,moving=moving,record=record)
    f.history=[(24.,ORIGINS[origin])]*19
    if record:f.trail=[f.pos];f.goal_trail=[f.g]
    return f

@dataclass(frozen=True)
class Observation:
    active: bool
    delayed: tuple | None
    goal_y: float


def observe(f):
    """今のxは反応帯への入場だけに使う。高さの情報は履歴だけ。"""
    active = f.x > 88
    return Observation(active, f.history[0] if active else None, f.g)


def policy(observation, previous_direction):
    """観測と以前の向きだけで判断。速度・現在の球y・入力は受け取らない。"""
    if not observation.active:
        return previous_direction
    diff = observation.delayed[1] - observation.goal_y
    if abs(diff) > 2:
        return -1 if diff > 0 else 1
    return previous_direction


def remember(f, observation):
    # 減算前の値を保存。30だったら、その後30tickは惰性の目標速度を保つ。
    coasting = f.memory_ticks > 0
    f.sensing = observation.active
    if observation.active:
        f.sensed = observation.delayed
        f.direction = policy(observation, f.direction)
        f.memory_ticks = 30
    elif f.memory_ticks > 0:
        f.memory_ticks -= 1
    return coasting


def act_goal(level, f, observation, coasting):
    """意図を加速度制限付きの運動へ変換。レール端で速度も0にする。"""
    desired = f.direction * level.speed if f.moving and (observation.active or coasting) else 0.
    oldg = f.g
    dv = max(-90 * DT, min(90 * DT, desired - f.gv))
    f.gv += dv
    f.g += f.gv * DT
    if f.g <= 28:
        f.g, f.gv = 28., 0.
    if f.g >= 100:
        f.g, f.gv = 100., 0.
    return oldg, f.g - oldg


def physics(level, f):
    if f.status != 'flying':
        return f
    observation = observe(f)
    coasting = remember(f, observation)
    oldg, dg = act_goal(level, f, observation, coasting)
    remain=1.;elapsed=0.
    for _ in range(4):
        d=(f.vx*DT*remain,f.vy*DT*remain);p=f.pos
        hits=[]
        for i,b in enumerate(level.blocks):
            t=circle_box(p,d,b)
            if t is not None:hits.append((t,0,'block'))
        h=gate_hit(p,d,oldg+dg*elapsed,dg*remain)
        if h is not None:hits.append((h,0,'post'))
        for boundary,axis,sense,name in ((15.,1,-1,'top'),(113.,1,1,'bottom'),(11.,0,-1,'left'),(179.,0,1,'goal')):
            if d[axis]*sense>EPS:
                t=(boundary-p[axis])/d[axis]
                if -EPS<=t<=1+EPS:hits.append((max(0.,t),1 if name=='goal' else 0,name))
        if not hits:
            f.x+=d[0];f.y+=d[1];break
        t,_,reason=min(hits,key=lambda h:(round(h[0],10),h[1],h[2]))
        f.x+=d[0]*t;f.y+=d[1]*t;elapsed+=remain*t;remain*=1-t
        if f.record:f.trail.append(f.pos)
        if reason in ('top','bottom'):
            f.vy=-f.vy;f.bounces+=1;f.y+=.01 if reason=='top' else -.01
            if remain<EPS:break
        else:
            f.status='scored' if reason=='goal' else reason
            f.first_contact_goal=oldg+dg*elapsed;f.g=f.first_contact_goal
            break
    f.ticks+=1
    f.history.append(f.pos);f.history.pop(0)
    if f.record and (f.ticks%4==0 or f.status!='flying'):
        f.trail.append(f.pos);f.goal_trail.append(f.g)
    if f.status=='flying' and f.ticks>=720:f.status='timeout'
    return f

def simulate(level,origin,angle,power,moving=True,record=False):
    f=launch(level,origin,angle,power,moving,record)
    while f.status=='flying':physics(level,f)
    return f

@dataclass(frozen=True)
class Input:
    pressed:tuple=()
    held:tuple=()
@dataclass
class Game:
    level:int=2
    origin:int=1
    angle:float=0.
    mode:str='select'
    paused:bool=False
    power:float=20.
    charging:bool=False
    require_release:bool=True
    attempts:int=0
    flight:Flight|None=None
    previous:Flight|None=None
    moving:bool=True

def fresh(level=2,moving=True):return Game(level=level,moving=moving)
def frame(s,i=Input()):
    p=set(i.pressed);h=set(i.held)
    if 'R' in p:return fresh(s.level,s.moving)
    if s.require_release:
        if 'SPACE' not in h:s.require_release=False
        space=False
    else:space='SPACE' in p
    if s.mode=='won':
        return s
    if s.mode=='select':
        if 'LEFT' in p:s.origin=(s.origin-1)%3
        if 'RIGHT' in p:s.origin=(s.origin+1)%3
        if space:s.mode='aim';s.require_release=True
        return s
    if s.mode=='flying':
        for _ in range(4):physics(LEVEL,s.flight)
        if s.flight.status!='flying':s.mode='won' if s.flight.status=='scored' else 'result'
        return s
    if 'Z' in p:
        s.previous=s.flight;s.flight=None;s.mode='select';s.charging=False;s.power=20.;s.require_release=True
        return s
    if s.mode=='result':
        if space:
            s.previous=s.flight;s.flight=None;s.mode='aim';s.power=20.;s.require_release=True
        return s
    s.angle=max(-75.,min(75.,s.angle+2*('RIGHT'in h)-2*('LEFT'in h)+.4*('DOWN'in h)-.4*('UP'in h)))
    if space:
        s.charging=True;s.power=20.
        return s
    if s.charging:
        if 'SPACE' in h:s.power=min(100.,s.power+80/21)
        else:
            s.flight=launch(LEVEL,s.origin,s.angle,s.power,s.moving)
            s.attempts+=1;s.charging=False;s.mode='flying'
    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 = 4
STAGE_TITLE = 'INTENT + ACCELERATION / CASE C'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'R', 'Z')

class Controller:
    def __init__(self):
        self.state = fresh()

    def tick(self, pressed=(), held=()):
        pressed = tuple(k for k in pressed if k in CONTROLS)
        held = tuple(k for k in held if k in CONTROLS)
        self.state = frame(self.state, Input(pressed, held))

    def snapshot(self):
        return {'state': asdict(self.state), 'hash': state_hash(self.state)}


def draw_board(pyxel, controller):
    s = controller.state
    f = s.flight
    g = getattr(f, 'g', LEVEL.goal)
    pyxel.cls(1)
    pyxel.text(12, 10, f'FLEEING GOAL 016 / STEP {STAGE}', 10)
    pyxel.text(12, 24, STAGE_TITLE, 7)
    ox, oy, scale = 10, 44, 2
    def point(x, y):
        return ox+x*scale, oy+y*scale
    def line(a, b, color):
        pyxel.line(*point(*a), *point(*b), color)
    pyxel.rect(26, 68, 352, 208, 0)
    for x in range(16, 184, 12):
        line((x, 12), (x, 116), 1)
    if STAGE >= 2:
        line((8, 12), (176, 12), 7)
        line((8, 116), (176, 116), 7)
        line((8, 12), (8, 116), 13)
        for x,y,z,w in LEVEL.blocks:
            pyxel.rect(*point(x,y), (z-x)*2, (w-y)*2, 4)
            pyxel.rectb(*point(x,y), (z-x)*2, (w-y)*2, 9)
        line((176, 12), (176, g-12), 7)
        line((176, g+12), (176, 116), 7)
        line((176, g-12), (187, g-12), 7)
        line((176, g+12), (187, g+12), 7)
        line((187, g-12), (187, g+12), 6)
        line((191, 28), (191, 100), 5)
    if STAGE >= 3:
        for y in range(12,116,5):
            line((88,y),(88,y+2),10)
        pyxel.text(194, 72, 'SENSE x > 88', 10)
        if f and f.sensed:
            x,y=f.sensed
            pyxel.circb(*point(x,y),8,10)
            line((x,y),(176,g),5)
        if f:
            direction=f.direction
            line((193,g),(193,g+direction*8),10)
            line((193,g+direction*8),(191,g+direction*5),10)
            line((193,g+direction*8),(195,g+direction*5),10)
    if getattr(s,'previous',None):
        for a,b in zip(s.previous.trail,s.previous.trail[1:]):line(a,b,5)
    if f:
        for a,b in zip(f.trail,f.trail[1:]):line(a,b,6)
        pyxel.circ(*point(f.x,f.y),6,10 if f.status=='scored' else 7)
        pyxel.circb(*point(f.x,f.y),6,0)
    else:
        for k,y in enumerate(ORIGINS):
            if STAGE==1 and k!=1:continue
            pyxel.circb(*point(24,y),9,10 if k==s.origin else 5)
        x,y=24,ORIGINS[s.origin]
        a=math.radians(s.angle)
        line((x,y),(x+20*math.cos(a),y+20*math.sin(a)),10)
        pyxel.circ(*point(x,y),6,10 if s.charging else 7)
    lines=[('MODE',s.mode.upper(),10),('ANGLE',f'{s.angle:+.1f} deg',7),
           ('POWER',f'{s.power:.2f}',10),('ATTEMPTS',str(s.attempts),7),
           ('PHYSICS TICK',str(f.ticks if f else 0),6)]
    if f:lines += [('BALL',f.status.upper(),7)]
    if STAGE>=2:lines += [('GOAL Y',f'{g:.3f}',7),('BOUNCES',str(f.bounces if f else 0),6)]
    if STAGE>=3:lines += [('DELAY','18 ticks = 0.15 s',10),('SENSE',str(bool(f and f.sensing)),7),
                          ('DIRECTION',str(f.direction if f else LEVEL.direction),10)]
    if STAGE>=4:lines += [('GOAL SPEED',f'{f.gv if f else 0:.3f} px/s',6),
                          ('MEMORY',str(f.memory_ticks if f else 0),6)]
    for n,(label,value,color) in enumerate(lines):
        pyxel.text(410,48+n*16,label,13)
        pyxel.text(497,48+n*16,value,color)
    pyxel.rectb(30,298,346,12,6)
    pyxel.rect(32,300,(s.power-20)/80*342,8,10)
    pyxel.text(12, 322, 'ARROWS: aim / fine adjust   SPACE: press, hold, release   R: reset', 7)
    pyxel.text(12, 337, 'One UI frame = 4 physics ticks. Speed is px/s. No gravity.', 6)
    notes={1:'LAB: charge + straight motion. No walls, goal, selection, or pause.',
           2:'LAB: static original C. Swept collisions + reflection; no sensor / AI.',
           3:'LAB: old-ball observation + policy arrow. Goal action is still disabled.',
           4:'Original C physics. Bottom +28 deg, power 20: try a slow wall return.',
           5:'Original C + title/pause. P: pause  Z: select again. R beats P.'}
    pyxel.text(12,353,notes[STAGE],10)
    pyxel.text(12,369,'SELECT: LEFT/RIGHT then SPACE; release SPACE before starting charge.' if STAGE>=2
               else 'After reset/retry: release SPACE first. Short tap gives power 20.',7)
    pyxel.text(12,385,'WHITE: solid posts/walls  BROWN: block  YELLOW RING: old observed ball' if STAGE>=2
               else 'Hold 21 more UI frames after the new press for maximum power.',13)
    if getattr(controller,'title',False):
        pyxel.rect(50,113,322,110,1);pyxel.rectb(50,113,322,110,10)
        pyxel.text(72,132,'FLEEING GOAL / ORIGINAL CASE C',10)
        pyxel.text(72,152,'Can a slow ball beat a fleeing goal?',7)
        pyxel.text(72,173,'The goal sees only an old ball position.',6)
        pyxel.text(72,200,'SPACE: start, then release once',10)
    elif getattr(s,'paused',False):
        pyxel.rect(70,150,292,42,1);pyxel.rectb(70,150,292,42,10)
        pyxel.text(88,165,'PAUSED: physics + AI + history frozen',10)
    elif s.mode in ('result','won'):
        pyxel.rect(42,154,334,43,1);pyxel.rectb(42,154,334,43,10)
        pyxel.text(56,165,'SCORED! / R: reset same case' if s.mode=='won' else 'RESULT: '+f.status.upper(),10)
        if s.mode!='won':pyxel.text(56,182,'SPACE: retry aim / Z: select position' if STAGE>=2 else 'SPACE: retry aim',7)


def demo_keys(controller, frame_number):
    """技術QA用の一定入力。AIへの未来情報ではなくプレイヤーの操作列。"""
    # 05は最初の2フレームでタイトルを抜ける。他は直接開始。
    if STAGE==5:
        if frame_number==1:return ('SPACE',)
        frame_number-=2
    if STAGE==1:
        if 2<=frame_number<=23:return ('SPACE',)
        return ()
    # 下を選択し、28度を14入力フレームで作り、最弱で放す。
    if frame_number==2:return ('RIGHT',)
    if frame_number==4:return ('SPACE',)
    if 6<=frame_number<=19:return ('RIGHT',)
    if frame_number==21:return ('SPACE',)
    return ()


def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None):
    from pathlib import Path
    import pyxel  # モデルのimportと標準ライブラリテストには不要。
    controller=Controller()
    codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    frame_number=0
    old_held=set()
    changes=[]
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    report=str(Path(report).resolve()) if report else None
    def update():
        nonlocal frame_number,old_held
        frame_number+=1
        if headless:
            if replay is not None:
                held=set(replay[frame_number-1]) if frame_number<=len(replay) else set()
            else:
                held=set(demo_keys(controller,frame_number) if demo else ())
            pressed=held-old_held
            old_held=held
        else:
            held={name for name,code in codes.items() if pyxel.btn(code)}
            pressed={name for name,code in codes.items() if pyxel.btnp(code)}
        old_mode=controller.state.mode
        controller.tick(pressed,held)
        if pressed or controller.state.mode!=old_mode:
            changes.append({'frame':frame_number,'pressed':sorted(pressed),'held':sorted(held),
                            'mode':controller.state.mode,'hash':state_hash(controller.state)})
    def draw():
        draw_board(pyxel,controller)
        if headless and frame_number>=frames:
            if screenshot:pyxel.screenshot(screenshot,scale=1)
            if report:
                Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic controller inputs; silent; not human play',
                    'stage':STAGE,'requested_frames':frames,'actual_frames':frame_number,'demo':demo,
                    'changes':changes,'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(630,406,title=f'Fleeing Goal / Step {STAGE}',fps=30,headless=headless)
    pyxel.run(update,draw)


if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless',action='store_true')
    parser.add_argument('--frames',type=int,default=180)
    parser.add_argument('--demo',action='store_true')
    parser.add_argument('--replay-json',help='UTF-8 JSON list of held-key lists, one per UI frame')
    parser.add_argument('--screenshot',help='PNG output; headless only; relative to current directory')
    parser.add_argument('--report',help='UTF-8 JSON output; headless only')
    args=parser.parse_args()
    if not 12<=args.frames<=100000:parser.error('Use 12 through 100000 frames')
    if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):
        parser.error('QA input/output options require --headless')
    if args.demo and args.replay_json:parser.error('Choose demo or replay, not both')
    replay=None
    if args.replay_json:
        try:replay=json.loads(Path(args.replay_json).read_text(encoding='utf-8'))
        except (OSError,ValueError) as error:parser.error(str(error))
        if not isinstance(replay,list) or any(not isinstance(keys,list) or any(not isinstance(k,str) or k not in CONTROLS for k in keys) for keys in replay):
            parser.error('Replay must be a list of lists of supported key names')
    main(args.headless,args.frames,args.demo,replay,args.screenshot,args.report)
STEP 05 / 05

停止と場面をまたぐSPACEを安全に扱う

タイトル画面、入力の優先順位、キーを離すまでの待ち、すべての時計の停止。

元のゲームにある、タイトル画面からの開始と、P での一時停止を加えます。タイトル(表紙)を抜けたときの SPACE で、場所選びや溜めまで続けて実行しないように、一度 SPACE を離すまで待ちます。本編で入力を調べる優先順は、R→P→停止中→SPACE を離すまでの待ち→成功→場所選び→飛行中→Z→結果→照準 です。P を押すと、溜めていた力は取り消されます。再開したフレームでも、物理は動かしません。

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

05_controller.py / 注目する部分
    if 'P' in p:
        s.paused=not s.paused
        # Pause cancels a partially held shot. Resume cannot accidentally release it.
        s.charging=False;s.power=20.;s.require_release=True
        return s

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_controller.py
macOS / Linux
.venv/bin/python 05_controller.py
画面で見ることタイトルで SPACE を押し続けても、場所選びの画面で止まります。P を押すと、球・AI・履歴・物理tickのすべてが止まります。再開したときに、勝手に発射することはありません。R と P を同時に押すと、リセットになります。照準の画面で Z と SPACE を同時に押すと、場所選びに戻ります。飛んでいる間の Z は無視されます。
  • R が P より先、P が「停止中かどうか」の判定より先だと分かる
  • 停止中は physics を呼ばず、履歴も増やさない
  • 「SPACE だけを離すまで待つ」と「すべてのキーを離すまで待つ」を混同しない
  • 元のゲームの面Cとどこまで同じか、何を省いたかを説明できる
つまずきやすいところ描画だけを止めて physics を呼び続けると、見えない間に球がぶつかってしまいます。すべてのキーを離すまで待つ仕組みに置きかえるのも、元のゲームとはちがいます。このゲームで離すのを待つのは SPACE だけです。照準の方向キーまで、まとめて止めることはしません。
少し変えてみよう

SPACE で開始→押し続ける→離す→SPACE で場所を決定→押し続ける→離す→新しく SPACE を押す→P の順に試してみましょう。P で溜めが消えること、再開してからキーを離しても球が出ないことを、test_33〜test_36 と照らし合わせて確かめます。

05_controller.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(475行)
05_controller.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.
# 教材画面はASCIIとオリジナル図形のみ。Noto字形データは使いません。

"""やわらかいゴール・教材5/5。説明と簡略化の範囲はLESSON.md。"""
from dataclasses import dataclass, field, asdict
import math
import json
import hashlib

# 位置px、速度px/s、角度degree。入力30Hz、物理120Hz。重力は加えない。
DT = 1 / 120
EPS = 1e-9
RADIUS = 3.0
ORIGINS = (32., 64., 96.)

@dataclass(frozen=True)
class Level:
    title: str
    goal: float
    direction: int
    speed: float
    blocks: tuple = ()

# 原作Cを一つ選ぶ。上向きに逃げ、下側に小さな衝立がある。
LEVEL = Level('待ってから戻る', 88., -1, 22., ((136., 82., 148., 94.),))

def ray_box(p,d,b):
    lo=0.;hi=1.
    for axis in (0,1):
        if abs(d[axis])<EPS:
            if not b[axis]-EPS<=p[axis]<=b[axis+2]+EPS:return None
        else:
            a=(b[axis]-p[axis])/d[axis];z=(b[axis+2]-p[axis])/d[axis]
            lo=max(lo,min(a,z));hi=min(hi,max(a,z))
            if lo>hi+EPS:return None
    return max(0.,lo) if lo<=1+EPS and hi>=0 else None

def ray_circle(p,d,c,r=RADIUS):
    q=(p[0]-c[0],p[1]-c[1]);a=d[0]*d[0]+d[1]*d[1]
    cc=q[0]*q[0]+q[1]*q[1]-r*r
    if cc<=EPS:return 0.
    if a<EPS:return None
    b=2*(q[0]*d[0]+q[1]*d[1]);disc=b*b-4*a*cc
    if disc<0:return None
    t=(-b-math.sqrt(max(0,disc)))/(2*a)
    return t if -EPS<=t<=1+EPS else None

def circle_box(p,d,b):
    x,y,z,w=b
    hits=[ray_box(p,d,(x-RADIUS,y,z+RADIUS,w)),ray_box(p,d,(x,y-RADIUS,z,w+RADIUS))]
    hits += [ray_circle(p,d,c) for c in ((x,y),(x,w),(z,y),(z,w))]
    return min((h for h in hits if h is not None),default=None)

def gate_hit(p,d,g,dg):
    """Moving posts, treated as capsules. y is transformed into gate-relative space."""
    q=(p[0],p[1]-g);v=(d[0],d[1]-dg)
    hits=[ray_box(q,v,(173.,-1000.,179.,-12.)),ray_box(q,v,(173.,12.,179.,1000.)),
          ray_circle(q,v,(176.,-12.)),ray_circle(q,v,(176.,12.))]
    return min((h for h in hits if h is not None),default=None)

@dataclass
class Flight:
    x:float
    y:float
    vx:float
    vy:float
    g:float
    direction:int
    gv:float=0.
    status:str='flying'
    ticks:int=0
    bounces:int=0
    trail:list=field(default_factory=list)
    goal_trail:list=field(default_factory=list)
    history:list=field(default_factory=list)
    sensed:tuple|None=None
    memory_ticks:int=0
    sensing:bool=False
    first_contact_goal:float|None=None
    moving:bool=True
    record:bool=True
    @property
    def age(self):return self.ticks*DT
    @property
    def pos(self):return self.x,self.y

def launch(level,origin,angle,power,moving=True,record=True):
    if origin not in (0,1,2) or not all(math.isfinite(v) for v in (angle,power)):raise ValueError('invalid launch')
    if not -75<=angle<=75 or not 20<=power<=100:raise ValueError('launch outside UI range')
    speed=50+(power-20)*70/80;a=math.radians(angle)
    f=Flight(24.,ORIGINS[origin],speed*math.cos(a),speed*math.sin(a),level.goal,level.direction,moving=moving,record=record)
    f.history=[(24.,ORIGINS[origin])]*19
    if record:f.trail=[f.pos];f.goal_trail=[f.g]
    return f

@dataclass(frozen=True)
class Observation:
    active: bool
    delayed: tuple | None
    goal_y: float


def observe(f):
    """今のxは反応帯への入場だけに使う。高さの情報は履歴だけ。"""
    active = f.x > 88
    return Observation(active, f.history[0] if active else None, f.g)


def policy(observation, previous_direction):
    """観測と以前の向きだけで判断。速度・現在の球y・入力は受け取らない。"""
    if not observation.active:
        return previous_direction
    diff = observation.delayed[1] - observation.goal_y
    if abs(diff) > 2:
        return -1 if diff > 0 else 1
    return previous_direction


def remember(f, observation):
    # 減算前の値を保存。30だったら、その後30tickは惰性の目標速度を保つ。
    coasting = f.memory_ticks > 0
    f.sensing = observation.active
    if observation.active:
        f.sensed = observation.delayed
        f.direction = policy(observation, f.direction)
        f.memory_ticks = 30
    elif f.memory_ticks > 0:
        f.memory_ticks -= 1
    return coasting


def act_goal(level, f, observation, coasting):
    """意図を加速度制限付きの運動へ変換。レール端で速度も0にする。"""
    desired = f.direction * level.speed if f.moving and (observation.active or coasting) else 0.
    oldg = f.g
    dv = max(-90 * DT, min(90 * DT, desired - f.gv))
    f.gv += dv
    f.g += f.gv * DT
    if f.g <= 28:
        f.g, f.gv = 28., 0.
    if f.g >= 100:
        f.g, f.gv = 100., 0.
    return oldg, f.g - oldg


def physics(level, f):
    if f.status != 'flying':
        return f
    observation = observe(f)
    coasting = remember(f, observation)
    oldg, dg = act_goal(level, f, observation, coasting)
    remain=1.;elapsed=0.
    for _ in range(4):
        d=(f.vx*DT*remain,f.vy*DT*remain);p=f.pos
        hits=[]
        for i,b in enumerate(level.blocks):
            t=circle_box(p,d,b)
            if t is not None:hits.append((t,0,'block'))
        h=gate_hit(p,d,oldg+dg*elapsed,dg*remain)
        if h is not None:hits.append((h,0,'post'))
        for boundary,axis,sense,name in ((15.,1,-1,'top'),(113.,1,1,'bottom'),(11.,0,-1,'left'),(179.,0,1,'goal')):
            if d[axis]*sense>EPS:
                t=(boundary-p[axis])/d[axis]
                if -EPS<=t<=1+EPS:hits.append((max(0.,t),1 if name=='goal' else 0,name))
        if not hits:
            f.x+=d[0];f.y+=d[1];break
        t,_,reason=min(hits,key=lambda h:(round(h[0],10),h[1],h[2]))
        f.x+=d[0]*t;f.y+=d[1]*t;elapsed+=remain*t;remain*=1-t
        if f.record:f.trail.append(f.pos)
        if reason in ('top','bottom'):
            f.vy=-f.vy;f.bounces+=1;f.y+=.01 if reason=='top' else -.01
            if remain<EPS:break
        else:
            f.status='scored' if reason=='goal' else reason
            f.first_contact_goal=oldg+dg*elapsed;f.g=f.first_contact_goal
            break
    f.ticks+=1
    f.history.append(f.pos);f.history.pop(0)
    if f.record and (f.ticks%4==0 or f.status!='flying'):
        f.trail.append(f.pos);f.goal_trail.append(f.g)
    if f.status=='flying' and f.ticks>=720:f.status='timeout'
    return f

def simulate(level,origin,angle,power,moving=True,record=False):
    f=launch(level,origin,angle,power,moving,record)
    while f.status=='flying':physics(level,f)
    return f

@dataclass(frozen=True)
class Input:
    pressed:tuple=()
    held:tuple=()
@dataclass
class Game:
    level:int=2
    origin:int=1
    angle:float=0.
    mode:str='select'
    paused:bool=False
    power:float=20.
    charging:bool=False
    require_release:bool=True
    attempts:int=0
    flight:Flight|None=None
    previous:Flight|None=None
    moving:bool=True

def fresh(level=2,moving=True):return Game(level=level,moving=moving)
def frame(s,i=Input()):
    p=set(i.pressed);h=set(i.held)
    if 'R' in p:return fresh(s.level,s.moving)
    if 'P' in p:
        s.paused=not s.paused
        # Pause cancels a partially held shot. Resume cannot accidentally release it.
        s.charging=False;s.power=20.;s.require_release=True
        return s
    if s.paused:return s
    if s.require_release:
        if 'SPACE' not in h:s.require_release=False
        space=False
    else:space='SPACE' in p
    if s.mode=='won':
        return s
    if s.mode=='select':
        if 'LEFT' in p:s.origin=(s.origin-1)%3
        if 'RIGHT' in p:s.origin=(s.origin+1)%3
        if space:s.mode='aim';s.require_release=True
        return s
    if s.mode=='flying':
        for _ in range(4):physics(LEVEL,s.flight)
        if s.flight.status!='flying':s.mode='won' if s.flight.status=='scored' else 'result'
        return s
    if 'Z' in p:
        s.previous=s.flight;s.flight=None;s.mode='select';s.charging=False;s.power=20.;s.require_release=True
        return s
    if s.mode=='result':
        if space:
            s.previous=s.flight;s.flight=None;s.mode='aim';s.power=20.;s.require_release=True
        return s
    s.angle=max(-75.,min(75.,s.angle+2*('RIGHT'in h)-2*('LEFT'in h)+.4*('DOWN'in h)-.4*('UP'in h)))
    if space:
        s.charging=True;s.power=20.
        return s
    if s.charging:
        if 'SPACE' in h:s.power=min(100.,s.power+80/21)
        else:
            s.flight=launch(LEVEL,s.origin,s.angle,s.power,s.moving)
            s.attempts+=1;s.charging=False;s.mode='flying'
    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
STAGE_TITLE = 'TITLE + PAUSE + SAFE INPUT'
CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'R', 'Z', 'P')

class Controller:
    def __init__(self):
        self.state = fresh()
        self.title = True

    def tick(self, pressed=(), held=()):
        pressed = tuple(k for k in pressed if k in CONTROLS)
        held = tuple(k for k in held if k in CONTROLS)
        if self.title:
            if 'SPACE' in pressed:
                self.title = False
                self.state = fresh()
            return
        self.state = frame(self.state, Input(pressed, held))

    def snapshot(self):
        return {'state': asdict(self.state), 'hash': state_hash(self.state), 'title': self.title}


def draw_board(pyxel, controller):
    s = controller.state
    f = s.flight
    g = getattr(f, 'g', LEVEL.goal)
    pyxel.cls(1)
    pyxel.text(12, 10, f'FLEEING GOAL 016 / STEP {STAGE}', 10)
    pyxel.text(12, 24, STAGE_TITLE, 7)
    ox, oy, scale = 10, 44, 2
    def point(x, y):
        return ox+x*scale, oy+y*scale
    def line(a, b, color):
        pyxel.line(*point(*a), *point(*b), color)
    pyxel.rect(26, 68, 352, 208, 0)
    for x in range(16, 184, 12):
        line((x, 12), (x, 116), 1)
    if STAGE >= 2:
        line((8, 12), (176, 12), 7)
        line((8, 116), (176, 116), 7)
        line((8, 12), (8, 116), 13)
        for x,y,z,w in LEVEL.blocks:
            pyxel.rect(*point(x,y), (z-x)*2, (w-y)*2, 4)
            pyxel.rectb(*point(x,y), (z-x)*2, (w-y)*2, 9)
        line((176, 12), (176, g-12), 7)
        line((176, g+12), (176, 116), 7)
        line((176, g-12), (187, g-12), 7)
        line((176, g+12), (187, g+12), 7)
        line((187, g-12), (187, g+12), 6)
        line((191, 28), (191, 100), 5)
    if STAGE >= 3:
        for y in range(12,116,5):
            line((88,y),(88,y+2),10)
        pyxel.text(194, 72, 'SENSE x > 88', 10)
        if f and f.sensed:
            x,y=f.sensed
            pyxel.circb(*point(x,y),8,10)
            line((x,y),(176,g),5)
        if f:
            direction=f.direction
            line((193,g),(193,g+direction*8),10)
            line((193,g+direction*8),(191,g+direction*5),10)
            line((193,g+direction*8),(195,g+direction*5),10)
    if getattr(s,'previous',None):
        for a,b in zip(s.previous.trail,s.previous.trail[1:]):line(a,b,5)
    if f:
        for a,b in zip(f.trail,f.trail[1:]):line(a,b,6)
        pyxel.circ(*point(f.x,f.y),6,10 if f.status=='scored' else 7)
        pyxel.circb(*point(f.x,f.y),6,0)
    else:
        for k,y in enumerate(ORIGINS):
            if STAGE==1 and k!=1:continue
            pyxel.circb(*point(24,y),9,10 if k==s.origin else 5)
        x,y=24,ORIGINS[s.origin]
        a=math.radians(s.angle)
        line((x,y),(x+20*math.cos(a),y+20*math.sin(a)),10)
        pyxel.circ(*point(x,y),6,10 if s.charging else 7)
    lines=[('MODE',s.mode.upper(),10),('ANGLE',f'{s.angle:+.1f} deg',7),
           ('POWER',f'{s.power:.2f}',10),('ATTEMPTS',str(s.attempts),7),
           ('PHYSICS TICK',str(f.ticks if f else 0),6)]
    if f:lines += [('BALL',f.status.upper(),7)]
    if STAGE>=2:lines += [('GOAL Y',f'{g:.3f}',7),('BOUNCES',str(f.bounces if f else 0),6)]
    if STAGE>=3:lines += [('DELAY','18 ticks = 0.15 s',10),('SENSE',str(bool(f and f.sensing)),7),
                          ('DIRECTION',str(f.direction if f else LEVEL.direction),10)]
    if STAGE>=4:lines += [('GOAL SPEED',f'{f.gv if f else 0:.3f} px/s',6),
                          ('MEMORY',str(f.memory_ticks if f else 0),6)]
    for n,(label,value,color) in enumerate(lines):
        pyxel.text(410,48+n*16,label,13)
        pyxel.text(497,48+n*16,value,color)
    pyxel.rectb(30,298,346,12,6)
    pyxel.rect(32,300,(s.power-20)/80*342,8,10)
    pyxel.text(12, 322, 'ARROWS: aim / fine adjust   SPACE: press, hold, release   R: reset', 7)
    pyxel.text(12, 337, 'One UI frame = 4 physics ticks. Speed is px/s. No gravity.', 6)
    notes={1:'LAB: charge + straight motion. No walls, goal, selection, or pause.',
           2:'LAB: static original C. Swept collisions + reflection; no sensor / AI.',
           3:'LAB: old-ball observation + policy arrow. Goal action is still disabled.',
           4:'Original C physics. Bottom +28 deg, power 20: try a slow wall return.',
           5:'Original C + title/pause. P: pause  Z: select again. R beats P.'}
    pyxel.text(12,353,notes[STAGE],10)
    pyxel.text(12,369,'SELECT: LEFT/RIGHT then SPACE; release SPACE before starting charge.' if STAGE>=2
               else 'After reset/retry: release SPACE first. Short tap gives power 20.',7)
    pyxel.text(12,385,'WHITE: solid posts/walls  BROWN: block  YELLOW RING: old observed ball' if STAGE>=2
               else 'Hold 21 more UI frames after the new press for maximum power.',13)
    if getattr(controller,'title',False):
        pyxel.rect(50,113,322,110,1);pyxel.rectb(50,113,322,110,10)
        pyxel.text(72,132,'FLEEING GOAL / ORIGINAL CASE C',10)
        pyxel.text(72,152,'Can a slow ball beat a fleeing goal?',7)
        pyxel.text(72,173,'The goal sees only an old ball position.',6)
        pyxel.text(72,200,'SPACE: start, then release once',10)
    elif getattr(s,'paused',False):
        pyxel.rect(70,150,292,42,1);pyxel.rectb(70,150,292,42,10)
        pyxel.text(88,165,'PAUSED: physics + AI + history frozen',10)
    elif s.mode in ('result','won'):
        pyxel.rect(42,154,334,43,1);pyxel.rectb(42,154,334,43,10)
        pyxel.text(56,165,'SCORED! / R: reset same case' if s.mode=='won' else 'RESULT: '+f.status.upper(),10)
        if s.mode!='won':pyxel.text(56,182,'SPACE: retry aim / Z: select position' if STAGE>=2 else 'SPACE: retry aim',7)


def demo_keys(controller, frame_number):
    """技術QA用の一定入力。AIへの未来情報ではなくプレイヤーの操作列。"""
    # 05は最初の2フレームでタイトルを抜ける。他は直接開始。
    if STAGE==5:
        if frame_number==1:return ('SPACE',)
        frame_number-=2
    if STAGE==1:
        if 2<=frame_number<=23:return ('SPACE',)
        return ()
    # 下を選択し、28度を14入力フレームで作り、最弱で放す。
    if frame_number==2:return ('RIGHT',)
    if frame_number==4:return ('SPACE',)
    if 6<=frame_number<=19:return ('RIGHT',)
    if frame_number==21:return ('SPACE',)
    return ()


def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None):
    from pathlib import Path
    import pyxel  # モデルのimportと標準ライブラリテストには不要。
    controller=Controller()
    codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS}
    frame_number=0
    old_held=set()
    changes=[]
    screenshot=str(Path(screenshot).resolve()) if screenshot else None
    report=str(Path(report).resolve()) if report else None
    def update():
        nonlocal frame_number,old_held
        frame_number+=1
        if headless:
            if replay is not None:
                held=set(replay[frame_number-1]) if frame_number<=len(replay) else set()
            else:
                held=set(demo_keys(controller,frame_number) if demo else ())
            pressed=held-old_held
            old_held=held
        else:
            held={name for name,code in codes.items() if pyxel.btn(code)}
            pressed={name for name,code in codes.items() if pyxel.btnp(code)}
        old_mode=controller.state.mode
        controller.tick(pressed,held)
        if pressed or controller.state.mode!=old_mode:
            changes.append({'frame':frame_number,'pressed':sorted(pressed),'held':sorted(held),
                            'mode':controller.state.mode,'hash':state_hash(controller.state)})
    def draw():
        draw_board(pyxel,controller)
        if headless and frame_number>=frames:
            if screenshot:pyxel.screenshot(screenshot,scale=1)
            if report:
                Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic controller inputs; silent; not human play',
                    'stage':STAGE,'requested_frames':frames,'actual_frames':frame_number,'demo':demo,
                    'changes':changes,'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            pyxel.quit()
    pyxel.init(630,406,title=f'Fleeing Goal / Step {STAGE}',fps=30,headless=headless)
    pyxel.run(update,draw)


if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless',action='store_true')
    parser.add_argument('--frames',type=int,default=180)
    parser.add_argument('--demo',action='store_true')
    parser.add_argument('--replay-json',help='UTF-8 JSON list of held-key lists, one per UI frame')
    parser.add_argument('--screenshot',help='PNG output; headless only; relative to current directory')
    parser.add_argument('--report',help='UTF-8 JSON output; headless only')
    args=parser.parse_args()
    if not 12<=args.frames<=100000:parser.error('Use 12 through 100000 frames')
    if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):
        parser.error('QA input/output options require --headless')
    if args.demo and args.replay_json:parser.error('Choose demo or replay, not both')
    replay=None
    if args.replay_json:
        try:replay=json.loads(Path(args.replay_json).read_text(encoding='utf-8'))
        except (OSError,ValueError) as error:parser.error(str(error))
        if not isinstance(replay,list) or any(not isinstance(keys,list) or any(not isinstance(k,str) or k not in CONTROLS for k in keys) for keys in replay):
            parser.error('Replay must be a list of lists of supported key names')
    main(args.headless,args.frames,args.demo,replay,args.screenshot,args.report)
AFTER STEP 05

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

元のゲームの面C(LEVELS[2])。ゴールの高さ g88、最初の向き-1、最大速度22px/s、加速度90px/s²。衝立は(136,82,148,94)。球の半径3、速さ50〜120、反応帯は x>88、観測の遅れ18/120秒、向きを覚える時間30tick、開口の半分の幅12、ゴールが動けるレールの範囲28〜100、6秒で球を回収。

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

比べる点5ステップの教材元の完成版
段階01は中央からまっすぐ飛ぶだけ、02はゴールを止めた面Cでの衝突、03はゴールを止めた面Cで観測と動きたい向き、04で面Cの物理、05でタイトルと一時停止。最初から、動くゴールとすべてのルールが使われている。
面と操作元のゲームの面Cの1面だけ。面AとB、タイトルで左右キーを押す面の切り替え、成功後のN、F12、--baseline、元のリプレイ画面は省略。01には場所選びとZがなく、01〜04にはPとタイトル(表紙)がない。A・B・Cの3面。タイトルでの左右キー、成功後のN、F12、ゴールを止めた比較もある。
AIの分割observe・policy・remember・act_goal に分け、04と05の結果を元のゲームの面Cと比べてある。同じ順番の処理が、physics の前半にまとめて書かれている。今の x は反応帯の判定に使い、向きの判断には18tick前の球の y だけを使う。
表示と素材英字(ASCII)と図形で描き、実行画面は英語、本文は日本語。Noto の権利の文書は原文のまま同梱し、文字の形のデータ(字形)は使わない。日本語の文字、独自の画面配置、録画や動画を作る機能への入り口。
検証画面を出さずに動かす(headless)新しい描画の確認、ソースから作って保存したテスト用データ(fixture)、プログラムで作った入力。人による試し遊び、OSからの入力、ブラウザー、音は、まだ確かめていない。元のゲームにすでにある品質確認(QA)や素材を、教材の新しい確認の証拠として使い回さない。

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

  1. model.py:LEVELS[0]・LEVELS[1] を加えて、3面すべてをそろえる
  2. app.py:面の選択、N、F12、--baseline、日本語の描画
  3. model.py:circle_box・gate_hit の、円と四角形の掃引(通り道での当たり判定)
  4. experiment.py:成功する角度と力の範囲、比較の実験
自分のゲームに変えるなら

最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。

教材をダウンロードする

無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について

検証について

どこまで動作を確認したかを見る

2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。

この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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