# 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. # 教材にはNoto字形を含めません。ASCIIと図形だけを描きます。 """ゴムひもの山歩き・教材 3/5。Python 3.11+ / Pyxel 2.9.9。""" from dataclasses import dataclass, replace, asdict from math import hypot, isfinite FPS = 30 DT = 1 / 120 # 秒。1描画の間に4回更新する RADIUS = 4.0 GRAVITY = 100.0 # px/s²。画面の下が正 MAX_SPEED = 100.0 # px/s REACH = 68.0 @dataclass(frozen=True) class Rock: left: float right: float top: float danger: bool = False @dataclass(frozen=True) class Route: title: str rocks: tuple pins: tuple start: tuple goal: int hint: str ROUTE = Route('SPRING LAB', (Rock(8, 50, 104),), ((70, 48),), (36, 100), -1, 'Attach, swing, release') @dataclass(frozen=True) class Input: move: int = 0 toggle: bool = False reel: int = 0 # -1: 短くする、+1: 長くする @dataclass(frozen=True) class State: x: float y: float vx: float = 0.0 vy: float = 0.0 grounded: int = 0 elapsed: float = 0.0 pin: int = -1 length: float = 38.0 tension: float = 0.0 reason: str = 'start' def initial(route): return State(*route.start) def tension_accel(point, velocity, anchor, length): # 杭への差(dx,dy)、距離d、長さ1の向き(nx,ny)を求める。 dx,dy=anchor[0]-point[0],anchor[1]-point[1];d=hypot(dx,dy) if d<=length or d<1e-9:return (0.,0.,0.) nx,ny=dx/d,dy/d outward=-(velocity[0]*nx+velocity[1]*ny) force=max(0.,min(240.,14.*(d-length)+3.*outward)) return nx*force,ny*force,force def step(route, s, inp=Input(), dt=DT): if not 0 < dt <= 1 / 30: raise ValueError('dt must be positive and <= 1/30') if not all(isfinite(v) for v in (s.x, s.y, s.vx, s.vy)): raise ValueError('nonfinite state') if inp.toggle: if s.pin >= 0: s = replace(s, pin=-1, tension=0.0, reason='release') elif hypot(route.pins[0][0]-s.x, route.pins[0][1]-s.y) <= REACH: s = replace(s, pin=0, reason='attach') else: s = replace(s, reason='out_of_reach') if s.pin >= 0: # 巻取りは自然長を変える。人物を杭へワープさせない。 s = replace(s, length=max(14.0, min(42.0, s.length + max(-1, min(1, inp.reel)) * 12 * dt))) move = max(-1, min(1, inp.move)) ax, ay = 24.0 * move, GRAVITY vx, vy, ground = s.vx, s.vy, s.grounded if ground >= 0: rock = route.rocks[ground] if not rock.left <= s.x <= rock.right: ground = -1 else: vx, vy = 36.0 * move, 0.0 force = 0.0 if s.pin >= 0: tx, ty, force = tension_accel((s.x, s.y), (vx, vy), route.pins[s.pin], s.length) ax += tx ay += ty if ground >= 0: ax = 0.0 if ay < -0.1: ground = -1 else: ay = 0.0 # 加速度→速度→位置。dtを掛けて単位を合わせる。 vx += ax * dt vy += ay * dt speed = hypot(vx, vy) if speed > MAX_SPEED: vx *= MAX_SPEED / speed vy *= MAX_SPEED / speed x, y = s.x + vx * dt, s.y + vy * dt # 01〜03だけの簡略判定。上から棚の上面を越えた時だけ止める。 for i, rock in enumerate(route.rocks): if vy > 0 and s.y <= rock.top - RADIUS <= y and rock.left <= x <= rock.right: y, vy, ground = rock.top - RADIUS, 0.0, i if x < 4: x, vx = 4.0, max(0.0, vx) if x > 188: x, vx = 188.0, min(0.0, vx) if y < 4: y, vy = 4.0, max(0.0, vy) if ground >= 0 and not route.rocks[ground].left <= x <= route.rocks[ground].right: ground = -1 if y > 140: return initial(route) # 練習版は開始位置へ戻す(経過も初期化) return replace(s, x=x, y=y, vx=vx, vy=vy, grounded=ground, elapsed=s.elapsed+dt, tension=force) def frame(route, state, inp=Input()): for n in range(4): once = inp if n == 0 else replace(inp, toggle=False) state = step(route, state, once) return state class Controller: """実操作とリプレイの両方が通る、同じ入力窓口。""" def __init__(self): self.state = initial(ROUTE) self.paused = False self.space_ready = True def tick(self, held=(), pressed=()): held, pressed = set(held), set(pressed) if 'SPACE' not in held: self.space_ready = True if 'R' in pressed: self.state = initial(ROUTE) self.paused = False self.space_ready = False return if 'P' in pressed: self.paused = not self.paused self.space_ready = False return if self.paused: return inp = Input(move=int('RIGHT' in held)-int('LEFT' in held), toggle='SPACE' in pressed and self.space_ready, reel=int('DOWN' in held)-int('UP' in held)) self.state = frame(ROUTE, self.state, inp) KEYS = ('LEFT', 'RIGHT', 'P', 'R', 'SPACE', 'UP', 'DOWN') def sample_keys(pyxel): """Pyxelの連続入力btnと押し始めbtnpを分ける。""" return ([name for name in KEYS if pyxel.btn(getattr(pyxel, 'KEY_'+name))], [name for name in KEYS if pyxel.btnp(getattr(pyxel, 'KEY_'+name))]) def main(headless=False, replay=None, screenshot=None, report=None): import json from pathlib import Path # PyxelがCWDを変更しても、指定した出力先を維持する。 screenshot = str(Path(screenshot).resolve()) if screenshot else None report = str(Path(report).resolve()) if report else None import pyxel control = Controller() frames, events = 0, [] duration = max(12, len(replay or [])) def update(): nonlocal frames frames += 1 if headless: if replay is not None and frames <= len(replay): e = replay[frames-1] control.tick(e.get('held', []), e.get('pressed', [])) events.append({'frame': frames, 'input': e, 'state': asdict(control.state)}) else: control.tick(*sample_keys(pyxel)) def draw(): s = control.state pyxel.cls(1) pyxel.tri(0,240,82,64,188,240,5) pyxel.tri(120,240,250,74,384,240,13) for r in ROUTE.rocks: x,y,w = r.left*2, r.top*2, (r.right-r.left)*2 pyxel.rect(x,y,w,288-y,4 if not r.danger else 2) pyxel.line(x,y,x+w,y,11 if not r.danger else 8) for yy in range(int(y)+12, 288, 15): pyxel.line(x+2,yy,x+w-2,yy,9 if not r.danger else 8) for n, (x,y) in enumerate(ROUTE.pins): pyxel.circ(x*2,y*2,4,9) pyxel.text(x*2+8,y*2-2,str(n+1),7) if s.pin >= 0: anchor = ROUTE.pins[s.pin] pyxel.line(s.x*2,s.y*2,anchor[0]*2,anchor[1]*2,10 if s.tension>0 else 6) pyxel.circ(s.x*2,s.y*2,RADIUS*2,10) pyxel.circb(s.x*2,s.y*2,RADIUS*2,7) pyxel.line(s.x*2,s.y*2,s.x*2+s.vx*.3,s.y*2+s.vy*.3,8) pyxel.rect(0,0,384,31,1) pyxel.text(8,7,'ROPE HIKE / STEP 3 - '+ROUTE.title,10) pyxel.text(8,20,f'POS {s.x:6.2f},{s.y:6.2f} VEL {s.vx:6.2f},{s.vy:6.2f}',7) pyxel.rect(0,288,384,96,1) pyxel.text(8,293,f'TIME {s.elapsed:5.2f}s GROUND {s.grounded} RED LINE = VELOCITY',7) pyxel.text(8,307,f'LENGTH {s.length:4.1f} TENSION {s.tension:5.1f} PIN {s.pin+1}',10) pyxel.text(8,321,'EVENT '+s.reason.upper(),7) pyxel.text(8,347,'ARROWS WALK-REEL / SPACE ATTACH-RELEASE',7) pyxel.text(8,361,'P PAUSE / R RESET / ESC QUIT / SILENT',6) if control.paused: pyxel.rect(92,130,202,24,0) pyxel.text(106,139,'PAUSED - P TO RESUME',10) if headless and frames >= duration: if screenshot: pyxel.screenshot(screenshot, scale=2) if report: Path(report).write_text(json.dumps({ 'label': 'Synthetic Controller input; real Pyxel headless; silent', 'render_frames': frames, 'input_frames': len(events), 'events': events, 'final_state': asdict(s), 'paused': control.paused, }, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') pyxel.quit() pyxel.init(384,384,title='Rope Hike - Step 3',fps=FPS,headless=headless) pyxel.run(update,draw) if __name__ == '__main__': import argparse import json from pathlib import Path parser = argparse.ArgumentParser(description=__doc__) modes = parser.add_mutually_exclusive_group() modes.add_argument('--headless-smoke', action='store_true') modes.add_argument('--headless-replay', help='UTF-8 JSON held/pressed list') parser.add_argument('--screenshot') parser.add_argument('--report') args = parser.parse_args() headless = args.headless_smoke or args.headless_replay is not None if not headless and (args.screenshot or args.report): parser.error('screenshot/report require headless mode') replay = None if args.headless_replay: replay = json.loads(Path(args.headless_replay).read_text(encoding='utf-8')) if not isinstance(replay, list): parser.error('Replay must be a list') previous = set() for e in replay: if not isinstance(e, dict) or set(e)-{'held','pressed'} or any( not isinstance(e.get(k, []), list) or any(not isinstance(v,str) or v not in KEYS for v in e.get(k, [])) for k in ('held','pressed')): parser.error('Use supported held/pressed key names') held, pressed = set(e.get('held', [])), set(e.get('pressed', [])) if not pressed <= held or pressed & previous: parser.error('pressed must be newly held keys') previous = held main(headless, replay, args.screenshot, args.report)