シミュレーションLESSON 81重力・大気抵抗・有限燃料を扱う軌道パズル原作020

すれすれ衛星の作り方

低く飛んで観測し、そのあと一周できる軌道へ戻ります。
重力・大気の抵抗・限られた燃料を、小さな時間の刻みごとに計算します。数秒先までの短い予測と、一周できるかを調べる帰還の条件を、分けて作ります。観測窓(観測できる角度の範囲)の入口へ戻る仕組みも作ります。

完成版の遊び方低い空で観測しながら、大気の抵抗で速度が落ちることを読みます。短い噴射で、安全に一周できる帰還の軌道へ移ります。

すれすれ衛星の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の便Bを、自動で動かして描いた画面です。下の教材では、01〜04で便Aを使い、05で便A・B・Cの3つの便を扱います。
PLAY / 試験公開

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

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

完成版の主な操作:方向キーで噴射、Spaceで出発、Zで観測窓の入口へ戻る、Rでやり直し、Pで一時停止、Mで便を選ぶ、Nで次の便へ

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

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

うまく表示されないとき

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

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

短い時間の軌道の予測と、一周分の帰還の判定を分ける。

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

1. ベクトルと、決まった時間の刻みで進める kick-drift-kick
2. 衛星から見た向き(局所座標)、長さをそろえる正規化、半端に残った燃料
3. 大気の抵抗と位置の条件を、同じ刻みの中で計算する
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / tuple(タプル)、書き換えられない dataclass と replace / 速度と加速度のちがい、三角関数の基本(本文で補足します)
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
Space:選択画面から始める。キーを離してから操作する / 左右:02以降は接線方向の噴射(左は進む向きと逆、右は同じ向き)。05の選択画面では便A・B・Cを選ぶ / 上下:02以降は外向き・内向きの噴射 / P:一時停止・再開 / R:同じ便の、読み込んだときの最初の状態に戻す。コードを読み直すわけではない / Z:05だけ。直前の観測窓の入口へ戻る / M:05だけ。便の選択画面へ / N:05だけ。成功したあとに次の便へ / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_gravity.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

位置ではなく、速度を曲げて周回させる

ベクトルと、決まった時間の刻みで進める kick-drift-kick。

最初は、半径46の円い軌道を見ます。Space で開始し、キーを離すと、1秒に30フレーム(描画の回数)ずつ進みます。1フレームの中では、dt=1/120 秒の物理の更新を4回行います。描画の回数と、物理の時間を混ぜないこと。これが最初の設計のポイントです。矢印キーは、まだ使えません。P で止めると、座標と白い速度の矢印を見比べられます。

Body(衛星の位置や速度などをまとめたもの)の x・y は、惑星の中心から見た座標です。画面の下向きが +y です。半径(中心からの距離)は hypot(x,y) で求めます。最初の位置は (46,0)、速度は (0,-sqrt(MU/46)) なので、衛星はまず上へ進みます。重力は、惑星の中心へ向かう加速度です。位置に直接「左に1」を足すのではありません。速度が変わった結果として、位置が進みます。惑星の見た目は半径22、衛星の当たり判定の半径は2です。なので、ぶつかる距離(接触半径)は24です。

KDK(kick-drift-kick)は、次の順番で進めます。まず、速度に「加速度×dt/2」を足します(kick)。次に、その半分だけ進めた速度で、位置を dt だけ進めます(drift)。最後に、新しい位置での加速度を求めて、もう一度その加速度×dt/2 を速度に足します(kick)。描画用の trail(通った跡の線)は、モデルの物理の一部ではありません。swept_contact は、前の位置から新しい位置までの線分の上を調べます。そのため、1刻みで惑星を通り抜けてしまうような場合も、見落としにくくなります。接触した時刻の表示は、元のゲームと同じく、その刻みの終わりの時刻です。

この段階は、重力だけです。Body には、あとで使う燃料と spin の欄を先に用意しておきますが、燃料は減りません。大気の抵抗、噴射、観測、帰還、外側へ出すぎたときの失敗は、まだありません。速さの上限40と、惑星への接触は、最初から入れておきます。KDKという名前の方法を使っていても、これはゲーム用の模型です。あとで加える抵抗・推力・速さの制限があり、時間も決まった大きさで区切って計算します。なので、現実の宇宙機の動きと同じになるとは限りません。

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

01_gravity.py / 注目する部分
    hx=b.vx+ax*dt/2;hy=b.vy+ay*dt/2
    nx=b.x+hx*dt;ny=b.y+hy*dt

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_gravity.py
macOS / Linux
.venv/bin/python 01_gravity.py
画面で見ることSpace を押してキーを離し、約1秒待ちます。RADIUS(半径)はほぼ46のまま、TIME(時間)は約1.000です。自動のチェックポイント(決まった時点の記録)では、位置は(43.1935,-15.8216)です。P で止めている間は、時間も位置も変わりません。
  • 物理の dt と、描画の回数(fps)を分ける
  • 重力の向きと、速度の向きを区別する
  • 半分だけ進めた速度と新しい位置を使って、KDKを組み立てる
つまずきやすいところ2回目の加速度にも古い位置を使うと、KDKになりません。mid は、新しい位置と、半分だけ進めた速度を持つ Body です。描画の中で step を呼ぶと、見ているだけで状態が進んでしまいます。
少し変えてみよう

START_RADIUS=46.0 を START_RADIUS=50.0 に、1か所だけ変えます。保存して、Esc で終了し、もう一度実行します。RADIUS は50、最初の速さは sqrt(12000/50) ≒ 15.4919 になります。半径46の灰色の補助の円は、比べるために残ります。確かめたら46に戻します。R は読み込んだときの状態に戻すだけで、ソースコードを読み直しません。

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

"""020 すれすれ衛星 / 第1段階。説明は LESSON.md。単独実行できます。"""
STAGE=1
from dataclasses import dataclass, replace, asdict
import math, json, hashlib
MU=12000.0
CONTACT=24.0
DT=1/120
FPS=30
THRUST=6.0
BURN=4.0
MAX_SPEED=40.0
TAU=math.tau
START_RADIUS=46.0
@dataclass(frozen=True)
class Body:
    x:float;y:float;vx:float;vy:float;fuel:float=100.;spin:int=-1
@dataclass(frozen=True)
class State:
    b:Body
    t:float=0.
    mode:str='play'
    paused:bool=False
    drag:float=0.
@dataclass(frozen=True)
class Input:
    tangent:int=0
    radial:int=0
    pause:bool=False
    restart:bool=False

def fresh():
    return State(Body(START_RADIUS,0.,0.,-math.sqrt(MU/START_RADIUS)))
def radius(b):return math.hypot(b.x,b.y)

def angle(b):return math.atan2(b.y,b.x)

def elements(b):
    r=max(radius(b),1e-12);v2=b.vx*b.vx+b.vy*b.vy
    e=v2/2-MU/r;h=b.x*b.vy-b.y*b.vx
    ecc=math.sqrt(max(0.,1+2*e*h*h/(MU*MU)))
    peri=h*h/(MU*(1+ecc))
    apo=math.inf if e>=0 else -MU/(2*e)*(1+ecc)
    return peri,apo,e,h

def swept_contact(x,y,nx,ny,r=CONTACT):
    if x*x+y*y<=r*r:return 0.
    dx=nx-x;dy=ny-y;a=dx*dx+dy*dy
    if a<1e-24:return None
    b=2*(x*dx+y*dy);c=x*x+y*y-r*r;disc=b*b-4*a*c
    if disc<0:return None
    t=(-b-math.sqrt(disc))/(2*a)
    return t if 0<=t<=1 else None
def acceleration(b):
    r=max(radius(b),CONTACT)
    return -MU*b.x/r**3,-MU*b.y/r**3

def drift(b,dt=DT):
    """Gravity-only KDK; no fuel, thrust or atmosphere in lesson 01."""
    if dt<=0:return b,False
    if not all(math.isfinite(v) for v in (b.x,b.y,b.vx,b.vy,dt)):raise ValueError('nonfinite state')
    if radius(b)<=CONTACT:return b,True
    ax,ay=acceleration(b)
    hx=b.vx+ax*dt/2;hy=b.vy+ay*dt/2
    nx=b.x+hx*dt;ny=b.y+hy*dt
    hit=swept_contact(b.x,b.y,nx,ny)
    if hit is not None:
        return replace(b,x=b.x+(nx-b.x)*hit,y=b.y+(ny-b.y)*hit,vx=hx,vy=hy),True
    mid=replace(b,x=nx,y=ny,vx=hx,vy=hy)
    ax,ay=acceleration(mid)
    vx=hx+ax*dt/2;vy=hy+ay*dt/2
    speed=math.hypot(vx,vy)
    if speed>MAX_SPEED:vx*=MAX_SPEED/speed;vy*=MAX_SPEED/speed
    return replace(mid,vx=vx,vy=vy),False
def step(s,i=Input(),dt=DT):
    if i.restart:return fresh()
    if i.pause:return replace(s,paused=not s.paused)
    if s.paused or s.mode!='play':return s
    b,hit=drift(s.b,dt=dt)
    return replace(s,b=b,t=s.t+dt,mode='impact' if hit else 'play')

def frame(s,i=Input()):
    if i.pause or i.restart:return step(s,i)
    for _ in range(4):s=step(s,i)
    return s

STAGE_NAMES=("GRAVITY KDK","FUEL + LOCAL THRUST","AIR + OBSERVATION","PREVIEW + RETURN","ENTRY UNDO + FLIGHTS")

# --- The model above is independent from Pyxel. ---
KEYS=('SPACE','P','R','LEFT','RIGHT','UP','DOWN','Z','M','N')
STAGE_NAME=STAGE_NAMES[STAGE-1]

