# 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. """日陰の追いかけっこ・教材 5/5。Python 3.11+ / Pyxel 2.9.9。""" from dataclasses import dataclass, replace, asdict import math, hashlib, json DT=1/120 RADIUS=3.0 PERIOD=24.0 SHADOW_LENGTH=36.0 EPS=1e-7 @dataclass(frozen=True) class Level: name:str buildings:tuple awnings:tuple items:tuple phase:float LEVELS=( Level('一本の影',((64,44,80,64),),((40,88,11),(140,88,11)),((100,64),),math.pi/4), Level('つながる渡り廊下',((52,44,68,64),(98,60,114,80)),((28,80,11),(84,104,11),(164,64,11)),((86,64),(138,80)),7*math.pi/4), Level('近道のまぶしさ',((52,44,68,64),(112,40,128,60)),((28,80,11),(88,104,11),(168,100,11)),((92,52),(144,72)),math.pi/4), ) @dataclass(frozen=True) class Input: x:int=0 y:int=0 run:bool=False pause:bool=False restart:bool=False checkpoint:bool=False @dataclass(frozen=True) class Snapshot: x:float y:float time:float collected:int awning:int sun_seconds:float=0.0 @dataclass(frozen=True) class State: level:int x:float y:float time:float=0.0 exposure:float=0.0 collected:int=0 status:str='play' paused:bool=False shade:bool=True awning:int=0 checkpoint:Snapshot=None rotating:bool=True sun_seconds:float=0.0 def initial(level=0,rotating=True): x,y,_=LEVELS[level].awnings[0] return State(level,x,y,checkpoint=Snapshot(x,y,0.0,0,0),rotating=rotating) def cross(a,b,c):return (b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0]) def convex_hull(points): p=sorted(set(points)) if len(p)<=1:return tuple(p) lo=[];hi=[] for q in p: while len(lo)>1 and cross(lo[-2],lo[-1],q)<=EPS:lo.pop() lo.append(q) for q in reversed(p): while len(hi)>1 and cross(hi[-2],hi[-1],q)<=EPS:hi.pop() hi.append(q) return tuple(lo[:-1]+hi[:-1]) def shadows(level,time,rotating=True): spec=LEVELS[level];a=spec.phase+(2*math.pi*(time%PERIOD)/PERIOD if rotating else 0) dx,dy=SHADOW_LENGTH*math.cos(a),SHADOW_LENGTH*math.sin(a) result=[] for x1,y1,x2,y2 in spec.buildings: c=((x1,y1),(x2,y1),(x2,y2),(x1,y2)) result.append(convex_hull(c+tuple((x+dx,y+dy) for x,y in c))) return tuple(result) def inside(p,poly,strict=False): return len(poly)>2 and all(cross(a,b,p)>(EPS if strict else -EPS) for a,b in zip(poly,poly[1:]+poly[:1])) def distance_segment(p,a,b): dx,dy=b[0]-a[0],b[1]-a[1];dd=dx*dx+dy*dy t=max(0,min(1,((p[0]-a[0])*dx+(p[1]-a[1])*dy)/dd)) if dd>EPS else 0 return math.hypot(p[0]-a[0]-t*dx,p[1]-a[1]-t*dy) def covered_interval(a,b,poly): """Parametric portion of AB inside convex polygon, or None.""" low,high=0.0,1.0 for c,d in zip(poly,poly[1:]+poly[:1]): h0=cross(c,d,a);h1=cross(c,d,b);delta=h1-h0 if abs(delta)0:low=max(low,-h0/delta) else:high=min(high,-h0/delta) if low>high+EPS:return None return (max(0,low),min(1,high)) if low<=high else None def disk_in_union(p,r,polys): """Union outer/hole edges, rather than a center-only or 8-point shortcut.""" if not any(inside(p,poly) for poly in polys):return False for i,poly in enumerate(polys): for a,b in zip(poly,poly[1:]+poly[:1]): cuts=[0.0,1.0] for j,other in enumerate(polys): if j!=i: interval=covered_interval(a,b,other) if interval:cuts.extend(interval) cuts=sorted(set(cuts)) for u,v in zip(cuts,cuts[1:]): if v-u=0 or disk_in_union((s.x,s.y),RADIUS,shadows(s.level,s.time,s.rotating)) def sweep_circle(p,d,rect,r=RADIUS): """Earliest rounded-rectangle collision. Tangencies while moving away skip.""" x,y=p;dx,dy=d;x1,y1,x2,y2=rect;hits=[] for edge,lo,hi,axis,normal in ((x1-r,y1,y2,0,(-1,0)),(x2+r,y1,y2,0,(1,0)),(y1-r,x1,x2,1,(0,-1)),(y2+r,x1,x2,1,(0,1))): vel=dx if axis==0 else dy;pos=x if axis==0 else y if abs(vel)=-EPS:continue t=(edge-pos)/vel;other=(y+dy*t) if axis==0 else (x+dx*t) if -EPS<=t<=1+EPS and lo-EPS<=other<=hi+EPS:hits.append((max(0,t),normal)) aa=dx*dx+dy*dy if aa>EPS: for cx,cy,sx,sy in ((x1,y1,-1,-1),(x2,y1,1,-1),(x2,y2,1,1),(x1,y2,-1,1)): ox,oy=x-cx,y-cy;bb=2*(ox*dx+oy*dy);cc=ox*ox+oy*oy-r*r;disc=bb*bb-4*aa*cc if disc<-EPS:continue t=(-bb-math.sqrt(max(0,disc)))/(2*aa) if -EPS<=t<=1+EPS: hx,hy=x+dx*t-cx,y+dy*t-cy if hx*sx>=-EPS and hy*sy>=-EPS and hx*dx+hy*dy<-EPS:hits.append((max(0,t),(hx/r,hy/r))) return min(hits,key=lambda h:h[0]) if hits else None def move(s,dx,dy): p=(s.x,s.y);d=(dx,dy) for _ in range(4): hits=[hit for box in LEVELS[s.level].buildings if (hit:=sweep_circle(p,d,box))] if not hits:p=(p[0]+d[0],p[1]+d[1]);break t,n=min(hits,key=lambda h:h[0]);p=(p[0]+d[0]*t+n[0]*1e-5,p[1]+d[1]*t+n[1]*1e-5) rem=(d[0]*(1-t),d[1]*(1-t));dot=rem[0]*n[0]+rem[1]*n[1] d=(rem[0]-min(0,dot)*n[0],rem[1]-min(0,dot)*n[1]) if math.hypot(*d)=0 else max(0,min(100,s.exposure+(-35 if shade else 40 if inp.run and norm else 25)*dt)) new=replace(new,shade=shade,exposure=exposure,sun_seconds=s.sun_seconds+(0 if shade else dt)) if exposure>=100-EPS:return replace(new,status='found',exposure=100) collected=s.collected for i,(ix,iy) in enumerate(LEVELS[s.level].items): if math.hypot(x-ix,y-iy)<=5.5:collected|=1<=0 and awning!=s.awning:new=replace(new,checkpoint=Snapshot(x,y,time,collected,awning,new.sun_seconds)) if awning==len(LEVELS[s.level].awnings)-1 and collected==(1<=4 and 'Z' in pressed: self.state=frame(self.state,Input(checkpoint=True));self.locked=True;return if STAGE>=3 and 'H' in pressed: self.forecast=not self.forecast;return if self.locked: if not held.intersection(('UP','DOWN','LEFT','RIGHT','SPACE')):self.locked=False return if self.state.status=='success': if 'N' in pressed: self.level=(self.level+1)%3;self.reset();self.locked=True return u=Input(int('RIGHT' in held)-int('LEFT' in held), int('DOWN' in held)-int('UP' in held),'SPACE' in held) self.state=frame(self.state,u) def demo_input(self): tick=self.demo_tick;self.demo_tick+=1 if STAGE==1: return Input(1,0,True) if tick<45 else Input(0,-1) if tick<75 else Input() if STAGE==2: dx,dy=90-self.state.x,75-self.state.y if math.hypot(dx,dy)<1:return Input() return Input(0 if abs(dx)<.55 else 1 if dx>0 else -1,0 if abs(dy)<.55 else 1 if dy>0 else -1,True) if STAGE==3:return Input(1,0,True) if STAGE==4: if tick==190:return Input(checkpoint=True) if tick>=190:return Input() route=((28,104),(84,104)) else: if tick<105:return Input() route=((86,72),(86,64),(88,86),(138,86),(138,80),(164,64)) s=self.state while self.waypoint0 else -1, 0 if abs(dy)<.55 else 1 if dy>0 else -1,True) def demo_update(self): # Semantic input to the real update path; not OS-keyboard evidence. u=self.demo_input();held=set() if u.x:held.add('RIGHT' if u.x>0 else 'LEFT') if u.y:held.add('DOWN' if u.y>0 else 'UP') if u.run:held.add('SPACE') pressed={'Z'} if getattr(u,'checkpoint',False) else set() self.control(pressed,held) def draw(self,p): s=self.state;spec=LEVELS[self.level] p.cls(1) p.text(12,10,'017 MOVING SHADOWS / STAGE '+str(STAGE),10) p.text(12,26,('PAUSED' if s.paused else s.status.upper())+' GARDEN '+str(self.level+1)+' T '+format(s.time,'.3f')+' s',7) p.text(12,40,'X '+format(s.x,'.2f')+' Y '+format(s.y,'.2f'),6) if STAGE>=3: p.text(226,26,'EXPOSURE '+format(s.exposure,'.2f'),10) p.rectb(330,24,102,9,7);p.rect(331,25,s.exposure,7,8) p.text(226,40,'SUN '+format(s.sun_seconds,'.3f')+' s',6) ox,oy=24,46 def xy(x,y):return ox+x*2,oy+y*2 def polygon(poly,color,outline=False,dotted=False): points=[xy(*v) for v in poly] if not outline: for j in range(1,len(points)-1):p.tri(*points[0],*points[j],*points[j+1],color) else: for a,b in zip(points,points[1:]+points[:1]): if dotted: n=max(1,int(math.dist(a,b)/5)) for j in range(0,n,2): u=j/n;v=min(j+1,n)/n p.line(a[0]+(b[0]-a[0])*u,a[1]+(b[1]-a[1])*u,a[0]+(b[0]-a[0])*v,a[1]+(b[1]-a[1])*v,color) else:p.line(*a,*b,color) p.rect(ox+8,oy+40,368,208,15) p.clip(ox+8,oy+40,368,208) for x in range(8,188,16): for y in range(24,124,16):p.pset(*xy(x,y),9) if STAGE>=2: for poly in shadows(s.level,s.time,s.rotating):polygon(poly,1);polygon(poly,6,True) if STAGE>=3 and self.forecast: for poly in shadows(s.level,s.time+2,s.rotating):polygon(poly,13,True,True) if STAGE>=3: for i,(x,y,r) in enumerate(spec.awnings): x,y=xy(x,y);p.circ(x,y,r*2,3);p.circb(x,y,r*2,11);p.circb(x,y,r*2-3,11) p.text(x-12,y+26,'EXIT' if STAGE==5 and i==len(spec.awnings)-1 else 'A'+str(i),3) if STAGE==5 and i==len(spec.awnings)-1: p.line(x+7,y-7,x+7,y+6,7);p.tri(x+8,y-7,x+17,y-4,x+8,y-1,10) for x1,y1,x2,y2 in spec.buildings: x,y=xy(x1,y1);p.rect(x,y,(x2-x1)*2,(y2-y1)*2,4);p.rectb(x,y,(x2-x1)*2,(y2-y1)*2,9) if STAGE==5: for i,(x,y) in enumerate(spec.items): if not s.collected & (1<=2: a=spec.phase+(2*math.pi*(s.time%PERIOD)/PERIOD if s.rotating else 0) cx,cy=400,67;p.circb(cx,cy,12,6);p.line(cx,cy,cx+10*math.cos(a),cy+10*math.sin(a),10) p.text(12,65,'FULL BODY: '+('SHADE' if s.shade else 'SUN'),11 if s.shade else 8) labels={1:'NORMALIZED WALK 28 / RUN 46 / SWEPT WALLS',2:'FIXED SHADOW / UNION BOUNDARY / BODY RADIUS 3',3:'ROTATION 24s / SUN +25 / MOVING RUN +40',4:'AWNING ENTRY SAVES TIME / Z REWINDS',5:'ALL PAPERS + LAST AWNING = SUCCESS'} p.text(12,310,labels[STAGE],10) if STAGE>=3:p.text(12,326,'SHADE -35/s / AWNING 0 / FOUND AT 100',6) else:p.text(12,326,'PRACTICE: NO EXPOSURE OR FAILURE YET',6) if STAGE>=4: c=s.checkpoint;p.text(12,342,'SAVE A'+str(c.awning)+' T '+format(c.time,'.3f')+' SUN '+format(c.sun_seconds,'.3f'),7) if STAGE==5:p.text(250,342,'PAPERS '+str(s.collected.bit_count())+'/'+str(len(spec.items)),11) if s.status=='found':p.text(12,358,'FOUND / '+('Z RETURN OR ' if STAGE>=4 else '')+'R RESTART',8) elif s.status=='success':p.text(12,358,'SUCCESS / N NEXT GARDEN',11) else:p.text(12,358,'MODEL 120 Hz / DRAW 30 FPS / FIXED SIM TIME',7) p.text(12,376,'ARROWS MOVE SPACE RUN P PAUSE R RESET ESC QUIT',6) p.text(12,390,'1/2/3 GARDEN'+(' H +2s OUTLINE' if STAGE>=3 else '')+(' Z TIME RETURN' if STAGE>=4 else ''),6) def parse_args(argv=None): """Reject invalid QA flags before importing or initializing Pyxel.""" 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();finished=False p.init(448,410,title='017 Moving Shadows',fps=30,display_scale=2,headless=args.headless_smoke) keys={k:getattr(p,'KEY_'+k) for k in KEYS} def update(): if args.headless_smoke: if args.demo_checkpoint 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()