# 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. """014 第5段階。独立実行。日本語の解説はLESSON.md。""" from dataclasses import dataclass, field, asdict from copy import deepcopy import math, json, hashlib DT = 1 / 120 # 物理の1刻み。入力は30Hzなので4回進める。 SPEED = 42.0 # px/s。斜めも同じ速さ。 PLAYER_R = 3.0 BOUNDS = (12., 16., 180., 120.) Vec = tuple[float, float] START = (24., 96.) DUST = ((40.,80.), (56.,80.), (72.,80.), (88.,80.), (120.,48.), (136.,48.)) LIFE = 2.4 GRACE = .18 WALL_R = 1.0 ROCK_R = 5.0 def add(a,b):return a[0]+b[0],a[1]+b[1] def sub(a,b):return a[0]-b[0],a[1]-b[1] def mul(a,k):return a[0]*k,a[1]*k def dot(a,b):return a[0]*b[0]+a[1]*b[1] def norm(a): d=math.hypot(*a) return mul(a,1/d) if d>1e-12 else (0.,0.) def closest(p,a,b): v=sub(b,a);den=dot(v,v) t=max(0.,min(1.,dot(sub(p,a),v)/den)) if den>1e-12 else 0. return add(a,mul(v,t)) def distance(p,a,b):return math.dist(p,closest(p,a,b)) def sweep_circle(p,d,c,r): q=sub(p,c);aa=dot(d,d);cc=dot(q,q)-r*r if cc<=1e-8: n=norm(q) if dot(d,n)<-1e-10:return 0.,n return None if aa<1e-16:return None bb=2*dot(q,d);disc=bb*bb-4*aa*cc if disc<0:return None t=(-bb-math.sqrt(max(0.,disc)))/(2*aa) if -1e-9<=t<=1+1e-9: t=max(0.,min(1.,t));n=norm(sub(add(p,mul(d,t)),c)) if dot(d,n)<-1e-10:return t,n return None def sweep_capsule(p,d,a,b,r): """Earliest hit against a radius-expanded segment; endpoints included.""" hits=[];v=sub(b,a);length=math.hypot(*v) for c in (a,b): hit=sweep_circle(p,d,c,r) if hit:hits.append(hit) if length>1e-12: u=mul(v,1/length);n=(-u[1],u[0]);q=sub(p,a);s=dot(q,n);ds=dot(d,n) for side in (-1,1): if abs(ds)<1e-12:continue t=(side*r-s)/ds if -1e-9<=t<=1+1e-9 and ds*side<0: x=dot(add(q,mul(d,t)),u) if -1e-9<=x<=length+1e-9:hits.append((max(0.,t),mul(n,side))) if abs(s)=0 else -1) if dot(d,nn)<-1e-10:hits.append((0.,nn)) return min(hits,key=lambda h:(h[0],h[1])) if hits else None @dataclass class Trail: a:Vec b:Vec born:float left:bool=False @dataclass class Rock: pos:Vec vel:Vec @dataclass class State: level:int pos:Vec dust:list[Vec] rocks:list[Rock] trails:list[Trail]=field(default_factory=list) age:float=0. status:str='playing' collected:int=0 collisions:int=0 blocks:int=0 reason:str='' collision_point:Vec|None=None wall_enabled:bool=True swept_distance:float=0. moving_distance:float=0. max_trails:int=0 last_block:float=-10. def new_state(level=1,wall_enabled=True): if level != 1: raise ValueError("This tutorial keeps original B only") return State(1,START,list(DUST),[Rock((104.,24.),(0.,18.))],wall_enabled=wall_enabled) def boundary_hits(p,d,r): x0,y0,x1,y1=BOUNDS;out=[] for axis,lo,hi in ((0,x0+r,x1-r),(1,y0+r,y1-r)): if d[axis]<-1e-12 and p[axis]+d[axis]<=lo: n=(1.,0.) if axis==0 else (0.,1.);out.append(((lo-p[axis])/d[axis],n)) if d[axis]>1e-12 and p[axis]+d[axis]>=hi: n=(-1.,0.) if axis==0 else (0.,-1.);out.append(((hi-p[axis])/d[axis],n)) return out def first_hit(p,d,r,trails=()): hits=boundary_hits(p,d,r) for t in trails: h=sweep_capsule(p,d,t.a,t.b,r+WALL_R) if h:hits.append(h) if not hits:return None tt=min(h[0] for h in hits) ns=sorted(set(h[1] for h in hits if abs(h[0]-tt)<1e-7)) # Equal-time corner normals are combined, avoiding scan-order dependence. n=norm((sum(v[0] for v in ns),sum(v[1] for v in ns))) if n==(0.,0.):n=ns[0] return max(0.,min(1.,tt)),n def append_trail(s,a,b): """At most 2 px per timed cell; collinear cells are drawn continuously. A current cell is extended only while its length is <=2 px and its owner has not left. Its own birth time is retained, never refreshed by extension. At 42 px/s this coarsens expiry by at most 0.048 seconds, documented in UI. """ if math.dist(a,b)<1e-9:return last=s.trails[-1] if s.trails else None if last and not last.left and math.dist(last.b,a)<1e-7 and math.dist(last.a,b)<=2.00001: u=sub(last.b,last.a);v=sub(b,last.b) if abs(u[0]*v[1]-u[1]*v[0])<1e-7 and dot(u,v)>=0: last.b=b;return s.trails.append(Trail(a,b,s.age)) def tick(s,axis=(0.,0.),clean=False,dt=DT): if s.status!='playing':return if not (math.isfinite(dt) and 0PLAYER_R+WALL_R+.02:t.left=True active=[t for t in s.trails if t.left and s.age-t.born>=GRACE] if s.wall_enabled else [] d=mul(norm(axis),SPEED*dt);old=s.pos;hit=first_hit(old,d,PLAYER_R,active) if hit: frac,n=hit;s.pos=add(add(old,mul(d,frac)),mul(n,.001)) if s.age-s.last_block>.2:s.blocks+=1;s.last_block=s.age s.reason='edge' if any(abs(t-frac)<1e-7 for t,n in boundary_hits(old,d,PLAYER_R)) else 'trail' else:s.pos=add(old,d) moved=math.dist(old,s.pos);s.moving_distance+=moved if clean: remaining=[p for p in s.dust if distance(p,old,s.pos)>5.+1e-9] s.collected+=len(s.dust)-len(remaining);s.dust=remaining if moved>1e-8:append_trail(s,old,s.pos);s.swept_distance+=moved # The new trail already reflects rocks this tick. Rock/player uses relative # swept circles, so even crossing between endpoints cannot skip a collision. for rock in s.rocks: remaining=dt;elapsed=0. for _ in range(4): if remaining<1e-10:break p=rock.pos;delta=mul(rock.vel,remaining) hit=first_hit(p,delta,ROCK_R,s.trails if s.wall_enabled else ()) fraction=hit[0] if hit else 1. rd=mul(delta,fraction) player_start=add(old,mul(sub(s.pos,old),elapsed/dt)) player_delta=mul(sub(s.pos,old),remaining*fraction/dt) contact=sweep_circle(sub(p,player_start),sub(rd,player_delta),(0.,0.),ROCK_R+PLAYER_R) if contact or math.dist(p,player_start)150:self.history.pop(0) for _ in range(4):tick(self.state,axis,clean) STAGE = 5 STAGE_TITLE = 'TITLE + PAUSE + EXACT REWIND' CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'R', 'P', 'Z') DEMO_RUNS = ((11, ('UP', 'SPACE')), (79, ('RIGHT', 'SPACE')), (1, ('RIGHT', 'UP', 'SPACE')), (22, ('UP', 'SPACE')), (1, ('LEFT', 'UP', 'SPACE')), (7, ('LEFT', 'SPACE'))) class Controller: def __init__(self): self.session = Session() self.title = STAGE == 5 @property def state(self): return self.session.state def tick(self, pressed=(), held=()): pressed = set(pressed); held = set(held) if self.title: if 'SPACE' in pressed: self.title = False; self.session.reset() return edge = None if 'R' in pressed: edge = 'restart' elif STAGE == 5 and 'P' in pressed: edge = 'pause' elif STAGE == 5 and 'Z' in pressed: edge = 'rewind' axis = (int('RIGHT' in held)-int('LEFT' in held), int('DOWN' in held)-int('UP' in held)) self.session.frame(axis, 'SPACE' in held, edge) def snapshot(self): return {'state': asdict(self.state), 'title': self.title, 'paused': getattr(self.session,'paused',False), 'rewinds': getattr(self.session,'rewinds',0), 'history_hashes': [state_hash(s) for s in getattr(self.session,'history',[])], 'hash': state_hash(self.state)} def state_hash(state): return hashlib.sha256(json.dumps(asdict(state),sort_keys=True,separators=(',',':'),ensure_ascii=False).encode('utf-8')).hexdigest() def draw_board(pyxel, controller): """観察するだけ。ここで寿命・離脱・物理を更新しない。""" s = controller.state pyxel.cls(1) pyxel.text(12,10,f'STARLIGHT SWEEPER 014 / STEP {STAGE}',10) pyxel.text(12,25,STAGE_TITLE,7) ox, oy, scale = 0, 26, 3 def point(p): return ox+p[0]*scale, oy+p[1]*scale def line(a,b,color): pyxel.line(*point(a),*point(b),color) def dashed(a,b,color): length=math.dist(a,b) if length<1e-5:return for j in range(0,int(length)+1,3): line(add(a,mul(sub(b,a),j/length)),add(a,mul(sub(b,a),min(1,(j+1.4)/length))),color) pyxel.rect(*point((12,16)),504,312,0) pyxel.rectb(*point((12,16)),505,313,5) for x in range(18,180,12): for y in range(22,120,12):pyxel.pset(*point((x,y)),1) for t in getattr(s,'trails',[]): remain=LIFE-(s.age-t.born) safe=not getattr(t,'left',False) or s.age-t.born1.2 else 9 if remain>.5 else 5 if safe or remain<.5:dashed(t.a,t.b,color) else: line(t.a,t.b,color) line(add(t.a,(0,.6)),add(t.b,(0,.6)),color) pyxel.pset(*point(add(t.a,(0,-1.1))),7) for p in s.dust: x,y=point(p);pyxel.line(x-5,y,x+5,y,6);pyxel.line(x,y-5,x,y+5,6) for r in getattr(s,'rocks',[]): pyxel.circ(*point(r.pos),15,13);pyxel.circb(*point(r.pos),15,7) end=add(r.pos,mul(norm(r.vel),10));line(r.pos,end,8) v=norm(r.vel);tip=add(end,mul(v,-3));side=(-v[1]*2,v[0]*2) line(end,add(tip,side),8);line(end,sub(tip,side),8) pyxel.circ(*point(s.pos),9,7);pyxel.circ(*point(s.pos),6,12) if getattr(s,'collision_point',None):pyxel.circb(*point(s.collision_point),22,8) active=sum(bool(getattr(t,'left',False) and s.age-t.born>=GRACE) for t in getattr(s,'trails',[])) if STAGE>=2 else 0 info=[('CASE','B / CROSSING ROCK'),('STATUS',s.status.upper()),('AGE',f'{s.age:.3f} s'), ('DUST',f'{s.collected} / 6'),('POSITION',f'{s.pos[0]:.2f}, {s.pos[1]:.2f}'),('SPEED','42 px/s'),('BLOCKS',str(s.blocks))] if STAGE>=2:info += [('CELLS',str(len(s.trails))),('LIFE','2.4 s'),('OWNER WALLS',str(active)),('GRACE','0.18 s' if STAGE>=3 else 'not used yet')] if STAGE>=4:info += [('ROCK VY',f'{s.rocks[0].vel[1]:.2f} px/s')] if STAGE==5:info += [('HISTORY',str(len(controller.session.history))),('REWINDS',str(controller.session.rewinds))] for n,(key,value) in enumerate(info): pyxel.text(557,58+n*20,key,13);pyxel.text(650,58+n*20,value,7) pyxel.text(12,406,'ARROWS: move SPACE (hold): sweep R: reset ESC: quit',7) notes={1:'LAB: sweep dust only. No trails, self walls, or rocks.', 2:'LAB: finite trail cells only. They do not block you yet.', 3:'LAB: owner wall + exit grace. Original B rock is still omitted.', 4:'Original B physics: new trails reflect the rock immediately.', 5:'Original B + title / pause / rewind. P: pause Z: rewind 1 second'} pyxel.text(12,423,notes[STAGE],10) pyxel.text(12,440,'CYAN CROSS: dust BLUE: player GRAY: rock DASHED: owner-safe / expiring',6) pyxel.text(12,456,'30 Hz input / 120 Hz physics. Drawing never advances the world.',13) if controller.title: pyxel.rect(75,148,414,96,1);pyxel.rectb(75,148,414,96,10) pyxel.text(95,168,'STARLIGHT SWEEPER / CASE B',10) pyxel.text(95,190,'Sweep across first, then travel to the upper dust.',7) pyxel.text(95,219,'SPACE: start (holding it sweeps on the next frame)',10) elif getattr(controller.session,'paused',False): pyxel.rect(36,43,505,22,1);pyxel.rectb(36,43,505,22,10) pyxel.text(48,51,'PAUSED: rock / lifetime / history all frozen',10) elif s.status!='playing': pyxel.rect(36,43,505,22,1);pyxel.rectb(36,43,505,22,10) pyxel.text(48,51,'CLEAR! / R: reset' if s.status=='clear' else 'ROCK CONTACT / R: reset',10) def demo_keys(controller, frame_number): """原作B成功入力をrun-length化。状態変更やテレポートはしない。""" if STAGE==5: if frame_number==1:return ('SPACE',) frame_number-=1 for count,keys in DEMO_RUNS: if frame_number<=count:return keys frame_number-=count return () def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None): from pathlib import Path import pyxel # モデルのimport、標準ライブラリの試験には不要。 controller=Controller();frame_number=0;old_held=set();changes=[] codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS} screenshot=str(Path(screenshot).resolve()) if screenshot else None report=str(Path(report).resolve()) if report else None def update(): nonlocal frame_number,old_held frame_number+=1 if headless: held=set(replay[frame_number-1]) if replay is not None and frame_number<=len(replay) else set(demo_keys(controller,frame_number) if demo else ()) pressed=held-old_held;old_held=held else: held={name for name,code in codes.items() if pyxel.btn(code)} pressed={name for name,code in codes.items() if pyxel.btnp(code)} previous=controller.state.status controller.tick(pressed,held) if pressed or previous!=controller.state.status: changes.append({'frame':frame_number,'pressed':sorted(pressed),'held':sorted(held),'status':controller.state.status,'hash':state_hash(controller.state)}) def draw(): draw_board(pyxel,controller) if headless and frame_number>=frames: if screenshot:pyxel.screenshot(screenshot,scale=1) if report:Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic input, not human play', 'stage':STAGE,'requested_frames':frames,'actual_frames':frame_number,'demo':demo,'changes':changes, 'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8') pyxel.quit() pyxel.init(790,477,title=f'Starlight Sweeper / Step {STAGE}',fps=30,headless=headless) pyxel.run(update,draw) if __name__=='__main__': import argparse from pathlib import Path parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless',action='store_true') parser.add_argument('--frames',type=int,default=180) parser.add_argument('--demo',action='store_true') parser.add_argument('--replay-json',help='UTF-8 JSON list of held-key lists, one per frame') parser.add_argument('--screenshot',help='PNG output, headless only; current directory relative') parser.add_argument('--report',help='UTF-8 JSON output, headless only') args=parser.parse_args() if not 12<=args.frames<=100000:parser.error('Use 12 through 100000 frames') if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report):parser.error('QA options require --headless') if args.demo and args.replay_json:parser.error('Choose demo or replay, not both') replay=None if args.replay_json: try:replay=json.loads(Path(args.replay_json).read_text(encoding='utf-8')) except (OSError,ValueError) as error:parser.error(str(error)) if not isinstance(replay,list) or any(not isinstance(keys,list) or any(not isinstance(k,str) or k not in CONTROLS for k in keys) for keys in replay):parser.error('Replay must be a list of lists of supported key names') main(args.headless,args.frames,args.demo,replay,args.screenshot,args.report)