class App:
    def __init__(self):
        self.s=fresh()
        self.title=True
        self.gate=False
        self.last=Input()
        self.trail=[]
        self.preview=()
        self.ticks=0
    def control(self,held,edges):
        held=set(held)&set(KEYS);edges=set(edges)&set(KEYS)
        if self.gate:
            if not held:self.gate=False
            return
        if self.title:
            if STAGE==5:
                if 'LEFT' in edges:self.s=fresh((self.s.level-1)%3,self.s.drag)
                elif 'RIGHT' in edges:self.s=fresh((self.s.level+1)%3,self.s.drag)
            if 'SPACE' in edges:
                self.s=fresh(self.s.level,self.s.drag) if STAGE>=3 else fresh()
                self.title=False;self.gate=True;self.trail=[];self.preview=()
            return
        if STAGE==5 and 'M' in edges:
            self.title=True;self.gate=True;self.preview=();return
        if STAGE==5 and self.s.mode=='success' and 'N' in edges:
            self.s=fresh((self.s.level+1)%3,self.s.drag)
            self.gate=True;self.trail=[];self.preview=();return
        flags={'pause':'P' in edges,'restart':'R' in edges}
        if STAGE==5:flags['rewind']='Z' in edges
        tan=int('RIGHT' in held)-int('LEFT' in held) if STAGE>=2 else 0
        rad=int('UP' in held)-int('DOWN' in held) if STAGE>=2 else 0
        self.last=Input(tan,rad,**flags)
        self.s=frame(self.s,self.last)
        if any(flags.values()):
            self.gate=True;self.trail=[];self.preview=()
        self.ticks+=1
        if not self.s.paused:
            self.trail.append((self.s.b.x,self.s.b.y));self.trail=self.trail[-120:]
        if STAGE>=4 and (self.ticks%6==0 or self.s.paused or any(flags.values())):
            self.preview=predict(self.s.b,drag=self.s.drag)[1]
    def draw(self,p):
        s=self.s;b=s.b;r=radius(b)
        def point(x,y):return 240+x*1.42,171+y*1.42
        def arc(rad,lo,hi,color,dashed=False):
            for k in range(96):
                if dashed and k%4>=2:continue
                a=lo+(hi-lo)*k/96;c=lo+(hi-lo)*(k+1)/96
                p.line(*point(rad*math.cos(a),rad*math.sin(a)),*point(rad*math.cos(c),rad*math.sin(c)),color)
        def arrow(x,y,dx,dy,color):
            p.line(x,y,x+dx,y+dy,color);length=math.hypot(dx,dy)
            if length:
                ux,uy=dx/length,dy/length
                p.line(x+dx,y+dy,x+dx-ux*4-uy*2,y+dy-uy*4+ux*2,color)
                p.line(x+dx,y+dy,x+dx-ux*4+uy*2,y+dy-uy*4-ux*2,color)
        p.cls(1)
        for n in range(90):p.pset(12+(n*71)%456,44+(n*47)%253,5 if n%3 else 13)
        p.text(12,8,'LOW SATELLITE / '+str(STAGE)+' '+STAGE_NAME,10)
        level=s.level if STAGE>=3 else 0
        p.text(12,24,'FLIGHT '+'ABC'[level]+' / GAME PHYSICS / SILENT',6)
        if STAGE>=3:
            p.circ(240,171,36*1.42,2);arc(36,0,TAU,13,True);arc(88,0,TAU,5,True)
            l=LEVELS[s.level]
            for j,w in enumerate(l.windows):
                color=11 if s.progress[j]>=l.target else 10 if s.inside[j] else 6
                arc(52,w-math.pi/6,w+math.pi/6,color)
                for a in (w-math.pi/6,w+math.pi/6):p.line(*point(26*math.cos(a),26*math.sin(a)),*point(52*math.cos(a),52*math.sin(a)),color)
                x,y=point(64*math.cos(w),64*math.sin(w));p.text(x-18,y-3,f'W{j+1} {s.progress[j]:.1f}',color)
        else:arc(46,0,TAU,5,True)
        p.circ(240,171,22*1.42,5);p.circb(240,171,22*1.42,6)
        p.elli(222,152,24,13,3);p.tri(236,170,255,162,246,191,3)
        p.circ(228,160,2,11);p.circ(253,177,2,11)
        arc(CONTACT,0,TAU,13,True)
        for q in self.trail[::3]:p.pset(*point(*q),13)
        for q in self.preview[::2]:p.pset(*point(*q),10)
        x,y=point(b.x,b.y);arrow(x,y,b.vx*.75,b.vy*.75,7)
        p.rect(x-8,y-2,5,5,12);p.rect(x+4,y-2,5,5,12);p.rect(x-2,y-3,5,7,7);p.pset(x,y,1)
        p.text(12,282,'WHITE: TRUE VELOCITY',7)
        p.text(272,282,'YELLOW: 4s COAST PREVIEW' if STAGE>=4 else 'GREY: RECENT TRAIL',10 if STAGE>=4 else 13)
        if STAGE>=2:
            ux=b.x/max(r,1e-12);uy=b.y/max(r,1e-12)
            arrow(425,240,-uy*b.spin*20,ux*b.spin*20,11);arrow(425,240,ux*20,uy*20,14)
            p.text(385,263,'TAN / OUT',14)
        p.rect(0,301,480,139,0)
        p.text(12,310,f'RADIUS {r:6.2f}  TIME {s.t:6.3f}  MODE {s.mode.upper()}',7)
        if STAGE>=2:
            p.text(12,325,f'FUEL {b.fuel:6.3f} / 100',11);p.rectb(288,324,176,9,5);p.rect(289,325,b.fuel*1.74,7,11)
        else:p.text(12,325,'GRAVITY ONLY: NO THRUST / NO ATMOSPHERE',6)
        if STAGE>=3:
            text='WINDOWS '+', '.join(f'{v:.1f}/{LEVELS[s.level].target:g}' for v in s.progress)
            p.text(12,341,text+f'  AIR {s.drag:g}',6)
        else:p.text(12,341,'KICK(dt/2) -> DRIFT(dt) -> KICK(dt/2)',6)
        if STAGE>=4:
            peri,apo,energy,h=elements(b)
            p.text(12,357,f'PERI {peri:.2f} APO {apo:.2f}  CERT {s.certified}  HOLD {s.stable:.2f}/3',11)
        elif STAGE==3:p.text(12,357,'OBSERVATION ONLY / RETURN TEST ADDED IN STAGE 4',10)
        else:p.text(12,357,'MODEL dt=1/120 / FOUR STEPS PER FRAME / SPEED CAP 40',5)
        p.text(12,375,'LEFT/RIGHT: RETRO/PRO  UP/DOWN: OUT/IN' if STAGE>=2 else 'WATCH THE ORBIT; POSITION IS NOT A KEY INPUT',7)
        p.text(12,391,'P: PAUSE  R: RESET LOADED STATE  ESC: QUIT',7)
        p.text(12,407,'Z: ENTRY UNDO  M: MENU  N: NEXT AFTER SUCCESS' if STAGE==5 else 'SAVE CODE -> QUIT -> RERUN TO APPLY EDITS',6)
        p.text(12,425,'PAUSED / RELEASE P BEFORE RESUME' if s.paused else 'RELEASE ALL KEYS' if self.gate else 'FICTIONAL MODEL / NOT FOR REAL AEROSPACE OR SAFETY',10 if s.paused or self.gate else 5)
        if s.mode in ('success','impact','escape'):
            p.rect(134,50,212,26,0);p.rectb(134,50,212,26,11 if s.mode=='success' else 8)
            p.text(146,60,s.mode.upper()+' / R TO RETRY',11 if s.mode=='success' else 8)
        if self.title:
            p.rect(60,96,360,137,0);p.rectb(60,96,360,137,6)
            p.text(80,114,'STAGE '+str(STAGE)+': '+STAGE_NAME,10)
            p.text(80,135,'SPACE: START, THEN RELEASE KEYS',7)
            p.text(80,156,'LEFT/RIGHT: SELECT A, B, C' if STAGE==5 else 'FLIGHT A / FOLLOW LESSON.md FOR EXERCISES',6)
            hints=('A: IDLE IS A VALID SUCCESS','B: IDLE IS SLOW BUT VALID','C: IDLE WILL HIT THE PLANET')
            p.text(80,177,hints[level] if STAGE>=4 else 'PRACTICE / NO RETURN SUCCESS YET',11)
            p.text(80,205,'CONTACT R=24 / NUMBERS ARE RADII',5)

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

def make_checkpoint(app):
    """Bounded automatic control recipe; not human input or a full playtest."""
    if STAGE==5:tap(app,'RIGHT');tap(app,'RIGHT')
    tap(app,'SPACE')
    frames={1:30,2:30,3:45,4:130,5:300}[STAGE]
    for n in range(frames):
        held={'RIGHT','UP'} if STAGE==2 else {'RIGHT'} if STAGE==5 and n<6 else set()
        app.control(held,set())
    if STAGE==5:tap(app,'Z')

