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