# 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. """逆さの池・教材 3/5:再装填と浅い水面の静止。Python 3.11+ / Pyxel 2.9.9。""" from dataclasses import dataclass, replace, asdict from math import 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 Level: name: str portals: tuple start: tuple @dataclass(frozen=True) class Input: move: int = 0 jump: bool = False @dataclass(frozen=True) class State: x: float y: float vx: float = 0. vy: float = 0. gravity: int = 1 grounded: bool = True time: float = 0. status: str = 'playing' crossings: int = 0 armed: bool = True floating: bool = False @dataclass(frozen=True) class Event: kind: str before: State x: float y: float LEVELS = (Level('Open practice pond', ((4, 188),), (90., 60.)),) FLOOR = 116. def initial(level): return State(*level.start, grounded=False) def move_practice(x, y, vx, vy, dt): """Teaching-only endpoint clamp; no shelves and no swept collision.""" x += vx * dt y += vy * dt normals = [] if x < 7: x, vx = 7., 0.; normals.append((1, 0)) if x > 185: x, vx = 185., 0.; normals.append((-1, 0)) if y < 12: y, vy = 12., 0.; normals.append((0, 1)) if y > FLOOR: y, vy = FLOOR, 0.; normals.append((0, -1)) return x, y, vx, vy, tuple(normals) def step(level, s, control=Input(), dt=DT): if s.status != 'playing': return s, () if not (0 < dt <= 1/30): raise ValueError('Use a bounded fixed step') if not all(isfinite(v) for v in (s.x, s.y, s.vx, s.vy)): raise ValueError('Nonfinite state') move = max(-1, min(1, control.move)) vx, vy, ground = s.vx, s.vy, s.grounded if move: vx = max(-MAX_VX, min(MAX_VX, vx + move * 100 * dt)) else: drag = (120 if ground else 12) * dt vx = max(0, vx-drag) if vx > 0 else min(0, vx+drag) floating = s.floating if control.jump and ground: vy = -s.gravity * JUMP ground = False floating = False elif floating: x, y, vx, _, _ = move_practice(s.x, s.y, vx, 0, dt) return replace(s, x=x, vx=vx, vy=0, time=s.time+dt), () vy += s.gravity * GRAVITY * dt if vy * s.gravity > MAX_VY: vy = s.gravity * MAX_VY x, y, vx, vy, normals = move_practice(s.x, s.y, vx, vy, dt) gravity, armed, crossings, events = s.gravity, s.armed, s.crossings, [] if abs(s.y-WATER) >= 8-EPS: armed = True crosses = (s.y < WATER <= y and s.gravity == 1) or (s.y > WATER >= y and s.gravity == -1) if crosses: 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 vy * vy / (2 * GRAVITY) < 8: x, y, vy = cx, WATER, 0 floating, ground = True, True else: rem = dt * (1-t) vy += 2 * gravity * GRAVITY * rem x, y, vx, vy, after = move_practice(cx, WATER, vx, vy, rem) normals += after else: x, y, vy = cx, WATER, 0 floating, ground = True, True elif not armed and ((s.gravity == -1 and s.y >= WATER and y < WATER) or (s.gravity == 1 and s.y <= WATER and y > WATER)): t = (WATER-s.y) / (y-s.y) if y != s.y else 0 x, y, vy = s.x+(x-s.x)*t, WATER, 0 floating, ground = True, True if not floating: ground = any(ny == -gravity for _, ny in normals) return replace(s, x=x, y=y, vx=vx, vy=vy, gravity=gravity, grounded=ground, crossings=crossings, armed=armed, floating=floating, time=s.time+dt), tuple(events) def frame(level, s, control=Input()): events = [] for i in range(4): s, event = step(level, s, Input(control.move, control.jump and i == 0)) events.extend(event) 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() # The renderer and controls are shared scaffolding; the model above grows by stage. STAGE = 3 DEMO_FRAMES = 90 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()