def main(headless=False,screenshot=None,demo=False,report=None):
    # CLI resolves output paths before even importing Pyxel (which may change cwd).
    import pyxel as p
    p.init(480,440,title='Low satellite / stage '+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)
            if report:
                from pathlib import Path
                Path(report).write_text(json.dumps({'stage':STAGE,'state':asdict(app.s),'title':app.title,'gate':app.gate,'recipe':'automatic checkpoint' if demo else 'initial'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            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='Standalone orbital lesson; fictional physics.')
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:parser.error('automated outputs require --headless-smoke')
    for name,suffix in (('screenshot','.png'),('report','.json')):
        value=getattr(args,name)
        if value:
            path=Path(value)
            if path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists() or path.is_symlink():parser.error('use a new '+suffix+' in an existing directory')
            setattr(args,name,str(path.resolve()))
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint,args.report)
STEP 02 / 05

有限燃料で、接線と半径方向へ噴く

衛星から見た向き(局所座標)、長さをそろえる正規化、半端に残った燃料。

右は、周回と同じ向きへの接線方向(円に沿って進む向き)の噴射です。左はその逆向きです。上は惑星の外向き、下は内向きの噴射です。画面の上下左右へ衛星を動かす操作ではありません。衛星が惑星の反対側へ回ると、衛星から見た軸(局所軸)も一緒に回ります。画面の小さな TAN(接線)と OUT(外向き)の矢印を見ましょう。接線の向きは、角運動量(回る勢い)の符号 spin で決めます。そのため、白い実際の速度の矢印と、いつも同じ向きになるとは限りません。

半径方向の単位ベクトル(長さ1の向き)は (x/r,y/r)、接線方向は (-uy×spin,ux×spin) です。左右と上下を同時に押すと、入力のベクトルの長さは sqrt(2) になります。そこで正規化(長さを1にそろえること)して、1方向だけのときと同じ大きさの推力6にします。こうすると、斜めの入力が「こっそり強い近道」になりません。使う燃料も同じです。同じ軸の反対向きのキー(左と右など)を同時に押すと、その軸の入力は0になります。

噴射している間は、1秒に燃料を4ずつ使います。1刻みに必要な燃料は BURN×dt です。残りの燃料がこれより少ないときは、残っている分だけを使います。そして、その割合 fraction で、その刻みの平均の推力を弱めます。大事なのは順番です。先に、入力された斜めの向きを正規化します。そのあとで、半端な燃料の割合を掛けます。半端な分まで、もう一度長さ1に正規化してしまうと、半分の燃料で全力の噴射ができてしまいます。燃料が0のときは、キーを押しても噴射しないのと同じになります。

ここで、元のゲームと同じ acceleration と drift を使い始めます。ただし、State.drag=0 にして毎刻み呼ぶので、この段階では大気の抵抗はありません。重力・噴射・燃料・速さの上限・接触を比べる練習です。観測と帰還はまだありません。R で燃料を100に戻せます。P や R を矢印と同時に押したあとは、すべてのキーを離すまで噴射を止めます。この仕組みを入力ゲートと呼びます。

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

02_fuel_thrust.py / 注目する部分
    n=math.hypot(tangent,radial)
    if n>1:tan/=n;rad/=n

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_fuel_thrust.py
macOS / Linux
.venv/bin/python 02_fuel_thrust.py
画面で見ることSpace を押して離し、右と上を同時に約1秒押します。自動のチェックポイントでは、FUEL(燃料)は96.000、TIME は1.000、位置は(45.0808,-18.1715)です。右だけを同じ時間押しても、燃料は96です。進行方向と、白い速度の矢印は、同じ向きとは限りません。
  • 衛星から見た接線の向きと、実際の速度を区別する
  • 斜めに噴射したときの推力と燃料を、1方向のときとそろえる
  • 半端に残った燃料を、平均の推力に直す
つまずきやすいところ燃料が半刻み分しか残っていないときに、入力の長さをいつも1に戻してしまうと、推力が2倍になります。drift の中では、元の tangent と radial から求めた n で割ります。そして、fraction で弱めた長さをそのまま使います。
少し変えてみよう

THRUST=6.0 を THRUST=3.0 に変えて、保存して終了し、もう一度実行します。同じように右と上を1秒押して、結果を比べます。推力による加速度は半分になりますが、燃料は1秒に4ずつ減るままです。qa/exercise_checks.py は、ちょうど30フレーム分の入力を入れたコピーを使って確かめます。確かめたら6に戻します。

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

"""020 すれすれ衛星 / 第2段階。説明は LESSON.md。単独実行できます。"""
STAGE=2
from dataclasses import dataclass, replace, asdict
import math, json, hashlib
MU=12000.0
CONTACT=24.0
DT=1/120
FPS=30
THRUST=6.0
BURN=4.0
MAX_SPEED=40.0
TAU=math.tau
START_RADIUS=46.0
@dataclass(frozen=True)
class Body:
    x:float;y:float;vx:float;vy:float;fuel:float=100.;spin:int=-1
@dataclass(frozen=True)
class State:
    b:Body
    t:float=0.
    mode:str='play'
    paused:bool=False
    drag:float=0.
@dataclass(frozen=True)
class Input:
    tangent:int=0
    radial:int=0
    pause:bool=False
    restart:bool=False

def fresh():
    return State(Body(START_RADIUS,0.,0.,-math.sqrt(MU/START_RADIUS)))
def radius(b):return math.hypot(b.x,b.y)

def angle(b):return math.atan2(b.y,b.x)

def elements(b):
    r=max(radius(b),1e-12);v2=b.vx*b.vx+b.vy*b.vy
    e=v2/2-MU/r;h=b.x*b.vy-b.y*b.vx
    ecc=math.sqrt(max(0.,1+2*e*h*h/(MU*MU)))
    peri=h*h/(MU*(1+ecc))
    apo=math.inf if e>=0 else -MU/(2*e)*(1+ecc)
    return peri,apo,e,h

def swept_contact(x,y,nx,ny,r=CONTACT):
    if x*x+y*y<=r*r:return 0.
    dx=nx-x;dy=ny-y;a=dx*dx+dy*dy
    if a<1e-24:return None
    b=2*(x*dx+y*dy);c=x*x+y*y-r*r;disc=b*b-4*a*c
    if disc<0:return None
    t=(-b-math.sqrt(disc))/(2*a)
    return t if 0<=t<=1 else None
def acceleration(b,tan,rad,drag):
    r=radius(b);den=max(r,CONTACT);ux=b.x/max(r,1e-12);uy=b.y/max(r,1e-12)
    tx=-uy*b.spin;ty=ux*b.spin
    n=math.hypot(tan,rad);scale=THRUST/max(n,1.)
    lam=drag*max(0.,min(1.,(36-r)/12))
    return (-MU*b.x/den**3-lam*b.vx+scale*(tan*tx+rad*ux),
            -MU*b.y/den**3-lam*b.vy+scale*(tan*ty+rad*uy))

def drift(b,tangent=0,radial=0,dt=DT,drag=.015):
    """KDK; fuel-limited mean thrust; swept planet contact beats observation."""
    if dt<=0:return b,False
    if not all(math.isfinite(x) for x in (b.x,b.y,b.vx,b.vy,b.fuel,dt)):raise ValueError('nonfinite state')
    if radius(b)<=CONTACT:return b,True
    active=bool(tangent or radial);used=min(max(b.fuel,0.),BURN*dt) if active else 0.
    fraction=used/(BURN*dt) if active else 0.
    tan=tangent*fraction;rad=radial*fraction
    # Normalize requested diagonal before fractional fuel throttle.
    n=math.hypot(tangent,radial)
    if n>1:tan/=n;rad/=n
    ax,ay=acceleration(b,tan,rad,drag)
    hx=b.vx+ax*dt/2;hy=b.vy+ay*dt/2
    nx=b.x+hx*dt;ny=b.y+hy*dt
    hit=swept_contact(b.x,b.y,nx,ny)
    if hit is not None:
        return replace(b,x=b.x+(nx-b.x)*hit,y=b.y+(ny-b.y)*hit,vx=hx,vy=hy,fuel=max(0.,b.fuel-used*hit)),True
    mid=replace(b,x=nx,y=ny,vx=hx,vy=hy)
    ax,ay=acceleration(mid,tan,rad,drag)
    vx=hx+ax*dt/2;vy=hy+ay*dt/2
    speed=math.hypot(vx,vy)
    if speed>MAX_SPEED:vx*=MAX_SPEED/speed;vy*=MAX_SPEED/speed
    h=nx*vy-ny*vx;spin=(1 if h>0 else -1) if abs(h)>1e-8 else b.spin
    return Body(nx,ny,vx,vy,max(0.,min(100.,b.fuel-used)),spin),False
def step(s,i=Input(),dt=DT):
    if i.restart:return fresh()
    if i.pause:return replace(s,paused=not s.paused)
    if s.paused or s.mode!='play':return s
    b,hit=drift(s.b,i.tangent,i.radial,dt,s.drag)
    return replace(s,b=b,t=s.t+dt,mode='impact' if hit else 'play')

def frame(s,i=Input()):
    if i.pause or i.restart:return step(s,i)
    for _ in range(4):s=step(s,i)
    return s

STAGE_NAMES=("GRAVITY KDK","FUEL + LOCAL THRUST","AIR + OBSERVATION","PREVIEW + RETURN","ENTRY UNDO + FLIGHTS")

# --- The model above is independent from Pyxel. ---
KEYS=('SPACE','P','R','LEFT','RIGHT','UP','DOWN','Z','M','N')
STAGE_NAME=STAGE_NAMES[STAGE-1]

class App:
    def __init__(self):
        self.s=fresh()
        self.title=True
        self.gate=False
        self.last=Input()
        self.trail=[]
        self.preview=()
        self.ticks=0
    def control(self,held,edges):
        held=set(held)&set(KEYS);edges=set(edges)&set(KEYS)
        if self.gate:
            if not held:self.gate=False
            return
        if self.title:
            if STAGE==5:
                if 'LEFT' in edges:self.s=fresh((self.s.level-1)%3,self.s.drag)
                elif 'RIGHT' in edges:self.s=fresh((self.s.level+1)%3,self.s.drag)
            if 'SPACE' in edges:
                self.s=fresh(self.s.level,self.s.drag) if STAGE>=3 else fresh()
                self.title=False;self.gate=True;self.trail=[];self.preview=()
            return
        if STAGE==5 and 'M' in edges:
            self.title=True;self.gate=True;self.preview=();return
        if STAGE==5 and self.s.mode=='success' and 'N' in edges:
            self.s=fresh((self.s.level+1)%3,self.s.drag)
            self.gate=True;self.trail=[];self.preview=();return
        flags={'pause':'P' in edges,'restart':'R' in edges}
        if STAGE==5:flags['rewind']='Z' in edges
        tan=int('RIGHT' in held)-int('LEFT' in held) if STAGE>=2 else 0
        rad=int('UP' in held)-int('DOWN' in held) if STAGE>=2 else 0
        self.last=Input(tan,rad,**flags)
        self.s=frame(self.s,self.last)
        if any(flags.values()):
            self.gate=True;self.trail=[];self.preview=()
        self.ticks+=1
        if not self.s.paused:
            self.trail.append((self.s.b.x,self.s.b.y));self.trail=self.trail[-120:]
        if STAGE>=4 and (self.ticks%6==0 or self.s.paused or any(flags.values())):
            self.preview=predict(self.s.b,drag=self.s.drag)[1]
    def draw(self,p):
        s=self.s;b=s.b;r=radius(b)
        def point(x,y):return 240+x*1.42,171+y*1.42
        def arc(rad,lo,hi,color,dashed=False):
            for k in range(96):
                if dashed and k%4>=2:continue
                a=lo+(hi-lo)*k/96;c=lo+(hi-lo)*(k+1)/96
                p.line(*point(rad*math.cos(a),rad*math.sin(a)),*point(rad*math.cos(c),rad*math.sin(c)),color)
        def arrow(x,y,dx,dy,color):
            p.line(x,y,x+dx,y+dy,color);length=math.hypot(dx,dy)
            if length:
                ux,uy=dx/length,dy/length
                p.line(x+dx,y+dy,x+dx-ux*4-uy*2,y+dy-uy*4+ux*2,color)
                p.line(x+dx,y+dy,x+dx-ux*4+uy*2,y+dy-uy*4-ux*2,color)
        p.cls(1)
        for n in range(90):p.pset(12+(n*71)%456,44+(n*47)%253,5 if n%3 else 13)
        p.text(12,8,'LOW SATELLITE / '+str(STAGE)+' '+STAGE_NAME,10)
        level=s.level if STAGE>=3 else 0
        p.text(12,24,'FLIGHT '+'ABC'[level]+' / GAME PHYSICS / SILENT',6)
        if STAGE>=3:
            p.circ(240,171,36*1.42,2);arc(36,0,TAU,13,True);arc(88,0,TAU,5,True)
            l=LEVELS[s.level]
            for j,w in enumerate(l.windows):
                color=11 if s.progress[j]>=l.target else 10 if s.inside[j] else 6
                arc(52,w-math.pi/6,w+math.pi/6,color)
                for a in (w-math.pi/6,w+math.pi/6):p.line(*point(26*math.cos(a),26*math.sin(a)),*point(52*math.cos(a),52*math.sin(a)),color)
                x,y=point(64*math.cos(w),64*math.sin(w));p.text(x-18,y-3,f'W{j+1} {s.progress[j]:.1f}',color)
        else:arc(46,0,TAU,5,True)
        p.circ(240,171,22*1.42,5);p.circb(240,171,22*1.42,6)
        p.elli(222,152,24,13,3);p.tri(236,170,255,162,246,191,3)
        p.circ(228,160,2,11);p.circ(253,177,2,11)
        arc(CONTACT,0,TAU,13,True)
        for q in self.trail[::3]:p.pset(*point(*q),13)
        for q in self.preview[::2]:p.pset(*point(*q),10)
        x,y=point(b.x,b.y);arrow(x,y,b.vx*.75,b.vy*.75,7)
        p.rect(x-8,y-2,5,5,12);p.rect(x+4,y-2,5,5,12);p.rect(x-2,y-3,5,7,7);p.pset(x,y,1)
        p.text(12,282,'WHITE: TRUE VELOCITY',7)
        p.text(272,282,'YELLOW: 4s COAST PREVIEW' if STAGE>=4 else 'GREY: RECENT TRAIL',10 if STAGE>=4 else 13)
        if STAGE>=2:
            ux=b.x/max(r,1e-12);uy=b.y/max(r,1e-12)
            arrow(425,240,-uy*b.spin*20,ux*b.spin*20,11);arrow(425,240,ux*20,uy*20,14)
            p.text(385,263,'TAN / OUT',14)
        p.rect(0,301,480,139,0)
        p.text(12,310,f'RADIUS {r:6.2f}  TIME {s.t:6.3f}  MODE {s.mode.upper()}',7)
        if STAGE>=2:
            p.text(12,325,f'FUEL {b.fuel:6.3f} / 100',11);p.rectb(288,324,176,9,5);p.rect(289,325,b.fuel*1.74,7,11)
        else:p.text(12,325,'GRAVITY ONLY: NO THRUST / NO ATMOSPHERE',6)
        if STAGE>=3:
            text='WINDOWS '+', '.join(f'{v:.1f}/{LEVELS[s.level].target:g}' for v in s.progress)
            p.text(12,341,text+f'  AIR {s.drag:g}',6)
        else:p.text(12,341,'KICK(dt/2) -> DRIFT(dt) -> KICK(dt/2)',6)
        if STAGE>=4:
            peri,apo,energy,h=elements(b)
            p.text(12,357,f'PERI {peri:.2f} APO {apo:.2f}  CERT {s.certified}  HOLD {s.stable:.2f}/3',11)
        elif STAGE==3:p.text(12,357,'OBSERVATION ONLY / RETURN TEST ADDED IN STAGE 4',10)
        else:p.text(12,357,'MODEL dt=1/120 / FOUR STEPS PER FRAME / SPEED CAP 40',5)
        p.text(12,375,'LEFT/RIGHT: RETRO/PRO  UP/DOWN: OUT/IN' if STAGE>=2 else 'WATCH THE ORBIT; POSITION IS NOT A KEY INPUT',7)
        p.text(12,391,'P: PAUSE  R: RESET LOADED STATE  ESC: QUIT',7)
        p.text(12,407,'Z: ENTRY UNDO  M: MENU  N: NEXT AFTER SUCCESS' if STAGE==5 else 'SAVE CODE -> QUIT -> RERUN TO APPLY EDITS',6)
        p.text(12,425,'PAUSED / RELEASE P BEFORE RESUME' if s.paused else 'RELEASE ALL KEYS' if self.gate else 'FICTIONAL MODEL / NOT FOR REAL AEROSPACE OR SAFETY',10 if s.paused or self.gate else 5)
        if s.mode in ('success','impact','escape'):
            p.rect(134,50,212,26,0);p.rectb(134,50,212,26,11 if s.mode=='success' else 8)
            p.text(146,60,s.mode.upper()+' / R TO RETRY',11 if s.mode=='success' else 8)
        if self.title:
            p.rect(60,96,360,137,0);p.rectb(60,96,360,137,6)
            p.text(80,114,'STAGE '+str(STAGE)+': '+STAGE_NAME,10)
            p.text(80,135,'SPACE: START, THEN RELEASE KEYS',7)
            p.text(80,156,'LEFT/RIGHT: SELECT A, B, C' if STAGE==5 else 'FLIGHT A / FOLLOW LESSON.md FOR EXERCISES',6)
            hints=('A: IDLE IS A VALID SUCCESS','B: IDLE IS SLOW BUT VALID','C: IDLE WILL HIT THE PLANET')
            p.text(80,177,hints[level] if STAGE>=4 else 'PRACTICE / NO RETURN SUCCESS YET',11)
            p.text(80,205,'CONTACT R=24 / NUMBERS ARE RADII',5)

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

def make_checkpoint(app):
    """Bounded automatic control recipe; not human input or a full playtest."""
    if STAGE==5:tap(app,'RIGHT');tap(app,'RIGHT')
    tap(app,'SPACE')
    frames={1:30,2:30,3:45,4:130,5:300}[STAGE]
    for n in range(frames):
        held={'RIGHT','UP'} if STAGE==2 else {'RIGHT'} if STAGE==5 and n<6 else set()
        app.control(held,set())
    if STAGE==5:tap(app,'Z')

def main(headless=False,screenshot=None,demo=False,report=None):
    # CLI resolves output paths before even importing Pyxel (which may change cwd).
    import pyxel as p
    p.init(480,440,title='Low satellite / stage '+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)
            if report:
                from pathlib import Path
                Path(report).write_text(json.dumps({'stage':STAGE,'state':asdict(app.s),'title':app.title,'gate':app.gate,'recipe':'automatic checkpoint' if demo else 'initial'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            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='Standalone orbital lesson; fictional physics.')
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:parser.error('automated outputs require --headless-smoke')
    for name,suffix in (('screenshot','.png'),('report','.json')):
        value=getattr(args,name)
        if value:
            path=Path(value)
            if path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists() or path.is_symlink():parser.error('use a new '+suffix+' in an existing directory')
            setattr(args,name,str(path.resolve()))
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint,args.report)
STEP 03 / 05

低く観る速さと、大気で失う速度

大気の抵抗と位置の条件を、同じ刻みの中で計算する。

半径36より内側に、薄い大気を加えます。抵抗の強さ lam は、半径36で0です。ぶつかる距離(接触半径)の24に近づくほど増えて、最大0.015になります。加速度には「-lam×速度」を加えます。観測は、半径が26~52の間で、角度が窓の中心から±30度以内にあると、自動で進みます。低く飛ぶほど、記録が進む速さは速くなります。半径34以下では1秒に25、44以上では1秒に8、その間はまっすぐな線でつないだ値(直線補間)です。

角度 atan2(y,x) の値は、-πとπの境目をまたぎます。wrap で角度の差を [-π,π) の範囲に戻してから、窓の幅の半分と比べます。こうすると、359度と0度が近いことを正しく扱えます。進み具合(進捗)は、min を使って目標の値で止めます。位置と速度は先に進めます。接触があれば、その刻みを impact(衝突)として返し、観測の点は足しません。外側のふち(外縁)では、半径88以上に2秒続けていると escape(離脱)になります。内側へ戻れば、その時計は0に戻ります。

画面は便Aで固定です。便A・B・Cの元のゲームのデータは、モデルの中に持っています。便Aは半径46なので、何もしなければ大気の中へは入りません。「抵抗を加えたのに、最初の円い軌道が急に遅くならない」のが正しい動きです。大気による変化は、同梱の短い実験で確かめます。同じ低い高さの最初の状態どうしを比べます。ウィンドウの範囲・燃料・重力などは同時に変えず、1か所だけを変える実験です。

ここでは観測だけを加えます。帰還の判定はまだありません。便Aの進み具合が8/8になっても、MODE PLAY のまま飛び続けます。観測が終わっただけでは、成功にしていません。画面にも OBSERVATION ONLY(観測だけ)と表示します。帰還の条件は、次の段階で加えます。これは「低いほど必ず良い」というゲームではありません。記録の速さと、そのあとの軌道の両方を考える、架空のゲームです。

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

03_air_observation.py / 注目する部分
    lam=drag*max(0.,min(1.,(36-r)/12))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_air_observation.py
macOS / Linux
.venv/bin/python 03_air_observation.py
画面で見ること便Aを、何も押さずに1.5秒進めると、WINDOWS 8.0/8、MODE PLAY になります。AIR 0.015 と表示されていても、半径は約46です。実験の 03_air_comparison.py で2.5秒を比べると、エネルギーの差は no air:0.000000、air:-2.230938 です。
  • 低い高さでの抵抗と、観測の進む速さを分けて考える
  • 角度の境目を wrap で扱う
  • 接触を、観測より先に処理する
つまずきやすいところ観測のあとで接触を調べると、同じ刻みで最後の観測点をもらってしまいます。すると、墜落したのに成功として扱うおそれがあります。drift の hit を先に返します。また、近点(軌道で惑星にいちばん近い点)と半径、半径と惑星の表面からの高さも、混同しないようにしましょう。
少し変えてみよう

python exercises/03_air_comparison.py を実行します。最後の experiment(.015) だけを experiment(0.) に変えて、保存してもう一度実行します。すると、2行とも差が0.000000になります。最初の位置34、最初の速さ、300刻みは同じです。元に戻すと、空気ありの行だけがマイナスに戻ります。これは画面の便Aの最初の状態ではありません。実験のために作った、低い高さの最初の状態だと区別して読みましょう。

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

"""020 すれすれ衛星 / 第3段階。説明は LESSON.md。単独実行できます。"""
STAGE=3
from dataclasses import dataclass, replace, asdict
import math, json, hashlib
MU=12000.0
CONTACT=24.0
DT=1/120
FPS=30
THRUST=6.0
BURN=4.0
MAX_SPEED=40.0
TAU=math.tau
@dataclass(frozen=True)
class Level:
    title:str
    r:float
    speed:float
    windows:tuple
    target:float
LEVELS=(Level('高い一周',46,math.sqrt(MU/46),(0.,),8.),
        Level('二つの窓',50,math.sqrt(MU/50),(0.,math.pi),40.),
        Level('下がり始めた軌道',36,17.5,(math.pi/2,-math.pi/2),32.))
@dataclass(frozen=True)
class Body:
    x:float;y:float;vx:float;vy:float;fuel:float=100.;spin:int=-1
@dataclass(frozen=True)
class State:
    level:int
    b:Body
    progress:tuple
    t:float=0.
    outside:float=0.
    mode:str='play'
    paused:bool=False
    drag:float=.015
    inside:tuple=()
@dataclass(frozen=True)
class Input:
    tangent:int=0
    radial:int=0
    pause:bool=False
    restart:bool=False

def fresh(level=0,drag=.015):
    l=LEVELS[level]
    b=Body(l.r,0.,0.,-l.speed)
    return State(level,b,(0.,)*len(l.windows),drag=drag,inside=tuple(in_window(0,w) for w in l.windows))
def radius(b):return math.hypot(b.x,b.y)
def angle(b):return math.atan2(b.y,b.x)
def wrap(a):return (a+math.pi)%TAU-math.pi
def in_window(a,w):return abs(wrap(a-w))<=math.pi/6

def elements(b):
    r=max(radius(b),1e-12);v2=b.vx*b.vx+b.vy*b.vy
    e=v2/2-MU/r;h=b.x*b.vy-b.y*b.vx
    ecc=math.sqrt(max(0.,1+2*e*h*h/(MU*MU)))
    peri=h*h/(MU*(1+ecc))
    apo=math.inf if e>=0 else -MU/(2*e)*(1+ecc)
    return peri,apo,e,h

def swept_contact(x,y,nx,ny,r=CONTACT):
    if x*x+y*y<=r*r:return 0.
    dx=nx-x;dy=ny-y;a=dx*dx+dy*dy
    if a<1e-24:return None
    b=2*(x*dx+y*dy);c=x*x+y*y-r*r;disc=b*b-4*a*c
    if disc<0:return None
    t=(-b-math.sqrt(disc))/(2*a)
    return t if 0<=t<=1 else None

def acceleration(b,tan,rad,drag):
    r=radius(b);den=max(r,CONTACT);ux=b.x/max(r,1e-12);uy=b.y/max(r,1e-12)
    tx=-uy*b.spin;ty=ux*b.spin
    n=math.hypot(tan,rad);scale=THRUST/max(n,1.)
    lam=drag*max(0.,min(1.,(36-r)/12))
    return (-MU*b.x/den**3-lam*b.vx+scale*(tan*tx+rad*ux),
            -MU*b.y/den**3-lam*b.vy+scale*(tan*ty+rad*uy))

def drift(b,tangent=0,radial=0,dt=DT,drag=.015):
    """KDK; fuel-limited mean thrust; swept planet contact beats observation."""
    if dt<=0:return b,False
    if not all(math.isfinite(x) for x in (b.x,b.y,b.vx,b.vy,b.fuel,dt)):raise ValueError('nonfinite state')
    if radius(b)<=CONTACT:return b,True
    active=bool(tangent or radial);used=min(max(b.fuel,0.),BURN*dt) if active else 0.
    fraction=used/(BURN*dt) if active else 0.
    tan=tangent*fraction;rad=radial*fraction
    # Normalize requested diagonal before fractional fuel throttle.
    n=math.hypot(tangent,radial)
    if n>1:tan/=n;rad/=n
    ax,ay=acceleration(b,tan,rad,drag)
    hx=b.vx+ax*dt/2;hy=b.vy+ay*dt/2
    nx=b.x+hx*dt;ny=b.y+hy*dt
    hit=swept_contact(b.x,b.y,nx,ny)
    if hit is not None:
        return replace(b,x=b.x+(nx-b.x)*hit,y=b.y+(ny-b.y)*hit,vx=hx,vy=hy,fuel=max(0.,b.fuel-used*hit)),True
    mid=replace(b,x=nx,y=ny,vx=hx,vy=hy)
    ax,ay=acceleration(mid,tan,rad,drag)
    vx=hx+ax*dt/2;vy=hy+ay*dt/2
    speed=math.hypot(vx,vy)
    if speed>MAX_SPEED:vx*=MAX_SPEED/speed;vy*=MAX_SPEED/speed
    h=nx*vy-ny*vx;spin=(1 if h>0 else -1) if abs(h)>1e-8 else b.spin
    return Body(nx,ny,vx,vy,max(0.,min(100.,b.fuel-used)),spin),False

def step(s,i=Input(),dt=DT):
    if i.restart:return fresh(s.level,s.drag)
    if i.pause:return replace(s,paused=not s.paused)
    if s.paused or s.mode!='play':return s
    b,hit=drift(s.b,i.tangent,i.radial,dt,s.drag)
    if hit:return replace(s,b=b,t=s.t+dt,mode='impact')
    r=radius(b);l=LEVELS[s.level]
    ins=tuple(in_window(angle(b),w) for w in l.windows)
    rate=25. if r<=34 else 8. if r>=44 else 25.-1.7*(r-34)
    p=tuple(min(l.target,v+(rate*dt if isin and 26<=r<=52 else 0)) for v,isin in zip(s.progress,ins))
    outside=s.outside+dt if r>=88 else 0.
    return replace(s,b=b,progress=p,t=s.t+dt,outside=outside,inside=ins,mode='escape' if outside>=2 else 'play')

def frame(s,i=Input()):
    if i.pause or i.restart:return step(s,i)
    for _ in range(4):s=step(s,i)
    return s

STAGE_NAMES=("GRAVITY KDK","FUEL + LOCAL THRUST","AIR + OBSERVATION","PREVIEW + RETURN","ENTRY UNDO + FLIGHTS")

# --- The model above is independent from Pyxel. ---
KEYS=('SPACE','P','R','LEFT','RIGHT','UP','DOWN','Z','M','N')
STAGE_NAME=STAGE_NAMES[STAGE-1]

class App:
    def __init__(self):
        self.s=fresh()
        self.title=True
        self.gate=False
        self.last=Input()
        self.trail=[]
        self.preview=()
        self.ticks=0
    def control(self,held,edges):
        held=set(held)&set(KEYS);edges=set(edges)&set(KEYS)
        if self.gate:
            if not held:self.gate=False
            return
        if self.title:
            if STAGE==5:
                if 'LEFT' in edges:self.s=fresh((self.s.level-1)%3,self.s.drag)
                elif 'RIGHT' in edges:self.s=fresh((self.s.level+1)%3,self.s.drag)
            if 'SPACE' in edges:
                self.s=fresh(self.s.level,self.s.drag) if STAGE>=3 else fresh()
                self.title=False;self.gate=True;self.trail=[];self.preview=()
            return
        if STAGE==5 and 'M' in edges:
            self.title=True;self.gate=True;self.preview=();return
        if STAGE==5 and self.s.mode=='success' and 'N' in edges:
            self.s=fresh((self.s.level+1)%3,self.s.drag)
            self.gate=True;self.trail=[];self.preview=();return
        flags={'pause':'P' in edges,'restart':'R' in edges}
        if STAGE==5:flags['rewind']='Z' in edges
        tan=int('RIGHT' in held)-int('LEFT' in held) if STAGE>=2 else 0
        rad=int('UP' in held)-int('DOWN' in held) if STAGE>=2 else 0
        self.last=Input(tan,rad,**flags)
        self.s=frame(self.s,self.last)
        if any(flags.values()):
            self.gate=True;self.trail=[];self.preview=()
        self.ticks+=1
        if not self.s.paused:
            self.trail.append((self.s.b.x,self.s.b.y));self.trail=self.trail[-120:]
        if STAGE>=4 and (self.ticks%6==0 or self.s.paused or any(flags.values())):
            self.preview=predict(self.s.b,drag=self.s.drag)[1]
    def draw(self,p):
        s=self.s;b=s.b;r=radius(b)
        def point(x,y):return 240+x*1.42,171+y*1.42
        def arc(rad,lo,hi,color,dashed=False):
            for k in range(96):
                if dashed and k%4>=2:continue
                a=lo+(hi-lo)*k/96;c=lo+(hi-lo)*(k+1)/96
                p.line(*point(rad*math.cos(a),rad*math.sin(a)),*point(rad*math.cos(c),rad*math.sin(c)),color)
        def arrow(x,y,dx,dy,color):
            p.line(x,y,x+dx,y+dy,color);length=math.hypot(dx,dy)
            if length:
                ux,uy=dx/length,dy/length
                p.line(x+dx,y+dy,x+dx-ux*4-uy*2,y+dy-uy*4+ux*2,color)
                p.line(x+dx,y+dy,x+dx-ux*4+uy*2,y+dy-uy*4-ux*2,color)
        p.cls(1)
        for n in range(90):p.pset(12+(n*71)%456,44+(n*47)%253,5 if n%3 else 13)
        p.text(12,8,'LOW SATELLITE / '+str(STAGE)+' '+STAGE_NAME,10)
        level=s.level if STAGE>=3 else 0
        p.text(12,24,'FLIGHT '+'ABC'[level]+' / GAME PHYSICS / SILENT',6)
        if STAGE>=3:
            p.circ(240,171,36*1.42,2);arc(36,0,TAU,13,True);arc(88,0,TAU,5,True)
            l=LEVELS[s.level]
            for j,w in enumerate(l.windows):
                color=11 if s.progress[j]>=l.target else 10 if s.inside[j] else 6
                arc(52,w-math.pi/6,w+math.pi/6,color)
                for a in (w-math.pi/6,w+math.pi/6):p.line(*point(26*math.cos(a),26*math.sin(a)),*point(52*math.cos(a),52*math.sin(a)),color)
                x,y=point(64*math.cos(w),64*math.sin(w));p.text(x-18,y-3,f'W{j+1} {s.progress[j]:.1f}',color)
        else:arc(46,0,TAU,5,True)
        p.circ(240,171,22*1.42,5);p.circb(240,171,22*1.42,6)
        p.elli(222,152,24,13,3);p.tri(236,170,255,162,246,191,3)
        p.circ(228,160,2,11);p.circ(253,177,2,11)
        arc(CONTACT,0,TAU,13,True)
        for q in self.trail[::3]:p.pset(*point(*q),13)
        for q in self.preview[::2]:p.pset(*point(*q),10)
        x,y=point(b.x,b.y);arrow(x,y,b.vx*.75,b.vy*.75,7)
        p.rect(x-8,y-2,5,5,12);p.rect(x+4,y-2,5,5,12);p.rect(x-2,y-3,5,7,7);p.pset(x,y,1)
        p.text(12,282,'WHITE: TRUE VELOCITY',7)
        p.text(272,282,'YELLOW: 4s COAST PREVIEW' if STAGE>=4 else 'GREY: RECENT TRAIL',10 if STAGE>=4 else 13)
        if STAGE>=2:
            ux=b.x/max(r,1e-12);uy=b.y/max(r,1e-12)
            arrow(425,240,-uy*b.spin*20,ux*b.spin*20,11);arrow(425,240,ux*20,uy*20,14)
            p.text(385,263,'TAN / OUT',14)
        p.rect(0,301,480,139,0)
        p.text(12,310,f'RADIUS {r:6.2f}  TIME {s.t:6.3f}  MODE {s.mode.upper()}',7)
        if STAGE>=2:
            p.text(12,325,f'FUEL {b.fuel:6.3f} / 100',11);p.rectb(288,324,176,9,5);p.rect(289,325,b.fuel*1.74,7,11)
        else:p.text(12,325,'GRAVITY ONLY: NO THRUST / NO ATMOSPHERE',6)
        if STAGE>=3:
            text='WINDOWS '+', '.join(f'{v:.1f}/{LEVELS[s.level].target:g}' for v in s.progress)
            p.text(12,341,text+f'  AIR {s.drag:g}',6)
        else:p.text(12,341,'KICK(dt/2) -> DRIFT(dt) -> KICK(dt/2)',6)
        if STAGE>=4:
            peri,apo,energy,h=elements(b)
            p.text(12,357,f'PERI {peri:.2f} APO {apo:.2f}  CERT {s.certified}  HOLD {s.stable:.2f}/3',11)
        elif STAGE==3:p.text(12,357,'OBSERVATION ONLY / RETURN TEST ADDED IN STAGE 4',10)
        else:p.text(12,357,'MODEL dt=1/120 / FOUR STEPS PER FRAME / SPEED CAP 40',5)
        p.text(12,375,'LEFT/RIGHT: RETRO/PRO  UP/DOWN: OUT/IN' if STAGE>=2 else 'WATCH THE ORBIT; POSITION IS NOT A KEY INPUT',7)
        p.text(12,391,'P: PAUSE  R: RESET LOADED STATE  ESC: QUIT',7)
        p.text(12,407,'Z: ENTRY UNDO  M: MENU  N: NEXT AFTER SUCCESS' if STAGE==5 else 'SAVE CODE -> QUIT -> RERUN TO APPLY EDITS',6)
        p.text(12,425,'PAUSED / RELEASE P BEFORE RESUME' if s.paused else 'RELEASE ALL KEYS' if self.gate else 'FICTIONAL MODEL / NOT FOR REAL AEROSPACE OR SAFETY',10 if s.paused or self.gate else 5)
        if s.mode in ('success','impact','escape'):
            p.rect(134,50,212,26,0);p.rectb(134,50,212,26,11 if s.mode=='success' else 8)
            p.text(146,60,s.mode.upper()+' / R TO RETRY',11 if s.mode=='success' else 8)
        if self.title:
            p.rect(60,96,360,137,0);p.rectb(60,96,360,137,6)
            p.text(80,114,'STAGE '+str(STAGE)+': '+STAGE_NAME,10)
            p.text(80,135,'SPACE: START, THEN RELEASE KEYS',7)
            p.text(80,156,'LEFT/RIGHT: SELECT A, B, C' if STAGE==5 else 'FLIGHT A / FOLLOW LESSON.md FOR EXERCISES',6)
            hints=('A: IDLE IS A VALID SUCCESS','B: IDLE IS SLOW BUT VALID','C: IDLE WILL HIT THE PLANET')
            p.text(80,177,hints[level] if STAGE>=4 else 'PRACTICE / NO RETURN SUCCESS YET',11)
            p.text(80,205,'CONTACT R=24 / NUMBERS ARE RADII',5)

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

def make_checkpoint(app):
    """Bounded automatic control recipe; not human input or a full playtest."""
    if STAGE==5:tap(app,'RIGHT');tap(app,'RIGHT')
    tap(app,'SPACE')
    frames={1:30,2:30,3:45,4:130,5:300}[STAGE]
    for n in range(frames):
        held={'RIGHT','UP'} if STAGE==2 else {'RIGHT'} if STAGE==5 and n<6 else set()
        app.control(held,set())
    if STAGE==5:tap(app,'Z')

def main(headless=False,screenshot=None,demo=False,report=None):
    # CLI resolves output paths before even importing Pyxel (which may change cwd).
    import pyxel as p
    p.init(480,440,title='Low satellite / stage '+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)
            if report:
                from pathlib import Path
                Path(report).write_text(json.dumps({'stage':STAGE,'state':asdict(app.s),'title':app.title,'gate':app.gate,'recipe':'automatic checkpoint' if demo else 'initial'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            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='Standalone orbital lesson; fictional physics.')
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:parser.error('automated outputs require --headless-smoke')
    for name,suffix in (('screenshot','.png'),('report','.json')):
        value=getattr(args,name)
        if value:
            path=Path(value)
            if path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists() or path.is_symlink():parser.error('use a new '+suffix+' in an existing directory')
            setattr(args,name,str(path.resolve()))
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint,args.report)
STEP 04 / 05

4秒の点線と、一周の帰還確認を分ける

数秒先の予測、安全側に決める帰還の認定、条件つきのタイマー。

黄色い点線は、噴射しない場合の4秒先までの予測です。今の Body のコピーに、同じ drift を480刻み当てはめて作ります。点線を描いても、本物の衛星の燃料や位置は変わりません。予測の点は8刻みごとに置きます。画面の点線は、ふだんは6フレームごとに更新し、一時停止したときや状態が変わったときにも更新します。そのため、点線がいつも最新の120Hz(1秒に120回)の計算どおりとは限りません。また、4秒より後に惑星にぶつかるかどうかは分かりません。

帰還は、別の return_check で調べます。まず、今の位置と速度から、仮の軌道の形(軌道要素)を求めます。そして、惑星から離れていかない軌道(束縛軌道)で、近点の半径が36.2以上、遠点(いちばん遠い点)が86以下のものを候補にします。そのあと、同じ積分器(少しずつ進めて計算する部分)を使って、噴射しないまま角度が一周するまで調べます。途中で半径が36未満になる、87を超える、接触する、のどれかがあれば認めません。最大7200刻み(60秒)で打ち切り、一周を確かめきれなかった候補も False にします。これは、現実の長い期間の安定や、衝突を避けられることを保証するものではありません。

すべての窓の観測が目標に届き、半径が42以上で、一周の認定がある。この状態を3秒保つと成功です。実際に燃料を使って噴射すると、certified(認定)は無効になります。噴射している間は、時間のかかる一周の確認は呼びません。噴射を止めて条件を満たしたときに、もう一度確かめます。燃料が0のときにキーを押しただけなら、実際には噴射していないので、認定は失われません。半径の条件か認定を失うと、stable は0に戻ります。

この段階で、元のゲームのふつうの物理・観測・帰還のルールがそろいます。画面は便Aで固定です。観測窓の入口へ戻すための状態は、次の段階で加えます。便Aは、何も押さなくても約4.008秒で成功する、短い入門の便です。成功しても、操作がうまい証拠にはなりません。便Bも、何も押さずに成功する安全な方法を、元のゲームが認めています。待つだけの作戦をつぶすための時間制限や、燃料が漏れる仕組みは加えていません。

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

04_prediction_return.py / 注目する部分
    cert=s.certified and b.fuel==s.b.fuel

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_prediction_return.py
macOS / Linux
.venv/bin/python 04_prediction_return.py
画面で見ることSpace を押して離し、そのまま何も押さないと、便Aは TIME 4.008 で SUCCESS(成功)になります。CERT は True、HOLD は約3.01です。点線は4秒先の予測です。exercises/04_short_vs_orbit.py は、preview hits:False と return certificate:False を同時に出力します。
  • 予測と、帰還の認定を分ける
  • 噴射したら、認定を無効にする
  • 認定と半径を、3秒保つ条件につなぐ
つまずきやすいところpredict の hit=False を、そのまま成功の条件にしてはいけません。4秒後にはまだ無事でも、そのあと低くなっていく軌道もあるからです。また、certified を一度 True にしたあと、噴射してもそのままにしておくと、古い軌道の認定を使い回してしまいます。
少し変えてみよう

python exercises/04_short_vs_orbit.py を実行します。b=m['Body'](50.,0.,0.,-13.) の1行を b=m['fresh']().b に変えて、保存してもう一度実行します。preview hits は False のまま、return certificate だけが True に変わります。元に戻して、「短い時間ぶつからない予測」と「一周の認定」のちがいを確かめましょう。

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

"""020 すれすれ衛星 / 第4段階。説明は LESSON.md。単独実行できます。"""
STAGE=4
"""Deterministic orbital game approximation; no Pyxel and no real navigation.
Coordinates: screen +y down. KDK gravity, each half-kick current drag/thrust.
Return requires bound osculating periapsis >=36.2, apoapsis <=86, outside drag.
A full no-thrust revolution is then verified with this very same integrator.
"""
from dataclasses import dataclass, replace, asdict
import math, json, hashlib
MU=12000.0
CONTACT=24.0
DT=1/120
FPS=30
THRUST=6.0
BURN=4.0
MAX_SPEED=40.0
TAU=math.tau
@dataclass(frozen=True)
class Level:
    title:str
    r:float
    speed:float
    windows:tuple
    target:float
LEVELS=(Level('高い一周',46,math.sqrt(MU/46),(0.,),8.),
        Level('二つの窓',50,math.sqrt(MU/50),(0.,math.pi),40.),
        Level('下がり始めた軌道',36,17.5,(math.pi/2,-math.pi/2),32.))
@dataclass(frozen=True)
class Body:
    x:float;y:float;vx:float;vy:float;fuel:float=100.;spin:int=-1
@dataclass(frozen=True)
class State:
    level:int
    b:Body
    progress:tuple
    t:float=0.
    outside:float=0.
    stable:float=0.
    mode:str='play'
    paused:bool=False
    drag:float=.015
    inside:tuple=()
    certified:bool=False
@dataclass(frozen=True)
class Input:
    tangent:int=0
    radial:int=0
    pause:bool=False
    restart:bool=False

def fresh(level=0,drag=.015):
    l=LEVELS[level]
    b=Body(l.r,0.,0.,-l.speed)
    return State(level,b,(0.,)*len(l.windows),drag=drag,inside=tuple(in_window(0,w) for w in l.windows))
def radius(b):return math.hypot(b.x,b.y)
def angle(b):return math.atan2(b.y,b.x)
def wrap(a):return (a+math.pi)%TAU-math.pi
def in_window(a,w):return abs(wrap(a-w))<=math.pi/6

def elements(b):
    r=max(radius(b),1e-12);v2=b.vx*b.vx+b.vy*b.vy
    e=v2/2-MU/r;h=b.x*b.vy-b.y*b.vx
    ecc=math.sqrt(max(0.,1+2*e*h*h/(MU*MU)))
    peri=h*h/(MU*(1+ecc))
    apo=math.inf if e>=0 else -MU/(2*e)*(1+ecc)
    return peri,apo,e,h

def swept_contact(x,y,nx,ny,r=CONTACT):
    if x*x+y*y<=r*r:return 0.
    dx=nx-x;dy=ny-y;a=dx*dx+dy*dy
    if a<1e-24:return None
    b=2*(x*dx+y*dy);c=x*x+y*y-r*r;disc=b*b-4*a*c
    if disc<0:return None
    t=(-b-math.sqrt(disc))/(2*a)
    return t if 0<=t<=1 else None

def acceleration(b,tan,rad,drag):
    r=radius(b);den=max(r,CONTACT);ux=b.x/max(r,1e-12);uy=b.y/max(r,1e-12)
    tx=-uy*b.spin;ty=ux*b.spin
    n=math.hypot(tan,rad);scale=THRUST/max(n,1.)
    lam=drag*max(0.,min(1.,(36-r)/12))
    return (-MU*b.x/den**3-lam*b.vx+scale*(tan*tx+rad*ux),
            -MU*b.y/den**3-lam*b.vy+scale*(tan*ty+rad*uy))

def drift(b,tangent=0,radial=0,dt=DT,drag=.015):
    """KDK; fuel-limited mean thrust; swept planet contact beats observation."""
    if dt<=0:return b,False
    if not all(math.isfinite(x) for x in (b.x,b.y,b.vx,b.vy,b.fuel,dt)):raise ValueError('nonfinite state')
    if radius(b)<=CONTACT:return b,True
    active=bool(tangent or radial);used=min(max(b.fuel,0.),BURN*dt) if active else 0.
    fraction=used/(BURN*dt) if active else 0.
    tan=tangent*fraction;rad=radial*fraction
    # Normalize requested diagonal before fractional fuel throttle.
    n=math.hypot(tangent,radial)
    if n>1:tan/=n;rad/=n
    ax,ay=acceleration(b,tan,rad,drag)
    hx=b.vx+ax*dt/2;hy=b.vy+ay*dt/2
    nx=b.x+hx*dt;ny=b.y+hy*dt
    hit=swept_contact(b.x,b.y,nx,ny)
    if hit is not None:
        return replace(b,x=b.x+(nx-b.x)*hit,y=b.y+(ny-b.y)*hit,vx=hx,vy=hy,fuel=max(0.,b.fuel-used*hit)),True
    mid=replace(b,x=nx,y=ny,vx=hx,vy=hy)
    ax,ay=acceleration(mid,tan,rad,drag)
    vx=hx+ax*dt/2;vy=hy+ay*dt/2
    speed=math.hypot(vx,vy)
    if speed>MAX_SPEED:vx*=MAX_SPEED/speed;vy*=MAX_SPEED/speed
    h=nx*vy-ny*vx;spin=(1 if h>0 else -1) if abs(h)>1e-8 else b.spin
    return Body(nx,ny,vx,vy,max(0.,min(100.,b.fuel-used)),spin),False

def predict(b,seconds=4.,dt=DT,drag=.015):
    points=[];hit=False
    for k in range(round(seconds/dt)):
        b,hit=drift(b,dt=dt,drag=drag)
        if k%8==0 or hit:points.append((b.x,b.y))
        if hit:break
    return b,tuple(points),hit

def return_check(b,drag=.015):
    """Conservative: exclude drag/escape, then verify an entire coast revolution.
    This certifies this game's return condition only, not real orbital stability.
    """
    p,a,e,h=elements(b)
    if p<36.2 or a>86 or e>=0:return False
    travel=0.;old=angle(b)
    for _ in range(120*60):
        b,hit=drift(b,drag=drag)
        if hit or radius(b)<36. or radius(b)>87:return False
        new=angle(b);travel+=abs(wrap(new-old));old=new
        if travel>=TAU:return True
    return False

def step(s,i=Input(),dt=DT):
    if i.restart:return fresh(s.level,s.drag)
    if i.pause:return replace(s,paused=not s.paused)
    if s.paused or s.mode!='play':return s
    b,hit=drift(s.b,i.tangent,i.radial,dt,s.drag)
    if hit:return replace(s,b=b,t=s.t+dt,mode='impact',certified=False,stable=0.)
    r=radius(b);l=LEVELS[s.level]
    ins=tuple(in_window(angle(b),w) for w in l.windows)
    rate=25. if r<=34 else 8. if r>=44 else 25.-1.7*(r-34)
    p=tuple(min(l.target,v+(rate*dt if isin and 26<=r<=52 else 0)) for v,isin in zip(s.progress,ins))
    outside=s.outside+dt if r>=88 else 0.
    # Certificate is invalidated by every actual burn; a full coast check on re-entry.
    cert=s.certified and b.fuel==s.b.fuel
    done=all(v>=l.target for v in p)
    if done and r>=42 and not cert and b.fuel==s.b.fuel:cert=return_check(b,s.drag)
    stable=s.stable+dt if done and r>=42 and cert else 0.
    mode='escape' if outside>=2 else 'success' if stable>=3 else 'play'
    return replace(s,b=b,progress=p,t=s.t+dt,outside=outside,stable=stable,inside=ins,certified=cert,mode=mode)

def frame(s,i=Input()):
    if i.pause or i.restart:return step(s,i)
    for _ in range(4):s=step(s,i)
    return s

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

STAGE_NAMES=("GRAVITY KDK","FUEL + LOCAL THRUST","AIR + OBSERVATION","PREVIEW + RETURN","ENTRY UNDO + FLIGHTS")

# --- The model above is independent from Pyxel. ---
KEYS=('SPACE','P','R','LEFT','RIGHT','UP','DOWN','Z','M','N')
STAGE_NAME=STAGE_NAMES[STAGE-1]

class App:
    def __init__(self):
        self.s=fresh()
        self.title=True
        self.gate=False
        self.last=Input()
        self.trail=[]
        self.preview=()
        self.ticks=0
    def control(self,held,edges):
        held=set(held)&set(KEYS);edges=set(edges)&set(KEYS)
        if self.gate:
            if not held:self.gate=False
            return
        if self.title:
            if STAGE==5:
                if 'LEFT' in edges:self.s=fresh((self.s.level-1)%3,self.s.drag)
                elif 'RIGHT' in edges:self.s=fresh((self.s.level+1)%3,self.s.drag)
            if 'SPACE' in edges:
                self.s=fresh(self.s.level,self.s.drag) if STAGE>=3 else fresh()
                self.title=False;self.gate=True;self.trail=[];self.preview=()
            return
        if STAGE==5 and 'M' in edges:
            self.title=True;self.gate=True;self.preview=();return
        if STAGE==5 and self.s.mode=='success' and 'N' in edges:
            self.s=fresh((self.s.level+1)%3,self.s.drag)
            self.gate=True;self.trail=[];self.preview=();return
        flags={'pause':'P' in edges,'restart':'R' in edges}
        if STAGE==5:flags['rewind']='Z' in edges
        tan=int('RIGHT' in held)-int('LEFT' in held) if STAGE>=2 else 0
        rad=int('UP' in held)-int('DOWN' in held) if STAGE>=2 else 0
        self.last=Input(tan,rad,**flags)
        self.s=frame(self.s,self.last)
        if any(flags.values()):
            self.gate=True;self.trail=[];self.preview=()
        self.ticks+=1
        if not self.s.paused:
            self.trail.append((self.s.b.x,self.s.b.y));self.trail=self.trail[-120:]
        if STAGE>=4 and (self.ticks%6==0 or self.s.paused or any(flags.values())):
            self.preview=predict(self.s.b,drag=self.s.drag)[1]
    def draw(self,p):
        s=self.s;b=s.b;r=radius(b)
        def point(x,y):return 240+x*1.42,171+y*1.42
        def arc(rad,lo,hi,color,dashed=False):
            for k in range(96):
                if dashed and k%4>=2:continue
                a=lo+(hi-lo)*k/96;c=lo+(hi-lo)*(k+1)/96
                p.line(*point(rad*math.cos(a),rad*math.sin(a)),*point(rad*math.cos(c),rad*math.sin(c)),color)
        def arrow(x,y,dx,dy,color):
            p.line(x,y,x+dx,y+dy,color);length=math.hypot(dx,dy)
            if length:
                ux,uy=dx/length,dy/length
                p.line(x+dx,y+dy,x+dx-ux*4-uy*2,y+dy-uy*4+ux*2,color)
                p.line(x+dx,y+dy,x+dx-ux*4+uy*2,y+dy-uy*4-ux*2,color)
        p.cls(1)
        for n in range(90):p.pset(12+(n*71)%456,44+(n*47)%253,5 if n%3 else 13)
        p.text(12,8,'LOW SATELLITE / '+str(STAGE)+' '+STAGE_NAME,10)
        level=s.level if STAGE>=3 else 0
        p.text(12,24,'FLIGHT '+'ABC'[level]+' / GAME PHYSICS / SILENT',6)
        if STAGE>=3:
            p.circ(240,171,36*1.42,2);arc(36,0,TAU,13,True);arc(88,0,TAU,5,True)
            l=LEVELS[s.level]
            for j,w in enumerate(l.windows):
                color=11 if s.progress[j]>=l.target else 10 if s.inside[j] else 6
                arc(52,w-math.pi/6,w+math.pi/6,color)
                for a in (w-math.pi/6,w+math.pi/6):p.line(*point(26*math.cos(a),26*math.sin(a)),*point(52*math.cos(a),52*math.sin(a)),color)
                x,y=point(64*math.cos(w),64*math.sin(w));p.text(x-18,y-3,f'W{j+1} {s.progress[j]:.1f}',color)
        else:arc(46,0,TAU,5,True)
        p.circ(240,171,22*1.42,5);p.circb(240,171,22*1.42,6)
        p.elli(222,152,24,13,3);p.tri(236,170,255,162,246,191,3)
        p.circ(228,160,2,11);p.circ(253,177,2,11)
        arc(CONTACT,0,TAU,13,True)
        for q in self.trail[::3]:p.pset(*point(*q),13)
        for q in self.preview[::2]:p.pset(*point(*q),10)
        x,y=point(b.x,b.y);arrow(x,y,b.vx*.75,b.vy*.75,7)
        p.rect(x-8,y-2,5,5,12);p.rect(x+4,y-2,5,5,12);p.rect(x-2,y-3,5,7,7);p.pset(x,y,1)
        p.text(12,282,'WHITE: TRUE VELOCITY',7)
        p.text(272,282,'YELLOW: 4s COAST PREVIEW' if STAGE>=4 else 'GREY: RECENT TRAIL',10 if STAGE>=4 else 13)
        if STAGE>=2:
            ux=b.x/max(r,1e-12);uy=b.y/max(r,1e-12)
            arrow(425,240,-uy*b.spin*20,ux*b.spin*20,11);arrow(425,240,ux*20,uy*20,14)
            p.text(385,263,'TAN / OUT',14)
        p.rect(0,301,480,139,0)
        p.text(12,310,f'RADIUS {r:6.2f}  TIME {s.t:6.3f}  MODE {s.mode.upper()}',7)
        if STAGE>=2:
            p.text(12,325,f'FUEL {b.fuel:6.3f} / 100',11);p.rectb(288,324,176,9,5);p.rect(289,325,b.fuel*1.74,7,11)
        else:p.text(12,325,'GRAVITY ONLY: NO THRUST / NO ATMOSPHERE',6)
        if STAGE>=3:
            text='WINDOWS '+', '.join(f'{v:.1f}/{LEVELS[s.level].target:g}' for v in s.progress)
            p.text(12,341,text+f'  AIR {s.drag:g}',6)
        else:p.text(12,341,'KICK(dt/2) -> DRIFT(dt) -> KICK(dt/2)',6)
        if STAGE>=4:
            peri,apo,energy,h=elements(b)
            p.text(12,357,f'PERI {peri:.2f} APO {apo:.2f}  CERT {s.certified}  HOLD {s.stable:.2f}/3',11)
        elif STAGE==3:p.text(12,357,'OBSERVATION ONLY / RETURN TEST ADDED IN STAGE 4',10)
        else:p.text(12,357,'MODEL dt=1/120 / FOUR STEPS PER FRAME / SPEED CAP 40',5)
        p.text(12,375,'LEFT/RIGHT: RETRO/PRO  UP/DOWN: OUT/IN' if STAGE>=2 else 'WATCH THE ORBIT; POSITION IS NOT A KEY INPUT',7)
        p.text(12,391,'P: PAUSE  R: RESET LOADED STATE  ESC: QUIT',7)
        p.text(12,407,'Z: ENTRY UNDO  M: MENU  N: NEXT AFTER SUCCESS' if STAGE==5 else 'SAVE CODE -> QUIT -> RERUN TO APPLY EDITS',6)
        p.text(12,425,'PAUSED / RELEASE P BEFORE RESUME' if s.paused else 'RELEASE ALL KEYS' if self.gate else 'FICTIONAL MODEL / NOT FOR REAL AEROSPACE OR SAFETY',10 if s.paused or self.gate else 5)
        if s.mode in ('success','impact','escape'):
            p.rect(134,50,212,26,0);p.rectb(134,50,212,26,11 if s.mode=='success' else 8)
            p.text(146,60,s.mode.upper()+' / R TO RETRY',11 if s.mode=='success' else 8)
        if self.title:
            p.rect(60,96,360,137,0);p.rectb(60,96,360,137,6)
            p.text(80,114,'STAGE '+str(STAGE)+': '+STAGE_NAME,10)
            p.text(80,135,'SPACE: START, THEN RELEASE KEYS',7)
            p.text(80,156,'LEFT/RIGHT: SELECT A, B, C' if STAGE==5 else 'FLIGHT A / FOLLOW LESSON.md FOR EXERCISES',6)
            hints=('A: IDLE IS A VALID SUCCESS','B: IDLE IS SLOW BUT VALID','C: IDLE WILL HIT THE PLANET')
            p.text(80,177,hints[level] if STAGE>=4 else 'PRACTICE / NO RETURN SUCCESS YET',11)
            p.text(80,205,'CONTACT R=24 / NUMBERS ARE RADII',5)

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

def make_checkpoint(app):
    """Bounded automatic control recipe; not human input or a full playtest."""
    if STAGE==5:tap(app,'RIGHT');tap(app,'RIGHT')
    tap(app,'SPACE')
    frames={1:30,2:30,3:45,4:130,5:300}[STAGE]
    for n in range(frames):
        held={'RIGHT','UP'} if STAGE==2 else {'RIGHT'} if STAGE==5 and n<6 else set()
        app.control(held,set())
    if STAGE==5:tap(app,'Z')

def main(headless=False,screenshot=None,demo=False,report=None):
    # CLI resolves output paths before even importing Pyxel (which may change cwd).
    import pyxel as p
    p.init(480,440,title='Low satellite / stage '+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)
            if report:
                from pathlib import Path
                Path(report).write_text(json.dumps({'stage':STAGE,'state':asdict(app.s),'title':app.title,'gate':app.gate,'recipe':'automatic checkpoint' if demo else 'initial'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            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='Standalone orbital lesson; fictional physics.')
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:parser.error('automated outputs require --headless-smoke')
    for name,suffix in (('screenshot','.png'),('report','.json')):
        value=getattr(args,name)
        if value:
            path=Path(value)
            if path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists() or path.is_symlink():parser.error('use a new '+suffix+' in an existing directory')
            setattr(args,name,str(path.resolve()))
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint,args.report)
STEP 05 / 05

観測窓の入口へ戻り、三つの便を比べる

状態をまるごと保存するスナップショットと、画面を切り替えるときの入力ゲート。

選択画面で左右キーを押して便A・B・Cを選び、Space で開始します。便Aは窓が1つで目標8です。便Bは向かい合う2つの窓で、目標は各40です。便Cは上と下の2つの窓で、目標は各32です。便AとBは、何も押さなくても成功するように作られたルールです。便Cは半径36、最初の速さ17.5から始まり、何も押さないと約20.800秒で惑星にぶつかります。燃料が0になっただけでは失敗になりません。失敗になるのは、ぶつかったときか、外側のふちにいられる猶予が切れたときです。

Z で、直前に入った観測窓の入口へ戻ります。inside が False から True に変わる刻みを見つけて、更新する前の状態を checkpoint に保存します。半径が観測の範囲から外れていても、角度が窓に入れば、そこが入口になります。戻すのは Body だけではありません。Body・進み具合・時間・外縁の時計・帰還の時計・inside・certified の7項目をまとめて戻します。窓に入る直前の状態なので、戻した直後に inside が False でも、おかしくはありません。

まだ入口を通っていなければ、最初の状態へ戻ります。一時停止中や失敗したあとでも、Z を押すと PLAY に戻り、paused(一時停止)を解除します。R は同じ便の最初の状態へ、M は選択画面へ戻ります。成功したあとだけ、N で次の便へ進めます。画面を切り替えるキーと矢印キーを、少しずらして離すこともあります。そのときも、すべてのキーが離れるまで次の噴射が混ざらないように、gate を使います。これは、保存ファイルやセーブスロットを追加したわけではありません。

最後の段階の Level・Body・State・Input、すべてのモデルの関数、便A・B・Cのデータは、元のゲームと照らし合わせてあります。プログラムの構造(AST)と数値の両方で比べています。画面は英字(ASCII)だけの480×440で、教材向けの作りです。元のゲームの日本語の文字、F12での保存、長いデモ、抵抗なしで比べる画面(UI)は省いています。実験のスクリプトは決まった入力の並びを使います。ただし、人が正確に操作できるかや、遊んで楽しい難しさかを確かめたものではありません。これは架空の力学です。航空宇宙の設計・航法・安全の判断には使えません。

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

05_checkpoint_flights.py / 注目する部分
def snapshot(s):return (s.b,s.progress,s.t,s.outside,s.stable,s.inside,s.certified)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_checkpoint_flights.py
macOS / Linux
.venv/bin/python 05_checkpoint_flights.py
画面で見ること自動のチェックポイントでは、便Cで最初の6フレームだけ右を押し、そのまま300フレームまで進めてから Z を押します。すると、TIME 9.050、WINDOWS 0.0/32・32.0/32、燃料99.200の状態に戻ります。便Cで2回噴射する実験では、success 14.375000、使った燃料は1.466667です。
  • 入口の直前の状態を、まとめて全部保存する
  • 失敗や一時停止のあとでも、同じように戻せる
  • 便AとBは待つだけで成功し、便Cは操作が必要だと区別する
つまずきやすいところ位置だけを戻すと、観測の点、燃料、帰還の認定が、戻したあとにも残ってしまいます。checkpoint に記録した7項目を、同じようにすべて戻します。キーを離すまで待つゲートは、時間を戻すモデルとは別の、画面側の操作です。
少し変えてみよう

python exercises/05_two_bursts.py で便Cを調べます。tangent=1 if n<6 or 225<=n<230 else 0 から「 or 225<=n<230」を消して保存し、もう一度実行します。2回目の短い噴射をなくした結果と比べましょう。2回目がないと、60秒の観測期間の終わりで play 60.000000 0.800000 になります。これは、そのあとも絶対に成功しないという意味ではありません。元に戻すと、14.375秒での成功がもう一度起きます。これは60秒(1800フレーム)だけの例で、すべての操作のタイミングを調べたわけではありません。画面でも便Cを選んで P・Z・R を試せます。ただし、手で操作したときに同じ時刻になるとは限りません。

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

"""020 すれすれ衛星 / 第5段階。説明は LESSON.md。単独実行できます。"""
STAGE=5
"""Deterministic orbital game approximation; no Pyxel and no real navigation.
Coordinates: screen +y down. KDK gravity, each half-kick current drag/thrust.
Return requires bound osculating periapsis >=36.2, apoapsis <=86, outside drag.
A full no-thrust revolution is then verified with this very same integrator.
"""
from dataclasses import dataclass, replace, asdict
import math, json, hashlib
MU=12000.0
CONTACT=24.0
DT=1/120
FPS=30
THRUST=6.0
BURN=4.0
MAX_SPEED=40.0
TAU=math.tau
@dataclass(frozen=True)
class Level:
    title:str
    r:float
    speed:float
    windows:tuple
    target:float
LEVELS=(Level('高い一周',46,math.sqrt(MU/46),(0.,),8.),
        Level('二つの窓',50,math.sqrt(MU/50),(0.,math.pi),40.),
        Level('下がり始めた軌道',36,17.5,(math.pi/2,-math.pi/2),32.))
@dataclass(frozen=True)
class Body:
    x:float;y:float;vx:float;vy:float;fuel:float=100.;spin:int=-1
@dataclass(frozen=True)
class State:
    level:int
    b:Body
    progress:tuple
    t:float=0.
    outside:float=0.
    stable:float=0.
    mode:str='play'
    paused:bool=False
    drag:float=.015
    checkpoint:tuple|None=None
    inside:tuple=()
    certified:bool=False
@dataclass(frozen=True)
class Input:
    tangent:int=0
    radial:int=0
    pause:bool=False
    restart:bool=False
    rewind:bool=False

def fresh(level=0,drag=.015):
    l=LEVELS[level]
    b=Body(l.r,0.,0.,-l.speed)
    return State(level,b,(0.,)*len(l.windows),drag=drag,inside=tuple(in_window(0,w) for w in l.windows))
def radius(b):return math.hypot(b.x,b.y)
def angle(b):return math.atan2(b.y,b.x)
def wrap(a):return (a+math.pi)%TAU-math.pi
def in_window(a,w):return abs(wrap(a-w))<=math.pi/6

def elements(b):
    r=max(radius(b),1e-12);v2=b.vx*b.vx+b.vy*b.vy
    e=v2/2-MU/r;h=b.x*b.vy-b.y*b.vx
    ecc=math.sqrt(max(0.,1+2*e*h*h/(MU*MU)))
    peri=h*h/(MU*(1+ecc))
    apo=math.inf if e>=0 else -MU/(2*e)*(1+ecc)
    return peri,apo,e,h

def swept_contact(x,y,nx,ny,r=CONTACT):
    if x*x+y*y<=r*r:return 0.
    dx=nx-x;dy=ny-y;a=dx*dx+dy*dy
    if a<1e-24:return None
    b=2*(x*dx+y*dy);c=x*x+y*y-r*r;disc=b*b-4*a*c
    if disc<0:return None
    t=(-b-math.sqrt(disc))/(2*a)
    return t if 0<=t<=1 else None

def acceleration(b,tan,rad,drag):
    r=radius(b);den=max(r,CONTACT);ux=b.x/max(r,1e-12);uy=b.y/max(r,1e-12)
    tx=-uy*b.spin;ty=ux*b.spin
    n=math.hypot(tan,rad);scale=THRUST/max(n,1.)
    lam=drag*max(0.,min(1.,(36-r)/12))
    return (-MU*b.x/den**3-lam*b.vx+scale*(tan*tx+rad*ux),
            -MU*b.y/den**3-lam*b.vy+scale*(tan*ty+rad*uy))

def drift(b,tangent=0,radial=0,dt=DT,drag=.015):
    """KDK; fuel-limited mean thrust; swept planet contact beats observation."""
    if dt<=0:return b,False
    if not all(math.isfinite(x) for x in (b.x,b.y,b.vx,b.vy,b.fuel,dt)):raise ValueError('nonfinite state')
    if radius(b)<=CONTACT:return b,True
    active=bool(tangent or radial);used=min(max(b.fuel,0.),BURN*dt) if active else 0.
    fraction=used/(BURN*dt) if active else 0.
    tan=tangent*fraction;rad=radial*fraction
    # Normalize requested diagonal before fractional fuel throttle.
    n=math.hypot(tangent,radial)
    if n>1:tan/=n;rad/=n
    ax,ay=acceleration(b,tan,rad,drag)
    hx=b.vx+ax*dt/2;hy=b.vy+ay*dt/2
    nx=b.x+hx*dt;ny=b.y+hy*dt
    hit=swept_contact(b.x,b.y,nx,ny)
    if hit is not None:
        return replace(b,x=b.x+(nx-b.x)*hit,y=b.y+(ny-b.y)*hit,vx=hx,vy=hy,fuel=max(0.,b.fuel-used*hit)),True
    mid=replace(b,x=nx,y=ny,vx=hx,vy=hy)
    ax,ay=acceleration(mid,tan,rad,drag)
    vx=hx+ax*dt/2;vy=hy+ay*dt/2
    speed=math.hypot(vx,vy)
    if speed>MAX_SPEED:vx*=MAX_SPEED/speed;vy*=MAX_SPEED/speed
    h=nx*vy-ny*vx;spin=(1 if h>0 else -1) if abs(h)>1e-8 else b.spin
    return Body(nx,ny,vx,vy,max(0.,min(100.,b.fuel-used)),spin),False

def predict(b,seconds=4.,dt=DT,drag=.015):
    points=[];hit=False
    for k in range(round(seconds/dt)):
        b,hit=drift(b,dt=dt,drag=drag)
        if k%8==0 or hit:points.append((b.x,b.y))
        if hit:break
    return b,tuple(points),hit

def return_check(b,drag=.015):
    """Conservative: exclude drag/escape, then verify an entire coast revolution.
    This certifies this game's return condition only, not real orbital stability.
    """
    p,a,e,h=elements(b)
    if p<36.2 or a>86 or e>=0:return False
    travel=0.;old=angle(b)
    for _ in range(120*60):
        b,hit=drift(b,drag=drag)
        if hit or radius(b)<36. or radius(b)>87:return False
        new=angle(b);travel+=abs(wrap(new-old));old=new
        if travel>=TAU:return True
    return False

def snapshot(s):return (s.b,s.progress,s.t,s.outside,s.stable,s.inside,s.certified)
def rewind(s):
    if s.checkpoint is None:return fresh(s.level,s.drag)
    b,p,t,o,st,ins,cert=s.checkpoint
    return replace(s,b=b,progress=p,t=t,outside=o,stable=st,inside=ins,certified=cert,mode='play',paused=False)

def step(s,i=Input(),dt=DT):
    if i.restart:return fresh(s.level,s.drag)
    if i.rewind:return rewind(s)
    if i.pause:return replace(s,paused=not s.paused)
    if s.paused or s.mode!='play':return s
    b,hit=drift(s.b,i.tangent,i.radial,dt,s.drag)
    if hit:return replace(s,b=b,t=s.t+dt,mode='impact',certified=False,stable=0.)
    r=radius(b);l=LEVELS[s.level]
    ins=tuple(in_window(angle(b),w) for w in l.windows)
    checkpoint=s.checkpoint
    if any(v and not old for v,old in zip(ins,s.inside)):checkpoint=snapshot(s)
    rate=25. if r<=34 else 8. if r>=44 else 25.-1.7*(r-34)
    p=tuple(min(l.target,v+(rate*dt if isin and 26<=r<=52 else 0)) for v,isin in zip(s.progress,ins))
    outside=s.outside+dt if r>=88 else 0.
    # Certificate is invalidated by every actual burn; a full coast check on re-entry.
    cert=s.certified and b.fuel==s.b.fuel
    done=all(v>=l.target for v in p)
    if done and r>=42 and not cert and b.fuel==s.b.fuel:cert=return_check(b,s.drag)
    stable=s.stable+dt if done and r>=42 and cert else 0.
    mode='escape' if outside>=2 else 'success' if stable>=3 else 'play'
    return replace(s,b=b,progress=p,t=s.t+dt,outside=outside,stable=stable,inside=ins,checkpoint=checkpoint,certified=cert,mode=mode)

def frame(s,i=Input()):
    if i.pause or i.restart or i.rewind:return step(s,i)
    for _ in range(4):s=step(s,i)
    return s

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

STAGE_NAMES=("GRAVITY KDK","FUEL + LOCAL THRUST","AIR + OBSERVATION","PREVIEW + RETURN","ENTRY UNDO + FLIGHTS")

# --- The model above is independent from Pyxel. ---
KEYS=('SPACE','P','R','LEFT','RIGHT','UP','DOWN','Z','M','N')
STAGE_NAME=STAGE_NAMES[STAGE-1]

class App:
    def __init__(self):
        self.s=fresh()
        self.title=True
        self.gate=False
        self.last=Input()
        self.trail=[]
        self.preview=()
        self.ticks=0
    def control(self,held,edges):
        held=set(held)&set(KEYS);edges=set(edges)&set(KEYS)
        if self.gate:
            if not held:self.gate=False
            return
        if self.title:
            if STAGE==5:
                if 'LEFT' in edges:self.s=fresh((self.s.level-1)%3,self.s.drag)
                elif 'RIGHT' in edges:self.s=fresh((self.s.level+1)%3,self.s.drag)
            if 'SPACE' in edges:
                self.s=fresh(self.s.level,self.s.drag) if STAGE>=3 else fresh()
                self.title=False;self.gate=True;self.trail=[];self.preview=()
            return
        if STAGE==5 and 'M' in edges:
            self.title=True;self.gate=True;self.preview=();return
        if STAGE==5 and self.s.mode=='success' and 'N' in edges:
            self.s=fresh((self.s.level+1)%3,self.s.drag)
            self.gate=True;self.trail=[];self.preview=();return
        flags={'pause':'P' in edges,'restart':'R' in edges}
        if STAGE==5:flags['rewind']='Z' in edges
        tan=int('RIGHT' in held)-int('LEFT' in held) if STAGE>=2 else 0
        rad=int('UP' in held)-int('DOWN' in held) if STAGE>=2 else 0
        self.last=Input(tan,rad,**flags)
        self.s=frame(self.s,self.last)
        if any(flags.values()):
            self.gate=True;self.trail=[];self.preview=()
        self.ticks+=1
        if not self.s.paused:
            self.trail.append((self.s.b.x,self.s.b.y));self.trail=self.trail[-120:]
        if STAGE>=4 and (self.ticks%6==0 or self.s.paused or any(flags.values())):
            self.preview=predict(self.s.b,drag=self.s.drag)[1]
    def draw(self,p):
        s=self.s;b=s.b;r=radius(b)
        def point(x,y):return 240+x*1.42,171+y*1.42
        def arc(rad,lo,hi,color,dashed=False):
            for k in range(96):
                if dashed and k%4>=2:continue
                a=lo+(hi-lo)*k/96;c=lo+(hi-lo)*(k+1)/96
                p.line(*point(rad*math.cos(a),rad*math.sin(a)),*point(rad*math.cos(c),rad*math.sin(c)),color)
        def arrow(x,y,dx,dy,color):
            p.line(x,y,x+dx,y+dy,color);length=math.hypot(dx,dy)
            if length:
                ux,uy=dx/length,dy/length
                p.line(x+dx,y+dy,x+dx-ux*4-uy*2,y+dy-uy*4+ux*2,color)
                p.line(x+dx,y+dy,x+dx-ux*4+uy*2,y+dy-uy*4-ux*2,color)
        p.cls(1)
        for n in range(90):p.pset(12+(n*71)%456,44+(n*47)%253,5 if n%3 else 13)
        p.text(12,8,'LOW SATELLITE / '+str(STAGE)+' '+STAGE_NAME,10)
        level=s.level if STAGE>=3 else 0
        p.text(12,24,'FLIGHT '+'ABC'[level]+' / GAME PHYSICS / SILENT',6)
        if STAGE>=3:
            p.circ(240,171,36*1.42,2);arc(36,0,TAU,13,True);arc(88,0,TAU,5,True)
            l=LEVELS[s.level]
            for j,w in enumerate(l.windows):
                color=11 if s.progress[j]>=l.target else 10 if s.inside[j] else 6
                arc(52,w-math.pi/6,w+math.pi/6,color)
                for a in (w-math.pi/6,w+math.pi/6):p.line(*point(26*math.cos(a),26*math.sin(a)),*point(52*math.cos(a),52*math.sin(a)),color)
                x,y=point(64*math.cos(w),64*math.sin(w));p.text(x-18,y-3,f'W{j+1} {s.progress[j]:.1f}',color)
        else:arc(46,0,TAU,5,True)
        p.circ(240,171,22*1.42,5);p.circb(240,171,22*1.42,6)
        p.elli(222,152,24,13,3);p.tri(236,170,255,162,246,191,3)
        p.circ(228,160,2,11);p.circ(253,177,2,11)
        arc(CONTACT,0,TAU,13,True)
        for q in self.trail[::3]:p.pset(*point(*q),13)
        for q in self.preview[::2]:p.pset(*point(*q),10)
        x,y=point(b.x,b.y);arrow(x,y,b.vx*.75,b.vy*.75,7)
        p.rect(x-8,y-2,5,5,12);p.rect(x+4,y-2,5,5,12);p.rect(x-2,y-3,5,7,7);p.pset(x,y,1)
        p.text(12,282,'WHITE: TRUE VELOCITY',7)
        p.text(272,282,'YELLOW: 4s COAST PREVIEW' if STAGE>=4 else 'GREY: RECENT TRAIL',10 if STAGE>=4 else 13)
        if STAGE>=2:
            ux=b.x/max(r,1e-12);uy=b.y/max(r,1e-12)
            arrow(425,240,-uy*b.spin*20,ux*b.spin*20,11);arrow(425,240,ux*20,uy*20,14)
            p.text(385,263,'TAN / OUT',14)
        p.rect(0,301,480,139,0)
        p.text(12,310,f'RADIUS {r:6.2f}  TIME {s.t:6.3f}  MODE {s.mode.upper()}',7)
        if STAGE>=2:
            p.text(12,325,f'FUEL {b.fuel:6.3f} / 100',11);p.rectb(288,324,176,9,5);p.rect(289,325,b.fuel*1.74,7,11)
        else:p.text(12,325,'GRAVITY ONLY: NO THRUST / NO ATMOSPHERE',6)
        if STAGE>=3:
            text='WINDOWS '+', '.join(f'{v:.1f}/{LEVELS[s.level].target:g}' for v in s.progress)
            p.text(12,341,text+f'  AIR {s.drag:g}',6)
        else:p.text(12,341,'KICK(dt/2) -> DRIFT(dt) -> KICK(dt/2)',6)
        if STAGE>=4:
            peri,apo,energy,h=elements(b)
            p.text(12,357,f'PERI {peri:.2f} APO {apo:.2f}  CERT {s.certified}  HOLD {s.stable:.2f}/3',11)
        elif STAGE==3:p.text(12,357,'OBSERVATION ONLY / RETURN TEST ADDED IN STAGE 4',10)
        else:p.text(12,357,'MODEL dt=1/120 / FOUR STEPS PER FRAME / SPEED CAP 40',5)
        p.text(12,375,'LEFT/RIGHT: RETRO/PRO  UP/DOWN: OUT/IN' if STAGE>=2 else 'WATCH THE ORBIT; POSITION IS NOT A KEY INPUT',7)
        p.text(12,391,'P: PAUSE  R: RESET LOADED STATE  ESC: QUIT',7)
        p.text(12,407,'Z: ENTRY UNDO  M: MENU  N: NEXT AFTER SUCCESS' if STAGE==5 else 'SAVE CODE -> QUIT -> RERUN TO APPLY EDITS',6)
        p.text(12,425,'PAUSED / RELEASE P BEFORE RESUME' if s.paused else 'RELEASE ALL KEYS' if self.gate else 'FICTIONAL MODEL / NOT FOR REAL AEROSPACE OR SAFETY',10 if s.paused or self.gate else 5)
        if s.mode in ('success','impact','escape'):
            p.rect(134,50,212,26,0);p.rectb(134,50,212,26,11 if s.mode=='success' else 8)
            p.text(146,60,s.mode.upper()+' / R TO RETRY',11 if s.mode=='success' else 8)
        if self.title:
            p.rect(60,96,360,137,0);p.rectb(60,96,360,137,6)
            p.text(80,114,'STAGE '+str(STAGE)+': '+STAGE_NAME,10)
            p.text(80,135,'SPACE: START, THEN RELEASE KEYS',7)
            p.text(80,156,'LEFT/RIGHT: SELECT A, B, C' if STAGE==5 else 'FLIGHT A / FOLLOW LESSON.md FOR EXERCISES',6)
            hints=('A: IDLE IS A VALID SUCCESS','B: IDLE IS SLOW BUT VALID','C: IDLE WILL HIT THE PLANET')
            p.text(80,177,hints[level] if STAGE>=4 else 'PRACTICE / NO RETURN SUCCESS YET',11)
            p.text(80,205,'CONTACT R=24 / NUMBERS ARE RADII',5)

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

def make_checkpoint(app):
    """Bounded automatic control recipe; not human input or a full playtest."""
    if STAGE==5:tap(app,'RIGHT');tap(app,'RIGHT')
    tap(app,'SPACE')
    frames={1:30,2:30,3:45,4:130,5:300}[STAGE]
    for n in range(frames):
        held={'RIGHT','UP'} if STAGE==2 else {'RIGHT'} if STAGE==5 and n<6 else set()
        app.control(held,set())
    if STAGE==5:tap(app,'Z')

def main(headless=False,screenshot=None,demo=False,report=None):
    # CLI resolves output paths before even importing Pyxel (which may change cwd).
    import pyxel as p
    p.init(480,440,title='Low satellite / stage '+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)
            if report:
                from pathlib import Path
                Path(report).write_text(json.dumps({'stage':STAGE,'state':asdict(app.s),'title':app.title,'gate':app.gate,'recipe':'automatic checkpoint' if demo else 'initial'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
            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='Standalone orbital lesson; fictional physics.')
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--report')
    args=parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:parser.error('automated outputs require --headless-smoke')
    for name,suffix in (('screenshot','.png'),('report','.json')):
        value=getattr(args,name)
        if value:
            path=Path(value)
            if path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists() or path.is_symlink():parser.error('use a new '+suffix+' in an existing directory')
            setattr(args,name,str(path.resolve()))
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint,args.report)
AFTER STEP 05

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

重力・大気の抵抗・限られた燃料を、KDKで計算します。半径26~52の間で、角度の窓の中にいると観測が進みます。すべての窓で目標を達成したあと、噴射なしで一周できると確かめられた軌道で、半径42以上を3秒保つと成功です。接触を先に調べ、半径88以上に2秒いると離脱です。便AとBは何も押さなくても成功し、便Cは何も押さないとぶつかります。これはわざとそう作ってあります。

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

比べる点5ステップの教材元の完成版
段階01は重力だけ。02は噴射と燃料があるが、抵抗はない。03は抵抗と観測があるが、帰還はない。04で帰還、05で入口への復元と便の選択。最初からすべての機能がある。ほかに、抵抗を0にした比較の条件もある。
画面480×440の英字(ASCII)。01~04は便Aで固定、05は便A・B・C。各段階は1ファイルで、assets(画像などの素材)はいらない。384×384、日本語の文字と便の選択、F12、長い自動リプレイ、比較の画面(UI)。
モデル05のモデルの定義すべて、定数、便A・B・Cのデータは、元のゲームと同じ。04は、入口へ戻すための欄と関数だけを省いている。model.py と app.py に分かれている。予測と一周の帰還確認で同じ積分器を使い、入口の状態を保存する。
検査出力コマンドライン(CLI)で指定したときだけ、最初の状態と短いチェックポイントの PNG と JSON を出す。起動した場所(cwd)をもとに絶対パスに直し、すでにある出力ファイルには書かない。media にデモとスクリーンショットを出す。

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

  1. model.py の acceleration・drift(衛星から見た軸、半端な燃料、KDK)
  2. model.py の swept_contact(線分と円が交わる点)
  3. model.py の predict・return_check(調べる時間の長さのちがい)
  4. model.py の step(接触を先に調べる、認定を無効にする、入口を保存する)
  5. app.py の lock と title の切り替え(遷移)
  6. tools/build_replays.py の、便Cで2回噴射する例と、やり方の比較(いちばん良い方法だという証明ではない)
自分のゲームに変えるなら

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

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