# 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. """逆さの池・教材 5/5:宝を取り、足場へ帰る。Python 3.11+ / Pyxel 2.9.9。""" """Continuous position, fixed-step inversion platformer; standard library only. Units: pixels and seconds. The water changes gravity, never world coordinates. """ from dataclasses import dataclass, replace, asdict from math import inf, hypot, isfinite import hashlib, json DT=1/120 GRAVITY=80.0 JUMP=76.0 MAX_VX=36.0 MAX_VY=70.0 HX,HY=3.0,4.0 WATER=64.0 EPS=0.001 @dataclass(frozen=True) class Rect: x1: float; y1: float; x2: float; y2: float name: str='rock' @dataclass(frozen=True) class Level: name: str portals: tuple rocks: tuple start: tuple items: tuple return_home: bool=True home_gravity: int=1 LEVELS=( Level('裏側の月',((72,112),),(Rect(116,84,148,88),),(42,60),(('moon',128,95),),False), Level('頭の上は道',((48,80),(136,168)),(Rect(92,36,120,40),Rect(92,84,128,88)),(30,60),(('leaf',106,30),('moon',106,95))), Level('同じ形、違う着地',((56,104),(144,176)),(Rect(104,36,132,40),Rect(108,80,132,84),Rect(144,88,180,92)),(34,60),(('leaf',118,30),('moon',120,91))), ) @dataclass(frozen=True) class State: x: float; y: float vx: float=0.; vy: float=0. gravity: int=1 grounded: bool=True armed: bool=True floating: bool=False collected: tuple=() crossings: int=0 distance: float=0. time: float=0. status: str='playing' checkpoint: tuple=(42.,60.,1) falls: int=0 message: str='' @dataclass(frozen=True) class Input: move: int=0 jump: bool=False @dataclass(frozen=True) class Event: kind: str before: State x: float y: float def initial(level): return State(*level.start,gravity=level.home_gravity,checkpoint=(*level.start,level.home_gravity)) def solid_rects(level): banks=[];last=4 for a,b in level.portals: if a>last:banks.append(Rect(last,64,last+(a-last),64,'bank')) last=b if last<188:banks.append(Rect(last,64,188,64,'bank')) # Top and bottom are visible safety stone, not offscreen landing surfaces. return tuple(banks)+level.rocks+(Rect(0,0,4,128,'edge'),Rect(188,0,192,128,'edge'),Rect(4,0,188,8,'edge'),Rect(4,120,188,128,'edge')) def portal_at(level,x): return any(a+HX+EPS=hi[axis]-1e-8:return None else: t1=(lo[axis]-p[axis])/d[axis];t2=(hi[axis]-p[axis])/d[axis] near[axis]=min(t1,t2);far[axis]=max(t1,t2);norm[axis]=-1 if d[axis]>0 else 1 enter=max(near);leave=min(far) if enter>leave+1e-9 or leave<0 or enter<-1e-7 or enter>1+1e-9:return None axis=0 if near[0]>=near[1]-1e-9 else 1 return max(0.,enter),(norm[axis],0) if axis==0 else (0,norm[axis]) def move_swept(level,x,y,vx,vy,dt): """Solve earliest static impacts, consume remaining time, at most four contacts.""" remaining=dt;normals=[] for _ in range(4): dx,dy=vx*remaining,vy*remaining hits=[] for i,r in enumerate(solid_rects(level)): hit=sweep(x,y,dx,dy,r) if hit:hits.append((hit[0],i,hit[1])) if not hits:x+=dx;y+=dy;break t,_,(nx,ny)=min(hits,key=lambda h:(round(h[0],10),h[1])) x+=dx*t+nx*EPS;y+=dy*t+ny*EPS remaining*=1-t if nx:vx=0. if ny:vy=0. normals.append((nx,ny)) if remaining<1e-9 or (vx==0 and vy==0):break return x,y,vx,vy,tuple(normals) def segment_near(a,b,p,r): dx,dy=b[0]-a[0],b[1]-a[1];den=dx*dx+dy*dy t=max(0,min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/den)) if den else 0 return hypot(a[0]+dx*t-p[0],a[1]+dy*t-p[1])<=r def step(level,s,control=Input(),dt=DT,preserve_momentum=True): """Input, old-state forces, velocity, earliest swept collision/water, progress. Tiny water oscillations settle visibly at the plane. SPACE always escapes. """ if s.status!='playing':return s,() if not (00 else min(0,vx+drag) floating=s.floating if control.jump and ground: vy=-s.gravity*JUMP;ground=False;floating=False elif floating: # The player visibly floats half in the pond. Horizontal motion stops at # solid banks, and SPACE jumps into the current side, rearming at 8px. nx,ny,nvx,_,normals=move_swept(level,s.x,s.y,vx,0,dt) return replace(s,x=nx,vx=nvx,vy=0,time=s.time+dt,distance=s.distance+abs(nx-s.x)),() vy+=s.gravity*GRAVITY*dt if vy*s.gravity>MAX_VY:vy=s.gravity*MAX_VY # Compute candidate physical motion first so a solid bank wins ties. x,y,vx,vy,normals=move_swept(level,s.x,s.y,vx,vy,dt) gravity=s.gravity;armed=s.armed;events=[];crossings=s.crossings if abs(s.y-WATER)>=8-EPS:armed=True crosses=(s.yWATER>=y and s.gravity==-1) # At the plane, a jump into the current side is never another crossing. if crosses and portal_at(level,s.x+(x-s.x)*(WATER-s.y)/(y-s.y)): t=(WATER-s.y)/(y-s.y);cx=s.x+(x-s.x)*t if armed: gravity=-s.gravity;crossings+=1;armed=False events.append(Event('cross',s,cx,WATER)) if not preserve_momentum:vx=vy=0. # A <=8px excursion is resolved as a stable float, no repeated sign # change, hidden position warp, or boost. Position stays on the plane. if vy*vy/(2*GRAVITY)<8: x,y=cx,WATER;vy=0;floating=True;ground=True else: # Split the old-force velocity update at the water event, then # integrate its remainder with the new sign (semi-implicit Euler). rem=dt*(1-t);vy+=2*gravity*GRAVITY*rem x,y,vx,vy,after=move_swept(level,cx,WATER,vx,vy,rem) normals+=after else: x,y=cx,WATER;vy=0;floating=True;ground=True # A shallow return from the new side must not leak through with wrong gravity. elif not armed and ((s.gravity==-1 and s.y>=WATER and yWATER)): t=(WATER-s.y)/(y-s.y) if y!=s.y else 0 x=s.x+(x-s.x)*t;y=WATER;vy=0;floating=True;ground=True if not floating:ground=any(ny==-gravity for _,ny in normals) got=list(s.collected) for name,ix,iy in level.items: if name not in got and segment_near((s.x,s.y),(x,y),(ix,iy),7):got.append(name) checkpoint=s.checkpoint if ground and not floating and ((gravity==1 and y<63) or (gravity==-1 and y>65)): checkpoint=(x,y,gravity) status='playing' if len(got)==len(level.items) and ground and (not level.return_home or (gravity==level.home_gravity and ground and abs(x-level.start[0])<11 and abs(y-level.start[1])<5)):status='success' new=State(x,y,vx,vy,gravity,ground,armed,floating,tuple(got),crossings,s.distance+hypot(x-s.x,y-s.y),s.time+dt,status,checkpoint,s.falls,'float' if floating else '') if x<-10 or x>202 or y<-12 or y>140: new=replace(new,x=checkpoint[0],y=checkpoint[1],gravity=checkpoint[2],vx=0,vy=0,grounded=True,armed=True,floating=False,falls=s.falls+1,message='岸へ戻った') return new,tuple(events) def frame(level,s,control=Input(),preserve_momentum=True): events=[] for i in range(4): s,e=step(level,s,Input(control.move,control.jump and i==0),preserve_momentum=preserve_momentum);events.extend(e) return s,tuple(events) def state_hash(s):return hashlib.sha256(json.dumps(asdict(s),sort_keys=True,separators=(',',':'),ensure_ascii=False).encode('utf-8')).hexdigest() def replay(level,actions,preserve_momentum=True): s=initial(level);trace=[] for action in actions: s,e=frame(level,s,Input(action.get('move',0),action.get('jump',False)),preserve_momentum) trace.append(s) return s,trace # The renderer and controls are shared scaffolding; the model above grows by stage. STAGE = 5 DEMO_FRAMES = 91 KEYS = ('LEFT', 'RIGHT', 'SPACE', 'P', 'R', 'Z', 'N', '1', '2', '3') DEMO_PLAN = ((14, 0, True), (82, 1, False), (20, 0, False)) class App: def __init__(self): self.level = 0 self.state = initial(LEVELS[self.level]) self.pause = False self.gate = 0 self.undo_state = None self.trail = [] self.demo_tick = 0 def reset(self): self.state = initial(LEVELS[self.level]) self.pause = False self.undo_state = None self.trail = [] self.gate = 2 def advance(self, control): before = self.state self.state, events = frame(LEVELS[self.level], self.state, control) for event in events: self.undo_state = event.before self.trail.append((self.state.x, self.state.y)) self.trail = self.trail[-40:] if before.status != 'success' and self.state.status == 'success': self.gate = 2 def control(self, pressed=(), held=()): # Two completely released updates prevent held keys leaking after a switch. if self.gate: self.gate = 2 if set(held).intersection(KEYS) else self.gate-1 return if 'R' in pressed: self.reset(); return if STAGE == 5: for index in range(3): if str(index+1) in pressed: self.level = index; self.reset(); return if 'P' in pressed: self.pause = not self.pause; self.gate = 2; return if self.pause: return if STAGE >= 4 and 'Z' in pressed: if self.undo_state is not None: self.state = self.undo_state; self.undo_state = None; self.trail = [] self.gate = 2 return if self.state.status == 'success': if STAGE == 5 and 'N' in pressed: self.level = (self.level+1) % len(LEVELS); self.reset() return self.advance(Input(int('RIGHT' in held)-int('LEFT' in held), 'SPACE' in pressed)) def demo_update(self): tick = self.demo_tick self.demo_tick += 1 if STAGE == 1: control = Input(1 if tick < 30 else 0, tick == 0) elif STAGE == 2: control = Input(1 if tick < 30 else 0) elif STAGE == 3: control = Input() else: control = Input() for count, move, jump in DEMO_PLAN: if tick < count: control = Input(move, bool(jump and tick == 0)); break tick -= count held = ('RIGHT',) if control.move > 0 else ('LEFT',) if control.move < 0 else () self.control(('SPACE',) if control.jump else (), held) def draw(self, p): s, level = self.state, LEVELS[self.level] p.cls(1) p.text(12, 10, '018 INVERTED POND / STAGE '+str(STAGE), 10) p.text(12, 24, ('PAUSED' if self.pause else s.status.upper())+' TIME '+format(s.time, '.3f')+' s', 11) p.text(12, 38, 'X '+format(s.x, '.3f')+' Y '+format(s.y, '.3f')+' VX '+format(s.vx, '.2f')+' VY '+format(s.vy, '.2f'), 7) p.text(12, 52, 'GRAVITY '+('DOWN' if s.gravity == 1 else 'UP')+' CROSSES '+str(getattr(s, 'crossings', 0))+' GROUNDED '+str(s.grounded), 10) if STAGE >= 3: p.text(12, 66, 'ARMED '+str(s.armed)+' FLOATING '+str(s.floating), 6) ox, oy = 32, 82 def xy(x, y): return ox+2*x, oy+2*y p.rect(ox, oy, 384, 256, 2) p.rect(ox, oy+128, 384, 128, 1) for x in range(4, 188, 12): p.line(*xy(x, 56), *xy(x+5, 56), 5) p.line(*xy(x, 72), *xy(x+5, 72), 5) p.text(ox+10, oy+20, 'WORLD 192 x 128 / +Y DOWN', 6) for x in range(4, 188): opened = STAGE >= 2 and any(a <= x < b for a, b in level.portals) if not opened: p.line(*xy(x, 63), *xy(x, 65), 6) elif x % 3 == 0: p.line(*xy(x, 64), *xy(x+1, 64), 12) p.rect(ox, oy, 8, 256, 5); p.rect(ox+376, oy, 8, 256, 5) p.rect(ox, oy, 384, 16, 5); p.rect(ox, oy+240, 384, 16, 5) if STAGE >= 4: for rock in level.rocks: x, y = xy(rock.x1, rock.y1) p.rect(x, y, (rock.x2-rock.x1)*2, (rock.y2-rock.y1)*2, 13) p.rectb(x, y, (rock.x2-rock.x1)*2, (rock.y2-rock.y1)*2, 7) hx, hy = xy(*level.start) p.rect(hx-9, hy-22, 18, 22, 4) p.tri(hx-13, hy-22, hx, hy-34, hx+13, hy-22, 9) if STAGE == 5: for name, ix, iy in level.items: x, y = xy(ix, iy) if name in s.collected: p.circb(x, y, 7, 5) elif name == 'moon': p.circ(x, y, 7, 10); p.circ(x+3, y-3, 6, 1) else: p.tri(x-7, y+2, x+6, y-7, x+4, y+5, 11) for x, y in self.trail[::3]: p.pset(*xy(x, y), 6) x, y = xy(s.x, s.y); g = s.gravity p.rect(x-5, y-7, 11, 15, 7) p.rect(x-4, y-6*g-1, 9, 4, 12) p.pset(x-2, y-4*g, 1); p.pset(x+2, y-4*g, 1) p.line(x+14, y-7*g, x+14, y+8*g, 10) p.line(x+10, y+4*g, x+14, y+8*g, 10) p.line(x+18, y+4*g, x+14, y+8*g, 10) if getattr(s, 'floating', False): p.ellib(x-9, y-3, 19, 7, 12) messages = {1: 'ACCELERATION -> VELOCITY -> POSITION / FLAT BANK', 2: 'CROSS THE LINE: SAME POSITION, NEW GRAVITY', 3: 'DASHES = 8 px / FLOAT? SPACE TO JUMP OUT', 4: 'SWEPT BANKS + SHELF / Z UNDO LAST CROSS', 5: 'ROOM '+str(self.level+1)+' / ITEMS '+str(len(getattr(s, 'collected', ())))+'/'+str(len(getattr(level, 'items', ())))} p.text(12, 350, messages[STAGE], 10) p.text(12, 364, 'LEFT/RIGHT MOVE SPACE JUMP P PAUSE R RESET', 7) p.text(12, 378, '1/2/3 ROOM Z UNDO N NEXT ESC QUIT' if STAGE == 5 else 'MODEL 120 Hz / VIEW 30 FPS / ESC QUIT', 6) if s.status == 'success': p.rect(108, 176, 232, 38, 1); p.rectb(108, 176, 232, 38, 11) p.text(144, 193, 'SUCCESS / N NEXT ROOM', 11) elif self.pause: p.rect(144, 182, 160, 26, 1); p.text(160, 193, 'PAUSED / P RESUME', 10) def parse_args(argv=None): """All QA arguments are bounded and validated before Pyxel is imported.""" 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='018 Inverted Pond', fps=30, display_scale=2, headless=args.headless_smoke) keys = {name: getattr(p, 'KEY_'+name) for name in KEYS} finished = False def update(): if args.headless_smoke: if args.demo_checkpoint and p.frame_count < DEMO_FRAMES: app.demo_update() else: app.control({name for name, key in keys.items() if p.btnp(key)}, {name for name, key in keys.items() if p.btn(key)}) 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()