# 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と図形だけを描きます。 """ゴムひもの山歩き・教材 4/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('LOW SHELF', (Rock(8, 50, 104), Rock(100, 178, 100)), ((70, 48),), (36, 100), 1, 'Land on the right shelf') @dataclass(frozen=True) class Input: move: int = 0 reel: int = 0 # -1 shorten, +1 lengthen toggle: bool = False recover: bool = False @dataclass(frozen=True) class State: x: float y: float vx: float = 0.0 vy: float = 0.0 grounded: int = 0 pin: int = -1 length: float = 38.0 elapsed: float = 0.0 falls: int = 0 uses: tuple = () status: str = 'walking' reason: str = 'start' tension: float = 0.0 warning: float = 0.0 def initial(route): return State(*route.start) def segment_box(a,b,box): """First normalized hit and outward normal, or None (slab sweep).""" lo,hi=0.0,1.0; normal=(0.0,0.0) for axis,(lower,upper) in enumerate(((box[0],box[2]),(box[1],box[3]))): p=a[axis];v=b[axis]-p if abs(v)<1e-12: if pupper:return None continue t1=(lower-p)/v;t2=(upper-p)/v n=(-1.0,0.0) if axis==0 else (0.0,-1.0) if t1>t2:t1,t2=t2,t1;n=(1.0,0.0) if axis==0 else (0.0,1.0) if t1>lo:lo=t1;normal=n hi=min(hi,t2) if lo>hi:return None if hi<0 or lo>1:return None return (lo,normal) def rope_clear(route, point, pin): # Endpoint is character center, always radius 4 outside a safe rock. for rock in route.rocks: if segment_box(point,pin,(rock.left,rock.top,rock.right,144)) is not None:return False return True def candidates(route,state): return tuple(i for i,p in enumerate(route.pins) if hypot(p[0]-state.x,p[1]-state.y)<=REACH and rope_clear(route,(state.x,state.y),p)) def tension_accel(point, velocity, anchor, length): 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 recover(route,state,reason='fall'): rock=route.rocks[0] return replace(state,x=route.start[0],y=rock.top-RADIUS,vx=0.,vy=0., grounded=0,pin=-1,length=38.,status='walking', tension=0.,warning=0.,falls=state.falls+int(reason!='return'),reason=reason) def step(route,s,inp=Input(),dt=DT,adjustable=True): if not 0=0:s=replace(s,pin=-1,warning=0.,tension=0.,reason='release') elif 0 in candidates(route,s): s=replace(s,pin=0,uses=s.uses+(0,),reason='attach') else:s=replace(s,reason='out_of_reach') if s.pin>=0 and adjustable:s=replace(s,length=max(14.,min(42.,s.length+max(-1,min(1,inp.reel))*12*dt))) ax=24.*max(-1,min(1,inp.move));ay=GRAVITY;force=0. vx,vy=s.vx,s.vy;ground=s.grounded if ground>=0: rock=route.rocks[ground] if not rock.left<=s.x<=rock.right:ground=-1 else:vx=36.*max(-1,min(1,inp.move));vy=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. if ay<-.1:ground=-1 else:ay=0. vx+=ax*dt;vy+=ay*dt speed=hypot(vx,vy) if speed>MAX_SPEED:vx*=MAX_SPEED/speed;vy*=MAX_SPEED/speed x=s.x+vx*dt;y=s.y+vy*dt # First swept collision; up to 4 contacts, remaining time discarded after 4. remaining=dt;px,py=s.x,s.y for _ in range(4): target=(px+vx*remaining,py+vy*remaining);hit=None for i,r in enumerate(route.rocks): # Ignore the current support if travelling tangentially/away. if i==ground and py<=r.top-RADIUS+.02 and vy<=0:continue h=segment_box((px,py),target,(r.left-RADIUS,r.top-RADIUS,r.right+RADIUS,148)) if h is not None and h[1]!=(0.,0.) and (hit is None or h[0]0:ground=i;vy=0. elif n[0]:vx=0. else:vy=0. remaining*=1.-t x,y=px,py if x<4:x=4.;vx=max(0.,vx) if x>188:x=188.;vx=min(0.,vx) if y<4:y=4.;vy=max(0.,vy) if ground>=0 and not route.rocks[ground].left<=x<=route.rocks[ground].right:ground=-1 out=replace(s,x=x,y=y,vx=vx,vy=vy,grounded=ground,tension=force,elapsed=s.elapsed+dt) if y>140:return recover(route,out,'fall') if out.pin>=0: if not rope_clear(route,(x,y),route.pins[out.pin]): return replace(s,x=old.x,y=old.y,vx=old.vx,vy=old.vy,status='snag',reason='snag',elapsed=out.elapsed) d=hypot(x-route.pins[out.pin][0],y-route.pins[out.pin][1]) warn=out.warning+dt if d>90 else 0. out=replace(out,warning=warn) if warn>=.3:out=replace(out,pin=-1,warning=0.,reason='too_far',tension=0.) return out def frame(route,state,inp=Input(),adjustable=True,substeps=4): for n in range(substeps): once=inp if n==0 else replace(inp,toggle=False,recover=False) state=step(route,state,once,1/(30*substeps),adjustable) 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), recover='Z' in pressed) self.state = frame(ROUTE, self.state, inp) KEYS = ('LEFT', 'RIGHT', 'P', 'R', 'SPACE', 'UP', 'DOWN', 'Z') 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) target = ROUTE.rocks[ROUTE.goal] fx, fy = (target.right-8)*2, target.top*2 pyxel.line(fx,fy,fx,fy-25,7) pyxel.tri(fx,fy-25,fx+14,fy-20,fx,fy-14,10) 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 4 - '+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,332,'PRACTICE ONLY / NO CLEAR / Z RETURNS TO START',6) pyxel.text(8,347,'ARROWS WALK-REEL / SPACE TOGGLE / Z RETURN',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 s.status == 'snag': pyxel.rect(92,156,202,24,0) pyxel.text(104,165,'ROPE BLOCKED - Z TO RETURN',8) 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 4',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)