# MIT License # # Copyright (c) 2026 Pyxel 100 contributors # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # # The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL. # See assets/NOTO-LICENSE.txt. """019 くるり荷車 / 第2段階。説明は LESSON.md。単独実行できます。""" STAGE=2 from dataclasses import dataclass, field from copy import deepcopy import math DT=1/120 FPS=30 RADIUS=7.0 WORLD=(192,128) TILE=2 def line(a,b): n=max(1,math.ceil(math.dist(a,b))) return [(a[0]+(b[0]-a[0])*i/n,a[1]+(b[1]-a[1])*i/n) for i in range(n+1)] def arc(c,r,a,b): n=max(1,math.ceil(abs(b-a)*r)) return [(c[0]+r*math.cos(a+(b-a)*i/n),c[1]+r*math.sin(a+(b-a)*i/n)) for i in range(n+1)] @dataclass(frozen=True) class Level: title:str start:tuple angle:float masses:tuple targets:tuple stops:tuple paths:tuple pads:tuple=() road:frozenset=field(default_factory=frozenset) def make_level(title,start,angle,masses,targets,stops,paths,pads=()): # Tile-centre rasterization; the exact visible tile boundary is the collision wall. road=set() for tx in range(WORLD[0]//TILE): for ty in range(WORLD[1]//TILE): p=(tx*TILE+1,ty*TILE+1) if any(any(math.dist(p,q)<=width/2 for q in path) for path,width in paths) or any(math.dist(p,c)<=r for c,r in pads):road.add((tx,ty)) return Level(title,start,angle,masses,targets,stops,paths,pads,frozenset(road)) B_PATH=line((24,100),(64,100))+arc((64,74),26,math.pi/2,0)+line((90,74),(90,60))+arc((116,60),26,math.pi,math.pi*1.5)+line((116,34),(166,34)) C_INNER=line((40,100),(84,100))+arc((84,76),24,math.pi/2,0)+line((108,76),(108,52))+arc((132,52),24,math.pi,math.pi*1.5)+line((132,28),(164,28)) C_OUTER=line((28,100),(28,52))+arc((52,52),24,math.pi,math.pi*1.5)+line((52,28),(164,28)) LEVELS=( make_level('まっすぐな市場',(24,100),0,(1,2,1),(1,2,2),((24,100),(86,100),(164,100)),((line((24,100),(164,100)),34),),(((24,100),20),((164,100),18))), make_level('左、そして右',(24,100),0,(3,1,1),(1,2,2),((24,100),(90,74),(166,34),(90,52)),((B_PATH,32),),(((24,100),20),((90,52),18),((166,34),18))), make_level('路地と広い外周',(40,100),0,(1,2,3),(1,1,1),((40,100),(164,28),(76,100)),((C_INNER,24),(C_OUTER,36)),(((40,100),34),((164,28),18),((76,100),15))) ) @dataclass class State: x:float y:float angle:float speed:float=0 owners:list=field(default_factory=lambda:['slot:0','slot:1','slot:2']) delivered:int=0 collisions:int=0 distance:float=0 seconds:float=0 trips:int=0 touching:bool=False touch_point:tuple|None=None departure_ready:bool=True pending_departure:int|None=None status:str='driving' message:str='' def initial(level):return State(*level.start,level.angle) def mass_bias(level,state): load=[(level.masses[i],int(o[-1])-1) for i,o in enumerate(state.owners) if o.startswith('slot:')] mass=2+sum(m for m,x in load) return mass,sum(m*x for m,x in load)/mass def omega(speed,steer,bias,b_term=True):return speed/24*(1.3*steer+(0.7*bias if b_term else 0)) def station(level,state): if abs(state.speed)>0.8:return None found=[(math.dist((state.x,state.y),p),i) for i,p in enumerate(level.stops) if math.dist((state.x,state.y),p)<=11] return min(found)[1] if found else None def transfer(level,state,parcel,target,stop): """Move/swap between slots or local storage; exactly one owner per parcel.""" if station(level,state)!=stop or state.owners[parcel]=='delivered':return False source=state.owners[parcel] if source.startswith('store:') and source!=f'store:{stop}':return False destination=f'store:{stop}' if target==3 else f'slot:{target}' if source==destination:return False if destination.startswith('slot:'): other=next((i for i,o in enumerate(state.owners) if o==destination),None) if other is not None:state.owners[other]=source state.owners[parcel]=destination return True def approach(v,target,amount):return min(target,v+amount) if v=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)