シミュレーションLESSON 71積み方で曲がり癖が変わる配達運転パズル原作019

くるり荷車の作り方

荷物を置く場所で、曲がり方が変わる。
積んだ荷物の重さの合計と、左右のかたより(偏り)を分けて計算し、ブレーキ・道路の壁との衝突・積み替えと配達を、1つずつ作ります。

完成版の遊び方箱の積み方と配達のしかたで、台車の曲がりぐせが変わります。積み替え・往復・バックも選べます。

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

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

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

完成版の主な操作:上下加減速、左右舵、Space制動/配達、Z積替、U出発時へ、R再開、P停止

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

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

うまく表示されないとき

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

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

台車に載っている荷物から、動きの数値を組み立てる。

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

1. 速さの更新と、時間の刻み方を分ける
2. 箱の入れ替え・保管と、重さのかたより(質量モーメント)
3. 円の台車と、2pxのタイルとの連続した衝突判定
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・条件分岐・関数・for / list と dataclass、文字列の startswith / sin・cos と角度、割合・「速さ×時間」
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
Space:表紙開始。運転中は長押し制動、04以降は新しい押下で配達。積替中は確定 / 上下:運転時は前後加速、積替時は箱選択 / 左右:02以降は舵、積替時は置き場所。05表紙では道路選択 / Z:02以降、停止区画で積替を開閉 / U:05、直前の出発へ戻す / P:05、一時停止/再開 / Q:05、表紙へ / N:05、成功後に次の道路 / R:現在道路の最初へ戻す。その後全キーを離す / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_drive.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

前進・惰性・制動を、四つの小刻みへ

速さの更新と、時間の刻み方を分ける。

最初は道路1の直線で練習します。箱 A・B・C の重さは 1・2・1、台車そのものは2なので、全部の重さ(全質量)は6です。左右の箱が同じ重さなので、偏りは0です。Space で始め、操作キーを全部一度離してから上矢印を押します。上で前へ、下で後ろ向きに加速し、Space を押し続けるとブレーキです。左右のハンドルと積み替えはまだ使えず、壁の衝突も配達もありません。画面の外へ出てしまったら R を押し、キーを全部離してやり直します。

速さは、位置とは別の変数として持ちます。throttle(アクセル)は、上だけなら1、下だけなら−1、両方なら0です。加速度 70/mass(重いほど加速しにくい)を dt 秒ぶん速さに加え、その速さで位置を動かします。速さの上限は前へ40、後ろへ−16です。下キーは、すぐ後ろへ飛ばすキーではなく、プラスの速さを減らしてから、マイナス(後ろ向き)へ進めていく入力です。単位はゲーム内の pixel、秒、rad(ラジアン:角度の単位)です。

Space を押していると、ブレーキを先に選び、上下の入力は無視します。approach は、目標の0を飛び越さないように近づける関数です。ブレーキは 100/mass、何も押さないときに自然に遅くなる量は8です。後ろ向きに動いているときも、0に近づきます。止まったあとに、プラスとマイナスが逆転して動き出す書き方にしないことが大切です。

画面は30fps(1秒に30回)で、物理の計算は dt=1/120秒を4回、つまり1回の表示で1/30秒ぶん進めます。draw は読むだけです。上を押したフレームが30回続くと、速さ 11.6667、x=29.88194、進んだ距離 5.88194 になります。人が1秒長押しすると、フレームの数が少しずれるので、正確な値は付属のテストか、自動確認で確かめます。この段階では、BIAS(偏り)の表示は0のままです。

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

01_drive.py / 注目する部分
    if brake:state.speed=approach(state.speed,0,100/mass*dt)
    elif throttle:state.speed=max(-16,min(40,state.speed+70/mass*max(-1,min(1,throttle))*dt))
    else:state.speed=approach(state.speed,0,8*dt)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_drive.py
macOS / Linux
.venv/bin/python 01_drive.py
画面で見ることSpace → 全部離す → 上を約1秒押します。前に進み、手を離すと自然に遅くなります。Space+上では、ブレーキが優先されます。--headless-smoke --demo-checkpoint での正確な30フレームの入力では、SPEED +11.67、POS (29.88,100.00)、DIST 5.88 になります。
  • 速さを先に、位置をあとに更新する
  • アクセルよりブレーキを優先する
  • 自然に遅くなるとき、0を通り越さない
つまずきやすいところ位置に毎回決まった値を足すと、重さも、勢い(慣性)もなくなってしまいます。また、画面の30fpsと、物理の DT 1/120 を混同せず、frame から step を4回呼びます。
少し変えてみよう

01_drive.py の「70/mass」の1か所だけを「35/mass」に変えてみましょう。同じ30フレームで、速さ 5.83333、距離 2.94097 になります。Space のブレーキ 100/mass は変わりません。確認したら70に戻してください。

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

"""019 くるり荷車 / 第1段階。説明は LESSON.md。単独実行できます。"""
STAGE=1
from dataclasses import dataclass, field
from copy import deepcopy
import math
DT=1/120
FPS=30
RADIUS=7.0
WORLD=(192,128)
TILE=2
def line(a,b):
    n=max(1,math.ceil(math.dist(a,b)))
    return [(a[0]+(b[0]-a[0])*i/n,a[1]+(b[1]-a[1])*i/n) for i in range(n+1)]

def arc(c,r,a,b):
    n=max(1,math.ceil(abs(b-a)*r))
    return [(c[0]+r*math.cos(a+(b-a)*i/n),c[1]+r*math.sin(a+(b-a)*i/n)) for i in range(n+1)]

@dataclass(frozen=True)
class Level:
    title:str
    start:tuple
    angle:float
    masses:tuple
    targets:tuple
    stops:tuple
    paths:tuple
    pads:tuple=()
    road:frozenset=field(default_factory=frozenset)

