# MIT License # # Copyright (c) 2026 Pyxel 100 contributors # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # # The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL. # See assets/NOTO-LICENSE.txt. """019 くるり荷車 / 第5段階。説明は LESSON.md。単独実行できます。""" STAGE=5 """Pure load-dependent cart approximation. Units: pixels, seconds, radians. The raster road and solid 2px cells are both drawn and collided with. Continuous swept circle vs each solid cell includes rounded corner contacts. """ from dataclasses import dataclass, field from copy import deepcopy import math DT=1/120 FPS=30 RADIUS=7.0 WORLD=(192,128) TILE=2 def line(a,b): n=max(1,math.ceil(math.dist(a,b))) return [(a[0]+(b[0]-a[0])*i/n,a[1]+(b[1]-a[1])*i/n) for i in range(n+1)] def arc(c,r,a,b): n=max(1,math.ceil(abs(b-a)*r)) return [(c[0]+r*math.cos(a+(b-a)*i/n),c[1]+r*math.sin(a+(b-a)*i/n)) for i in range(n+1)] @dataclass(frozen=True) class Level: title:str start:tuple angle:float masses:tuple targets:tuple stops:tuple paths:tuple pads:tuple=() road:frozenset=field(default_factory=frozenset) def make_level(title,start,angle,masses,targets,stops,paths,pads=()): # Tile-centre rasterization; the exact visible tile boundary is the collision wall. road=set() for tx in range(WORLD[0]//TILE): for ty in range(WORLD[1]//TILE): p=(tx*TILE+1,ty*TILE+1) if any(any(math.dist(p,q)<=width/2 for q in path) for path,width in paths) or any(math.dist(p,c)<=r for c,r in pads):road.add((tx,ty)) return Level(title,start,angle,masses,targets,stops,paths,pads,frozenset(road)) B_PATH=line((24,100),(64,100))+arc((64,74),26,math.pi/2,0)+line((90,74),(90,60))+arc((116,60),26,math.pi,math.pi*1.5)+line((116,34),(166,34)) C_INNER=line((40,100),(84,100))+arc((84,76),24,math.pi/2,0)+line((108,76),(108,52))+arc((132,52),24,math.pi,math.pi*1.5)+line((132,28),(164,28)) C_OUTER=line((28,100),(28,52))+arc((52,52),24,math.pi,math.pi*1.5)+line((52,28),(164,28)) LEVELS=( make_level('まっすぐな市場',(24,100),0,(1,2,1),(1,2,2),((24,100),(86,100),(164,100)),((line((24,100),(164,100)),34),),(((24,100),20),((164,100),18))), make_level('左、そして右',(24,100),0,(3,1,1),(1,2,2),((24,100),(90,74),(166,34),(90,52)),((B_PATH,32),),(((24,100),20),((90,52),18),((166,34),18))), make_level('路地と広い外周',(40,100),0,(1,2,3),(1,1,1),((40,100),(164,28),(76,100)),((C_INNER,24),(C_OUTER,36)),(((40,100),34),((164,28),18),((76,100),15))) ) @dataclass class State: x:float y:float angle:float speed:float=0 owners:list=field(default_factory=lambda:['slot:0','slot:1','slot:2']) delivered:int=0 collisions:int=0 distance:float=0 seconds:float=0 trips:int=0 touching:bool=False touch_point:tuple|None=None departure_ready:bool=True pending_departure:int|None=None status:str='driving' message:str='' def initial(level):return State(*level.start,level.angle) def mass_bias(level,state): load=[(level.masses[i],int(o[-1])-1) for i,o in enumerate(state.owners) if o.startswith('slot:')] mass=2+sum(m for m,x in load) return mass,sum(m*x for m,x in load)/mass def omega(speed,steer,bias,b_term=True):return speed/24*(1.3*steer+(0.7*bias if b_term else 0)) def station(level,state): if abs(state.speed)>0.8:return None found=[(math.dist((state.x,state.y),p),i) for i,p in enumerate(level.stops) if math.dist((state.x,state.y),p)<=11] return min(found)[1] if found else None def transfer(level,state,parcel,target,stop): """Move/swap between slots or local storage; exactly one owner per parcel.""" if station(level,state)!=stop or state.owners[parcel]=='delivered':return False source=state.owners[parcel] if source.startswith('store:') and source!=f'store:{stop}':return False destination=f'store:{stop}' if target==3 else f'slot:{target}' if source==destination:return False if destination.startswith('slot:'): other=next((i for i,o in enumerate(state.owners) if o==destination),None) if other is not None:state.owners[other]=source state.owners[parcel]=destination return True def deliver(level,state): if abs(state.speed)>5:return [] near=[i for i,p in enumerate(level.stops) if math.dist((state.x,state.y),p)<=11] ids=[i for i,o in enumerate(state.owners) if o.startswith('slot:') and level.targets[i] in near] for i in ids:state.owners[i]='delivered' state.delivered=sum(o=='delivered' for o in state.owners) if ids:state.message='配達した。荷重が変わった' if state.delivered==3:state.status='success';state.speed=0 return ids def circle_hit(p,d,c,r): a=d[0]**2+d[1]**2 if a<1e-20:return None q=(p[0]-c[0],p[1]-c[1]);b=2*(q[0]*d[0]+q[1]*d[1]);cc=q[0]**2+q[1]**2-r*r disc=b*b-4*a*cc if disc<0:return None t=(-b-math.sqrt(max(0,disc)))/(2*a) return t if -1e-10<=t<=1+1e-10 else None def sweep_box(p,d,box,r=RADIUS): x0,y0,x1,y1=box near=(min(x1,max(x0,p[0])),min(y1,max(y0,p[1]))) if math.dist(p,near)0: t=(x0-r-p[0])/d[0] if 0<=t<=1 and y0<=p[1]+d[1]*t<=y1:hits.append(t) if d[0]<0: t=(x1+r-p[0])/d[0] if 0<=t<=1 and y0<=p[1]+d[1]*t<=y1:hits.append(t) if d[1]>0: t=(y0-r-p[1])/d[1] if 0<=t<=1 and x0<=p[0]+d[0]*t<=x1:hits.append(t) if d[1]<0: t=(y1+r-p[1])/d[1] if 0<=t<=1 and x0<=p[0]+d[0]*t<=x1:hits.append(t) for c in ((x0,y0),(x0,y1),(x1,y0),(x1,y1)): t=circle_hit(p,d,c,r) if t is not None:hits.append(max(0,t)) return min(hits) if hits else None def sweep_road(level,p,q,r=RADIUS): d=(q[0]-p[0],q[1]-p[1]);hit=None for tx in range(math.floor((min(p[0],q[0])-r)/TILE),math.floor((max(p[0],q[0])+r)/TILE)+1): for ty in range(math.floor((min(p[1],q[1])-r)/TILE),math.floor((max(p[1],q[1])+r)/TILE)+1): if (tx,ty) in level.road:continue t=sweep_box(p,d,(tx*TILE,ty*TILE,(tx+1)*TILE,(ty+1)*TILE),r) if t is not None and (hit is None or t.3:state.touching=False;state.touch_point=None state.distance+=math.dist(old,(state.x,state.y));state.seconds+=dt def frame(level,state,throttle=0,steer=0,brake=False,unload=False,**kw): if throttle and not brake and state.status=='driving' and state.departure_ready and station(level,state) is not None: state.pending_departure=station(level,state);state.departure_ready=False if unload:deliver(level,state) for _ in range(4):step(level,state,throttle,steer,brake,**kw) if state.pending_departure is not None and math.dist((state.x,state.y),level.stops[state.pending_departure])>11: state.trips+=1;state.pending_departure=None if (not throttle or brake) and station(level,state) is not None:state.departure_ready=True def state_dict(state): from dataclasses import asdict return asdict(state) KEYS={'UP','DOWN','LEFT','RIGHT','SPACE','Z','U','P','R','N','Q'} class Game: def __init__(self): self.level=0;self.title=True;self.paused=False;self.editing=False;self.gate=False self.parcel=0;self.slot=0;self.reset(gate=False) @property def road(self):return LEVELS[self.level] def reset(self,gate=True): self.state=initial(self.road);self.checkpoint=deepcopy(self.state) self.paused=False;self.editing=False;self.gate=gate;self.parcel=0;self.slot=0 def update(self,held,edges): if self.gate: if not (held|edges)&KEYS:self.gate=False return if self.title: if 'LEFT' in edges:self.level=(self.level-1)%3;self.reset(False) if 'RIGHT' in edges:self.level=(self.level+1)%3;self.reset(False) if 'SPACE' in edges:self.title=False;self.reset() return if 'R' in edges:self.reset();return if 'Q' in edges:self.title=True;self.gate=True;return if 'P' in edges:self.paused=not self.paused;self.gate=True;return if self.paused:return if 'U' in edges: self.state=deepcopy(self.checkpoint);self.editing=False;self.gate=True self.state.message='直前の出発へ戻した';return if self.state.status=='success': if 'N' in edges:self.level=(self.level+1)%3;self.reset() return if 'Z' in edges: if self.editing:self.editing=False;self.gate=True elif station(self.road,self.state) is not None:self.editing=True;self.gate=True else:self.state.message='区画で止まってから Z' return if self.editing: if 'UP' in edges:self.parcel=(self.parcel-1)%3 if 'DOWN' in edges:self.parcel=(self.parcel+1)%3 if 'LEFT' in edges:self.slot=(self.slot-1)%4 if 'RIGHT' in edges:self.slot=(self.slot+1)%4 if 'SPACE' in edges: ok=transfer(self.road,self.state,self.parcel,self.slot,station(self.road,self.state)) self.state.message='積み直した' if ok else 'その荷物はここにない/同じ場所' return throttle=int('UP' in held)-int('DOWN' in held) steer=int('RIGHT' in held)-int('LEFT' in held) if throttle and 'SPACE' not in held and self.state.departure_ready and station(self.road,self.state) is not None: self.checkpoint=deepcopy(self.state) frame(self.road,self.state,throttle,steer,'SPACE' in held,'SPACE' in edges) class App: """UI reads state. The model does not import Pyxel.""" def __init__(self): self.game=Game() def control(self,held,edges): self.game.update(set(held),set(edges)) def draw(self,p): g=self.game;s=g.state;l=g.road p.cls(1) p.text(12,10,'WEIGHTED CART / STAGE '+str(STAGE)+' / ROAD '+str(g.level+1),10) mass,bias=mass_bias(l,s) p.text(12,25,f'MASS {mass} BIAS {bias:+.3f} SPEED {s.speed:+.2f}',7) p.text(12,39,f'DELIVERED {s.delivered}/3 HITS {s.collisions} TRIPS {s.trips}',6) def pos(q):return 48+q[0]*2,56+q[1]*2 p.rect(48,56,384,256,3) for tx,ty in l.road:p.rect(48+tx*4,56+ty*4,4,4,4) for i,q in enumerate(l.stops): x,y=pos(q);p.circb(x,y,22,6) label='DEPOT' if i==0 else ''.join('ABC'[j] for j,t in enumerate(l.targets) if t==i) or 'STORE' if g.level==1 and i==1:p.text(x+28,y-3,label,7) else:p.text(x-10,y-29,label,7) p.text(x-2,y+12,str(i),6) x,y=pos((s.x,s.y));p.circ(x,y,RADIUS*2,5);p.circb(x,y,RADIUS*2,7) forward=(math.cos(s.angle),math.sin(s.angle));normal=(-forward[1],forward[0]) p.line(x,y,x+forward[0]*19,y+forward[1]*19,10) for i,o in enumerate(s.owners): if o.startswith('slot:'): offset=(int(o[-1])-1)*8 bx=x+normal[0]*offset;by=y+normal[1]*offset p.rect(bx-3,by-3,7,7,(10,11,14)[i]);p.rectb(bx-3,by-3,7,7,1) p.circ(x+normal[0]*bias*12,y+normal[1]*bias*12,2,8) p.text(12,324,'A: '+s.owners[0]+' B: '+s.owners[1]+' C: '+s.owners[2],7) p.text(12,339,f'POS ({s.x:.2f}, {s.y:.2f}) ANGLE {s.angle:+.3f} DIST {s.distance:.2f}',6) p.text(12,355,'STATUS: '+s.status.upper()+(' / PAUSED' if g.paused else '')+(' / RELEASE KEYS' if g.gate else ''),10) p.text(12,375,'UP/DOWN: DRIVE SPACE: BRAKE'+(' + DELIVER ON PRESS' if STAGE>=4 else ''),7) p.text(12,391,('LEFT/RIGHT: STEER Z: CARGO ' if STAGE>=2 else 'STRAIGHT PRACTICE ')+'R: RESET ESC: QUIT',7) p.text(12,407,'U: DEPARTURE UNDO P: PAUSE Q: TITLE N: NEXT AFTER WIN' if STAGE==5 else ('WALL COLLISION ON' if STAGE>=3 else 'PRACTICE: WALL COLLISION OFF'),6) notices={'配達した。荷重が変わった':'DELIVERED: MASS AND BIAS CHANGED','壁で止まった。後退できる':'WALL CONTACT: REVERSE TO TRY AGAIN','直前の出発へ戻した':'RESTORED LAST DEPARTURE (CARGO INCLUDED)','区画で止まってから Z':'CARGO: STOP INSIDE A STATION FIRST','積み直した':'CARGO MOVED / SWAPPED','その荷物はここにない/同じ場所':'CARGO NOT HERE / ALREADY IN THAT PLACE'} p.text(12,423,notices.get(s.message,'NO TIME LIMIT / GAME LOAD MODEL, NOT REAL VEHICLE PHYSICS'),6) if g.title: p.rect(64,111,352,142,1);p.rectb(64,111,352,142,7) p.text(88,130,'CARGO CHANGES ACCELERATION AND TURNING',10) p.text(88,153,'SPACE: START; THEN RELEASE ALL KEYS',7) p.text(88,176,'LEFT/RIGHT: CHOOSE ROAD 1, 2, 3' if STAGE==5 else 'THIS LESSON USES ROAD '+str(g.level+1),6) p.text(88,199,'STOPPED CART CANNOT ROTATE' if STAGE>=2 else 'FIRST: ACCELERATE, COAST, BRAKE, REVERSE',7) p.text(88,222,'LOAD SLOTS: LEFT / CENTER / RIGHT',6) if g.editing: p.rect(60,105,360,169,1);p.rectb(60,105,360,169,6) p.text(78,119,'CARGO: UP/DOWN PICK; LEFT/RIGHT DESTINATION',10) for i,o in enumerate(s.owners): p.text(78,143+i*22,('> ' if i==g.parcel else ' ')+'ABC'[i]+f' MASS {l.masses[i]} : '+o,(10,11,14)[i]) p.text(78,214,'DEST: '+('LEFT','CENTER','RIGHT','LOCAL STORE')[g.slot],7) p.text(78,236,'SPACE: MOVE/SWAP Z: CLOSE; RELEASE KEYS',6) p.text(78,255,'RED DOT = LOAD BIAS; SLOT IS CART-RELATIVE',8) if g.paused:p.rectb(53,61,374,246,10) def tap(app,key): app.control({key},{key});app.control(set(),set()) def make_checkpoint(app): """Deterministic QA recipe, not an automatic solution or human playtest.""" if STAGE==5:tap(app,'RIGHT') tap(app,'SPACE') if STAGE==1: for _ in range(30):app.control({'UP'},set()) elif STAGE==2: tap(app,'Z');tap(app,'RIGHT');tap(app,'SPACE') elif STAGE==3: # Explicit synthetic fixture: drive at the road's upper wall. s=app.game.state;s.x,s.y=(64,100);s.angle=-math.pi/2;s.speed=40 for _ in range(45):app.control({'UP'},set()) elif STAGE==4: # Explicit synthetic fixture: press delivery at station 1 at speed 5. s=app.game.state;s.x,s.y=app.game.road.stops[1];s.speed=5 tap(app,'SPACE') else: tap(app,'Z');tap(app,'RIGHT');tap(app,'SPACE');tap(app,'Z') for _ in range(30):app.control({'UP'},set()) tap(app,'U') def main(headless=False,screenshot=None,demo=False): import pyxel as p p.init(480,444,title='Weighted cart lesson '+str(STAGE),fps=FPS,display_scale=2,headless=headless) app=App() if demo:make_checkpoint(app) keymap={k:getattr(p,'KEY_'+k) for k in KEYS} def update(): if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=3: if screenshot:app.draw(p);p.screen.save(screenshot,1) p.quit();return if not headless:app.control({k for k,v in keymap.items() if p.btn(v)},{k for k,v in keymap.items() if p.btnp(v)}) p.run(update,lambda:app.draw(p)) if __name__=='__main__': import argparse from pathlib import Path parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless-smoke',action='store_true') parser.add_argument('--demo-checkpoint',action='store_true') parser.add_argument('--screenshot') args=parser.parse_args() if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode') if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode') if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory') if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve()) main(args.headless_smoke,args.screenshot,args.demo_checkpoint)