# 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)