# 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 第3段階。独立実行。日本語の解説は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 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 State: level: int pos: Vec dust: list[Vec] trails: list[Trail] = field(default_factory=list) swept_distance: float = 0. max_trails: int = 0 age: float = 0. status: str = 'playing' collected: int = 0 blocks: int = 0 reason: str = '' moving_distance: float = 0. last_block: float = -10. def new_state(): return State(1, START, list(DUST)) 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 0 < dt <= DT + 1e-10): raise ValueError('Use fixed substeps <= 1/120 second') if not all(math.isfinite(v) for v in axis): raise ValueError('Non-finite input') s.age += dt s.trails = [t for t in s.trails if s.age-t.born < LIFE-1e-9] for t in s.trails: if not t.left and distance(s.pos,t.a,t.b) > PLAYER_R+WALL_R+.02: t.left=True active = [t for t in s.trails if t.left and s.age-t.born >= GRACE] d = mul(norm(axis), SPEED * dt) old = s.pos hit = first_hit(old, d, PLAYER_R, active) if hit: fraction, normal = hit s.pos = add(add(old, mul(d, fraction)), mul(normal, .001)) if s.age - s.last_block > .2: s.blocks += 1; s.last_block = s.age s.reason = 'edge' if any(abs(t-fraction)<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 s.max_trails = max(s.max_trails, len(s.trails)) if not s.dust: s.status = 'clear'; s.reason = 'clear' class Session: """30Hzの入力から、120Hzの物理を4回呼ぶ。""" def __init__(self): self.reset() def reset(self): self.state = new_state() def frame(self, axis=(0.,0.), clean=False, edge=None): if edge == 'restart': self.reset(); return for _ in range(4): tick(self.state, axis, clean) STAGE = 3 STAGE_TITLE = 'OWNER EXIT + SELF WALLS' CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'R') 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)