def make_level(title,start,angle,masses,targets,stops,paths,pads=()):
    # Tile-centre rasterization; the exact visible tile boundary is the collision wall.
    road=set()
    for tx in range(WORLD[0]//TILE):
        for ty in range(WORLD[1]//TILE):
            p=(tx*TILE+1,ty*TILE+1)
            if any(any(math.dist(p,q)<=width/2 for q in path) for path,width in paths) or any(math.dist(p,c)<=r for c,r in pads):road.add((tx,ty))
    return Level(title,start,angle,masses,targets,stops,paths,pads,frozenset(road))
B_PATH=line((24,100),(64,100))+arc((64,74),26,math.pi/2,0)+line((90,74),(90,60))+arc((116,60),26,math.pi,math.pi*1.5)+line((116,34),(166,34))
C_INNER=line((40,100),(84,100))+arc((84,76),24,math.pi/2,0)+line((108,76),(108,52))+arc((132,52),24,math.pi,math.pi*1.5)+line((132,28),(164,28))
C_OUTER=line((28,100),(28,52))+arc((52,52),24,math.pi,math.pi*1.5)+line((52,28),(164,28))
LEVELS=(
make_level('まっすぐな市場',(24,100),0,(1,2,1),(1,2,2),((24,100),(86,100),(164,100)),((line((24,100),(164,100)),34),),(((24,100),20),((164,100),18))),
make_level('左、そして右',(24,100),0,(3,1,1),(1,2,2),((24,100),(90,74),(166,34),(90,52)),((B_PATH,32),),(((24,100),20),((90,52),18),((166,34),18))),
make_level('路地と広い外周',(40,100),0,(1,2,3),(1,1,1),((40,100),(164,28),(76,100)),((C_INNER,24),(C_OUTER,36)),(((40,100),34),((164,28),18),((76,100),15)))
)

@dataclass
class State:
    x:float
    y:float
    angle:float
    speed:float=0
    owners:list=field(default_factory=lambda:['slot:0','slot:1','slot:2'])
    delivered:int=0
    collisions:int=0
    distance:float=0
    seconds:float=0
    trips:int=0
    touching:bool=False
    touch_point:tuple|None=None
    departure_ready:bool=True
    pending_departure:int|None=None
    status:str='driving'
    message:str=''
def initial(level):return State(*level.start,level.angle)
def mass_bias(level,state):
    load=[(level.masses[i],int(o[-1])-1) for i,o in enumerate(state.owners) if o.startswith('slot:')]
    mass=2+sum(m for m,x in load)
    return mass,sum(m*x for m,x in load)/mass
def approach(v,target,amount):return min(target,v+amount) if v<target else max(target,v-amount)
def step(level,state,throttle=0,steer=0,brake=False,dt=DT,b_term=True,fixed_mass=None):
    if state.status!='driving':return
    mass,bias=mass_bias(level,state)
    if fixed_mass is not None:mass=fixed_mass
    # Brake wins over throttle; neutral drag never crosses zero.
    if brake:state.speed=approach(state.speed,0,100/mass*dt)
    elif throttle:state.speed=max(-16,min(40,state.speed+70/mass*max(-1,min(1,throttle))*dt))
    else:state.speed=approach(state.speed,0,8*dt)
    old=(state.x,state.y)
    # Stage 01 is straight practice: angle stays unchanged.
    proposed=(state.x+math.cos(state.angle)*state.speed*dt,state.y+math.sin(state.angle)*state.speed*dt)
    state.x,state.y=proposed
    state.distance+=math.dist(old,proposed);state.seconds+=dt


def frame(level,state,throttle=0,steer=0,brake=False,unload=False,**kw):
    for _ in range(4):step(level,state,throttle,steer,brake,**kw)

def state_dict(state):
    from dataclasses import asdict
    return asdict(state)

KEYS={'UP','DOWN','LEFT','RIGHT','SPACE','Z','U','P','R','N','Q'}
class Game:
    def __init__(self):
        self.level=0;self.title=True;self.paused=False;self.editing=False;self.gate=False
        self.parcel=0;self.slot=0;self.reset(gate=False)
    @property
    def road(self):return LEVELS[self.level]
    def reset(self,gate=True):
        self.state=initial(self.road);self.checkpoint=deepcopy(self.state)
        self.paused=False;self.editing=False;self.gate=gate;self.parcel=0;self.slot=0
    def update(self,held,edges):
        if self.gate:
            if not (held|edges)&KEYS:self.gate=False
            return
        if self.title:
            if 'SPACE' in edges:self.title=False;self.reset()
            return
        if 'R' in edges:self.reset();return
        if self.state.status=='success':
            return
        throttle=int('UP' in held)-int('DOWN' in held)
        steer=0
        frame(self.road,self.state,throttle,steer,'SPACE' in held,'SPACE' in edges)

class App:
    """UI reads state. The model does not import Pyxel."""
    def __init__(self):
        self.game=Game()
    def control(self,held,edges):
        self.game.update(set(held),set(edges))
    def draw(self,p):
        g=self.game;s=g.state;l=g.road
        p.cls(1)
        p.text(12,10,'WEIGHTED CART / STAGE '+str(STAGE)+' / ROAD '+str(g.level+1),10)
        mass,bias=mass_bias(l,s)
        p.text(12,25,f'MASS {mass}  BIAS {bias:+.3f}  SPEED {s.speed:+.2f}',7)
        p.text(12,39,f'DELIVERED {s.delivered}/3  HITS {s.collisions}  TRIPS {s.trips}',6)
        def pos(q):return 48+q[0]*2,56+q[1]*2
        p.rect(48,56,384,256,3)
        for tx,ty in l.road:p.rect(48+tx*4,56+ty*4,4,4,4)
        for i,q in enumerate(l.stops):
            x,y=pos(q);p.circb(x,y,22,6)
            label='DEPOT' if i==0 else ''.join('ABC'[j] for j,t in enumerate(l.targets) if t==i) or 'STORE'
            if g.level==1 and i==1:p.text(x+28,y-3,label,7)
            else:p.text(x-10,y-29,label,7)
            p.text(x-2,y+12,str(i),6)
        x,y=pos((s.x,s.y));p.circ(x,y,RADIUS*2,5);p.circb(x,y,RADIUS*2,7)
        forward=(math.cos(s.angle),math.sin(s.angle));normal=(-forward[1],forward[0])
        p.line(x,y,x+forward[0]*19,y+forward[1]*19,10)
        for i,o in enumerate(s.owners):
            if o.startswith('slot:'):
                offset=(int(o[-1])-1)*8
                bx=x+normal[0]*offset;by=y+normal[1]*offset
                p.rect(bx-3,by-3,7,7,(10,11,14)[i]);p.rectb(bx-3,by-3,7,7,1)
        p.circ(x+normal[0]*bias*12,y+normal[1]*bias*12,2,8)
        p.text(12,324,'A: '+s.owners[0]+'   B: '+s.owners[1]+'   C: '+s.owners[2],7)
        p.text(12,339,f'POS ({s.x:.2f}, {s.y:.2f}) ANGLE {s.angle:+.3f} DIST {s.distance:.2f}',6)
        p.text(12,355,'STATUS: '+s.status.upper()+(' / PAUSED' if g.paused else '')+(' / RELEASE KEYS' if g.gate else ''),10)
        p.text(12,375,'UP/DOWN: DRIVE   SPACE: BRAKE'+(' + DELIVER ON PRESS' if STAGE>=4 else ''),7)
        p.text(12,391,('LEFT/RIGHT: STEER   Z: CARGO   ' if STAGE>=2 else 'STRAIGHT PRACTICE   ')+'R: RESET   ESC: QUIT',7)
        p.text(12,407,'U: DEPARTURE UNDO   P: PAUSE   Q: TITLE   N: NEXT AFTER WIN' if STAGE==5 else ('WALL COLLISION ON' if STAGE>=3 else 'PRACTICE: WALL COLLISION OFF'),6)
        notices={'配達した。荷重が変わった':'DELIVERED: MASS AND BIAS CHANGED','壁で止まった。後退できる':'WALL CONTACT: REVERSE TO TRY AGAIN','直前の出発へ戻した':'RESTORED LAST DEPARTURE (CARGO INCLUDED)','区画で止まってから Z':'CARGO: STOP INSIDE A STATION FIRST','積み直した':'CARGO MOVED / SWAPPED','その荷物はここにない/同じ場所':'CARGO NOT HERE / ALREADY IN THAT PLACE'}
        p.text(12,423,notices.get(s.message,'NO TIME LIMIT / GAME LOAD MODEL, NOT REAL VEHICLE PHYSICS'),6)
        if g.title:
            p.rect(64,111,352,142,1);p.rectb(64,111,352,142,7)
            p.text(88,130,'CARGO CHANGES ACCELERATION AND TURNING',10)
            p.text(88,153,'SPACE: START; THEN RELEASE ALL KEYS',7)
            p.text(88,176,'LEFT/RIGHT: CHOOSE ROAD 1, 2, 3' if STAGE==5 else 'THIS LESSON USES ROAD '+str(g.level+1),6)
            p.text(88,199,'STOPPED CART CANNOT ROTATE' if STAGE>=2 else 'FIRST: ACCELERATE, COAST, BRAKE, REVERSE',7)
            p.text(88,222,'LOAD SLOTS: LEFT / CENTER / RIGHT',6)
        if g.editing:
            p.rect(60,105,360,169,1);p.rectb(60,105,360,169,6)
            p.text(78,119,'CARGO: UP/DOWN PICK; LEFT/RIGHT DESTINATION',10)
            for i,o in enumerate(s.owners):
                p.text(78,143+i*22,('> ' if i==g.parcel else '  ')+'ABC'[i]+f' MASS {l.masses[i]} : '+o,(10,11,14)[i])
            p.text(78,214,'DEST: '+('LEFT','CENTER','RIGHT','LOCAL STORE')[g.slot],7)
            p.text(78,236,'SPACE: MOVE/SWAP   Z: CLOSE; RELEASE KEYS',6)
            p.text(78,255,'RED DOT = LOAD BIAS; SLOT IS CART-RELATIVE',8)
        if g.paused:p.rectb(53,61,374,246,10)

def tap(app,key):
    app.control({key},{key});app.control(set(),set())

def make_checkpoint(app):
    """Deterministic QA recipe, not an automatic solution or human playtest."""
    if STAGE==5:tap(app,'RIGHT')
    tap(app,'SPACE')
    if STAGE==1:
        for _ in range(30):app.control({'UP'},set())
    elif STAGE==2:
        tap(app,'Z');tap(app,'RIGHT');tap(app,'SPACE')
    elif STAGE==3:
        # Explicit synthetic fixture: drive at the road's upper wall.
        s=app.game.state;s.x,s.y=(64,100);s.angle=-math.pi/2;s.speed=40
        for _ in range(45):app.control({'UP'},set())
    elif STAGE==4:
        # Explicit synthetic fixture: press delivery at station 1 at speed 5.
        s=app.game.state;s.x,s.y=app.game.road.stops[1];s.speed=5
        tap(app,'SPACE')
    else:
        tap(app,'Z');tap(app,'RIGHT');tap(app,'SPACE');tap(app,'Z')
        for _ in range(30):app.control({'UP'},set())
        tap(app,'U')

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(480,444,title='Weighted cart lesson '+str(STAGE),fps=FPS,display_scale=2,headless=headless)
    app=App()
    if demo:make_checkpoint(app)
    keymap={k:getattr(p,'KEY_'+k) for k in KEYS}
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=3:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit();return
        if not headless:app.control({k for k,v in keymap.items() if p.btn(v)},{k for k,v in keymap.items() if p.btnp(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 02 / 05

箱の所有場所から、重さと偏りを計算する

箱の入れ替え・保管と、重さのかたより(質量モーメント)。

道路2に変わり、荷台の左・真ん中・右に何を積むかを編集できます。最初の箱の重さは 3・1・1 です。全質量は7、偏りは (3×−1+1×0+1×1)/7=−2/7 です。位置の −1/0/+1 は、台車の向きから見た左・真ん中・右で、画面に対して決まった左右ではありません。角度がプラスのとき、台車は画面上で時計回りに回ります。赤い点が偏りの位置です。

owners は、箱ごとに「今どこにあるか」を1つの文字列で持ちます。slot:0〜2 は荷台、store:区画番号 はその区画に置いて保管している状態です。荷台に載っている箱の重さだけを台車の重さ2に足し、「荷台の箱の重さ×左右の位置」の合計を全質量で割って偏りを求めます。Z は、区画の中心から距離11以内で、速さの絶対値が0.8以下のときに開けます。上下で箱、左右で置き場所を選び、Space で確定、Z で閉じます。Z を押したあとは、キーを全部離す必要があります。

すでに箱がある荷台の場所に動かすと、相手の箱は元の場所へ移るので「入れ替え」になります。保管中の箱を、箱がある荷台の場所に入れると、相手は元の保管場所に移ります。離れた区画に保管した箱は取り戻せません。LOCAL STORE は今の区画に下ろすだけで、配達ではありません。この段階では、まだ配達済みにはなりません。

向きが変わる速さ(角速度)は、speed/24×(1.3×steer+0.7×bias) です。上で前に進んでいる間、左右のハンドルが使えます。止まっていると speed=0 なので、ハンドルだけでは回れず、後ろ向きに進むときは回る向きも逆になります。速さを落とすと曲がるのにかかる時間は長くなりますが、同じ積み方と同じハンドルなら、曲がる円の大きさ(旋回半径 speed/omega)は同じです。車らしい見た目から、ふつうの車の動きを想像せずに、このゲームの式を読んでください。壁は、次の段階まで通り抜けます。

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

02_load.py / 注目する部分
    load=[(level.masses[i],int(o[-1])-1) for i,o in enumerate(state.owners) if o.startswith('slot:')]
    mass=2+sum(m for m,x in load)
    return mass,sum(m*x for m,x in load)/mass

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_load.py
macOS / Linux
.venv/bin/python 02_load.py
画面で見ることSpace → 離す → Z → 離す → 右を1回押して離す → Space と押します。A が CENTER、B が LEFT、C が RIGHT になり、MASS 7 のまま BIAS 0.000 です。Z → 離す → 上 と押すと、ハンドルなしならまっすぐ進みます。
  • 箱の場所は、いつも1か所だけに決める
  • 重さの合計と、左右の偏りを区別する
  • 止まっているときは、向きを変えない
つまずきやすいところ真ん中へ移した A だけを数えて、B を消してしまわないでください。入れ替えても全質量は変わりません。保管した箱は、描く場所を変えるだけでなく、重さの計算からも外します。
少し変えてみよう

02_load.py の omega にある「0.7*bias」だけを「0.0*bias」に変えて保存し、ゲームを終了して 02_load.py を起動し直してみましょう。そのあと R → 全部離す で元の積み方に戻し、上を1秒押します。偏り −2/7 が表示されていても、ハンドルなしなら angle は0、y は100のままです。左右のハンドルでは曲がれます。確認したら0.7に戻します。

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

"""019 くるり荷車 / 第2段階。説明は LESSON.md。単独実行できます。"""
STAGE=2
from dataclasses import dataclass, field
from copy import deepcopy
import math
DT=1/120
FPS=30
RADIUS=7.0
WORLD=(192,128)
TILE=2
def line(a,b):
    n=max(1,math.ceil(math.dist(a,b)))
    return [(a[0]+(b[0]-a[0])*i/n,a[1]+(b[1]-a[1])*i/n) for i in range(n+1)]

def arc(c,r,a,b):
    n=max(1,math.ceil(abs(b-a)*r))
    return [(c[0]+r*math.cos(a+(b-a)*i/n),c[1]+r*math.sin(a+(b-a)*i/n)) for i in range(n+1)]

@dataclass(frozen=True)
class Level:
    title:str
    start:tuple
    angle:float
    masses:tuple
    targets:tuple
    stops:tuple
    paths:tuple
    pads:tuple=()
    road:frozenset=field(default_factory=frozenset)

def make_level(title,start,angle,masses,targets,stops,paths,pads=()):
    # Tile-centre rasterization; the exact visible tile boundary is the collision wall.
    road=set()
    for tx in range(WORLD[0]//TILE):
        for ty in range(WORLD[1]//TILE):
            p=(tx*TILE+1,ty*TILE+1)
            if any(any(math.dist(p,q)<=width/2 for q in path) for path,width in paths) or any(math.dist(p,c)<=r for c,r in pads):road.add((tx,ty))
    return Level(title,start,angle,masses,targets,stops,paths,pads,frozenset(road))
B_PATH=line((24,100),(64,100))+arc((64,74),26,math.pi/2,0)+line((90,74),(90,60))+arc((116,60),26,math.pi,math.pi*1.5)+line((116,34),(166,34))
C_INNER=line((40,100),(84,100))+arc((84,76),24,math.pi/2,0)+line((108,76),(108,52))+arc((132,52),24,math.pi,math.pi*1.5)+line((132,28),(164,28))
C_OUTER=line((28,100),(28,52))+arc((52,52),24,math.pi,math.pi*1.5)+line((52,28),(164,28))
LEVELS=(
make_level('まっすぐな市場',(24,100),0,(1,2,1),(1,2,2),((24,100),(86,100),(164,100)),((line((24,100),(164,100)),34),),(((24,100),20),((164,100),18))),
make_level('左、そして右',(24,100),0,(3,1,1),(1,2,2),((24,100),(90,74),(166,34),(90,52)),((B_PATH,32),),(((24,100),20),((90,52),18),((166,34),18))),
make_level('路地と広い外周',(40,100),0,(1,2,3),(1,1,1),((40,100),(164,28),(76,100)),((C_INNER,24),(C_OUTER,36)),(((40,100),34),((164,28),18),((76,100),15)))
)

@dataclass
class State:
    x:float
    y:float
    angle:float
    speed:float=0
    owners:list=field(default_factory=lambda:['slot:0','slot:1','slot:2'])
    delivered:int=0
    collisions:int=0
    distance:float=0
    seconds:float=0
    trips:int=0
    touching:bool=False
    touch_point:tuple|None=None
    departure_ready:bool=True
    pending_departure:int|None=None
    status:str='driving'
    message:str=''
def initial(level):return State(*level.start,level.angle)
def mass_bias(level,state):
    load=[(level.masses[i],int(o[-1])-1) for i,o in enumerate(state.owners) if o.startswith('slot:')]
    mass=2+sum(m for m,x in load)
    return mass,sum(m*x for m,x in load)/mass
def omega(speed,steer,bias,b_term=True):return speed/24*(1.3*steer+(0.7*bias if b_term else 0))
def station(level,state):
    if abs(state.speed)>0.8:return None
    found=[(math.dist((state.x,state.y),p),i) for i,p in enumerate(level.stops) if math.dist((state.x,state.y),p)<=11]
    return min(found)[1] if found else None
def transfer(level,state,parcel,target,stop):
    """Move/swap between slots or local storage; exactly one owner per parcel."""
    if station(level,state)!=stop or state.owners[parcel]=='delivered':return False
    source=state.owners[parcel]
    if source.startswith('store:') and source!=f'store:{stop}':return False
    destination=f'store:{stop}' if target==3 else f'slot:{target}'
    if source==destination:return False
    if destination.startswith('slot:'):
        other=next((i for i,o in enumerate(state.owners) if o==destination),None)
        if other is not None:state.owners[other]=source
    state.owners[parcel]=destination
    return True
def approach(v,target,amount):return min(target,v+amount) if v<target else max(target,v-amount)
def step(level,state,throttle=0,steer=0,brake=False,dt=DT,b_term=True,fixed_mass=None):
    if state.status!='driving':return
    mass,bias=mass_bias(level,state)
    if fixed_mass is not None:mass=fixed_mass
    # Brake wins over throttle; neutral drag never crosses zero.
    if brake:state.speed=approach(state.speed,0,100/mass*dt)
    elif throttle:state.speed=max(-16,min(40,state.speed+70/mass*max(-1,min(1,throttle))*dt))
    else:state.speed=approach(state.speed,0,8*dt)
    old=(state.x,state.y)
    state.angle=(state.angle+omega(state.speed,max(-1,min(1,steer)),bias,b_term)*dt+math.pi)%(2*math.pi)-math.pi
    proposed=(state.x+math.cos(state.angle)*state.speed*dt,state.y+math.sin(state.angle)*state.speed*dt)
    state.x,state.y=proposed
    state.distance+=math.dist(old,proposed);state.seconds+=dt


def frame(level,state,throttle=0,steer=0,brake=False,unload=False,**kw):
    for _ in range(4):step(level,state,throttle,steer,brake,**kw)

def state_dict(state):
    from dataclasses import asdict
    return asdict(state)

KEYS={'UP','DOWN','LEFT','RIGHT','SPACE','Z','U','P','R','N','Q'}
class Game:
    def __init__(self):
        self.level=1;self.title=True;self.paused=False;self.editing=False;self.gate=False
        self.parcel=0;self.slot=0;self.reset(gate=False)
    @property
    def road(self):return LEVELS[self.level]
    def reset(self,gate=True):
        self.state=initial(self.road);self.checkpoint=deepcopy(self.state)
        self.paused=False;self.editing=False;self.gate=gate;self.parcel=0;self.slot=0
    def update(self,held,edges):
        if self.gate:
            if not (held|edges)&KEYS:self.gate=False
            return
        if self.title:
            if 'SPACE' in edges:self.title=False;self.reset()
            return
        if 'R' in edges:self.reset();return
        if self.state.status=='success':
            return
        if 'Z' in edges:
            if self.editing:self.editing=False;self.gate=True
            elif station(self.road,self.state) is not None:self.editing=True;self.gate=True
            else:self.state.message='区画で止まってから Z'
            return
        if self.editing:
            if 'UP' in edges:self.parcel=(self.parcel-1)%3
            if 'DOWN' in edges:self.parcel=(self.parcel+1)%3
            if 'LEFT' in edges:self.slot=(self.slot-1)%4
            if 'RIGHT' in edges:self.slot=(self.slot+1)%4
            if 'SPACE' in edges:
                ok=transfer(self.road,self.state,self.parcel,self.slot,station(self.road,self.state))
                self.state.message='積み直した' if ok else 'その荷物はここにない/同じ場所'
            return
        throttle=int('UP' in held)-int('DOWN' in held)
        steer=int('RIGHT' in held)-int('LEFT' in held)
        frame(self.road,self.state,throttle,steer,'SPACE' in held,'SPACE' in edges)

class App:
    """UI reads state. The model does not import Pyxel."""
    def __init__(self):
        self.game=Game()
    def control(self,held,edges):
        self.game.update(set(held),set(edges))
    def draw(self,p):
        g=self.game;s=g.state;l=g.road
        p.cls(1)
        p.text(12,10,'WEIGHTED CART / STAGE '+str(STAGE)+' / ROAD '+str(g.level+1),10)
        mass,bias=mass_bias(l,s)
        p.text(12,25,f'MASS {mass}  BIAS {bias:+.3f}  SPEED {s.speed:+.2f}',7)
        p.text(12,39,f'DELIVERED {s.delivered}/3  HITS {s.collisions}  TRIPS {s.trips}',6)
        def pos(q):return 48+q[0]*2,56+q[1]*2
        p.rect(48,56,384,256,3)
        for tx,ty in l.road:p.rect(48+tx*4,56+ty*4,4,4,4)
        for i,q in enumerate(l.stops):
            x,y=pos(q);p.circb(x,y,22,6)
            label='DEPOT' if i==0 else ''.join('ABC'[j] for j,t in enumerate(l.targets) if t==i) or 'STORE'
            if g.level==1 and i==1:p.text(x+28,y-3,label,7)
            else:p.text(x-10,y-29,label,7)
            p.text(x-2,y+12,str(i),6)
        x,y=pos((s.x,s.y));p.circ(x,y,RADIUS*2,5);p.circb(x,y,RADIUS*2,7)
        forward=(math.cos(s.angle),math.sin(s.angle));normal=(-forward[1],forward[0])
        p.line(x,y,x+forward[0]*19,y+forward[1]*19,10)
        for i,o in enumerate(s.owners):
            if o.startswith('slot:'):
                offset=(int(o[-1])-1)*8
                bx=x+normal[0]*offset;by=y+normal[1]*offset
                p.rect(bx-3,by-3,7,7,(10,11,14)[i]);p.rectb(bx-3,by-3,7,7,1)
        p.circ(x+normal[0]*bias*12,y+normal[1]*bias*12,2,8)
        p.text(12,324,'A: '+s.owners[0]+'   B: '+s.owners[1]+'   C: '+s.owners[2],7)
        p.text(12,339,f'POS ({s.x:.2f}, {s.y:.2f}) ANGLE {s.angle:+.3f} DIST {s.distance:.2f}',6)
        p.text(12,355,'STATUS: '+s.status.upper()+(' / PAUSED' if g.paused else '')+(' / RELEASE KEYS' if g.gate else ''),10)
        p.text(12,375,'UP/DOWN: DRIVE   SPACE: BRAKE'+(' + DELIVER ON PRESS' if STAGE>=4 else ''),7)
        p.text(12,391,('LEFT/RIGHT: STEER   Z: CARGO   ' if STAGE>=2 else 'STRAIGHT PRACTICE   ')+'R: RESET   ESC: QUIT',7)
        p.text(12,407,'U: DEPARTURE UNDO   P: PAUSE   Q: TITLE   N: NEXT AFTER WIN' if STAGE==5 else ('WALL COLLISION ON' if STAGE>=3 else 'PRACTICE: WALL COLLISION OFF'),6)
        notices={'配達した。荷重が変わった':'DELIVERED: MASS AND BIAS CHANGED','壁で止まった。後退できる':'WALL CONTACT: REVERSE TO TRY AGAIN','直前の出発へ戻した':'RESTORED LAST DEPARTURE (CARGO INCLUDED)','区画で止まってから Z':'CARGO: STOP INSIDE A STATION FIRST','積み直した':'CARGO MOVED / SWAPPED','その荷物はここにない/同じ場所':'CARGO NOT HERE / ALREADY IN THAT PLACE'}
        p.text(12,423,notices.get(s.message,'NO TIME LIMIT / GAME LOAD MODEL, NOT REAL VEHICLE PHYSICS'),6)
        if g.title:
            p.rect(64,111,352,142,1);p.rectb(64,111,352,142,7)
            p.text(88,130,'CARGO CHANGES ACCELERATION AND TURNING',10)
            p.text(88,153,'SPACE: START; THEN RELEASE ALL KEYS',7)
            p.text(88,176,'LEFT/RIGHT: CHOOSE ROAD 1, 2, 3' if STAGE==5 else 'THIS LESSON USES ROAD '+str(g.level+1),6)
            p.text(88,199,'STOPPED CART CANNOT ROTATE' if STAGE>=2 else 'FIRST: ACCELERATE, COAST, BRAKE, REVERSE',7)
            p.text(88,222,'LOAD SLOTS: LEFT / CENTER / RIGHT',6)
        if g.editing:
            p.rect(60,105,360,169,1);p.rectb(60,105,360,169,6)
            p.text(78,119,'CARGO: UP/DOWN PICK; LEFT/RIGHT DESTINATION',10)
            for i,o in enumerate(s.owners):
                p.text(78,143+i*22,('> ' if i==g.parcel else '  ')+'ABC'[i]+f' MASS {l.masses[i]} : '+o,(10,11,14)[i])
            p.text(78,214,'DEST: '+('LEFT','CENTER','RIGHT','LOCAL STORE')[g.slot],7)
            p.text(78,236,'SPACE: MOVE/SWAP   Z: CLOSE; RELEASE KEYS',6)
            p.text(78,255,'RED DOT = LOAD BIAS; SLOT IS CART-RELATIVE',8)
        if g.paused:p.rectb(53,61,374,246,10)

def tap(app,key):
    app.control({key},{key});app.control(set(),set())

def make_checkpoint(app):
    """Deterministic QA recipe, not an automatic solution or human playtest."""
    if STAGE==5:tap(app,'RIGHT')
    tap(app,'SPACE')
    if STAGE==1:
        for _ in range(30):app.control({'UP'},set())
    elif STAGE==2:
        tap(app,'Z');tap(app,'RIGHT');tap(app,'SPACE')
    elif STAGE==3:
        # Explicit synthetic fixture: drive at the road's upper wall.
        s=app.game.state;s.x,s.y=(64,100);s.angle=-math.pi/2;s.speed=40
        for _ in range(45):app.control({'UP'},set())
    elif STAGE==4:
        # Explicit synthetic fixture: press delivery at station 1 at speed 5.
        s=app.game.state;s.x,s.y=app.game.road.stops[1];s.speed=5
        tap(app,'SPACE')
    else:
        tap(app,'Z');tap(app,'RIGHT');tap(app,'SPACE');tap(app,'Z')
        for _ in range(30):app.control({'UP'},set())
        tap(app,'U')

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(480,444,title='Weighted cart lesson '+str(STAGE),fps=FPS,display_scale=2,headless=headless)
    app=App()
    if demo:make_checkpoint(app)
    keymap={k:getattr(p,'KEY_'+k) for k in KEYS}
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=3:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit();return
        if not headless:app.control({k for k,v in keymap.items() if p.btn(v)},{k for k,v in keymap.items() if p.btnp(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 03 / 05

移動の途中で、最初の壁に止まる

円の台車と、2pxのタイルとの連続した衝突判定。

03 では、画面に描いた茶色の道路と同じ road の集まりを、当たり判定にも使います。道路は、2×2 pixel のタイルを、その中心が道に入るかで並べたものです。道に含まれないマスは固い壁で、画面の外も壁です。台車は半径7の円です。飾りの中心線や、見た目のなめらかなカーブを、別の当たり判定にはしていません。

step は、速さと角度から動いた先の位置 proposed を求めますが、いきなりそこへは置きません。元の位置 old から proposed まで動く線を調べ、その途中にかかる壁のマスについて sweep_box を呼びます。マスの辺には「半径の分だけふくらませた距離」で、角には circle_hit の二次方程式で、ぶつかる瞬間 t を探します。0〜1の中で一番小さい値が、最初にぶつかる瞬間です。角を四角くふくらませるだけでは、丸く当たるはずの部分の形が変わってしまいます。

ぶつかったら、最初にぶつかる点の0.01pixel 手前で止め、速さを0にします。壁にそって滑る、はね返る、箱が壊れる、失敗の画面、といったことはありません。壁に向かって上を押し続けても、触れている間(touching)は衝突の回数は増え続けません。ぶつかった点から0.3pixel より離れたら、次の衝突を数えられます。バックしてやり直せます。

止まっていると左右だけでは回れないので、壁で止まったときは、下でバックしながらハンドルを使います。Z での積み替えは 02 と同じ条件です。自動確認では、台車を (64,100)、角度 −π/2、速さ40 に置いた試験用の状態から、上を45フレーム入れます。HITS 1、速さ0、位置は約 (63.65,91.00) になります。ふつうのスタートから人がそこまで運転した記録ではありません。

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

03_walls.py / 注目する部分
    hit=sweep_road(level,old,proposed)
    if hit is not None:
        # Stop at first contact, not at proposed endpoint; no wall sliding.
        f=max(0,hit-.01/max(.01,math.dist(old,proposed)))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_walls.py
macOS / Linux
.venv/bin/python 03_walls.py
画面で見ることSpace → 離す → 上と左右で運転します。茶色の道路の端で止まって HITS が増え、同じ壁に押し続けても増え続けません。下でバックできます。自動確認は HITS 1、SPEED 0、POS 約 (63.65,91.00) です。
  • 描いたタイルで、当たり判定もする
  • 辺と、丸い角を、別々に調べる
  • 最初にぶつかった点で止める
つまずきやすいところ動いたあとの点だけを調べると、薄い壁を飛び越えてしまいます。ぶつかったあとも proposed を位置に代入すると、計算した「最初にぶつかる瞬間」が意味をなくします。
少し変えてみよう

03_walls.py の「RADIUS=7.0」を「RADIUS=5.0」に変えてみましょう。描かれる台車の円も小さくなり、せまい場所に近づけます。テスト用の壁 box=(40,0,42,20) に向かって、(0,10) から (100,10) まで進む線では、最初にぶつかる t が0.33から0.35に変わります。重さや曲がり方の式は変わりません。確認したら7.0に戻します。

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

"""019 くるり荷車 / 第3段階。説明は LESSON.md。単独実行できます。"""
STAGE=3
from dataclasses import dataclass, field
from copy import deepcopy
import math
DT=1/120
FPS=30
RADIUS=7.0
WORLD=(192,128)
TILE=2
def line(a,b):
    n=max(1,math.ceil(math.dist(a,b)))
    return [(a[0]+(b[0]-a[0])*i/n,a[1]+(b[1]-a[1])*i/n) for i in range(n+1)]

def arc(c,r,a,b):
    n=max(1,math.ceil(abs(b-a)*r))
    return [(c[0]+r*math.cos(a+(b-a)*i/n),c[1]+r*math.sin(a+(b-a)*i/n)) for i in range(n+1)]

@dataclass(frozen=True)
class Level:
    title:str
    start:tuple
    angle:float
    masses:tuple
    targets:tuple
    stops:tuple
    paths:tuple
    pads:tuple=()
    road:frozenset=field(default_factory=frozenset)

def make_level(title,start,angle,masses,targets,stops,paths,pads=()):
    # Tile-centre rasterization; the exact visible tile boundary is the collision wall.
    road=set()
    for tx in range(WORLD[0]//TILE):
        for ty in range(WORLD[1]//TILE):
            p=(tx*TILE+1,ty*TILE+1)
            if any(any(math.dist(p,q)<=width/2 for q in path) for path,width in paths) or any(math.dist(p,c)<=r for c,r in pads):road.add((tx,ty))
    return Level(title,start,angle,masses,targets,stops,paths,pads,frozenset(road))
B_PATH=line((24,100),(64,100))+arc((64,74),26,math.pi/2,0)+line((90,74),(90,60))+arc((116,60),26,math.pi,math.pi*1.5)+line((116,34),(166,34))
C_INNER=line((40,100),(84,100))+arc((84,76),24,math.pi/2,0)+line((108,76),(108,52))+arc((132,52),24,math.pi,math.pi*1.5)+line((132,28),(164,28))
C_OUTER=line((28,100),(28,52))+arc((52,52),24,math.pi,math.pi*1.5)+line((52,28),(164,28))
LEVELS=(
make_level('まっすぐな市場',(24,100),0,(1,2,1),(1,2,2),((24,100),(86,100),(164,100)),((line((24,100),(164,100)),34),),(((24,100),20),((164,100),18))),
make_level('左、そして右',(24,100),0,(3,1,1),(1,2,2),((24,100),(90,74),(166,34),(90,52)),((B_PATH,32),),(((24,100),20),((90,52),18),((166,34),18))),
make_level('路地と広い外周',(40,100),0,(1,2,3),(1,1,1),((40,100),(164,28),(76,100)),((C_INNER,24),(C_OUTER,36)),(((40,100),34),((164,28),18),((76,100),15)))
)

@dataclass
class State:
    x:float
    y:float
    angle:float
    speed:float=0
    owners:list=field(default_factory=lambda:['slot:0','slot:1','slot:2'])
    delivered:int=0
    collisions:int=0
    distance:float=0
    seconds:float=0
    trips:int=0
    touching:bool=False
    touch_point:tuple|None=None
    departure_ready:bool=True
    pending_departure:int|None=None
    status:str='driving'
    message:str=''
def initial(level):return State(*level.start,level.angle)
def mass_bias(level,state):
    load=[(level.masses[i],int(o[-1])-1) for i,o in enumerate(state.owners) if o.startswith('slot:')]
    mass=2+sum(m for m,x in load)
    return mass,sum(m*x for m,x in load)/mass
def omega(speed,steer,bias,b_term=True):return speed/24*(1.3*steer+(0.7*bias if b_term else 0))
def station(level,state):
    if abs(state.speed)>0.8:return None
    found=[(math.dist((state.x,state.y),p),i) for i,p in enumerate(level.stops) if math.dist((state.x,state.y),p)<=11]
    return min(found)[1] if found else None
def transfer(level,state,parcel,target,stop):
    """Move/swap between slots or local storage; exactly one owner per parcel."""
    if station(level,state)!=stop or state.owners[parcel]=='delivered':return False
    source=state.owners[parcel]
    if source.startswith('store:') and source!=f'store:{stop}':return False
    destination=f'store:{stop}' if target==3 else f'slot:{target}'
    if source==destination:return False
    if destination.startswith('slot:'):
        other=next((i for i,o in enumerate(state.owners) if o==destination),None)
        if other is not None:state.owners[other]=source
    state.owners[parcel]=destination
    return True
def circle_hit(p,d,c,r):
    a=d[0]**2+d[1]**2
    if a<1e-20:return None
    q=(p[0]-c[0],p[1]-c[1]);b=2*(q[0]*d[0]+q[1]*d[1]);cc=q[0]**2+q[1]**2-r*r
    disc=b*b-4*a*cc
    if disc<0:return None
    t=(-b-math.sqrt(max(0,disc)))/(2*a)
    return t if -1e-10<=t<=1+1e-10 else None
def sweep_box(p,d,box,r=RADIUS):
    x0,y0,x1,y1=box
    near=(min(x1,max(x0,p[0])),min(y1,max(y0,p[1])))
    if math.dist(p,near)<r-1e-8:return 0.
    hits=[]
    if d[0]>0:
        t=(x0-r-p[0])/d[0]
        if 0<=t<=1 and y0<=p[1]+d[1]*t<=y1:hits.append(t)
    if d[0]<0:
        t=(x1+r-p[0])/d[0]
        if 0<=t<=1 and y0<=p[1]+d[1]*t<=y1:hits.append(t)
    if d[1]>0:
        t=(y0-r-p[1])/d[1]
        if 0<=t<=1 and x0<=p[0]+d[0]*t<=x1:hits.append(t)
    if d[1]<0:
        t=(y1+r-p[1])/d[1]
        if 0<=t<=1 and x0<=p[0]+d[0]*t<=x1:hits.append(t)
    for c in ((x0,y0),(x0,y1),(x1,y0),(x1,y1)):
        t=circle_hit(p,d,c,r)
        if t is not None:hits.append(max(0,t))
    return min(hits) if hits else None
def sweep_road(level,p,q,r=RADIUS):
    d=(q[0]-p[0],q[1]-p[1]);hit=None
    for tx in range(math.floor((min(p[0],q[0])-r)/TILE),math.floor((max(p[0],q[0])+r)/TILE)+1):
        for ty in range(math.floor((min(p[1],q[1])-r)/TILE),math.floor((max(p[1],q[1])+r)/TILE)+1):
            if (tx,ty) in level.road:continue
            t=sweep_box(p,d,(tx*TILE,ty*TILE,(tx+1)*TILE,(ty+1)*TILE),r)
            if t is not None and (hit is None or t<hit):hit=t
    return hit
def clearance(level,p):
    """Distance from cart edge to closest solid tile (bounded 20px search)."""
    best=30.
    tx,ty=int(p[0]//TILE),int(p[1]//TILE)
    for a in range(tx-16,tx+17):
        for b in range(ty-16,ty+17):
            if (a,b) in level.road:continue
            q=(min((a+1)*TILE,max(a*TILE,p[0])),min((b+1)*TILE,max(b*TILE,p[1])))
            best=min(best,math.dist(p,q))
    return best-RADIUS
def approach(v,target,amount):return min(target,v+amount) if v<target else max(target,v-amount)
def step(level,state,throttle=0,steer=0,brake=False,dt=DT,b_term=True,fixed_mass=None):
    if state.status!='driving':return
    mass,bias=mass_bias(level,state)
    if fixed_mass is not None:mass=fixed_mass
    # Brake wins over throttle; neutral drag never crosses zero.
    if brake:state.speed=approach(state.speed,0,100/mass*dt)
    elif throttle:state.speed=max(-16,min(40,state.speed+70/mass*max(-1,min(1,throttle))*dt))
    else:state.speed=approach(state.speed,0,8*dt)
    old=(state.x,state.y)
    state.angle=(state.angle+omega(state.speed,max(-1,min(1,steer)),bias,b_term)*dt+math.pi)%(2*math.pi)-math.pi
    proposed=(state.x+math.cos(state.angle)*state.speed*dt,state.y+math.sin(state.angle)*state.speed*dt)
    hit=sweep_road(level,old,proposed)
    if hit is not None:
        # Stop at first contact, not at proposed endpoint; no wall sliding.
        f=max(0,hit-.01/max(.01,math.dist(old,proposed)))
        state.x=old[0]+(proposed[0]-old[0])*f;state.y=old[1]+(proposed[1]-old[1])*f
        state.speed=0
        if not state.touching:state.collisions+=1
        if not state.touching:state.touch_point=(state.x,state.y)
        state.touching=True;state.message='壁で止まった。後退できる'
    else:
        state.x,state.y=proposed
        if state.touch_point is not None and math.dist((state.x,state.y),state.touch_point)>.3:state.touching=False;state.touch_point=None
    state.distance+=math.dist(old,(state.x,state.y));state.seconds+=dt

def frame(level,state,throttle=0,steer=0,brake=False,unload=False,**kw):
    for _ in range(4):step(level,state,throttle,steer,brake,**kw)

def state_dict(state):
    from dataclasses import asdict
    return asdict(state)

KEYS={'UP','DOWN','LEFT','RIGHT','SPACE','Z','U','P','R','N','Q'}
class Game:
    def __init__(self):
        self.level=1;self.title=True;self.paused=False;self.editing=False;self.gate=False
        self.parcel=0;self.slot=0;self.reset(gate=False)
    @property
    def road(self):return LEVELS[self.level]
    def reset(self,gate=True):
        self.state=initial(self.road);self.checkpoint=deepcopy(self.state)
        self.paused=False;self.editing=False;self.gate=gate;self.parcel=0;self.slot=0
    def update(self,held,edges):
        if self.gate:
            if not (held|edges)&KEYS:self.gate=False
            return
        if self.title:
            if 'SPACE' in edges:self.title=False;self.reset()
            return
        if 'R' in edges:self.reset();return
        if self.state.status=='success':
            return
        if 'Z' in edges:
            if self.editing:self.editing=False;self.gate=True
            elif station(self.road,self.state) is not None:self.editing=True;self.gate=True
            else:self.state.message='区画で止まってから Z'
            return
        if self.editing:
            if 'UP' in edges:self.parcel=(self.parcel-1)%3
            if 'DOWN' in edges:self.parcel=(self.parcel+1)%3
            if 'LEFT' in edges:self.slot=(self.slot-1)%4
            if 'RIGHT' in edges:self.slot=(self.slot+1)%4
            if 'SPACE' in edges:
                ok=transfer(self.road,self.state,self.parcel,self.slot,station(self.road,self.state))
                self.state.message='積み直した' if ok else 'その荷物はここにない/同じ場所'
            return
        throttle=int('UP' in held)-int('DOWN' in held)
        steer=int('RIGHT' in held)-int('LEFT' in held)
        frame(self.road,self.state,throttle,steer,'SPACE' in held,'SPACE' in edges)

class App:
    """UI reads state. The model does not import Pyxel."""
    def __init__(self):
        self.game=Game()
    def control(self,held,edges):
        self.game.update(set(held),set(edges))
    def draw(self,p):
        g=self.game;s=g.state;l=g.road
        p.cls(1)
        p.text(12,10,'WEIGHTED CART / STAGE '+str(STAGE)+' / ROAD '+str(g.level+1),10)
        mass,bias=mass_bias(l,s)
        p.text(12,25,f'MASS {mass}  BIAS {bias:+.3f}  SPEED {s.speed:+.2f}',7)
        p.text(12,39,f'DELIVERED {s.delivered}/3  HITS {s.collisions}  TRIPS {s.trips}',6)
        def pos(q):return 48+q[0]*2,56+q[1]*2
        p.rect(48,56,384,256,3)
        for tx,ty in l.road:p.rect(48+tx*4,56+ty*4,4,4,4)
        for i,q in enumerate(l.stops):
            x,y=pos(q);p.circb(x,y,22,6)
            label='DEPOT' if i==0 else ''.join('ABC'[j] for j,t in enumerate(l.targets) if t==i) or 'STORE'
            if g.level==1 and i==1:p.text(x+28,y-3,label,7)
            else:p.text(x-10,y-29,label,7)
            p.text(x-2,y+12,str(i),6)
        x,y=pos((s.x,s.y));p.circ(x,y,RADIUS*2,5);p.circb(x,y,RADIUS*2,7)
        forward=(math.cos(s.angle),math.sin(s.angle));normal=(-forward[1],forward[0])
        p.line(x,y,x+forward[0]*19,y+forward[1]*19,10)
        for i,o in enumerate(s.owners):
            if o.startswith('slot:'):
                offset=(int(o[-1])-1)*8
                bx=x+normal[0]*offset;by=y+normal[1]*offset
                p.rect(bx-3,by-3,7,7,(10,11,14)[i]);p.rectb(bx-3,by-3,7,7,1)
        p.circ(x+normal[0]*bias*12,y+normal[1]*bias*12,2,8)
        p.text(12,324,'A: '+s.owners[0]+'   B: '+s.owners[1]+'   C: '+s.owners[2],7)
        p.text(12,339,f'POS ({s.x:.2f}, {s.y:.2f}) ANGLE {s.angle:+.3f} DIST {s.distance:.2f}',6)
        p.text(12,355,'STATUS: '+s.status.upper()+(' / PAUSED' if g.paused else '')+(' / RELEASE KEYS' if g.gate else ''),10)
        p.text(12,375,'UP/DOWN: DRIVE   SPACE: BRAKE'+(' + DELIVER ON PRESS' if STAGE>=4 else ''),7)
        p.text(12,391,('LEFT/RIGHT: STEER   Z: CARGO   ' if STAGE>=2 else 'STRAIGHT PRACTICE   ')+'R: RESET   ESC: QUIT',7)
        p.text(12,407,'U: DEPARTURE UNDO   P: PAUSE   Q: TITLE   N: NEXT AFTER WIN' if STAGE==5 else ('WALL COLLISION ON' if STAGE>=3 else 'PRACTICE: WALL COLLISION OFF'),6)
        notices={'配達した。荷重が変わった':'DELIVERED: MASS AND BIAS CHANGED','壁で止まった。後退できる':'WALL CONTACT: REVERSE TO TRY AGAIN','直前の出発へ戻した':'RESTORED LAST DEPARTURE (CARGO INCLUDED)','区画で止まってから Z':'CARGO: STOP INSIDE A STATION FIRST','積み直した':'CARGO MOVED / SWAPPED','その荷物はここにない/同じ場所':'CARGO NOT HERE / ALREADY IN THAT PLACE'}
        p.text(12,423,notices.get(s.message,'NO TIME LIMIT / GAME LOAD MODEL, NOT REAL VEHICLE PHYSICS'),6)
        if g.title:
            p.rect(64,111,352,142,1);p.rectb(64,111,352,142,7)
            p.text(88,130,'CARGO CHANGES ACCELERATION AND TURNING',10)
            p.text(88,153,'SPACE: START; THEN RELEASE ALL KEYS',7)
            p.text(88,176,'LEFT/RIGHT: CHOOSE ROAD 1, 2, 3' if STAGE==5 else 'THIS LESSON USES ROAD '+str(g.level+1),6)
            p.text(88,199,'STOPPED CART CANNOT ROTATE' if STAGE>=2 else 'FIRST: ACCELERATE, COAST, BRAKE, REVERSE',7)
            p.text(88,222,'LOAD SLOTS: LEFT / CENTER / RIGHT',6)
        if g.editing:
            p.rect(60,105,360,169,1);p.rectb(60,105,360,169,6)
            p.text(78,119,'CARGO: UP/DOWN PICK; LEFT/RIGHT DESTINATION',10)
            for i,o in enumerate(s.owners):
                p.text(78,143+i*22,('> ' if i==g.parcel else '  ')+'ABC'[i]+f' MASS {l.masses[i]} : '+o,(10,11,14)[i])
            p.text(78,214,'DEST: '+('LEFT','CENTER','RIGHT','LOCAL STORE')[g.slot],7)
            p.text(78,236,'SPACE: MOVE/SWAP   Z: CLOSE; RELEASE KEYS',6)
            p.text(78,255,'RED DOT = LOAD BIAS; SLOT IS CART-RELATIVE',8)
        if g.paused:p.rectb(53,61,374,246,10)

def tap(app,key):
    app.control({key},{key});app.control(set(),set())

def make_checkpoint(app):
    """Deterministic QA recipe, not an automatic solution or human playtest."""
    if STAGE==5:tap(app,'RIGHT')
    tap(app,'SPACE')
    if STAGE==1:
        for _ in range(30):app.control({'UP'},set())
    elif STAGE==2:
        tap(app,'Z');tap(app,'RIGHT');tap(app,'SPACE')
    elif STAGE==3:
        # Explicit synthetic fixture: drive at the road's upper wall.
        s=app.game.state;s.x,s.y=(64,100);s.angle=-math.pi/2;s.speed=40
        for _ in range(45):app.control({'UP'},set())
    elif STAGE==4:
        # Explicit synthetic fixture: press delivery at station 1 at speed 5.
        s=app.game.state;s.x,s.y=app.game.road.stops[1];s.speed=5
        tap(app,'SPACE')
    else:
        tap(app,'Z');tap(app,'RIGHT');tap(app,'SPACE');tap(app,'Z')
        for _ in range(30):app.control({'UP'},set())
        tap(app,'U')

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(480,444,title='Weighted cart lesson '+str(STAGE),fps=FPS,display_scale=2,headless=headless)
    app=App()
    if demo:make_checkpoint(app)
    keymap={k:getattr(p,'KEY_'+k) for k in KEYS}
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=3:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit();return
        if not headless:app.control({k for k,v in keymap.items() if p.btn(v)},{k for k,v in keymap.items() if p.btnp(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 04 / 05

減速と配達のタイミングを分ける

箱の行き先の最終状態と、処理の順番。

04 で、元のゲームのルールが全部そろいます。道路2では、A を区画1 (90,74) へ、B と C を区画2 (166,34) へ運びます。Space を押してブレーキをかけ、速さの絶対値が5以下で、目的の区画から距離11以内のときに、新しく Space を押すと、荷台の該当する箱を配達します。積み替えの速さの制限0.8と、配達の速さの制限5は、別の値です。

入力には、held(押している間ずっと)と edges(押した瞬間)の2種類を渡します。Space の held は毎フレームのブレーキ、edges は押した瞬間の配達です。速いまま Space を押して、押し続けて5以下になっても、自動では配達しません。一度離して、もう一度押します。配達の deliver は、そのフレームの4回の step より先に調べるので、「今回のブレーキのあとなら5になる」場合は、まだ間に合いません。

配達した箱は owners が delivered になり、次の物理計算から、すぐに重さと偏りが変わります。元の積み方で A だけを届けると、全質量は 7→4、偏りは −2/7→+1/4 です。軽くなって加速しやすくなるだけでなく、ハンドルなしのときの曲がりぐせも逆になります。区画に保管した箱は、その場所に来ただけでは配達されません。荷台に載せて届けます。

3つの箱がすべて delivered になると success で、速さは0になります。そのあとは運転しても、位置も時計も進みません。ぶつかっても失敗ではなく、時間制限もありません。R で道路2を最初からやり直せます。この段階には、次の道路・一時停止・出発時点に戻る機能はまだありません。TRIPS はアクセルを押した回数ではなく、止まった区画で出発の準備をし、その区画から11pixel より外へ出た回数です。

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

04_delivery.py / 注目する部分
    if unload:deliver(level,state)
    for _ in range(4):step(level,state,throttle,steer,brake,**kw)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_delivery.py
macOS / Linux
.venv/bin/python 04_delivery.py
画面で見ること元の積み方で区画1へ行き、Space でブレーキ → 離す → もう一度押す と、A:delivered、DELIVERED 1/3、MASS 4、BIAS +0.250 になります。B/C を区画2へ運ぶと SUCCESS、全質量2・偏り0です。自動確認は、区画1に直接置いた試験用の状態で、最初の配達だけを表示します。
  • 押し続けるブレーキと、押した瞬間の配達を分ける
  • 配達したら、重さを計算し直す
  • 3つ届けて成功したら、物理を止める
つまずきやすいところ押し続けている Space を、毎フレームの配達として扱わないでください。また、保管 store:1 は delivered(配達済み)とは別の状態で、保管しただけでは得点になりません。
少し変えてみよう

出発する前に Z → 離す → 右を3回(毎回離す)→ Space で、A を LOCAL STORE(その場に保管)に入れてみましょう。MASS 4・BIAS +0.25 になりますが、DELIVERED は0です。右を2回押して CENTER に戻し Space を押すと、A と B が入れ替わり、MASS 6、BIAS +0.1667、B は store:0 になります。保管と配達の違いを確かめたら、R で元に戻します。

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

"""019 くるり荷車 / 第4段階。説明は LESSON.md。単独実行できます。"""
STAGE=4
"""Pure load-dependent cart approximation. Units: pixels, seconds, radians.
The raster road and solid 2px cells are both drawn and collided with.
Continuous swept circle vs each solid cell includes rounded corner contacts.
"""
from dataclasses import dataclass, field
from copy import deepcopy
import math
DT=1/120
FPS=30
RADIUS=7.0
WORLD=(192,128)
TILE=2

def line(a,b):
    n=max(1,math.ceil(math.dist(a,b)))
    return [(a[0]+(b[0]-a[0])*i/n,a[1]+(b[1]-a[1])*i/n) for i in range(n+1)]
def arc(c,r,a,b):
    n=max(1,math.ceil(abs(b-a)*r))
    return [(c[0]+r*math.cos(a+(b-a)*i/n),c[1]+r*math.sin(a+(b-a)*i/n)) for i in range(n+1)]
@dataclass(frozen=True)
class Level:
    title:str
    start:tuple
    angle:float
    masses:tuple
    targets:tuple
    stops:tuple
    paths:tuple
    pads:tuple=()
    road:frozenset=field(default_factory=frozenset)

def make_level(title,start,angle,masses,targets,stops,paths,pads=()):
    # Tile-centre rasterization; the exact visible tile boundary is the collision wall.
    road=set()
    for tx in range(WORLD[0]//TILE):
        for ty in range(WORLD[1]//TILE):
            p=(tx*TILE+1,ty*TILE+1)
            if any(any(math.dist(p,q)<=width/2 for q in path) for path,width in paths) or any(math.dist(p,c)<=r for c,r in pads):road.add((tx,ty))
    return Level(title,start,angle,masses,targets,stops,paths,pads,frozenset(road))
B_PATH=line((24,100),(64,100))+arc((64,74),26,math.pi/2,0)+line((90,74),(90,60))+arc((116,60),26,math.pi,math.pi*1.5)+line((116,34),(166,34))
C_INNER=line((40,100),(84,100))+arc((84,76),24,math.pi/2,0)+line((108,76),(108,52))+arc((132,52),24,math.pi,math.pi*1.5)+line((132,28),(164,28))
C_OUTER=line((28,100),(28,52))+arc((52,52),24,math.pi,math.pi*1.5)+line((52,28),(164,28))
LEVELS=(
make_level('まっすぐな市場',(24,100),0,(1,2,1),(1,2,2),((24,100),(86,100),(164,100)),((line((24,100),(164,100)),34),),(((24,100),20),((164,100),18))),
make_level('左、そして右',(24,100),0,(3,1,1),(1,2,2),((24,100),(90,74),(166,34),(90,52)),((B_PATH,32),),(((24,100),20),((90,52),18),((166,34),18))),
make_level('路地と広い外周',(40,100),0,(1,2,3),(1,1,1),((40,100),(164,28),(76,100)),((C_INNER,24),(C_OUTER,36)),(((40,100),34),((164,28),18),((76,100),15)))
)
@dataclass
class State:
    x:float
    y:float
    angle:float
    speed:float=0
    owners:list=field(default_factory=lambda:['slot:0','slot:1','slot:2'])
    delivered:int=0
    collisions:int=0
    distance:float=0
    seconds:float=0
    trips:int=0
    touching:bool=False
    touch_point:tuple|None=None
    departure_ready:bool=True
    pending_departure:int|None=None
    status:str='driving'
    message:str=''

def initial(level):return State(*level.start,level.angle)
def mass_bias(level,state):
    load=[(level.masses[i],int(o[-1])-1) for i,o in enumerate(state.owners) if o.startswith('slot:')]
    mass=2+sum(m for m,x in load)
    return mass,sum(m*x for m,x in load)/mass

def omega(speed,steer,bias,b_term=True):return speed/24*(1.3*steer+(0.7*bias if b_term else 0))
def station(level,state):
    if abs(state.speed)>0.8:return None
    found=[(math.dist((state.x,state.y),p),i) for i,p in enumerate(level.stops) if math.dist((state.x,state.y),p)<=11]
    return min(found)[1] if found else None

def transfer(level,state,parcel,target,stop):
    """Move/swap between slots or local storage; exactly one owner per parcel."""
    if station(level,state)!=stop or state.owners[parcel]=='delivered':return False
    source=state.owners[parcel]
    if source.startswith('store:') and source!=f'store:{stop}':return False
    destination=f'store:{stop}' if target==3 else f'slot:{target}'
    if source==destination:return False
    if destination.startswith('slot:'):
        other=next((i for i,o in enumerate(state.owners) if o==destination),None)
        if other is not None:state.owners[other]=source
    state.owners[parcel]=destination
    return True

def deliver(level,state):
    if abs(state.speed)>5:return []
    near=[i for i,p in enumerate(level.stops) if math.dist((state.x,state.y),p)<=11]
    ids=[i for i,o in enumerate(state.owners) if o.startswith('slot:') and level.targets[i] in near]
    for i in ids:state.owners[i]='delivered'
    state.delivered=sum(o=='delivered' for o in state.owners)
    if ids:state.message='配達した。荷重が変わった'
    if state.delivered==3:state.status='success';state.speed=0
    return ids

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

def sweep_box(p,d,box,r=RADIUS):
    x0,y0,x1,y1=box
    near=(min(x1,max(x0,p[0])),min(y1,max(y0,p[1])))
    if math.dist(p,near)<r-1e-8:return 0.
    hits=[]
    if d[0]>0:
        t=(x0-r-p[0])/d[0]
        if 0<=t<=1 and y0<=p[1]+d[1]*t<=y1:hits.append(t)
    if d[0]<0:
        t=(x1+r-p[0])/d[0]
        if 0<=t<=1 and y0<=p[1]+d[1]*t<=y1:hits.append(t)
    if d[1]>0:
        t=(y0-r-p[1])/d[1]
        if 0<=t<=1 and x0<=p[0]+d[0]*t<=x1:hits.append(t)
    if d[1]<0:
        t=(y1+r-p[1])/d[1]
        if 0<=t<=1 and x0<=p[0]+d[0]*t<=x1:hits.append(t)
    for c in ((x0,y0),(x0,y1),(x1,y0),(x1,y1)):
        t=circle_hit(p,d,c,r)
        if t is not None:hits.append(max(0,t))
    return min(hits) if hits else None

def sweep_road(level,p,q,r=RADIUS):
    d=(q[0]-p[0],q[1]-p[1]);hit=None
    for tx in range(math.floor((min(p[0],q[0])-r)/TILE),math.floor((max(p[0],q[0])+r)/TILE)+1):
        for ty in range(math.floor((min(p[1],q[1])-r)/TILE),math.floor((max(p[1],q[1])+r)/TILE)+1):
            if (tx,ty) in level.road:continue
            t=sweep_box(p,d,(tx*TILE,ty*TILE,(tx+1)*TILE,(ty+1)*TILE),r)
            if t is not None and (hit is None or t<hit):hit=t
    return hit

def clearance(level,p):
    """Distance from cart edge to closest solid tile (bounded 20px search)."""
    best=30.
    tx,ty=int(p[0]//TILE),int(p[1]//TILE)
    for a in range(tx-16,tx+17):
        for b in range(ty-16,ty+17):
            if (a,b) in level.road:continue
            q=(min((a+1)*TILE,max(a*TILE,p[0])),min((b+1)*TILE,max(b*TILE,p[1])))
            best=min(best,math.dist(p,q))
    return best-RADIUS

def approach(v,target,amount):return min(target,v+amount) if v<target else max(target,v-amount)
def step(level,state,throttle=0,steer=0,brake=False,dt=DT,b_term=True,fixed_mass=None):
    if state.status!='driving':return
    mass,bias=mass_bias(level,state)
    if fixed_mass is not None:mass=fixed_mass
    # Brake wins over throttle; neutral drag never crosses zero.
    if brake:state.speed=approach(state.speed,0,100/mass*dt)
    elif throttle:state.speed=max(-16,min(40,state.speed+70/mass*max(-1,min(1,throttle))*dt))
    else:state.speed=approach(state.speed,0,8*dt)
    old=(state.x,state.y)
    state.angle=(state.angle+omega(state.speed,max(-1,min(1,steer)),bias,b_term)*dt+math.pi)%(2*math.pi)-math.pi
    proposed=(state.x+math.cos(state.angle)*state.speed*dt,state.y+math.sin(state.angle)*state.speed*dt)
    hit=sweep_road(level,old,proposed)
    if hit is not None:
        # Stop at first contact, not at proposed endpoint; no wall sliding.
        f=max(0,hit-.01/max(.01,math.dist(old,proposed)))
        state.x=old[0]+(proposed[0]-old[0])*f;state.y=old[1]+(proposed[1]-old[1])*f
        state.speed=0
        if not state.touching:state.collisions+=1
        if not state.touching:state.touch_point=(state.x,state.y)
        state.touching=True;state.message='壁で止まった。後退できる'
    else:
        state.x,state.y=proposed
        if state.touch_point is not None and math.dist((state.x,state.y),state.touch_point)>.3:state.touching=False;state.touch_point=None
    state.distance+=math.dist(old,(state.x,state.y));state.seconds+=dt

def frame(level,state,throttle=0,steer=0,brake=False,unload=False,**kw):
    if throttle and not brake and state.status=='driving' and state.departure_ready and station(level,state) is not None:
        state.pending_departure=station(level,state);state.departure_ready=False
    if unload:deliver(level,state)
    for _ in range(4):step(level,state,throttle,steer,brake,**kw)
    if state.pending_departure is not None and math.dist((state.x,state.y),level.stops[state.pending_departure])>11:
        state.trips+=1;state.pending_departure=None
    if (not throttle or brake) and station(level,state) is not None:state.departure_ready=True

def state_dict(state):
    from dataclasses import asdict
    return asdict(state)

KEYS={'UP','DOWN','LEFT','RIGHT','SPACE','Z','U','P','R','N','Q'}
class Game:
    def __init__(self):
        self.level=1;self.title=True;self.paused=False;self.editing=False;self.gate=False
        self.parcel=0;self.slot=0;self.reset(gate=False)
    @property
    def road(self):return LEVELS[self.level]
    def reset(self,gate=True):
        self.state=initial(self.road);self.checkpoint=deepcopy(self.state)
        self.paused=False;self.editing=False;self.gate=gate;self.parcel=0;self.slot=0
    def update(self,held,edges):
        if self.gate:
            if not (held|edges)&KEYS:self.gate=False
            return
        if self.title:
            if 'SPACE' in edges:self.title=False;self.reset()
            return
        if 'R' in edges:self.reset();return
        if self.state.status=='success':
            return
        if 'Z' in edges:
            if self.editing:self.editing=False;self.gate=True
            elif station(self.road,self.state) is not None:self.editing=True;self.gate=True
            else:self.state.message='区画で止まってから Z'
            return
        if self.editing:
            if 'UP' in edges:self.parcel=(self.parcel-1)%3
            if 'DOWN' in edges:self.parcel=(self.parcel+1)%3
            if 'LEFT' in edges:self.slot=(self.slot-1)%4
            if 'RIGHT' in edges:self.slot=(self.slot+1)%4
            if 'SPACE' in edges:
                ok=transfer(self.road,self.state,self.parcel,self.slot,station(self.road,self.state))
                self.state.message='積み直した' if ok else 'その荷物はここにない/同じ場所'
            return
        throttle=int('UP' in held)-int('DOWN' in held)
        steer=int('RIGHT' in held)-int('LEFT' in held)
        frame(self.road,self.state,throttle,steer,'SPACE' in held,'SPACE' in edges)

class App:
    """UI reads state. The model does not import Pyxel."""
    def __init__(self):
        self.game=Game()
    def control(self,held,edges):
        self.game.update(set(held),set(edges))
    def draw(self,p):
        g=self.game;s=g.state;l=g.road
        p.cls(1)
        p.text(12,10,'WEIGHTED CART / STAGE '+str(STAGE)+' / ROAD '+str(g.level+1),10)
        mass,bias=mass_bias(l,s)
        p.text(12,25,f'MASS {mass}  BIAS {bias:+.3f}  SPEED {s.speed:+.2f}',7)
        p.text(12,39,f'DELIVERED {s.delivered}/3  HITS {s.collisions}  TRIPS {s.trips}',6)
        def pos(q):return 48+q[0]*2,56+q[1]*2
        p.rect(48,56,384,256,3)
        for tx,ty in l.road:p.rect(48+tx*4,56+ty*4,4,4,4)
        for i,q in enumerate(l.stops):
            x,y=pos(q);p.circb(x,y,22,6)
            label='DEPOT' if i==0 else ''.join('ABC'[j] for j,t in enumerate(l.targets) if t==i) or 'STORE'
            if g.level==1 and i==1:p.text(x+28,y-3,label,7)
            else:p.text(x-10,y-29,label,7)
            p.text(x-2,y+12,str(i),6)
        x,y=pos((s.x,s.y));p.circ(x,y,RADIUS*2,5);p.circb(x,y,RADIUS*2,7)
        forward=(math.cos(s.angle),math.sin(s.angle));normal=(-forward[1],forward[0])
        p.line(x,y,x+forward[0]*19,y+forward[1]*19,10)
        for i,o in enumerate(s.owners):
            if o.startswith('slot:'):
                offset=(int(o[-1])-1)*8
                bx=x+normal[0]*offset;by=y+normal[1]*offset
                p.rect(bx-3,by-3,7,7,(10,11,14)[i]);p.rectb(bx-3,by-3,7,7,1)
        p.circ(x+normal[0]*bias*12,y+normal[1]*bias*12,2,8)
        p.text(12,324,'A: '+s.owners[0]+'   B: '+s.owners[1]+'   C: '+s.owners[2],7)
        p.text(12,339,f'POS ({s.x:.2f}, {s.y:.2f}) ANGLE {s.angle:+.3f} DIST {s.distance:.2f}',6)
        p.text(12,355,'STATUS: '+s.status.upper()+(' / PAUSED' if g.paused else '')+(' / RELEASE KEYS' if g.gate else ''),10)
        p.text(12,375,'UP/DOWN: DRIVE   SPACE: BRAKE'+(' + DELIVER ON PRESS' if STAGE>=4 else ''),7)
        p.text(12,391,('LEFT/RIGHT: STEER   Z: CARGO   ' if STAGE>=2 else 'STRAIGHT PRACTICE   ')+'R: RESET   ESC: QUIT',7)
        p.text(12,407,'U: DEPARTURE UNDO   P: PAUSE   Q: TITLE   N: NEXT AFTER WIN' if STAGE==5 else ('WALL COLLISION ON' if STAGE>=3 else 'PRACTICE: WALL COLLISION OFF'),6)
        notices={'配達した。荷重が変わった':'DELIVERED: MASS AND BIAS CHANGED','壁で止まった。後退できる':'WALL CONTACT: REVERSE TO TRY AGAIN','直前の出発へ戻した':'RESTORED LAST DEPARTURE (CARGO INCLUDED)','区画で止まってから Z':'CARGO: STOP INSIDE A STATION FIRST','積み直した':'CARGO MOVED / SWAPPED','その荷物はここにない/同じ場所':'CARGO NOT HERE / ALREADY IN THAT PLACE'}
        p.text(12,423,notices.get(s.message,'NO TIME LIMIT / GAME LOAD MODEL, NOT REAL VEHICLE PHYSICS'),6)
        if g.title:
            p.rect(64,111,352,142,1);p.rectb(64,111,352,142,7)
            p.text(88,130,'CARGO CHANGES ACCELERATION AND TURNING',10)
            p.text(88,153,'SPACE: START; THEN RELEASE ALL KEYS',7)
            p.text(88,176,'LEFT/RIGHT: CHOOSE ROAD 1, 2, 3' if STAGE==5 else 'THIS LESSON USES ROAD '+str(g.level+1),6)
            p.text(88,199,'STOPPED CART CANNOT ROTATE' if STAGE>=2 else 'FIRST: ACCELERATE, COAST, BRAKE, REVERSE',7)
            p.text(88,222,'LOAD SLOTS: LEFT / CENTER / RIGHT',6)
        if g.editing:
            p.rect(60,105,360,169,1);p.rectb(60,105,360,169,6)
            p.text(78,119,'CARGO: UP/DOWN PICK; LEFT/RIGHT DESTINATION',10)
            for i,o in enumerate(s.owners):
                p.text(78,143+i*22,('> ' if i==g.parcel else '  ')+'ABC'[i]+f' MASS {l.masses[i]} : '+o,(10,11,14)[i])
            p.text(78,214,'DEST: '+('LEFT','CENTER','RIGHT','LOCAL STORE')[g.slot],7)
            p.text(78,236,'SPACE: MOVE/SWAP   Z: CLOSE; RELEASE KEYS',6)
            p.text(78,255,'RED DOT = LOAD BIAS; SLOT IS CART-RELATIVE',8)
        if g.paused:p.rectb(53,61,374,246,10)

def tap(app,key):
    app.control({key},{key});app.control(set(),set())

def make_checkpoint(app):
    """Deterministic QA recipe, not an automatic solution or human playtest."""
    if STAGE==5:tap(app,'RIGHT')
    tap(app,'SPACE')
    if STAGE==1:
        for _ in range(30):app.control({'UP'},set())
    elif STAGE==2:
        tap(app,'Z');tap(app,'RIGHT');tap(app,'SPACE')
    elif STAGE==3:
        # Explicit synthetic fixture: drive at the road's upper wall.
        s=app.game.state;s.x,s.y=(64,100);s.angle=-math.pi/2;s.speed=40
        for _ in range(45):app.control({'UP'},set())
    elif STAGE==4:
        # Explicit synthetic fixture: press delivery at station 1 at speed 5.
        s=app.game.state;s.x,s.y=app.game.road.stops[1];s.speed=5
        tap(app,'SPACE')
    else:
        tap(app,'Z');tap(app,'RIGHT');tap(app,'SPACE');tap(app,'Z')
        for _ in range(30):app.control({'UP'},set())
        tap(app,'U')

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(480,444,title='Weighted cart lesson '+str(STAGE),fps=FPS,display_scale=2,headless=headless)
    app=App()
    if demo:make_checkpoint(app)
    keymap={k:getattr(p,'KEY_'+k) for k in KEYS}
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=3:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit();return
        if not headless:app.control({k for k,v in keymap.items() if p.btn(v)},{k for k,v in keymap.items() if p.btnp(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 05 / 05

積み方ごと、直前の出発へ戻す

まるごとの写し(深いコピー)と、キーが離されるのを待つ仕組み。

05 の model と Game は、元のゲームと同じ関数・クラスの定義です。表紙で左右キーを使って3つの道路を選べます。道路1はまっすぐ、道路2は左右に曲がる道、道路3は細い内側と広い外回りがあります。Space で始めたら、操作キーを全部離します。N は3つ配達したあとだけ次の道路へ。Q は表紙へ、R は今の道路の最初の積み方へ、P は一時停止です。

U は1フレーム戻す Undo ではなく、直前に出発した時点に戻す機能です。区画に止まっていて出発の準備ができている(departure_ready)とき、ブレーキなしで前後の入力をすると、その時点の状態を checkpoint に deepcopy(まるごと写し)します。速さ・位置・角度・箱の場所・配達数・壁に触れているか・距離・秒数・出発の回数まで含めた State の写しです。owners はリストなので、浅い写し(中身を共有するコピー)だと、積み替えたときに保存した記録まで変わってしまうおそれがあります。

出発と数えるには、止まった区画で入力し、そのあと区画から11pixel より外に出る必要があります。離れる前は pending_departure(出発の準備中)で、離れたときに trips を増やします。区画で何も押さないか、ブレーキをかけると、次の出発の準備ができます。配達したあと止まって、そこから出発した時点の写しなら、U で戻ってもその配達は残ります。R とは目的が違います。

表紙からの開始、積み替え画面の開け閉め、P、R、U、N、Q には、入力を一時止める仕組み(gate)があります。held と edges の両方から操作キーが全部なくなるまで、次の動きを受け付けません。Space を押したまま上を加え、Space だけを離しても、上が残っていればまだ待ちます。一時停止中や成功したときに押したキーを、次の画面に持ち越さないための作りです。P で止めている間は State の全項目が止まり、再開するにも P を押し直して全部離します。これは、保存ファイル、巻き戻し動画、全操作の履歴の機能ではありません。

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

05_departure.py / 注目する部分
        if throttle and 'SPACE' not in held and self.state.departure_ready and station(self.road,self.state) is not None:
            self.checkpoint=deepcopy(self.state)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_departure.py
macOS / Linux
.venv/bin/python 05_departure.py
画面で見ること表紙で右を1回 → 離す → Space → 離す。Z → 離す → 右 → 離す → Space で A を真ん中へ、Z → 離す。上で少し走ってから U → 全部離す と押します。道路2の出発点 (24,100)、MASS 7・BIAS 0、A:slot:1/B:slot:0/C:slot:2 に戻ります。R なら、最初の並び・偏り −2/7 に戻ります。
  • 出発する前の State を、まるごと写す
  • 出発時点に戻すことと、最初からやり直すことを区別する
  • キーが全部離されるのを待つ
つまずきやすいところU と R を同じ処理にしないでください。U は出発したときの箱の状態を保存し、R は道路の最初の状態を作り直します。入力を止めている間に、キーを1つだけ離しても、全部離したことにはなりません。
少し変えてみよう

道路2で A を真ん中に入れ替えて出発 → P → 全部離す と押します。上や Space を押しても、距離は変わりません。全部離す → P → 全部離す → 上 で再開します。U で真ん中積みの出発時点に、R で元の左積みに戻る違いを確かめてください。

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

"""019 くるり荷車 / 第5段階。説明は LESSON.md。単独実行できます。"""
STAGE=5
"""Pure load-dependent cart approximation. Units: pixels, seconds, radians.
The raster road and solid 2px cells are both drawn and collided with.
Continuous swept circle vs each solid cell includes rounded corner contacts.
"""
from dataclasses import dataclass, field
from copy import deepcopy
import math
DT=1/120
FPS=30
RADIUS=7.0
WORLD=(192,128)
TILE=2

def line(a,b):
    n=max(1,math.ceil(math.dist(a,b)))
    return [(a[0]+(b[0]-a[0])*i/n,a[1]+(b[1]-a[1])*i/n) for i in range(n+1)]
def arc(c,r,a,b):
    n=max(1,math.ceil(abs(b-a)*r))
    return [(c[0]+r*math.cos(a+(b-a)*i/n),c[1]+r*math.sin(a+(b-a)*i/n)) for i in range(n+1)]
@dataclass(frozen=True)
class Level:
    title:str
    start:tuple
    angle:float
    masses:tuple
    targets:tuple
    stops:tuple
    paths:tuple
    pads:tuple=()
    road:frozenset=field(default_factory=frozenset)

def make_level(title,start,angle,masses,targets,stops,paths,pads=()):
    # Tile-centre rasterization; the exact visible tile boundary is the collision wall.
    road=set()
    for tx in range(WORLD[0]//TILE):
        for ty in range(WORLD[1]//TILE):
            p=(tx*TILE+1,ty*TILE+1)
            if any(any(math.dist(p,q)<=width/2 for q in path) for path,width in paths) or any(math.dist(p,c)<=r for c,r in pads):road.add((tx,ty))
    return Level(title,start,angle,masses,targets,stops,paths,pads,frozenset(road))
B_PATH=line((24,100),(64,100))+arc((64,74),26,math.pi/2,0)+line((90,74),(90,60))+arc((116,60),26,math.pi,math.pi*1.5)+line((116,34),(166,34))
C_INNER=line((40,100),(84,100))+arc((84,76),24,math.pi/2,0)+line((108,76),(108,52))+arc((132,52),24,math.pi,math.pi*1.5)+line((132,28),(164,28))
C_OUTER=line((28,100),(28,52))+arc((52,52),24,math.pi,math.pi*1.5)+line((52,28),(164,28))
LEVELS=(
make_level('まっすぐな市場',(24,100),0,(1,2,1),(1,2,2),((24,100),(86,100),(164,100)),((line((24,100),(164,100)),34),),(((24,100),20),((164,100),18))),
make_level('左、そして右',(24,100),0,(3,1,1),(1,2,2),((24,100),(90,74),(166,34),(90,52)),((B_PATH,32),),(((24,100),20),((90,52),18),((166,34),18))),
make_level('路地と広い外周',(40,100),0,(1,2,3),(1,1,1),((40,100),(164,28),(76,100)),((C_INNER,24),(C_OUTER,36)),(((40,100),34),((164,28),18),((76,100),15)))
)
@dataclass
class State:
    x:float
    y:float
    angle:float
    speed:float=0
    owners:list=field(default_factory=lambda:['slot:0','slot:1','slot:2'])
    delivered:int=0
    collisions:int=0
    distance:float=0
    seconds:float=0
    trips:int=0
    touching:bool=False
    touch_point:tuple|None=None
    departure_ready:bool=True
    pending_departure:int|None=None
    status:str='driving'
    message:str=''

def initial(level):return State(*level.start,level.angle)
def mass_bias(level,state):
    load=[(level.masses[i],int(o[-1])-1) for i,o in enumerate(state.owners) if o.startswith('slot:')]
    mass=2+sum(m for m,x in load)
    return mass,sum(m*x for m,x in load)/mass

def omega(speed,steer,bias,b_term=True):return speed/24*(1.3*steer+(0.7*bias if b_term else 0))
def station(level,state):
    if abs(state.speed)>0.8:return None
    found=[(math.dist((state.x,state.y),p),i) for i,p in enumerate(level.stops) if math.dist((state.x,state.y),p)<=11]
    return min(found)[1] if found else None

def transfer(level,state,parcel,target,stop):
    """Move/swap between slots or local storage; exactly one owner per parcel."""
    if station(level,state)!=stop or state.owners[parcel]=='delivered':return False
    source=state.owners[parcel]
    if source.startswith('store:') and source!=f'store:{stop}':return False
    destination=f'store:{stop}' if target==3 else f'slot:{target}'
    if source==destination:return False
    if destination.startswith('slot:'):
        other=next((i for i,o in enumerate(state.owners) if o==destination),None)
        if other is not None:state.owners[other]=source
    state.owners[parcel]=destination
    return True

def deliver(level,state):
    if abs(state.speed)>5:return []
    near=[i for i,p in enumerate(level.stops) if math.dist((state.x,state.y),p)<=11]
    ids=[i for i,o in enumerate(state.owners) if o.startswith('slot:') and level.targets[i] in near]
    for i in ids:state.owners[i]='delivered'
    state.delivered=sum(o=='delivered' for o in state.owners)
    if ids:state.message='配達した。荷重が変わった'
    if state.delivered==3:state.status='success';state.speed=0
    return ids

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

def sweep_box(p,d,box,r=RADIUS):
    x0,y0,x1,y1=box
    near=(min(x1,max(x0,p[0])),min(y1,max(y0,p[1])))
    if math.dist(p,near)<r-1e-8:return 0.
    hits=[]
    if d[0]>0:
        t=(x0-r-p[0])/d[0]
        if 0<=t<=1 and y0<=p[1]+d[1]*t<=y1:hits.append(t)
    if d[0]<0:
        t=(x1+r-p[0])/d[0]
        if 0<=t<=1 and y0<=p[1]+d[1]*t<=y1:hits.append(t)
    if d[1]>0:
        t=(y0-r-p[1])/d[1]
        if 0<=t<=1 and x0<=p[0]+d[0]*t<=x1:hits.append(t)
    if d[1]<0:
        t=(y1+r-p[1])/d[1]
        if 0<=t<=1 and x0<=p[0]+d[0]*t<=x1:hits.append(t)
    for c in ((x0,y0),(x0,y1),(x1,y0),(x1,y1)):
        t=circle_hit(p,d,c,r)
        if t is not None:hits.append(max(0,t))
    return min(hits) if hits else None

def sweep_road(level,p,q,r=RADIUS):
    d=(q[0]-p[0],q[1]-p[1]);hit=None
    for tx in range(math.floor((min(p[0],q[0])-r)/TILE),math.floor((max(p[0],q[0])+r)/TILE)+1):
        for ty in range(math.floor((min(p[1],q[1])-r)/TILE),math.floor((max(p[1],q[1])+r)/TILE)+1):
            if (tx,ty) in level.road:continue
            t=sweep_box(p,d,(tx*TILE,ty*TILE,(tx+1)*TILE,(ty+1)*TILE),r)
            if t is not None and (hit is None or t<hit):hit=t
    return hit

def clearance(level,p):
    """Distance from cart edge to closest solid tile (bounded 20px search)."""
    best=30.
    tx,ty=int(p[0]//TILE),int(p[1]//TILE)
    for a in range(tx-16,tx+17):
        for b in range(ty-16,ty+17):
            if (a,b) in level.road:continue
            q=(min((a+1)*TILE,max(a*TILE,p[0])),min((b+1)*TILE,max(b*TILE,p[1])))
            best=min(best,math.dist(p,q))
    return best-RADIUS

def approach(v,target,amount):return min(target,v+amount) if v<target else max(target,v-amount)
def step(level,state,throttle=0,steer=0,brake=False,dt=DT,b_term=True,fixed_mass=None):
    if state.status!='driving':return
    mass,bias=mass_bias(level,state)
    if fixed_mass is not None:mass=fixed_mass
    # Brake wins over throttle; neutral drag never crosses zero.
    if brake:state.speed=approach(state.speed,0,100/mass*dt)
    elif throttle:state.speed=max(-16,min(40,state.speed+70/mass*max(-1,min(1,throttle))*dt))
    else:state.speed=approach(state.speed,0,8*dt)
    old=(state.x,state.y)
    state.angle=(state.angle+omega(state.speed,max(-1,min(1,steer)),bias,b_term)*dt+math.pi)%(2*math.pi)-math.pi
    proposed=(state.x+math.cos(state.angle)*state.speed*dt,state.y+math.sin(state.angle)*state.speed*dt)
    hit=sweep_road(level,old,proposed)
    if hit is not None:
        # Stop at first contact, not at proposed endpoint; no wall sliding.
        f=max(0,hit-.01/max(.01,math.dist(old,proposed)))
        state.x=old[0]+(proposed[0]-old[0])*f;state.y=old[1]+(proposed[1]-old[1])*f
        state.speed=0
        if not state.touching:state.collisions+=1
        if not state.touching:state.touch_point=(state.x,state.y)
        state.touching=True;state.message='壁で止まった。後退できる'
    else:
        state.x,state.y=proposed
        if state.touch_point is not None and math.dist((state.x,state.y),state.touch_point)>.3:state.touching=False;state.touch_point=None
    state.distance+=math.dist(old,(state.x,state.y));state.seconds+=dt

def frame(level,state,throttle=0,steer=0,brake=False,unload=False,**kw):
    if throttle and not brake and state.status=='driving' and state.departure_ready and station(level,state) is not None:
        state.pending_departure=station(level,state);state.departure_ready=False
    if unload:deliver(level,state)
    for _ in range(4):step(level,state,throttle,steer,brake,**kw)
    if state.pending_departure is not None and math.dist((state.x,state.y),level.stops[state.pending_departure])>11:
        state.trips+=1;state.pending_departure=None
    if (not throttle or brake) and station(level,state) is not None:state.departure_ready=True

def state_dict(state):
    from dataclasses import asdict
    return asdict(state)

KEYS={'UP','DOWN','LEFT','RIGHT','SPACE','Z','U','P','R','N','Q'}
class Game:
    def __init__(self):
        self.level=0;self.title=True;self.paused=False;self.editing=False;self.gate=False
        self.parcel=0;self.slot=0;self.reset(gate=False)
    @property
    def road(self):return LEVELS[self.level]
    def reset(self,gate=True):
        self.state=initial(self.road);self.checkpoint=deepcopy(self.state)
        self.paused=False;self.editing=False;self.gate=gate;self.parcel=0;self.slot=0
    def update(self,held,edges):
        if self.gate:
            if not (held|edges)&KEYS:self.gate=False
            return
        if self.title:
            if 'LEFT' in edges:self.level=(self.level-1)%3;self.reset(False)
            if 'RIGHT' in edges:self.level=(self.level+1)%3;self.reset(False)
            if 'SPACE' in edges:self.title=False;self.reset()
            return
        if 'R' in edges:self.reset();return
        if 'Q' in edges:self.title=True;self.gate=True;return
        if 'P' in edges:self.paused=not self.paused;self.gate=True;return
        if self.paused:return
        if 'U' in edges:
            self.state=deepcopy(self.checkpoint);self.editing=False;self.gate=True
            self.state.message='直前の出発へ戻した';return
        if self.state.status=='success':
            if 'N' in edges:self.level=(self.level+1)%3;self.reset()
            return
        if 'Z' in edges:
            if self.editing:self.editing=False;self.gate=True
            elif station(self.road,self.state) is not None:self.editing=True;self.gate=True
            else:self.state.message='区画で止まってから Z'
            return
        if self.editing:
            if 'UP' in edges:self.parcel=(self.parcel-1)%3
            if 'DOWN' in edges:self.parcel=(self.parcel+1)%3
            if 'LEFT' in edges:self.slot=(self.slot-1)%4
            if 'RIGHT' in edges:self.slot=(self.slot+1)%4
            if 'SPACE' in edges:
                ok=transfer(self.road,self.state,self.parcel,self.slot,station(self.road,self.state))
                self.state.message='積み直した' if ok else 'その荷物はここにない/同じ場所'
            return
        throttle=int('UP' in held)-int('DOWN' in held)
        steer=int('RIGHT' in held)-int('LEFT' in held)
        if throttle and 'SPACE' not in held and self.state.departure_ready and station(self.road,self.state) is not None:
            self.checkpoint=deepcopy(self.state)
        frame(self.road,self.state,throttle,steer,'SPACE' in held,'SPACE' in edges)

class App:
    """UI reads state. The model does not import Pyxel."""
    def __init__(self):
        self.game=Game()
    def control(self,held,edges):
        self.game.update(set(held),set(edges))
    def draw(self,p):
        g=self.game;s=g.state;l=g.road
        p.cls(1)
        p.text(12,10,'WEIGHTED CART / STAGE '+str(STAGE)+' / ROAD '+str(g.level+1),10)
        mass,bias=mass_bias(l,s)
        p.text(12,25,f'MASS {mass}  BIAS {bias:+.3f}  SPEED {s.speed:+.2f}',7)
        p.text(12,39,f'DELIVERED {s.delivered}/3  HITS {s.collisions}  TRIPS {s.trips}',6)
        def pos(q):return 48+q[0]*2,56+q[1]*2
        p.rect(48,56,384,256,3)
        for tx,ty in l.road:p.rect(48+tx*4,56+ty*4,4,4,4)
        for i,q in enumerate(l.stops):
            x,y=pos(q);p.circb(x,y,22,6)
            label='DEPOT' if i==0 else ''.join('ABC'[j] for j,t in enumerate(l.targets) if t==i) or 'STORE'
            if g.level==1 and i==1:p.text(x+28,y-3,label,7)
            else:p.text(x-10,y-29,label,7)
            p.text(x-2,y+12,str(i),6)
        x,y=pos((s.x,s.y));p.circ(x,y,RADIUS*2,5);p.circb(x,y,RADIUS*2,7)
        forward=(math.cos(s.angle),math.sin(s.angle));normal=(-forward[1],forward[0])
        p.line(x,y,x+forward[0]*19,y+forward[1]*19,10)
        for i,o in enumerate(s.owners):
            if o.startswith('slot:'):
                offset=(int(o[-1])-1)*8
                bx=x+normal[0]*offset;by=y+normal[1]*offset
                p.rect(bx-3,by-3,7,7,(10,11,14)[i]);p.rectb(bx-3,by-3,7,7,1)
        p.circ(x+normal[0]*bias*12,y+normal[1]*bias*12,2,8)
        p.text(12,324,'A: '+s.owners[0]+'   B: '+s.owners[1]+'   C: '+s.owners[2],7)
        p.text(12,339,f'POS ({s.x:.2f}, {s.y:.2f}) ANGLE {s.angle:+.3f} DIST {s.distance:.2f}',6)
        p.text(12,355,'STATUS: '+s.status.upper()+(' / PAUSED' if g.paused else '')+(' / RELEASE KEYS' if g.gate else ''),10)
        p.text(12,375,'UP/DOWN: DRIVE   SPACE: BRAKE'+(' + DELIVER ON PRESS' if STAGE>=4 else ''),7)
        p.text(12,391,('LEFT/RIGHT: STEER   Z: CARGO   ' if STAGE>=2 else 'STRAIGHT PRACTICE   ')+'R: RESET   ESC: QUIT',7)
        p.text(12,407,'U: DEPARTURE UNDO   P: PAUSE   Q: TITLE   N: NEXT AFTER WIN' if STAGE==5 else ('WALL COLLISION ON' if STAGE>=3 else 'PRACTICE: WALL COLLISION OFF'),6)
        notices={'配達した。荷重が変わった':'DELIVERED: MASS AND BIAS CHANGED','壁で止まった。後退できる':'WALL CONTACT: REVERSE TO TRY AGAIN','直前の出発へ戻した':'RESTORED LAST DEPARTURE (CARGO INCLUDED)','区画で止まってから Z':'CARGO: STOP INSIDE A STATION FIRST','積み直した':'CARGO MOVED / SWAPPED','その荷物はここにない/同じ場所':'CARGO NOT HERE / ALREADY IN THAT PLACE'}
        p.text(12,423,notices.get(s.message,'NO TIME LIMIT / GAME LOAD MODEL, NOT REAL VEHICLE PHYSICS'),6)
        if g.title:
            p.rect(64,111,352,142,1);p.rectb(64,111,352,142,7)
            p.text(88,130,'CARGO CHANGES ACCELERATION AND TURNING',10)
            p.text(88,153,'SPACE: START; THEN RELEASE ALL KEYS',7)
            p.text(88,176,'LEFT/RIGHT: CHOOSE ROAD 1, 2, 3' if STAGE==5 else 'THIS LESSON USES ROAD '+str(g.level+1),6)
            p.text(88,199,'STOPPED CART CANNOT ROTATE' if STAGE>=2 else 'FIRST: ACCELERATE, COAST, BRAKE, REVERSE',7)
            p.text(88,222,'LOAD SLOTS: LEFT / CENTER / RIGHT',6)
        if g.editing:
            p.rect(60,105,360,169,1);p.rectb(60,105,360,169,6)
            p.text(78,119,'CARGO: UP/DOWN PICK; LEFT/RIGHT DESTINATION',10)
            for i,o in enumerate(s.owners):
                p.text(78,143+i*22,('> ' if i==g.parcel else '  ')+'ABC'[i]+f' MASS {l.masses[i]} : '+o,(10,11,14)[i])
            p.text(78,214,'DEST: '+('LEFT','CENTER','RIGHT','LOCAL STORE')[g.slot],7)
            p.text(78,236,'SPACE: MOVE/SWAP   Z: CLOSE; RELEASE KEYS',6)
            p.text(78,255,'RED DOT = LOAD BIAS; SLOT IS CART-RELATIVE',8)
        if g.paused:p.rectb(53,61,374,246,10)

def tap(app,key):
    app.control({key},{key});app.control(set(),set())

def make_checkpoint(app):
    """Deterministic QA recipe, not an automatic solution or human playtest."""
    if STAGE==5:tap(app,'RIGHT')
    tap(app,'SPACE')
    if STAGE==1:
        for _ in range(30):app.control({'UP'},set())
    elif STAGE==2:
        tap(app,'Z');tap(app,'RIGHT');tap(app,'SPACE')
    elif STAGE==3:
        # Explicit synthetic fixture: drive at the road's upper wall.
        s=app.game.state;s.x,s.y=(64,100);s.angle=-math.pi/2;s.speed=40
        for _ in range(45):app.control({'UP'},set())
    elif STAGE==4:
        # Explicit synthetic fixture: press delivery at station 1 at speed 5.
        s=app.game.state;s.x,s.y=app.game.road.stops[1];s.speed=5
        tap(app,'SPACE')
    else:
        tap(app,'Z');tap(app,'RIGHT');tap(app,'SPACE');tap(app,'Z')
        for _ in range(30):app.control({'UP'},set())
        tap(app,'U')

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(480,444,title='Weighted cart lesson '+str(STAGE),fps=FPS,display_scale=2,headless=headless)
    app=App()
    if demo:make_checkpoint(app)
    keymap={k:getattr(p,'KEY_'+k) for k in KEYS}
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=3:
            if screenshot:app.draw(p);p.screen.save(screenshot,1)
            p.quit();return
        if not headless:app.control({k for k,v in keymap.items() if p.btn(v)},{k for k,v in keymap.items() if p.btnp(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory')
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
AFTER STEP 05

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

ルールのまとめ:台車の重さ2に、荷台の箱の重さだけを足します。左右の位置 −1/0/+1 と重さを掛けた合計を全質量で割って、偏りを求めます。速さによって曲がり方が変わり、2pxの道路と半径7の円の台車で、途中までの衝突を調べます。区画で箱を入れ替えたり保管したりしながら、3つの箱を届けます。止まったままの旋回・時間制限・ぶつかって失敗、はありません。現実の車の設計に使える物理ではありません。

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

比べる点5ステップの教材元の完成版
段階01 まっすぐ走る、02 荷物の重さとハンドル、03 途中までの衝突判定、04 配達、05 入力の制御と出発時点への復帰。01/02 は壁を無視します。最初から、荷物の重さ・衝突・積み替え・配達・3つの道路・出発時点への復帰があります。
表示と起動英数字の画面(UI)、1つのファイルだけで起動。01 は道路1、02〜04 は道路2だけ、05 は3つの道路。予測の線・日本語の文字・音はなし。日本語の文字・飾り・短い予測の線・自動デモと、複数のファイル。
モデル比較04/05 のモデルの定義すべてと、05 の Game の定義を、プログラムの構造(AST)で比べています。7つの記録した道順を毎フレーム照らし合わせ、境目の値や入力の並びも検査しています。小数で表される状態の全部を調べたわけではありません。3つの道路、作者が用意した7つの道順。元のゲームのファイルは変えずに、別に配っています。
検証限界Python 3.12.14 / Pyxel 2.9.9 での自動テストと、実際の描画で確かめています。Windows/macOS の実際のパソコン、ブラウザー、音、人が学べるかどうかは、まだ確かめていません。同じ ZIP に入っている過去の動作確認(QA)と、今回新しく切り離して実行した確認を、混同しないでください。

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

  1. model.py の mass_bias / omega:プラス・マイナスの向きと単位
  2. model.py の sweep_box / circle_hit:角と辺にぶつかる計算
  3. model.py の frame:配達 → 4回の step → 出発の回数、の順番
  4. game.py の reset / update:入力を止める仕組み(gate)と、出発時点の写し(checkpoint)
  5. plans.py / solution_replays.json:作者が用意した7つの道順、外回りと分けて配達する道順
自分のゲームに変えるなら

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

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