# 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. """穴あき風船・教材 4/5:給気口と局所再開。Python 3.11+ / Pyxel 2.9.9。""" from dataclasses import dataclass, replace, asdict import math, json, hashlib DT = 1 / 120 RADIUS = 7. THRUST = 90. LEAK = .5 BURN = 6. SPEED_LIMIT = 55. @dataclass(frozen=True) class Level: title:str start:tuple ports:tuple goal:tuple walls:tuple=() spikes:tuple=() marks:tuple=() wind:tuple=(10.,0.) LEVELS=( Level('ふくらんだまま',(24.,100.),((24.,100.),),(164.,36.),marks=((88.,42.),(140.,25.))), Level('低い給気口',(24.,36.),((24.,36.),(108.,103.)),(166.,32.),walls=((72.,40.,96.,76.),),spikes=((64.,8.,120.,24.),),marks=((56.,91.),(144.,60.)),wind=(-12.,8.)), Level('満タンなら上がる',(24.,100.),((24.,100.),(92.,100.)),(169.,98.),walls=((104.,8.,148.,76.),(104.,116.,148.,136.)),marks=((123.,96.),(159.,103.)),wind=(0.,0.)), ) @dataclass(frozen=True) class Input: x:int=0 y:int=0 space:bool=False @dataclass(frozen=True) class State: level:int=0 x:float=24. y:float=100. vx:float=0. vy:float=0. air:float=80. dock:int=0 checkpoint:int=0 status:str='flying' time:float=0. consumed:float=0. fills:int=0 reason:str='' thrust:tuple=(0.,0.) linked:bool=True def initial(level=0,linked=True): x,y=LEVELS[level].start return State(level=level,x=x,y=y,linked=linked) def restore(s): x,y=LEVELS[s.level].ports[s.checkpoint] return replace(s,x=x,y=y,vx=0.,vy=0.,air=80.,dock=s.checkpoint,status='flying',reason='',thrust=(0.,0.)) def sweep_box(p,d,box,r=RADIUS): """Point segment versus inflated AABB. Returns earliest t, outward normal. Exact touching while moving away/tangential is not a fresh collision. """ x,y=p;dx,dy=d;a,b,c,e=box;a-=r;b-=r;c+=r;e+=r near=-math.inf;far=math.inf;normal=(0.,0.) for pos,delta,low,high,n in ((x,dx,a,c,(-1.,0.)),(y,dy,b,e,(0.,-1.))): if abs(delta)<1e-12: if poshigh:return None continue t1=(low-pos)/delta;t2=(high-pos)/delta;nn=n if t1>t2:t1,t2=t2,t1;nn=(-n[0],-n[1]) if t1>near:near=t1;normal=nn far=min(far,t2) if near>far:return None if far<0 or near< -1e-8 or near>1:return None if d[0]*normal[0]+d[1]*normal[1]>=0:return None return max(0.,near),normal def move(s,vx,vy,dt): l=LEVELS[s.level];x,y=s.x,s.y;left=dt;path=[(x,y)] # Ordinary perimeter. Bottom recovery net is visible, not a timed penalty. walls=((-20.,-20.,0.,144.),(192.,-20.,212.,144.),(0.,0.,192.,8.))+l.walls for _ in range(4): d=(vx*left,vy*left);hits=[] for i,box in enumerate(walls): h=sweep_box((x,y),d,box) if h:hits.append((h[0],0,i,h[1])) for i,box in enumerate(l.spikes): h=sweep_box((x,y),d,box) if h:hits.append((h[0],-1,i,h[1])) if vy>0 and y+d[1]>=129: hits.append(((129-y)/max(1e-20,d[1]),-2,0,(0.,-1.))) if not hits:x+=d[0];y+=d[1];path.append((x,y));break t,kind,_,n=min(hits,key=lambda z:(z[0],z[1],z[2])) x+=d[0]*t;y+=d[1]*t;path.append((x,y)) if kind<0:return x,y,0.,0.,'net' if kind==-2 else 'spike',path x+=n[0]*.01;y+=n[1]*.01 vdot=vx*n[0]+vy*n[1];vx-=vdot*n[0];vy-=vdot*n[1] left*=1-t if left<1e-10:break return x,y,vx,vy,'',path def step(s,u=Input(),dt=DT): if not math.isfinite(dt) or not 0=0:s=replace(s,dock=-1) else: candidates=[(math.hypot(s.x-x,s.y-y),i) for i,(x,y) in enumerate(l.ports)] dist,i=min(candidates) if dist<=12 and math.hypot(s.vx,s.vy)<=15: x,y=l.ports[i];s=replace(s,dock=i,checkpoint=i,x=x,y=y,vx=0.,vy=0.,fills=s.fills+1) if s.dock>=0: return replace(s,air=min(100.,s.air+35*dt),time=s.time+dt,thrust=(0.,0.)) ux=max(-1,min(1,u.x));uy=max(-1,min(1,u.y));mag=math.hypot(ux,uy) if mag:ux/=mag;uy/=mag leak=min(s.air,LEAK*dt);remaining=s.air-leak spend=min(remaining,BURN*dt) if mag else 0. fraction=spend/(BURN*dt) if mag else 0. air=max(0.,remaining-spend) wind=l.wind[0] if s.y<64 else l.wind[1] buoyancy=air if s.linked else 65. ax=THRUST*ux*fraction+(wind-s.vx)/1.4-.7*s.vx ay=THRUST*uy*fraction+65.-buoyancy-.7*s.vy vx=s.vx+ax*dt;vy=s.vy+ay*dt speed=math.hypot(vx,vy) if speed>SPEED_LIMIT:vx*=SPEED_LIMIT/speed;vy*=SPEED_LIMIT/speed x,y,vx,vy,reason,path=move(s,vx,vy,dt) if reason:return replace(s,x=x,y=y,vx=0.,vy=0.,air=air,status='retry',reason=reason,time=s.time+dt,consumed=s.consumed+leak+spend,thrust=(0.,0.)) return replace(s,x=x,y=y,vx=vx,vy=vy,air=air,time=s.time+dt,consumed=s.consumed+leak+spend,thrust=(ux*fraction,uy*fraction)) def frame(s,u=Input()): for i in range(4):s=step(s,u if i==0 else replace(u,space=False)) return s STAGE = 4 DEFAULT_LEVEL = 1 DEMO_FRAMES = 240 KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'P', 'R', 'Z', 'N', '1', '2', '3') class App: def __init__(self): self.level = DEFAULT_LEVEL self.reset() def reset(self): self.state = initial(self.level) if STAGE >= 3 else initial() self.pause = False self.demo_tick = 0 self.route_index = 0 def control(self, pressed, held): # Edges are for commands. Directions are continuously held controls. if 'R' in pressed: self.reset(); return if STAGE >= 3: for key in ('1', '2', '3'): if key in pressed: self.level = int(key)-1; self.reset(); return if 'P' in pressed: self.pause = not self.pause; return if self.pause: return if self.state.status == 'retry': if STAGE >= 4 and 'Z' in pressed: self.state = restore(self.state) return if self.state.status == 'success': if 'N' in pressed: self.level = (self.level+1)%3; self.reset() return u = Input(int('RIGHT' in held)-int('LEFT' in held), int('DOWN' in held)-int('UP' in held), 'SPACE' in pressed if STAGE >= 4 else False) self.state = frame(self.state, u) def demo_input(self): """Automatic semantic input, not OS-keyboard or human play evidence.""" tick = self.demo_tick; self.demo_tick += 1 if STAGE <= 2: return Input(1, 0) if tick < 30 else (Input(0, -1) if tick < 50 else Input()) if STAGE == 3: return Input(1, 0) s = self.state if STAGE == 4 and s.fills >= 1: return Input() if s.status != 'flying': return Input() if s.dock >= 0: return Input(space=True) if s.air >= 99.7 else Input() if STAGE == 4: route = ((45., 94.), (108., 103.)) target = route[self.route_index] if self.route_index == 0 and math.hypot(s.x-target[0], s.y-target[1]) < 10: self.route_index = 1; target = route[1] if self.route_index == 1 and math.hypot(s.x-target[0], s.y-target[1]) < 11 and math.hypot(s.vx, s.vy) < 14: return Input(space=True) else: target = LEVELS[self.level].goal tx = max(-30., min(30., (target[0]-s.x)*1.5)) ty = max(-30., min(30., (target[1]-s.y)*1.5)) wind = LEVELS[self.level].wind[0] if s.y < 64 else LEVELS[self.level].wind[1] ax = (tx-s.vx)*3-((wind-s.vx)/1.4-.7*s.vx) ay = (ty-s.vy)*3-(65-s.air-.7*s.vy) return Input(1 if ax > 12 else -1 if ax < -12 else 0, 1 if ay > 12 else -1 if ay < -12 else 0) def demo_update(self): u = self.demo_input() held = set() if u.x: held.add('RIGHT' if u.x > 0 else 'LEFT') if u.y: held.add('DOWN' if u.y > 0 else 'UP') pressed = {'SPACE'} if u.space else set() self.control(pressed, held) def draw(self, p): s = self.state p.cls(1) p.text(8, 8, '015 LEAKY BALLOON / STAGE '+str(STAGE), 10) p.text(8, 22, ('PAUSED' if self.pause else s.status.upper())+' T '+format(s.time, '.2f')+' s', 11) p.text(8, 35, 'X '+format(s.x, '.1f')+' Y '+format(s.y, '.1f')+' SPEED '+format(math.hypot(s.vx,s.vy), '.1f'), 7) if STAGE >= 2: p.text(210, 22, 'AIR '+format(s.air, '.1f'), 10) p.rectb(288, 20, 102, 9, 7); p.rect(289, 21, s.air, 7, 11) p.line(354, 18, 354, 30, 8) p.text(210, 35, 'USED '+format(s.consumed, '.2f'), 6) ox, oy = 32, 48 p.rect(ox, oy+16, 384, 242, 12) p.rect(ox, oy+128, 384, 130, 6) for x in range(16, 192, 24): p.line(ox+x*2, oy+16, ox+x*2, oy+258, 13) p.line(ox, oy+128, ox+384, oy+128, 7) if STAGE >= 3: level = LEVELS[s.level] p.text(ox+5, oy+19, 'ROUTE '+str(s.level+1)+' / Y64 WIND BOUNDARY', 1) for yy, w in ((40, level.wind[0]), (100, level.wind[1])): p.text(ox+4, oy+yy*2, 'W '+format(w, '+.0f'), 1) if w: d = 1 if w > 0 else -1 for xx in (45, 165): x=ox+xx*2; y=oy+yy*2 p.line(x-d*14,y,x+d*14,y,5) p.line(x+d*14,y,x+d*8,y-4,5) p.line(x+d*14,y,x+d*8,y+4,5) for a,b,c,d in level.walls: p.rect(ox+a*2, oy+b*2, (c-a)*2, (d-b)*2, 4) p.rectb(ox+a*2, oy+b*2, (c-a)*2, (d-b)*2, 9) for a,b,c,d in level.spikes: p.rect(ox+a*2,oy+b*2,(c-a)*2,(d-b)*2-8,8) for x in range(int(a*2),int(c*2),12): p.tri(ox+x,oy+d*2-8,ox+x+12,oy+d*2-8,ox+x+6,oy+d*2,8) for x in range(0,384,12): p.line(ox+x,oy+258,ox+x+12,oy+267,13) p.line(ox+x,oy+267,ox+x+12,oy+258,13) else: p.text(ox+5,oy+19,'PRACTICE BOUNDS / NO FAIL',1) p.line(ox,oy+258,ox+384,oy+258,5) if STAGE >= 4: for i,(x,y) in enumerate(level.ports): x=ox+x*2; y=oy+y*2 p.circ(x,y,17,5);p.circb(x,y,24,7) p.text(x-2,y+15,str(i),10) if s.dock == i: p.line(x,y,x,y+24,10) p.text(326,35,'DOCK '+str(s.dock),7) if STAGE == 5: gx,gy=level.goal; gx=ox+gx*2; gy=oy+gy*2 p.circb(gx,gy,28,11);p.line(gx+29,gy-20,gx+29,gy+20,4) p.tri(gx+29,gy-20,gx+29,gy-7,gx+43,gy-14,11) for i,(x,y) in enumerate(level.marks): if i not in s.collected: x=ox+x*2;y=oy+y*2;p.tri(x-6,y-3,x+7,y,x-6,y+4,7) x=ox+s.x*2; y=oy+s.y*2 radius=9+(s.air*.045 if STAGE >= 2 else 3.6) if s.status != 'retry': p.circ(x,y,radius,8);p.circ(x-4,y-4,3,14);p.circb(x,y,14,1) p.line(x,y+radius,x,y+22,4);p.rect(x-4,y+21,9,5,9) ux,uy=s.thrust if ux or uy: p.line(x-ux*15,y-uy*15,x-ux*27,y-uy*27,10) else: for i in range(8): a=i*math.pi/4;p.line(x+math.cos(a)*10,y+math.sin(a)*10,x+math.cos(a)*20,y+math.sin(a)*20,8) p.text(8,326,'MODEL: 120 Hz / VIEW: 30 FPS / FIXED RADIUS 7',6) if STAGE == 1: message='NORMALIZED JET + INERTIA / NO AIR COST YET' elif STAGE == 2: message='LEAK 0.5/s + JET 6/s / AIR < 65: SINK' elif STAGE == 3: message='SWEEP WALLS / RED SPIKES + NET = RETRY' elif STAGE == 4: message='ATTACH <=12 px AND <=15 px/s / FILL 35/s' else: message='LAND '+format(s.landed,'.3f')+'/0.4s PAPER '+str(len(s.collected))+'/2' p.text(8,342,message,10) if STAGE >= 3 and s.status == 'retry': p.text(8,354,'RETRY: '+s.reason.upper(),8) elif STAGE >= 4 and s.dock >= 0: p.text(8,354,'REFILLING / SPACE TO DEPART / FILLS '+str(s.fills),11) elif STAGE == 5 and s.status == 'success': p.text(8,354,'SUCCESS / N NEXT ROUTE / R RESTART',11) else: p.text(8,354,'ARROWS HOLD: JET / RELEASE: COAST',7) p.text(8,369,'ARROWS JET P PAUSE R RESET ESC QUIT',6) if STAGE >= 4: p.text(8,383,'SPACE ATTACH/DETACH Z RETRY 1/2/3 ROUTE',6) elif STAGE == 3: p.text(8,383,'1/2/3 SELECT ROUTE / R RESTART AFTER RETRY',6) else: p.text(8,383,'SIMULATION SECONDS, NOT WALL-CLOCK TIMING',6) def parse_args(argv=None): """Reject invalid QA flags before importing or initializing Pyxel.""" 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('--frames', type=int) parser.add_argument('--screenshot') parser.add_argument('--state-output') args = parser.parse_args(argv) if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_output): parser.error('QA options require --headless-smoke') minimum = DEMO_FRAMES if args.demo_checkpoint else 4 if args.frames is None: args.frames = minimum if not minimum <= args.frames <= 600: parser.error('--frames must be between '+str(minimum)+' and 600') for name,suffix in (('screenshot','.png'),('state_output','.json')): value = getattr(args,name) if value is not None and (not value.strip() or Path(value).suffix.lower() != suffix): parser.error('--'+name.replace('_','-')+' requires a '+suffix+' file path') return args def main(argv=None): args = parse_args(argv) from pathlib import Path import pyxel as p app = App() p.init(448,400,title='015 Leaky Balloon',fps=30,display_scale=2,headless=args.headless_smoke) keys = {k:getattr(p,'KEY_'+k) for k in KEYS} finished = False def update(): if args.headless_smoke: # Initial capture is intentionally frozen at initial(). if args.demo_checkpoint and p.frame_count < DEMO_FRAMES: app.demo_update() else: app.control({k for k,v in keys.items() if p.btnp(v)},{k for k,v in keys.items() if p.btn(v)}) def draw(): nonlocal finished app.draw(p) if args.headless_smoke and p.frame_count >= args.frames-1 and not finished: finished = True for name in ('screenshot','state_output'): value=getattr(args,name) if value: path=Path(value).resolve();path.parent.mkdir(parents=True,exist_ok=True) if name == 'screenshot': p.screenshot(str(path),scale=2) else: path.write_text(json.dumps(asdict(app.state),ensure_ascii=False,indent=2)+'\n',encoding='utf-8') p.quit() p.run(update,draw) if __name__ == '__main__': main()