# 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. """穴あき風船・教材 2/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 Input: x:int=0 y:int=0 space:bool=False @dataclass(frozen=True) class State: x: float = 24. y: float = 100. vx: float = 0. vy: float = 0. air: float = 80. consumed: float = 0. linked: bool = True status: str = 'flying' time: float = 0. thrust: tuple = (0., 0.) def initial(): return State() def step(s, u=Input(), dt=DT): if not math.isfinite(dt) or not 0 < dt <= 1/30: raise ValueError('fixed small positive dt required') 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) buoyancy = air if s.linked else 65. ax = THRUST * ux * fraction - 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 = max(7., min(185., s.x + vx * dt)) y = max(15., min(129., s.y + vy * dt)) if x in (7., 185.): vx = 0. if y in (15., 129.): vy = 0. return replace(s, x=x, y=y, vx=vx, vy=vy, time=s.time+dt, air=air, 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 = 2 DEFAULT_LEVEL = 0 DEMO_FRAMES = 90 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()