PageSourceSearch

https://geometrylite.net/native-dash/v2.14/game.js

js geometrylite.net collected 2026-10-07 06:04:22 UTC 297,527 bytes, 2,866 lines download raw bytes

1(()=>{'use strict';
2
3// core.mjs
4/** Deterministic rhythm physics. Sensors use contact, never music time or a replay. */
5const DT=1/240, FLOOR=420, ROOF=76, SIZE=32, G=2400, JUMP=715;
6const clamp=(n,a,b)=>Math.max(a,Math.min(b,n));
7const boxHits=(a,b)=>a.x<b.x+b.w&&a.x+a.w>b.x&&a.y<b.y+b.h&&a.y+a.h>b.y;
8function circleHits(x,y,r,b){return (x-clamp(x,b.x,b.x+b.w))**2+(y-clamp(y,b.y,b.y+b.h))**2<r*r;}
9function triangleHits(p,b){
10 const q=[[b.x,b.y],[b.x+b.w,b.y],[b.x+b.w,b.y+b.h],[b.x,b.y+b.h]],axes=[[1,0],[0,1]];
11 for(let i=0;i<3;i++){const a=p[i],z=p[(i+1)%3];axes.push([a[1]-z[1],z[0]-a[0]]);}
12 return axes.every(([x,y])=>{const a=p.map(v=>v[0]*x+v[1]*y),z=q.map(v=>v[0]*x+v[1]*y);return Math.max(...a)>Math.min(...z)&&Math.max(...z)>Math.min(...a);});
13}
14function shapeAt(o,t){if(!o.motion)return o;return {...o,y:o.y+Math.sin(t*o.motion.speed+o.motion.phase)*o.motion.range};}
15/** Enemies (stage 9 and community levels only). Every pose and shot is a pure function of
16 *  (object, t, beat=60/bpm) built from + - * / % and Math.floor: no Math.sin and no stored spawn
17 *  state, so every engine rounds identically and snapshots need no new keys. A defeat is an id in
18 *  `used` ('boss:<id>:<j>' for one boss dive). o.x..o.x+o.w, o.y..o.y+o.h is the whole sweep, so
19 *  nearby(180) and the editor see a patrol exactly as it moves. */
20const ENEMY='enemy',ENEMY_BODY={walker:[52,28],flyer:[40,28],thrower:[40,44],boss:[64,48]},FLYER_AMP=45,STOMP_BOUNCE=610,SHOT_CUE_AHEAD=900,BOSS_LEAD=420,BOSS_HOVER=ROOF+34;
21const foeTri=u=>{const p=(u%2+2)%2;return p<1?p:2-p;};
22const foeBob=u=>{const f=(u%1+1)%1;return f<.5?16*f*(.5-f):-16*(f-.5)*(1-f);};
23/** Body box of a walker, flyer or thrower at t: triangle-wave patrol, piecewise-parabola bob. */
24function enemyAt(o,t,beat){
25 const b=t/beat+(o.phase||0);let x=o.x,y=o.y+o.h-o.bh,face=-1;
26 if(o.leg){const u=b/o.leg;x=o.x+foeTri(u)*(o.w-o.bw);face=(u%2+2)%2<1?1:-1;}
27 if(o.kind==='flyer'){const amp=(o.h-o.bh)/2;y=o.y+amp-amp*foeBob(b/(o.bob||2));}
28 return {x,y,w:o.bw,h:o.bh,face};
29}
30/** Shot k of a (static) thrower leaves its hand at o.x on beat at+k*every and lives `life` beats:
31 *  'roll' along the floor (jump it), 'high' at head height (stay down). */
32function shotsAt(o,t,beat){
33 const s=o.shot,out=[];if(!s)return out;const n=Math.floor((t/beat-s.at)/s.every);
34 for(let k=Math.max(0,n-Math.floor(s.life/s.every));k<=n&&k<(s.count??1e9);k++){
35  const launch=(s.at+k*s.every)*beat,age=t-launch,r=s.r||8;if(age<0||age>s.life*beat)continue;
36  out.push({k,x:o.x-s.speed*age,y:s.style==='high'?FLOOR-60:FLOOR-r,r,launch});
37 }
38 return out;
39}
40/** Boss: o.x..o.x+o.w is the arena and pattern char j is the beat the player reaches o.x+j*unit.
41 *  It reads like a chart: 'r' roller (press on that beat), 'h' high shot (rest: it passes 40ms
42 *  before the beat, so an on-beat or late press is safe), 'D' dive (press on that beat to stomp),
43 *  '.' rest. parseLevel keeps speed portals out of the arena, so the player is at speed*t here. */
44const BOSS_AIM=.49;
45const BOSS_MEET=.3,BOSS_DUCK=-.04,BOSS_SIT=.55,BOSS_DIVE=2;
46function bossPlan(o,level){const unit=level.speed*60/level.bpm,out=[];
47 for(let j=0;j<o.pattern.length;j++){const c=o.pattern[j];if(c!=='.'){const A=o.x+j*unit;out.push({j,c,A,T:A/level.speed});}}return out;}
48function bossAt(o,t,level){
49 const beat=60/level.bpm,hx=level.speed*t+BOSS_LEAD,hy=BOSS_HOVER+10*foeBob(t/beat/2),ground=FLOOR-o.bh;
50 for(const a of bossPlan(o,level)){if(a.c!=='D')continue;const X=a.A+level.speed*BOSS_AIM-o.bw/2,s=(t-a.T-BOSS_SIT)/beat;
51  if(s<-.5-BOSS_DIVE||s>1.5)continue;
52  if(s<-.5){const u=(s+.5+BOSS_DIVE)/BOSS_DIVE;return {x:hx+(X-hx)*u,y:hy+(ground-hy)*u*u,w:o.bw,h:o.bh,dive:a.j};}
53  if(s<=.5)return {x:X,y:ground,w:o.bw,h:o.bh,dive:a.j};
54  const u=s-.5;return {x:X+(hx-X)*u,y:ground+(hy-ground)*u*(2-u),w:o.bw,h:o.bh,dive:a.j};
55 }
56 return {x:hx,y:hy,w:o.bw,h:o.bh,dive:-1};
57}
58function bossShotsAt(o,t,level){const beat=60/level.bpm,out=[],F=2*beat,v=BOSS_LEAD/F-level.speed;
59 for(const a of bossPlan(o,level)){if(a.c!=='r'&&a.c!=='h')continue;const lag=a.c==='r'?BOSS_MEET:BOSS_DUCK,M=a.A+level.speed*lag,Tm=a.T+lag;
60  if(t<Tm-F||t>Tm+beat)continue;out.push({k:a.j,x:M+v*(Tm-t),y:a.c==='h'?FLOOR-60:FLOOR-9,r:9,launch:Tm-F});}
61 return out;
62}
63/** Shots that come within SHOT_CUE_AHEAD px ahead of the player between (t0,x0) and (t1,x1): launched
64 *  inside that zone, or flying into it. Each shot closes on the player, so it is cued exactly once. */
65function shotCues(o,t0,x0,t1,x1,level){const beat=60/level.bpm,boss=o.kind==='boss',at=t=>boss?bossShotsAt(o,t,level):shotsAt(o,t,beat),out=[];
66 const before=new Map(at(t0).map(p=>[p.k,p.x-x0]));
67 for(const p of at(t1)){const d=p.x-x1;if(d<=0||d>SHOT_CUE_AHEAD)continue;const d0=before.get(p.k);
68  if(d0===undefined||d0>SHOT_CUE_AHEAD)out.push({k:p.k,style:boss?(o.pattern[p.k]==='h'?'high':'roll'):o.shot.style,x:p.x});}
69 return out;
70}
71class World{
72 constructor(level){this.level=level;this.foes=false;this.shooters=[];for(const o of level.objects||[])if(o.type===ENEMY){this.foes=true;if(o.shot||o.kind==='boss')this.shooters.push(o);}this.reset();}
73 reset(snapshot){Object.assign(this,{t:0,x:0,y:FLOOR-SIZE/2,vy:0,speed:this.level.speed,mode:this.level.startMode||'cube',gravity:this.level.startGravity||1,size:this.level.startSize||1,angle:0,grounded:true,dead:false,won:false,held:false,pressedAt:-9,jumpAt:-9,portalAt:-9,coins:0,jumps:0,section:0,cursor:0,death:null});this.events=[];this.used=new Set();if(this.level.startMode&&['ship','wave','ufo'].includes(this.level.startMode))this.y=(FLOOR+ROOF)/2;if(this.gravity<0)this.y=ROOF+SIZE*this.size/2;if(snapshot){Object.assign(this,snapshot);this.used=new Set(snapshot.used);this.events=[];this.held=false;this.pressedAt=-9;this.dead=false;this.won=false;}}
74 copy(){const w=new World(this.level);w.reset(this.snapshot());return w;}
75 snapshot(){const s={};for(const k of ['t','x','y','vy','speed','mode','gravity','size','angle','grounded','pressedAt','jumpAt','portalAt','coins','jumps','section','cursor'])s[k]=this[k];s.used=[...this.used];return s;}
76 emit(kind,o={}){this.events.push({kind,t:this.t,x:this.x,y:this.y,...o});if(this.events.length>128)this.events.shift();}
77 die(o){if(this.dead||this.won)return;this.dead=true;this.death=typeof o==='string'?o:o.id;this.emit('death');}
78 nearby(distance=260){const obs=this.level.objects;while(this.cursor<obs.length&&obs[this.cursor].x+(obs[this.cursor].w||0)+120<this.x)this.cursor++;
79 const a=[];for(let i=this.cursor;i<obs.length&&obs[i].x<this.x+distance;i++)a.push(obs[i]);return a;}
80 /** One enemy body per call, stomp first, then the hurt box. Stomp = coming down onto the upper half of
81  *  the body: the previous bottom no lower than the enemy's previous half line, so the enemy's own motion
82  *  never eats the margin (mirrored under inverted gravity). A rising body (a jump, pad or bounce on its
83  *  way up) bonks walkers, flyers and throwers; any other contact with the body inset 3px kills. Flying
84  *  modes only die. */
85 enemy(raw,b,half,prevY,held,dt,flying){
86  const lv=this.level,beat=60/lv.bpm,boss=raw.kind==='boss',e=boss?bossAt(raw,this.t,lv):enemyAt(raw,this.t,beat),was=boss?bossAt(raw,this.t-dt,lv):enemyAt(raw,this.t-dt,beat);
87  const key=boss?'boss:'+raw.id+':'+e.dive:raw.id;if(boss&&(e.dive<0||this.used.has(key)))return;
88  const cross=this.x+half-3>e.x&&this.x-half+3<e.x+e.w,up=this.gravity===1;
89  if(!flying&&cross&&(up?this.vy>=0&&prevY+half<=was.y+was.h/2&&this.y+half>=e.y:this.vy<=0&&prevY-half>=was.y+was.h/2&&this.y-half<=e.y+e.h)){
90   this.used.add(key);this.y=up?e.y-half:e.y+e.h+half;this.vy=-this.gravity*(held?JUMP:STOMP_BOUNCE);this.grounded=false;this.pressedAt=-9;this.emit('stomp',{id:raw.id,foe:raw.kind});
91   if(boss&&bossPlan(raw,lv).filter(a=>a.c==='D'&&this.used.has('boss:'+raw.id+':'+a.j)).length>=raw.hp){this.used.add(raw.id);this.emit('bossdown',{id:raw.id});}
92   return;
93  }
94  if(!boxHits(b,{x:e.x+3,y:e.y+3,w:e.w-6,h:e.h-6}))return;
95  if(!flying&&!boss&&this.vy*this.gravity<0){this.used.add(raw.id);this.emit('bonk',{id:raw.id,foe:raw.kind});return;}
96  this.die(raw);
97 }
98 /** Shots of throwers and the boss, from the list derived at construction: a shot can be far from
99  *  its thrower, so it is judged here instead of widening nearby(). A defeated shooter's shots vanish.
100  *  A shot coming within SHOT_CUE_AHEAD of the player (thrown there, or flying in from off screen) emits
101  *  'shot' once: the sound that goes with the screen-edge chevron. Presentation only: replays never read events. */
102 volley(dt,prevX){const lv=this.level,beat=60/lv.bpm,h=SIZE*this.size*.39,b={x:this.x-h,y:this.y-h,w:h*2,h:h*2};
103  for(const o of this.shooters){if(this.used.has(o.id))continue;const boss=o.kind==='boss';
104   if(boss||(o.x+o.w>=this.x&&o.x-o.shot.speed*o.shot.life*beat<=this.x+SHOT_CUE_AHEAD))for(const p of shotCues(o,this.t-dt,prevX,this.t,this.x,lv))this.emit('shot',{id:o.id,k:p.k,style:p.style,from:p.x});
105   if(boss?b.x+b.w<o.x-BOSS_LEAD||b.x>o.x+o.w+BOSS_LEAD:b.x>o.x+o.w+16||b.x+b.w<o.x-o.shot.speed*o.shot.life*beat-16)continue;
106   for(const p of boss?bossShotsAt(o,this.t,lv):shotsAt(o,this.t,beat))if(circleHits(p.x,p.y,p.r,b))return this.die('shot:'+o.id+':'+p.k);
107  }
108 }
109 floorAt(x){return !this.level.gaps?.some(g=>x>g.x&&x<g.x+g.w);}
110 step(held=false,pressed=held&&!this.held,dt=DT){
111  if(this.dead||this.won)return;dt=clamp(dt,0,1/120);this.t+=dt;this.held=held;if(pressed)this.pressedAt=this.t;
112  const half=SIZE*this.size/2,hitHalf=half*.78,prevX=this.x,prevY=this.y,wasGrounded=this.grounded;
113  this.x+=this.speed*dt;
114  const nearby=this.nearby(180);
115  // Mode/velocity changes are contact-triggered. Mode gates never assign position.
116  for(const o of nearby){if(o.type!=='portal'||this.used.has(o.id))continue;
117   if(prevX+half<=o.x+1e-7&&this.x+half>=o.x&&this.y+half>o.y&&this.y-half<o.y+o.h){
118    this.used.add(o.id);const before={y:this.y,vy:this.vy,mode:this.mode};
119    if(o.mode)this.mode=o.mode;if(o.gravity)this.gravity=o.gravity;if(o.speed)this.speed=o.speed;
120    if(o.size){const oldHalf=half;this.size=o.size;if(this.grounded)this.y+=(oldHalf-SIZE*this.size/2)*this.gravity;}
121    this.portalAt=this.t;this.section=o.section??this.section;this.emit('portal',{before,after:{y:this.y,vy:this.vy,mode:this.mode},label:o.label||this.mode});
122   }
123  }
124  const actualHalf=SIZE*this.size/2;
125  if(['cube','robot','ball','spider'].includes(this.mode)&&this.grounded&&(held||this.t-this.pressedAt<.07)){
126   if(this.mode==='ball'&&this.t-this.pressedAt<.07){this.pressedAt=-9;this.gravity*=-1;this.vy=this.gravity*105;this.grounded=false;this.emit('flip');}
127   else if(this.mode==='spider'&&this.t-this.pressedAt<.07){
128    this.pressedAt=-9;this.gravity*=-1;let dest=this.gravity===1?FLOOR-actualHalf:ROOF+actualHalf;
129    for(const o of nearby)if(o.type==='block'&&this.x>o.x&&this.x<o.x+o.w){if(this.gravity===1&&o.y>this.y)dest=Math.min(dest,o.y-actualHalf);if(this.gravity===-1&&o.y+o.h<this.y)dest=Math.max(dest,o.y+o.h+actualHalf);}
130    const from=this.y;this.y=dest;this.vy=0;this.grounded=true;this.emit('spider',{from});
131   }else if(this.mode==='cube'||this.mode==='robot'){
132    this.vy=-JUMP*this.gravity*(this.size===.6?.86:1);this.grounded=false;this.jumpAt=this.t;this.pressedAt=-9;this.jumps++;this.emit('jump');
133   }
134  }
135  if(this.mode==='ufo'&&pressed){this.vy=-500*this.gravity;this.jumps++;this.emit('jump');}
136  if(this.mode==='ship'){this.vy=clamp(this.vy+(held?-1350:1000)*this.gravity*dt,-370,370);this.y+=this.vy*dt;this.angle=Math.atan2(this.vy,this.speed)*.6;}
137  else if(this.mode==='wave'){
138   const target=(held?-1:1)*this.speed*this.gravity;this.vy=this.t-this.portalAt<.07?this.vy+(target-this.vy)*dt*40:target;this.y+=this.vy*dt;this.angle=Math.atan2(this.vy,this.speed);
139  }else{
140   this.vy+=G*this.gravity*dt;
141   if(this.mode==='robot'&&held&&this.t-this.jumpAt<.17&&this.vy*this.gravity<0)this.vy-=G*.68*this.gravity*dt;
142   this.y+=this.vy*dt;if(!this.grounded)this.angle+=this.gravity*7.9*dt;
143  }
144  this.grounded=false;
145  const flying=['ship','wave','ufo'].includes(this.mode);
146  if(this.y+actualHalf>=FLOOR&&this.floorAt(this.x)){
147   if(flying){if(this.y+actualHalf>FLOOR+2)this.die('floor');}else if(this.gravity===1){this.y=FLOOR-actualHalf;this.vy=0;this.grounded=true;}
148  }
149  if(this.y-actualHalf<=ROOF&&this.level.hasRoof){
150   if(flying){if(this.y-actualHalf<ROOF-2)this.die('ceiling');}else if(this.gravity===-1){this.y=ROOF+actualHalf;this.vy=0;this.grounded=true;}
151  }
152  if(this.y>FLOOR+160||this.y<-150)this.die('void');
153  for(const raw of nearby){if(this.dead)break;const o=shapeAt(raw,this.t),h=SIZE*this.size*.39,b={x:this.x-h,y:this.y-h,w:h*2,h:h*2};
154   if(o.type==='spike'){
155    const tri=o.flip?[[o.x,o.y],[o.x+o.w,o.y],[o.x+o.w/2,o.y+o.h]]:[[o.x,o.y+o.h],[o.x+o.w,o.y+o.h],[o.x+o.w/2,o.y]];
156    if(triangleHits(tri,b))this.die(o);
157   }else if(o.type==='saw'&&circleHits(o.x,o.y,o.r*.84,b))this.die(o);
158   else if(o.type==='block'){
159    const cross=this.x+actualHalf-3>o.x&&this.x-actualHalf+3<o.x+o.w;
160    if(!flying&&cross&&this.gravity===1&&this.vy>=0&&prevY+actualHalf<=o.y+4&&this.y+actualHalf>=o.y){this.y=o.y-actualHalf;this.vy=0;this.grounded=true;}
161    else if(!flying&&cross&&this.gravity===-1&&this.vy<=0&&prevY-actualHalf>=o.y+o.h-4&&this.y-actualHalf<=o.y+o.h){this.y=o.y+o.h+actualHalf;this.vy=0;this.grounded=true;}
162    else if(boxHits(b,o))this.die(o);
163   }else if(o.type==='slope'){
164    if(this.x+actualHalf>o.x&&this.x-actualHalf<o.x+o.w){const ratio=clamp((this.x-o.x)/o.w,0,1),surface=o.y+o.h*(o.reverse?ratio:1-ratio);
165     if(!flying&&this.gravity===1&&this.vy>=0&&prevY+actualHalf<=surface+this.speed*dt*o.h/o.w+5&&this.y+actualHalf>=surface){this.y=surface-actualHalf;this.vy=0;this.grounded=true;}
166     else if(this.y+hitHalf>surface+5&&this.y-hitHalf<o.y+o.h)this.die(o);
167    }
168   }else if(o.type==='orb'&&!this.used.has(o.id)&&circleHits(o.x,o.y,32,b)&&this.t-this.pressedAt<.07){this.used.add(o.id);this.vy=-850*this.gravity;this.grounded=false;this.pressedAt=-9;
168this.emit('orb');}
169   else if(o.type==='pad'&&!this.used.has(o.id)&&boxHits({x:this.x-actualHalf,y:this.y-actualHalf,w:actualHalf*2,h:actualHalf*2},o)){
170    this.used.add(o.id);this.vy=-1000*this.gravity;this.grounded=false;this.emit('pad');
171   }else if(o.type==='coin'&&!this.used.has(o.id)&&circleHits(o.x,o.y,23,b)){this.used.add(o.id);this.coins|=1<<o.coin;this.emit('coin');}
172   else if(o.type===ENEMY&&!this.used.has(o.id))this.enemy(raw,b,actualHalf,prevY,held,dt,flying);
173  }
174  if(this.shooters.length&&!this.dead)this.volley(dt,prevX);
175  if(this.grounded&&!wasGrounded)this.emit('land');
176  if(this.grounded)this.angle+=(Math.round(this.angle/(Math.PI/2))*Math.PI/2-this.angle)*Math.min(1,dt*40);
177  if(this.x>=this.level.length&&!this.dead){this.won=true;this.emit('win');}
178 }
179}
180
181
182// levels.mjs
183
184/** Music-first authored charts; every phrase below is explicitly arranged. */
185const THEMES=[
186 {name:'PRISM CITY',hue:208,accent:'#52f5ff',secondary:'#beff44',sky:'#062558',style:'city',grad:'#137bbb,#a73cce'},
187 {name:'CRYSTAL PULSE',hue:282,accent:'#eb7bff',secondary:'#6dffff',sky:'#290944',style:'crystal',grad:'#3a1470,#c04ee0'},
188 {name:'SOLAR ENGINE',hue:15,accent:'#ffb74a',secondary:'#fff286',sky:'#420c24',style:'factory',grad:'#9e3023,#e09729'},
189 {name:'DEEP CURRENT',hue:174,accent:'#75ffe7',secondary:'#ffe68b',sky:'#003237',style:'ocean',grad:'#10625d,#30aaa3'}
190];
191const DEFINITIONS=[
192 {id:'prism-break',name:'PRISM BREAK',tag:'Through the electric skyline',bpm
192:132,key:52,speed:340,beats:128,difficulty:'NORMAL',stars:4,theme:0,
193  cues:[[0,'AWAKEN',0],[16,'BUILD',0],[32,'LIFT OFF',1],[48,'AFTERGLOW',1],[64,'INVERT',3],[80,'IGNITION',2],[96,'WAVELENGTH',1],[112,'HOME RUN',0]],
194  chart:[[4,'single'],[7.5,'double'],[11,'single'],[14,'triple'],[18,'stair'],[21.5,'double'],[25,'ledge'],[28,'pad'],[32,'ship'],[36,'flight',275],[40,'flight',205],[44,'flight',285],[48,'cube'],[52,'double'],[55,'stair'],[59,'triple'],[64,'invert'],[69,'topDouble'],[73,'topTriple'],[77,'topSingle'],[80,'restore'],[85,'stair'],[89,'double'],[93,'triple'],[96,'wave'],[100,'waveGap',265],[104,'waveGap',210],[108,'waveGap',290],[112,'cube'],[116,'double'],[119.5,'stair'],[123,'triple']],
195  coins:[[14.55,299],[55.75,306],[119.9,308]]},
196 {id:'velvet-current',name:'VELVET CURRENT',tag:'A different kind of gravity',bpm:120,key:57,speed:325,beats:112,difficulty:'HARD',stars:6,theme:3,
197  cues:[[0,'SUBMERGE',3],[16,'FLUX',1],[32,'ASCEND',0],[48,'ECHO',1],[64,'BREAKTHROUGH',2],[80,'CURRENT',3],[96,'SURFACE',0]],
198  chart:[[4,'double'],[8,'ledge'],[12,'triple'],[16,'ufo'],[20,'flight',275],[24,'flight',210],[28,'flight',280],[32,'cube'],[36,'stair'],[39.5,'double'],[43,'pad'],[48,'ship'],[52,'flight',260],[56,'flight',190],[60,'flight',280],[64,'cube'],[68,'triple'],[71,'ledge'],[75,'stair'],[80,'wave'],[84,'waveGap',270],[88,'waveGap',190],[92,'waveGap',275],[96,'spider'],[100,'double'],[104,'topTriple'],[108,'cube']],coins:[[8.6,377],[40.05,299],[104.5,399]]},
199 {id:'solar-fracture',name:'SOLAR FRACTURE',tag:'Enter the heart of the machine',bpm:144,key:50,speed:355,beats:128,difficulty:'HARDER',stars:8,theme:2,
200  cues:[[0,'POWER ON',2],[16,'ACCELERATE',2],[32,'FREE FALL',1],[48,'CORE',2],[64,'POLARITY',3],[80,'REACTOR',2],[96,'OVERDRIVE',1],[112,'ESCAPE',0]],
201  chart:[[4,'single'],[7,'double'],[10,'stair'],[14,'triple'],[18,'ledge'],[21.5,'triple'],[25,'double'],[28,'pad'],[32,'ship'],[36,'flight',240],[40,'flight',170],[44,'flight',265],[48,'robot'],[52,'triple'],[55,'stair'],[56.75,'ball'],[59,'double'],[64,'invert'],[69,'topDouble'],[73,'topTriple'],[77,'topDouble'],[80,'restore'],[84,'double'],[87,'stair'],[90,'triple'],[93,'double'],[96,'wave'],[100,'waveGap',230],[104,'waveGap',160],[108,'waveGap',260],[112,'cube'],[116,'triple'],[119,'stair'],[123,'triple']],coins:[[10.65,299],[55.65,297],[119.65,299]]},
202 {id:'crystal-pulse',name:'CRYSTAL PULSE',tag:'Shatter the light, keep the beat',bpm:132,key:52,speed:340,beats:112,difficulty:'HARD',stars:6,theme:1,trackId:'prism-break',
203  cues:[[0,'SHIMMER',1],[16,'FRACTURE',1],[32,'DRIFT',0],[48,'REFRACT',1],[64,'PRISM',3],[80,'SURGE',2],[96,'CLARITY',1]],
204  chart:[[4,'double'],[7.5,'single'],[11,'stair'],[14,'triple'],[18,'ledge'],[21.5,'double'],[25,'stair'],[28,'pad'],[32,'ufo'],[36,'flight',270],[40,'flight',200],[44,'flight',280],[48,'cube'],[52,'double'],[55,'stair'],[59,'triple'],[64,'robot'],[67,'double'],[70.5,'stair'],[74,'triple'],[80,'ball'],[83,'double'],[86,'single'],[89,'triple'],[94,'restore'],[100,'double'],[103.5,'stair'],[107,'triple']],
205  coins:[[14.55,299],[59.55,299],[107.55,299]]},
206 {id:'low-tide',name:'LOW TIDE',tag:'Find your feet, then find your wings',bpm:120,key:57,speed:325,beats:112,difficulty:'EASY',stars:2,theme:3,trackId:'velvet-current',
207  cues:[[0,'FIRST STEPS',3],[16,'RIPPLE',3],[32,'TAKE FLIGHT',0],[48,'SHALLOWS',3],[64,'UNDERTOW',1],[80,'SWELL',3],[96,'OPEN WATER',0]],
208  chart:[[8,'single'],[12,'double'],[17.5,'single'],[20.5,'stair'],[24,'single'],[27.5,'double'],[32,'ship'],[36,'flight',250],[40,'flight',220],[44,'flight',270],[48,'cube'],[52,'ledge'],[57,'single'],[61,'stair'],[66,'single'],[70,'double'],[74,'ledge'],[80,'single'],[81.5,'double'],[84,'single'],[85.5,'double'],[88,'stair'],[91.5,'single'],[96,'ship'],[100,'flight',240],[104,'flight',280],[108,'flight',210]],
209  coins:[[12.55,299],[40.2,219],[104.2,279]]},
210 {id:'gravity-well',name:'GRAVITY WELL',tag:'Up is down. Down is up.',bpm:132,key:52,speed:340,beats:128,difficulty:'HARDER',stars:9,theme:1,trackId:'prism-break',
211  cues:[[0,'ORBIT',1],[16,'FLIP SIDE',3],[32,'ZERO G',0],[48,'DRIFT',1],[64,'SPIDERWEB',3],[80,'UPSIDE DOWN',2],[96,'EVENT HORIZON',3],[112,'ESCAPE VELOCITY',1]],
212  chart:[[4,'double'],[7,'stair'],[10.5,'triple'],[14,'double'],[16,'ball'],[18,'single'],[20,'topSingle'],[22,'double'],[24,'topDouble'],[26,'triple'],[28,'topTriple'],[30,'restore'],[32,'ship'],[35,'flight',220],[38,'flight',300],[41,'flight',200],[44,'flight',280],[48,'cube'],[50,'double'],[52,'ledge'],[55.5,'single'],[59,'double'],[64,'spider'],[66,'single'],[68,'topSingle'],[70,'double'],[72,'topDouble'],[74,'single'],[76,'topSingle'],[78,'restore'],[80,'invert'],[81.5,'topDouble'],[83,'topSingle'],[84.5,'topTriple'],[86,'topDouble'],[87.5,'topSingle'],[89,'topTriple'],[91,'restore'],[92.5,'double'],[94,'triple'],[96,'ball'],[97.5,'single'],[98.75,'topSingle'],[100,'double'],[101.25,'topDouble'],[102.5,'triple'],[103.75,'topTriple'],[105,'single'],[106.25,'topSingle'],[107.5,'double'],[108.75,'topDouble'],[110,'restore'],[112,'wave'],[115,'waveGap',230],[118,'waveGap',285],[121,'waveGap',215],[124,'cube'],[125.5,'triple']],
213  coins:[[38.2,300],[70.5,92],[102.8,92]]},
214 {id:'flipside',name:'FLIPSIDE',tag:'Every drop turns the world over',bpm:144,key:50,speed:355,beats:128,difficulty:'INSANE',stars:10,theme:2,trackId:'solar-fracture',
215  cues:[[0,'MAGNETIZE',2],[16,'CHARGE',2],[32,'TURBULENCE',1],[48,'HEAVY METAL',2],[64,'SPIN CYCLE',3],[80,'WEBBED',3],[96,'POLARITY STORM',0],[112,'FALLOUT',2]],
216  chart:[[4,'single'],[8,'double'],[10.5,'triple'],[13,'stair'],[16,'single'],[17.75,'double'],[19.5,'triple'],[21.5,'stair'],[23.5,'double'],[25.5,'ledge'],[28.5,'pad'],[32,'ship'],[34,'flight',200],[36,'flight',300],[38,'flight',180],[40,'flight',290],[42,'flight',210],[44,'flight',300],[46,'flight',200],[48,'robot'],[50,'single'],[52,'double'],[54,'triple'],[57,'ledge'],[60,'stair'],[64,'ball'],[65,'single'],[66.5,'topSingle'],[68,'double'],[69.5,'topDouble'],[71,'triple'],[72.5,'topTriple'],[74,'restore'],[76,'double'],[78,'stair'],[80,'spider'],[81,'single'],[82.75,'topSingle'],[84.5,'double'],[86.5,'topDouble'],[88.5,'single'],[90.25,'topSingle'],[92,'restore'],[96,'invert'],[97.25,'topSingle'],[99,'topTriple'],[100.5,'topSingle'],[102.25,'topTriple'],[103.8125,'restore'],[105,'single'],[106.75,'triple'],[108.25,'single'],[110,'triple'],[112,'invert'],[113.25,'topSingle'],[115,'topTriple'],[116.5,'topSingle'],[118.25,'topTriple'],[119.8125,'restore'],[121,'double'],[123,'stair'],[125.25,'triple']],
217  coins:[[20.1,299],[38.2,184],[99.5,197]]},
218 {id:'nova-cascade',name:'NOVA CASCADE',tag:'Everything you learned, all at once',bpm:144,key:50,speed:355,beats:128,difficulty:'DEMON',stars:14,theme:2,trackId:'solar-fracture',
219  cues:[[0,'COLLAPSE',2],[16,'FUSION',1],[32,'SHOCKWAVE',2],[48,'ORBITAL',3],[64,'SINGULARITY',0],[80,'ANTIMATTER',1],[96,'SUPERNOVA',2],[112,'AFTERSHOCK',0]],
220  chart:[[4,'single'],[8,'triple'],[9.75,'double'],[11.5,'stair'],[13.25,'triple'],[16,'robot'],[18,'double'],[20,'triple'],[22,'stair'],[24,'double'],[26,'triple'],[28,'single'],[32,'ship'],[36,'flight',250],[37.5,'flight',170],[39,'flight',300],[40.5,'flight',210],[42,'flight',290],[43.5,'flight',180],[45,'flight',260],[46.5,'flight',230],[48,'ball'],[50,'single'],[53,'topSingle'],[56,'double'],[59,'topDouble'],[62,'restore'],[64,'spider'],[66,'single'],[68,'topSingle'],[70,'double'],[72.25,'topDouble'],[74.5,'triple'],[77,'topTriple'],[79,'restore'],[80,'invert'],[82,'topTriple'],[83.75,'topDouble'],[85.5,'topSingle'],[87.25,'topTriple'],[89,'topDouble'],[90.75,'topTriple'],[92.5,'restore'],[96,'wave'],[100,'w
220aveGap',250],[101.5,'waveGap',200],[103,'waveGap',260],[104.5,'waveGap',210],[106,'waveGap',270],[107.5,'waveGap',220],[109,'waveGap',260],[110.5,'waveGap',230],[112,'cube'],[114,'triple'],[115.5,'double'],[117,'stair'],[118.75,'triple'],[120.25,'single'],[121.75,'double'],[123.25,'stair'],[125,'triple'],[126.5,'single']],
221  coins:[[20.7,266],[52,92],[125.7,299]]},
222 {id:'critter-crossing',name:'CRITTER CROSSING',tag:'Hop on their heads, keep the beat',bpm:120,key:57,speed:325,beats:112,difficulty:'NORMAL',stars:3,theme:0,trackId:'velvet-current',
223  cues:[[0,'FIRST CONTACT',0],[16,'PATROL',3],[32,'TAKE FLIGHT',0],[48,'BUZZ',3],[64,'PITCHERS',0],[80,'MIXER',3],[96,'BIG BOSS',0]],
224  chart:[[4,'single'],[8,'stomp'],[11.5,'double'],[14,'stomp'],[17,'stair'],[20,'patrol',64],[23,'single'],[26,'patrol',96],[29,'double'],
225   [32,'ship'],[36,'flight',250],[40,'flight',220],[44,'flight',270],[48,'cube'],[51,'flyer'],[54,'ledge'],[58.5,'flyer'],[62,'double'],
226   [65,'pitch'],[68,'single'],[71,'pitch'],[74,'stair'],[77,'pitch'],[81,'stomp'],[84,'double'],[86.5,'patrol',80],[89.5,'flyer'],[92.5,'double'],
227   [96,'boss',2,'..r.h.D...r.r.D.']],
228  coins:[[40.2,219],[59.99,258],[111.55,254]]}
229];
230/** Single source of truth for how many official stages ship; every other stage count derives from it. */
231const OFFICIAL_STAGE_COUNT=DEFINITIONS.length;
232/** Menu position is presentation only: saves, replays, official:N and content hashes stay keyed by DEFINITIONS index. */
233const MENU_ORDER=[4,8,0,3,1,2,5,6,7];
234/** The stage index one menu step (dir +1 next, -1 previous) away from index, wrapping at either end. */
235function nextIndex(order,index,dir){const p=order.indexOf(index);if(p<0)throw Error('Unknown official stage '+index);return order[(p+dir+order.length)%order.length];}
236/** Enemy chart verbs (stage 9+ only; stages 1-8 never reach them). Each places its enemy so that a
237 *  press ON beat b puts the running player over the stomp target FOE_AIM seconds later: the chart
238 *  beat is 52-76ms before the stomp window closes and 152-228ms after it opens (tests/enemy.test.mjs
239 *  measures every kind), and a late press still clears or bonks. tests/solver.mjs aims with the same
240 *  constants, so the rehearsal presses on the beat. */
241const ENEMY_KINDS=['walker','flyer','thrower','boss'],ENEMY_VERBS=['stomp','patrol','flyer','pitch','boss'];
242const FOE_AIM={walker:.49,patrol:.515,flyer:.44,thrower:.49,boss:BOSS_AIM},FOE_BALL_MEET=.3,FOE_FLYER_BOB_AT_AIM=.9;
243/** A walker on patrol closes faster, which narrows its stomp window: it is aimed 25ms further ahead. */
244const foeAim=o=>o.kind==='walker'&&o.leg?FOE_AIM.patrol:FOE_AIM[o.kind];
245function enemyMacro(type,b,p,q,d){
246 const beat=60/d.bpm,v=d.speed,xb=b*beat*v,mod=(u,m)=>(u%m+m)%m,[ww,wh]=ENEMY_BODY.walker;
247 if(type==='stomp')return [{type:ENEMY,kind:'walker',x:xb+v*FOE_AIM.walker-ww/2,y:FLOOR-wh,w:ww,h:wh,bw:ww,bh:wh}];
248 // Mid-sweep and walking toward the player at the aim moment: triangle phase 1.5 of 2.
249 if(type==='patrol'){const span=p??96,leg=q??2,mid=xb+v*FOE_AIM.patrol-ww/2;
250  return [{type:ENEMY,kind:'walker',x:mid-span/2,y:FLOOR-wh,w:span+ww,h:wh,bw:ww,bh:wh,leg,phase:mod(1.5*leg-(b+FOE_AIM.patrol/beat),2*leg)}];}
251 // Rising out of the bottom of its bob at the aim moment, so it comes up to meet the stomp.
252 if(type==='flyer'){const [fw,fh]=ENEMY_BODY.flyer;
253  return [{type:ENEMY,kind:'flyer',x:xb+v*FOE_AIM.flyer-fw/2,y:FLOOR-2-fh-2*FLYER_AMP,w:fw,h:fh+2*FLYER_AMP,bw:fw,bh:fh,bob:2,phase:mod(2*FOE_FLYER_BOB_AT_AIM-(b+FOE_AIM.flyer/beat),2)}];}
254 // Hop the pitch: the thrower winds up on screen (one beat), throws on the half beat before b and the
255 // ball meets the player FOE_BALL_MEET after the press, under the same jump that lands on the thrower.
256 if(type==='pitch'){const [tw,th]=ENEMY_BODY.thrower,x0=xb+v*FOE_AIM.thrower-tw/2,tb=b*beat+FOE_BALL_MEET,launch=(b-.5)*beat;
257  return [{type:ENEMY,kind:'thrower',x:x0,y:FLOOR-th,w:tw,h:th,bw:tw,bh:th,shot:{style:'roll',at:b-.5,every:4,life:(tb-launch+.8)/beat,speed:(x0-v*tb)/(tb-launch),r:8,count:1}}];}
258 if(type==='boss'){const [bw,bh]=ENEMY_BODY.boss;return [{type:ENEMY,kind:'boss',x:xb,y:ROOF,w:q.length*beat*v,h:FLOOR-ROOF,bw,bh,hp:p,pattern:q}];}
259 return [];
260}
261function levelData(index=0){
262 const d=DEFINITIONS[index];if(!d)throw Error('Unknown official stage '+index);const beat=60/d.bpm;
263 const l={...d,index,beat,length:d.beats*beat*d.speed,duration:d.beats*beat,hasRoof:true,gaps:[],objects:[],decor:[],cues:d.cues.map(([b,name,theme])=>({b,t:b*beat,x:b*beat*d.speed,name,theme}))};
264 const x=b=>b*beat*d.speed;
265 let serial=0;
266 const add=o=>{const ob={id:serial++,...o};l.objects.push(ob);return ob;};
267 const spike=(xx,flip=false)=>add({type:'spike',x:xx,y:flip?ROOF:FLOOR-34,w:34,h:34,flip});
268 const block=(xx,w,h,y=FLOOR-h)=>add({type:'block',x:xx,y,w,h});
269 const portal=(b,props)=>add({type:'portal',x:x(b)+SIZE/2,y:ROOF,h:FLOOR-ROOF,w:36,...props});
270 for(const [b,type,center,extra]of d.chart){
271  const xx=x(b);
272  if(ENEMY_VERBS.includes(type))for(const o of enemyMacro(type,b,center,extra,d))add(o);
273  if(type==='single')spike(xx+83);
274  if(type==='double'){spike(xx+68);spike(xx+102);}
275  if(type==='triple'){spike(xx+56);spike(xx+90);spike(xx+124);}
276  if(type==='stair'){block(xx+69,40,35);block(xx+109,52,68);}
277  if(type==='ledge'){add({type:'slope',x:xx+76,y:FLOOR-52,w:90,h:52});block(xx+166,56,52);add({type:'slope',x:xx+222,y:FLOOR-52,w:90,h:52,reverse:true});l.decor.push({x:xx+92,y:FLOOR-86,type:'chevron'});}
278  if(type==='pad'){add({type:'pad',x:xx,y:FLOOR-8,w:38,h:9});block(xx+138,62,112);}
279  if(['ship','wave','cube','ufo','robot','ball','spider'].includes(type)){
280   // Enter flying modes physically in mid-air, not by teleporting or requiring
281   // a near-instant input to avoid a ground collision after the mode switch.
282   if(['ship','wave','ufo'].includes(type))add({type:'pad',x:x(b-.5),y:FLOOR-8,w:38,h:9});
283   portal(b,{mode:type,label:type.toUpperCase()});
284  }
285  if(type==='invert')portal(b,{gravity:-1,mode:'cube',label:'GRAV
285ITY'});
286  if(type==='restore')portal(b,{gravity:1,mode:'cube',label:'RESTORE'});
287  if(type==='topSingle')spike(xx+83,true);
288  if(type==='topDouble'){spike(xx+68,true);spike(xx+102,true);}
289  if(type==='topTriple'){spike(xx+56,true);spike(xx+90,true);spike(xx+124,true);}
290  if(type==='flight'||type==='waveGap'){
291   const gap=type==='waveGap'?132:110,top=center-gap,bottom=center+gap;
292   if(top>ROOF+8)block(xx,65,top-ROOF,ROOF);
293   if(bottom<FLOOR-8)block(xx,65,FLOOR-bottom,bottom);
294   l.decor.push({x:xx+32,y:center,type:'guide'});
295  }
296 }
297 for(const[b,y]of d.coins)add({type:'coin',x:x(b),y,r:15,w:30,h:30,coin:d.coins.findIndex(z=>z[0]===b)});
298 // Scenic props never collide: composition follows the authored phrases.
299 for(let b=0;b<d.beats;b+=4){const cue=[...l.cues].reverse().find(c=>c.b<=b);
300  l.decor.push({type:(b/4)%3?'monolith':'gear',x:x(b)+80,y:100+Math.sin(b*.6)*30,h:140+(b%16)*9,theme:cue.theme});
301 }
302 l.objects.sort((a,b)=>a.x-b.x);return l;
303}
304function cueAt(level,x){let c=level.cues[0];for(const next of level.cues){if(next.x>x)break;c=next;}return c;}
305const MODES=['cube','ship','wave','ufo','robot','ball','spider'];
306const TRACKS=['prism-break','velvet-current','solar-fracture'];
307function emptyLevel(){
308 const l=levelData(0);return {...l,id:'workshop',trackId:'prism-break',name:'MY FIRST LEVEL',objects:[],decor:[],beats:64,length:l.speed*l.beat*64,duration:l.beat*64,custom:true,startMode:'cube',cues:[{b:0,t:0,x:0,name:'YOUR CREATION',theme:0}]};
309}
310function exportLevel(level){return {format:'geometrylite-rush/2',name:level.name,bpm:level.bpm,beats:level.beats,speed:level.speed,theme:level.cues[0].theme,startMode:level.startMode||'cube',trackId:level.trackId||'prism-break',objects:level.objects};}
311/** Enemy fields are copied one by one, each finite and bounded; anything else is dropped. One body
312 *  size and one lane per kind, so a silhouette always means the same timing. */
313function enemyFields(o,l){
314 const n=(v,min,max,what)=>{if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)throw Error('Invalid enemy '+what);return v;},near=(a,b)=>Math.abs(a-b)<1e-6;
315 if(!ENEMY_KINDS.includes(o.kind))throw Error('Invalid enemy kind');
316 const [bw,bh]=ENEMY_BODY[o.kind],v={kind:o.kind,bw,bh};
317 if(o.bw!==bw||o.bh!==bh)throw Error('Each enemy kind has one body size');
318 if(o.kind==='boss'){
319  if(typeof o.pattern!=='string'||!/^[.rhD]{4,32}$/.test(o.pattern))throw Error('Boss pattern uses . r h D (4-32 beats)');
320  // The arena width is derived, not read, so it is bounded here: in beats, and multiplied in the order the level length is
321  // ((beats*beat)*speed). Rounding is monotonic, so an arena that fits never overshoots by an ulp and a saved level parses again.
322  if(o.pattern.length>l.beats)throw Error('Boss pattern is longer than the level');
323  const acts=[...o.pattern].map((c,j)=>[c,j]).filter(([c])=>c==='r'||c==='D');
324  for(let i=1;i<acts.length;i++)if(acts[i][1]-acts[i-1][1]<(acts[i-1][0]!=='D'?2:acts[i][0]==='D'?4:3))throw Error('Boss presses need 2 beats of air (3 after a dive, 4 between dives)');
325  for(const [c,j] of [...o.pattern].map((c,j)=>[c,j]))if(c==='h'&&(o.pattern[j-1]==='r'||o.pattern[j-1]==='D'||o.pattern[j+1]==='r'||o.pattern[j+1]==='D'))throw Error('A high shot needs a rest beat on both sides');
326  v.hp=n(o.hp,1,8,'hp');if(!Number.isInteger(v.hp)||acts.filter(([c])=>c==='D').length<v.hp)throw Error('Boss needs at least hp dives');
327  v.pattern=o.pattern;return {...v,y:ROOF,h:FLOOR-ROOF,w:o.pattern.length*l.beat*l.speed};
328 }
329 if(o.kind==='flyer'){if(!near(o.h,bh+2*FLYER_AMP)||!near(o.y+o.h,FLOOR-2))throw Error('A flyer bobs on its own lane');}
330 else if(!near(o.h,bh)||!near(o.y+o.h,FLOOR))throw Error('Walkers and throwers stand on the floor');
331 if(o.w<bw-1e-6)throw Error('Enemy body exceeds its area');
332 if(o.w-bw>320)throw Error('Patrol span is at most 320');
333 if(o.kind==='thrower'&&o.w>bw+1e-6)throw Error('Throwers stand still');
334 if(o.leg!==undefined){v.leg=n(o.leg,1,16,'patrol beats');if((o.w-bw)/(v.leg*l.beat)>200)throw Error('Patrol too fast to read');}
335 else if(o.w>bw+1e-6)throw Error('A patrol needs beats per leg');
336 if(o.phase!==undefined)v.phase=n(o.phase,-64,64,'phase');
337 if(o.kind==='flyer')v.bob=n(o.bob??2,1,8,'bob');
338 if(o.kind==='thrower'){const s=o.shot;if(!s||!['roll','high'].includes(s.style))throw Error('A thrower needs a roll or high shot');
339  v.shot={style:s.style,at:n(s.at,-64,128,'shot start'),every:n(s.every,1,16,'shot beats'),life:n(s.life,.5,16,'shot life'),speed:n(s.speed,20,520,'shot speed'),r:n(s.r??8,6,12,'shot size')};
340  if(v.shot.life/v.shot.every>8)throw Error('At most eight shots in the air');
341  if(s.count!==undefined){v.shot.count=n(s.count,1,64,'shot count');if(!Number.isInteger(v.shot.count))throw Error('Invalid enemy shot count');}}
342 else if(o.shot!==undefined)throw Error('Only throwers throw');
343 return v;
344}
345/** Editor property edits for one enemy. Pure: returns a new object, and parseLevel still judges it. */
346function enemyEdit(o,key,value,level){
347 const beat=60/level.bpm,base={id:o.id,type:ENEMY,x:o.x},body=k=>{const [bw,bh]=ENEMY_BODY[k];return {bw,bh};};
348 if(key==='kind'){
349  if(value==='flyer'){const b=body('flyer');return {...base,kind:value,y:FLOOR-2-b.bh-2*FLYER_AMP,w:b.bw,h:b.bh+2*FLYER_AMP,...b,bob:2};}
350  if(value==='thrower'){const b=body('thrower');return {...base,kind:value,y:FLOOR-b.bh,w:b.bw,h:b.bh,...b,shot:{style:'roll',at:0,every:4,life:4,speed:200,r:8}};}
351  if(value==='boss'){const b=body('boss');return {...base,kind:value,y:ROOF,w:16*beat*level.speed,h:FLOOR-ROOF,...b,hp:2,pattern:'..r.h.D...r.r.D.'};}
352  const b=body('walker');return {...base,kind:'walker',y:FLOOR-b.bh,w:b.bw,h:b.bh,...b};
353 }
354 if(key==='span'){const {leg,...rest}=o,span=+value;return span>0?{...rest,w:o.bw+span,leg:leg??2}:{...rest,w:o.bw};}
355 if(key==='shot'&&o.shot)return {...o,shot:{...o.shot,style:value}};
356 if(key==='every'&&o.shot)return {...o,shot:{...o.shot,every:+value,life:Math.min(o.shot.life,+value*8)}};
357 if(key==='hp')return {...o,hp:+value};
358 if(key==='pattern')return {...o,pattern:String(value).slice(0,32)};
359 return o;
360}
361/** Imports contain data only. Every numeric field is finite and bounded. */
362function parseLevel(data){
363 if(!data||data.format!=='geometrylite-rush/2'||!Array.isArray(data.objects)||data.objects.length>1500)throw Error('Invalid level file (maximum 1,500 objects)');
364 const l=emptyLevel();l.name=String(data.name||'MY FIRST LEVEL').replace(/[<>]/g,'').slice(0,32);
365 const number=(key,min,max,fallback)=>{if(data[key]===undefined)return fallback;if(typeof data[key]!=='number'||!Number.isFinite(data[key])||data[key]<min||data[key]>max)throw Error('Invalid '+key);return data[key];};
366 l.bpm=number('bpm',80,180,132);l.beats=number('beats',16,128,64);l.speed=number('speed',180,520,340);l.beat=60/l.bpm;l.duration=l.beats*l.beat;l.length=l.duration*l.speed;
367 l.startMode=data.startMode||'cube';if(!MODES.includes(l.startMode))throw Error('Invalid starting mode');
368 l.trackId=data.trackId||'prism-break';if(![...TRACKS,'local'].includes(l.trackId))throw Error('Only supplied or local music can be selected');
369 const theme=number('theme',0,3,0);if(!Number.isInteger(theme))throw Error('Invalid theme');l.cues=[{b:0,t:0,x:0,name:'YOUR CREATION',theme}];let coins=0,foes=0,bosses=0;
370 l.objects=data.objects.map((o,id)=>{
371  if(!o||!['block','spike','saw','coin','pad','orb','portal','slope',ENEMY].includes(o.type))throw Error('Unsupported object');
372  for(const n of ['x','y','w','h'])if(typeof o[n]!=='number'||!Number.isFinite(o[n]))throw Error('Invalid coordinates');
373  // A boss's w, y and h are derived from its pattern and the level (enemyFields), so its stored w, which a tempo or speed
374  // edit makes stale, is not held to a cap here; enemyFields bounds the pattern by the level's beats instead.
375  if(o.x<100||o.x>l.length-40||o.y<ROOF-100||o.y>FLOOR+80||o.w<1||(o.w>400&&!(o.type===ENEMY&&o.kind==='boss'))||o.h<1||o.h>400)throw Error('Object outside editing bounds');
376  const v={id,type:o.type,x:o.x,y:o.y,w:o.w,h:o.h,r:20,flip:!!o.flip,reverse:!!o.reverse};
377  if(o.r!==undefined){if(!Number.isFinite(o.r)||o.r<8||o.r>60)throw Error('Invalid radius');v.r=o.r;}
378  if(o.alpha!==undefined){if(!Number.isFinite(o.alpha)||o.alpha<.25||o.alpha>1)throw Error('Opacity must keep hazards visible');v.alpha=o.alpha;}
379  if(o.motion){for(const k of ['range','speed','phase'])if(!Number.isFinite(o.motion[k]))throw Error('Invalid motion');if(o.motion.range<0||o.motion.range>100||o.motion.speed<.1||o.motion.speed>8)throw Error('Unsafe motion range');v.motion={range:o.motion.range,speed:o.motion.speed,phase:o.motion.phase};}
380  if(o.type==='coin'){if(coins>=3)throw Error('A level supports three unique coins');v.coin=coins++;}
381  if(o.type===ENEMY){if(o.motion)throw Error('Enemies move by patrol, not motion');if(++foes>64)throw Error('A level supports 64 enemies');Object.assign(v,enemyFields(o,l));if(v.kind==='boss'&&++bosses>1)throw Error('A level supports one boss');}
382  if(o.type==='portal'){
383   if(o.mode){if(!MODES.includes(o.mode))throw Error('Invalid mode');v.mode=o.mode;}
384   if(o.gravity!==undefined){if(![1,-1].includes(o.gravity))throw Error('Invalid gravity');v.gravity=o.gravity;}
385   if(o.speed!==undefined){if(!Number.isFinite(o.speed)||o.speed<180||o.speed>520)throw Error('Invalid portal speed');v.speed=o.speed;}
386   if(o.size!==undefined){if(![.6,1].includes(o.size))throw Error('Invalid size');v.size=o.size;}
387   if(!v.mode&&!v.gravity&&!v.speed&&!v.size)throw Error('Select a portal action');
388   v.label=v.mode?.toUpperCase()|| (v.gravity?'GRAVITY':v.speed?'SPEED':'SIZE');
389  }
390  return v;
391 }).sort((a,b)=>a.x-b.x);
392 // The boss times its attacks by music time, so the player must still be at level speed in its arena.
393 const boss=l.objects.find(o=>o.type===ENEMY&&o.kind==='boss');
394 if(boss&&l.objects.some(o=>o.type==='portal'&&o.speed!==undefined&&o.x<boss.x+boss.w))throw Error('A boss arena keeps the level speed: move speed portals after it');
395 return l;
396}
397
398
399// collision.mjs
400
401/**
402 * Solid objects may not share space: a stack is not a shape the physics in core.mjs can
403 * resolve, and it is not a shape a player can read. Pure geometry, no DOM, no storage —
404 * the server imports this beside levels.mjs to judge a submission the same way the editor
405 * judges a draft.
406 */
407// Triggers (pad, orb, coin, portal) are meant to be entered, and portals deliberately span
408// the full height, so only the four that stop or kill the player are judged here.
409const SOLID_TYPES=['block','slope','spike','saw'];
410
411// core.mjs judges a spike by the triangle inside its box, a saw by a circle of r*.84 and a
412// block or slope by its box. An editor rule works on the space an object claims, not on the
413// smaller shape that kills: the bounding box of what is drawn is what must not be shared.
414// A moving object claims every row it passes through: core.mjs's shapeAt offsets y by
415// sin()*range, so the sweep reaches `range` either side of where the object is authored.
416function objectBounds(o){
417 const range=o.motion?.range>0?o.motion.range:0;
418 if(o.type==='saw'||o.type==='orb'||o.type==='coin'){const r=o.r??0;return {x:o.x-r,y:o.y-r-range,w:r*2,h:r*2+range*2};}
419 return {x:o.x,y:o.y-range,w:o.w??0,h:(o.h??0)+range*2};
420}
421// A slope is a wedge, not a box. core.mjs walks its surface as y + h*(reverse?t:1-t) and
422// treats only what is under that line, down to the base, as solid; the air above the ramp is
423// exactly where an author wants to put a spike. Monotonic, so one end of the shared span is
424// the highest the surface gets there. A moving slope has no editor control, so it falls back.
425function clearsRamp(slope,box,eps){
426 if(slope.motion?.range>0)return false;
427 const at=x=>{const t=clamp((x-slope.x)/slope.w,0,1);return slope.y+slope.h*(slope.reverse?t:1-t);};
428 const surface=Math.min(at(Math.max(slope.x,box.x)),at(Math.min(slope.x+slope.w,box.x+box.w)));
429 return !(box.y+box.h-eps>surface&&box.y+eps<slope.y+slope.h);
430}
431// An enemy claims its whole patrol/bob sweep (x,y,w,h), so objectBounds' default box covers it; a
432// boss arena is a stretch of music, not a body.
433const solid=o=>!!o&&(SOLID_TYPES.includes(o.type)||(o.type==='enemy'&&o.kind!=='boss'));
434// Strictly: two objects placed edge to edge on the same 24px grid line share a boundary and
435// no area, which is exactly how a floor of blocks is built.
436function overlaps(a,b,eps=0.01){
437 const p=objectBounds(a),q=objectBounds(b);
438 if(!(p.x+p.w-eps>q.x&&q.x+q.w-eps>p.x&&p.y+p.h-eps>q.y&&q.y+q.h-eps>p.y))return false;
439 if(a.type==='slope'&&b.type!=='slope')return !clearsRamp(a,q,eps);
440 if(b.type==='slope'&&a.type!=='slope')return !clearsRamp(b,p,eps);
441 return true;
442}
443/** Every solid pair sharing space, as {a,b} object ids, in object order. */
444function findOverlaps(objects){
445 const solids=(objects||[]).filter(solid),found=[];
446 for(let i=0;i<solids.length;i++)for(let j=i+1;j<solids.length;j++)
447  if(overlaps(solids[i],solids[j]))found.push({a:solids[i].id,b:solids[j].id});
448 return found;
449}
450/**
451 * A solid dropped on another is nearly always meant to sit beside it: the official stages
452 * build their double and triple spikes edge to edge, and a 34-wide spike cannot reach its
453 * neighbour on a 24px grid. Slide it flush with whichever side is nearer, but never further
454 * than one grid step, so a drop deep inside another object is still the mistake it looks like.
455 * Returns the settled candidate, the original if it never overlapped, or null if nothing fits.
456 */
457function snapClear(objects,candidate,ignoreId,limit=24){
458 if(!collides(objects,candidate,ignoreId))return candidate;
459 const p=objectBounds(candidate),deltas=[];
460 for(const o of objects||[]){
461  if(!solid(o)||o.id===ignoreId||o===candidate||!overlaps(o,candidate))continue;
462  const q=objectBounds(o);deltas.push(q.x-(p.x+p.w),q.x+q.w-p.x);
463 }
464 for(const d of deltas.sort((a,b)=>Math.abs(a)-Math.abs(b))){
465  if(!d||Math.abs(d)>limit)continue;
466  const moved={...candidate,x:candidate.x+d};
467  if(!collides(objects,moved,ignoreId))return moved;
468 }
469 return null;
470}
471/** Would `candidate` land on top of something? `ignoreId` is the object it replaces. */
472function collides(objects,candidate,ignoreId){
473 if(!solid(candidate))return false;
474 return (objects||[]).some(o=>solid(o)&&o.id!==ignoreId&&o!==candidate&&overlaps(o,candidate));
475}
476/**
477 * Shots fly through nothing, so nothing may stand in their way: a thrower's lane runs from its hand
478 * to where its shot expires, at the shot's height; a boss arena runs from its first beat to its hover
479 * point past the last one. Every solid (enemy sweeps included) crossing a lane is reported as {a,b}
480 * (enemy id, solid id). Pure geometry for the editor and the server alike.
481 */
482function shotLane(o,level){
483 const beat=60/level.bpm;
484 if(o?.type==='enemy'&&o.kind==='thrower'&&o.shot){const r=o.shot.r||8,len=o.shot.speed*o.shot.life*beat,y=o.shot.style==='high'?FLOOR-60:FLOOR-r;return {x:o.x-len-r,y:y-r,w:len+r,h:2*r};}
485 if(o?.type==='enemy'&&o.kind==='boss')return {x:o.x,y:ROOF,w:o.w+BOSS_LEAD,h:FLOOR-ROOF};
486 return null;
487}
488const crosses=(a,b,eps=.01)=>a.x+a.w-eps>b.x&&b.x+b.w-eps>a.x&&a.y+a.h-eps>b.y&&b.y+b.h-eps>a.y;
489function findShotConflicts(objects,level){
490 const all=objects||[],found=[];
491 for(const o of all){const lane=shotLane(o,level);if(!lane)continue;
492  for(const s of all)if(s!==o&&s.id!==o.id&&solid(s)&&crosses(lane,objectBounds(s)))found.push({a:o.id,b:s.id});}
493 return found;
494}
495/** Would placing `candidate` (replacing ignoreId) put a solid in a shot lane, or a lane through a solid? */
496function blocksShots(objects,candidate,ignoreId,level){
497 const next=[...(objects||[]).filter(o=>o.id!==ignoreId&&o!==candidate),candidate];
498 return findShotConflicts(next,level).some(p=>p.a===candidate.id||p.b===candidate.id||(candidate.id===undefined&&(p.a===undefined||p.b===undefined)));
499}
500
501
502// progression.mjs
503/** Cosmetic progression derived from the existing per-stage records and jump count.
504 *  Nothing here is competitive: it never talks to the platform, adds no save fields,
505 *  and cannot affect records or verified runs. Cloud merges are high-water, so a
506 *  derived unlock can only ever grow across devices. */
507
508const FREE_COLORS=[0,1,2], FREE_ICONS=[0];
509const coinCount=mask=>(mask&1?1:0)+(mask&2?1:0)+(mask&4?1:0);
510function progressSummary(store){
511 const stats=store?.stats||[];let cleared=0,stars=0,coins=0,fullCoins=0;
512 for(let i=0;i<OFFICIAL_STAGE_COUNT;i++){const s=stats[i]||{};if(s.best===100){cleared++;stars+=levelData(i).stars;}const c=coinCount(s.coins|0);coins+=c;if(c===3)fullCoins++;}
513 return {cleared,stars,coins,fullCoins,jumps:store?.jumps|0,stages:OFFICIAL_STAGE_COUNT};
514}
515/* One cosmetic per rule, unlocked by a single summary metric. Order is display order. */
516const UNLOCKS=[
517 {kind:'color',index:3,metric:'cleared',need:1,label:'CLEAR 1 STAGE'},
518 {kind:'color',index:4,metric:'coins',need:3,label:'COLLECT 3 COINS'},
519 {kind:'icon',index:1,metric:'jumps',need:500,label:'500 JUMPS'},
520 {kind:'color',index:5,metric:'cleared',need:2,label:'CLEAR 2 STAGES'},
521 {kind:'color',index:6,metric:'stars',need:12,label:'EARN 12 STARS'},
522 {kind:'icon',index:2,metric:'cleared',need:3,label:'CLEAR 3 STAGES'},
523 {kind:'color',index:7,metric:'fullCoins',need:2,label:'ALL COINS ON 2 STAGES'},
524];
525function unlocks(store){
526 const summary=progressSummary(store),colors=new Set(FREE_COLORS),icons=new Set(FREE_ICONS),pending=[];
527 for(const rule of UNLOCKS){const have=summary[rule.metric];if(have>=rule.need)(rule.kind==='color'?colors:icons).add(rule.index);else pending.push({...rule,have});}
528 return {summary,colors,icons,pending};
529}
530
531
532// level-bridge.mjs
533
534/** External levels arrive as untrusted data. Nothing here reads or writes storage. */
535const LOAD_LEVEL_TYPE='overdrive:load-level';
536const LOAD_RESULT_TYPE='overdrive:load-level-result';
537// A sandboxed frame without allow-top-navigation cannot reach the community page itself, so
538// it asks its host to do it. Silence means no host is listening, not that it worked.
539const OPEN_COMMUNITY_TYPE='overdrive:open-community';
540const OPEN_COMMUNITY_RESULT_TYPE='overdrive:open-community-result';
541const OPEN_COMMUNITY_TIMEOUT_MS=400;
542const NAVIGATION_CHECK_MS=300;
543function parseLoadRequest(message){
544 if(!message||typeof message!=='object'||Array.isArray(message)||message.type!==LOAD_LEVEL_TYPE)throw Error('Invalid level load request');
545 const practice=message.practice===undefined?false:message.practice;
546 if(typeof practice!=='boolean')throw Error('Invalid practice flag in the level load request');
547 return {level:parseLevel(message.level),practice};
548}
549
550
551// run-replay.mjs
552/** Minimal, bounded input protocol shared by the browser and Node verifier.
553 * tick is zero-based, applied BEFORE World.step(..., DT); ticks is a count.
554 * Same-tick edges keep array order. There is no client state restoration.
555 */
556
557
558const INPUT_FORMAT_VERSION='overdrive-input-v1';
559// SHA-256 of the exact core.mjs bytes. CI rejects drift without a version change.
560const SIMULATION_VERSION='2d6402b40c492930a342f06e4c65b52a43ad5600b0399d40e3efc7d4fd8d54b4';
561const REPLAY_RULESET='overdrive-normal-240-v1';
562const REPLAY_VERIFIER='deterministic-replay-v1';
563const MAX_REPLAY_TICKS=28800; // 120 seconds, well above shipped stages.
564const MAX_INPUT_EVENTS=8192;
565const MAX_EDGES_PER_TICK=4;
566class ReplayError extends Error {
567  constructor(code,message=code){super(message);this.name='ReplayError';this.code=code;}
568}
569const replayError=code=>{throw new ReplayError(code);};
570function replayObject(value,keys,code){
571  if(!value||typeof value!=='object'||Array.isArray(value))replayError(code);
572  const actual=Object.keys(value);
573  if(actual.length!==keys.length||actual.some(k=>!keys.includes(k)))replayError(code);
574  return value;
575}
576function replayInt(value,min,max,code){
577  if(!Number.isSafeInteger(value)||value<min||value>max)replayError(code);
578  return value;
579}
580// The editor export omits several World inputs. Bind those explicitly as well.
581function replayLevelContent(level){
582  return {format:'overdrive-replay-level/1',level:exportLevel(level),physics:{
583    length:level.length,hasRoof:!!level.hasRoof,gaps:level.gaps||[],
584    startGravity:level.startGravity||1,startSize:level.startSize||1}};
585}
586function ticksToMs(ticks){return Math.round(ticks*1000/240);}
587function parseReplay(value){
588  const p=replayObject(value,['inputFormatVersion','simulationVersion','ticks','events','claimed'],'REPLAY_SHAPE');
589  if(p.inputFormatVersion!==INPUT_FORMAT_VERSION)replayError('INPUT_VERSION_UNSUPPORTED');
590  if(p.simulationVersion!==SIMULATION_VERSION)replayError('SIMULATION_VERSION_UNSUPPORTED');
591  const ticks=replayInt(p.ticks,1,MAX_REPLAY_TICKS,'REPLAY_TICK_LIMIT');
592  if(!Array.isArray(p.events)||p.events.length>MAX_INPUT_EVENTS)replayError('REPLAY_EVENT_LIMIT');
593  let previous=-1,held=false,atTick=0;
594  const events=[];
595  for(const raw of p.events){
596    const e=replayObject(raw,['tick','action'],'REPLAY_EVENT_SHAPE');
597    const tick=replayInt(e.tick,0,ticks-1,'REPLAY_EVENT_TICK');
598    if(tick<previous)replayError('REPLAY_EVENT_ORDER');
599    atTick=tick===previous?atTick+1:1;
600    if(atTick>MAX_EDGES_PER_TICK)replayError('REPLAY_EDGE_LIMIT');
601    if(e.action!=='press'&&e.action!=='release')replayError('REPLAY_EVENT_ACTION');
602    const down=e.action==='press';
603    if(down===held)replayError('REPLAY_REDUNDANT_EDGE');
604    held=down;previous=tick;events.push({tick,action:e.action});
605  }
606  const c=replayObject(p.claimed,['completed','durationMs','coins'],'REPLAY_CLAIM_SHAPE');
607  if(typeof c.completed!=='boolean')replayError('REPLAY_CLAIM_COMPLETED');
608  const durationMs=replayInt(c.durationMs,0,ticksToMs(MAX_REPLAY_TICKS),'REPLAY_CLAIM_DURATION');
609  const coins=replayInt(c.coins,0,7,'REPLAY_CLAIM_COINS');
610  return {inputFormatVersion:INPUT_FORMAT_VERSION,simulationVersion:SIMULATION_VERSION,ticks,events,
611    claimed:{completed:c.completed,durationMs,coins}};
612}
613/** Runs only a trusted server-selected level, never an uploaded level/snapshot.
614 * The first death/win is terminal: no suffix ticks or post-terminal events.
615 * Render clocks, audio clocks, RNG and device frame rate are not inputs.
616 */
617function replayInputs(level,value){
618  const p=parseReplay(value),world=new World(level);
619  let edge=0,held=false,terminalTick=0;
620  for(let tick=0;
620tick<p.ticks;tick++){
621    let pressed=false;
622    while(edge<p.events.length&&p.events[edge].tick===tick){
623      const down=p.events[edge++].action==='press';
624      pressed=pressed||(down&&!held);held=down;
625    }
626    world.step(held,pressed,DT);
627    terminalTick=tick+1;
628    if(world.dead||world.won){
629      if(terminalTick!==p.ticks)replayError('REPLAY_AFTER_TERMINAL');
630      break;
631    }
632  }
633  if(!world.dead&&!world.won)replayError('REPLAY_NOT_TERMINAL');
634  if(edge!==p.events.length)replayError('REPLAY_UNCONSUMED_EVENTS');
635  const result={completed:world.won&&!world.dead,terminal:world.dead?'death':'finish',
636    terminalTick,durationMs:ticksToMs(terminalTick),coins:world.coins,score:0};
637  if(p.claimed.completed!==result.completed)replayError('REPLAY_COMPLETION_MISMATCH');
638  if(p.claimed.durationMs!==result.durationMs)replayError('REPLAY_DURATION_MISMATCH');
639  if(p.claimed.coins!==result.coins)replayError('REPLAY_COINS_MISMATCH');
640  return result;
641}
642/** Captures the input actually consumed by a physics tick, not DOM timestamps.
643 * Canonical edges preserve held + any rising edge, including quick taps.
644 * Overflows/invalidation never silently truncate a supposedly verified run.
645 */
646class InputRecorder {
647  constructor(){this.events=[];this.ticks=0;this.held=false;this.invalid=null;this.finished=false;}
648  invalidate(reason='RUN_INTERRUPTED'){this.invalid ||= reason;}
649  capture(tick,held,pressed){
650    if(this.invalid||this.finished)return;
651    if(tick!==this.ticks||typeof held!=='boolean'||typeof pressed!=='boolean'||tick>=MAX_REPLAY_TICKS){this.invalidate('RECORDER_TICK_MISMATCH');return;}
652    const edges=[];
653    let current=this.held;
654    const add=down=>{edges.push({tick,action:down?'press':'release'});current=down;};
655    // A rising edge is not necessarily reflected in final held (tap within tick).
656    if(pressed){if(current)add(false);add(true);if(!held)add(false);}
657    else if(held!==current){
658      if(held){this.invalidate('RECORDER_MISSING_PRESS');return;}
659      add(false);
660    }
661    if(this.events.length+edges.length>MAX_INPUT_EVENTS){this.invalidate('REPLAY_EVENT_LIMIT');return;}
662    this.events.push(...edges);this.held=held;this.ticks++;
663  }
664  finish({completed,coins}){
665    if(this.invalid)replayError(this.invalid);
666    if(this.finished)replayError('RECORDER_ALREADY_FINISHED');
667    const p=parseReplay({inputFormatVersion:INPUT_FORMAT_VERSION,simulationVersion:SIMULATION_VERSION,
668      ticks:this.ticks,events:this.events,claimed:{completed,durationMs:ticksToMs(this.ticks),coins}});
669    this.finished=true;
670    return p;
671  }
672}
673
674
675// run-client.mjs
676/** Opt-in bridge. Anonymous/offline/practice/watch/custom play stays local.
677 * Never writes nonce/evidence to localStorage or changes cloud/local rewards.
678 */
679
680class RunClient {
681  constructor({enabled=false,fetcher=globalThis.fetch?.bind(globalThis),timeoutMs=4000}={}){
682    this.enabled=enabled===true;this.fetcher=fetcher;this.timeoutMs=timeoutMs;
683  }
684  async request(path,body){
685    if(!this.fetcher)throw new Error('Verified runs are unavailable. This run stays local.');
686    const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),this.timeoutMs);
687    try {
688      const response=await this.fetcher('/api/overdrive/'+path,{
689        method:body===undefined?'GET':'POST',credentials:'same-origin',redirect:'error',
690        signal:controller.signal,headers:{'Content-Type':'application/json'},...(body===undefined?{}:{body})
691      });
692      const data=await response.json();
693      if(!response.ok){const error=new Error(data?.error?.message||'Verified run service unavailable');error.status=response.status;error.code=data?.error?.code;throw error;}
694      return data;
695    } finally {clearTimeout(timer);}
696  }
697  async start({stage,level,practice=false,watch=false,custom=false}){
698    if(!this.enabled||practice||watch||custom)return null;
699    const {user}=await this.request('me');
700    if(!user?.id)throw new Error('Sign in to record a verified run. This run stays local.');
701    const bytes=new TextEncoder().encode(JSON.stringify(replayLevelContent(level)));
702    const hash=[...new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256',bytes))].map(x=>x.toString(16).padStart(2,'0')).join('');
703    const session=await this.request('runs',JSON.stringify({accountId:user.id,targetKind:'official',stage}));
704    if(session.targetKind!=='official'||session.targetId!==`official:${stage+1}`||session.contentHash!==hash||
705      session.rulesetVersion!==REPLAY_RULESET||session.verifierVersion!==REPLAY_VERIFIER||
706      session.simulationVersion!==SIMULATION_VERSION||session.inputFormatVersion!==INPUT_FORMAT_VERSION||
707      typeof session.runId!=='string'||!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(session.runId)||
708      typeof session.nonce!=='string'||session.nonce.length<20||session.nonce.length>128||
709      !Number.isSafeInteger(session.targetRevision)||session.targetRevision<1||
710      !Number.isFinite(Date.parse(session.expiresAt))||Date.parse(session.expiresAt)<=Date.now())
711      throw new Error('This build does not match the issued replay environment. This run stays local.');
712    return {accountId:user.id,session:Object.freeze({...session}),recorder:new InputRecorder(),payload:null,inFlight:null,settled:null};
713  }
714  finish(attempt,claim){
715    if(!attempt)return Promise.resolve(null);
716    if(attempt.settled)return Promise.resolve(attempt.settled);
717    if(attempt.inFlight)return attempt.inFlight;
718    try {
719      // Serialize ONCE;
719 every network retry has identical nonce and evidence.
720      attempt.payload ||= JSON.stringify({accountId:attempt.accountId,runId:attempt.session.runId,
721        nonce:attempt.session.nonce,replay:attempt.recorder.finish(claim)});
722    } catch(error){return Promise.reject(error);}
723    attempt.inFlight=(async()=>{
724      for(let retry=0;retry<2;retry++){
725        try {
726          const response=await this.request(`runs/${attempt.session.runId}/submit`,attempt.payload);
727          if(response.runId!==attempt.session.runId||!['accepted','rejected','expired'].includes(response.status))throw new Error('Invalid run settlement response');
728          if(response.status==='accepted'&&response.result?.verification_tier!=='deterministic_replay')throw new Error('Run was not verified by deterministic replay');
729          attempt.settled=response;return response;
730        } catch(error){
731          // Never switch accounts, change the evidence or issue a replacement run.
732          if(retry===1||(error.status&&error.status<500))throw error;
733          await new Promise(resolve=>setTimeout(resolve,100));
734        }
735      }
736    })().finally(()=>{attempt.inFlight=null;});
737    return attempt.inFlight;
738  }
739}
740
741
742// level-client.mjs
743/** Opt-in Workshop bridge for cloud drafts. Cloud identity never enters the level
744 * JSON, so the exported file and its content hash are unchanged by saving.
745 */
746const fail=(message,code,status)=>{const error=new Error(message);error.code=code;if(status)error.status=status;return error;};
747class LevelClient{
748  // fetch is resolved per request, not bound at construction: the Workshop builds a
749  // client before the page decides how requests travel, and tests inject their own.
750  constructor({baseURL='/api/overdrive/',fetch=null}={}){
751    this.baseURL=baseURL;this.fetcher=fetch;this.accountId=null;
752  }
753  async request(path,method,body){
754    const send=this.fetcher||globalThis.fetch?.bind(globalThis);
755    if(!send)throw fail('Cloud drafts are unavailable. Your level stays on this device.','FETCH_UNAVAILABLE');
756    const response=await send(this.baseURL+path,{
757      method,credentials:'same-origin',redirect:'error',
758      ...(body===undefined?{}:{headers:{'content-type':'application/json'},body:JSON.stringify(body)}),
759    });
760    const data=await response.json().catch(()=>null);
761    if(!response.ok)throw fail(data?.error?.message||`Cloud request failed (${response.status})`,
762      data?.error?.code||'HTTP_'+response.status,response.status);
763    if(!data||typeof data!=='object')throw fail('The cloud returned an unexpected response.','INVALID_RESPONSE',response.status);
764    return data;
765  }
766  /** Anonymous visitors are a normal answer here, not an error. */
767  async me(){
768    const data=await this.request('me','GET');
769    const user=data.user&&typeof data.user==='object'?data.user:null;
770    this.accountId=typeof user?.id==='string'&&user.id?user.id:null;
771    return {user};
772  }
773  async account(){
774    if(this.accountId)return this.accountId;
775    await this.me();
776    if(!this.accountId)throw fail('Sign in on the site to save to cloud.','SIGN_IN_REQUIRED',401);
777    return this.accountId;
778  }
779  /** Reads need no account: the server decides what this visitor may see. */
780  async get(uuid){
781    return this.request('levels/'+uuid,'GET');
782  }
783  async saveDraft({uuid,expectedRevision,level}){
784    const accountId=await this.account();
785    return this.request('levels/'+uuid,'PUT',{accountId,expectedRevision,level});
786  }
787  async submit({uuid,expectedRevision}){
788    const accountId=await this.account();
789    return this.request(`levels/${uuid}/submit`,'POST',{accountId,expectedRevision});
790  }
791}
792
793
794// quality.mjs
795/** Pure, deterministic acceptance helpers. No browser clock or privileged replay. */
796const LIMITS = Object.freeze({objects:1500,history:40,jsonBytes:1024*1024,audioBytes:20*1024*1024});
797function finite(value,min,max,fallback){return typeof value==='number'&&Number.isFinite(value)?Math.min(max,Math.max(min,value)):fallback;}
798/** 100 Hz score envelopes from decoded PCM. Silence never generates a fake beat. */
799function analysePCM(samples,sampleRate,rate=100){
800 if(!samples?.length||!Number.isFinite(sampleRate)||sampleRate<8000)throw new Error('Invalid decoded audio');
801 const hop=Math.max(1,Math.round(sampleRate/rate)),count=Math.ceil(samples.length/hop),bands=new Float32Array(count*3);
802 const bassA=1-Math.exp(-2*Math.PI*180/sampleRate),trebleA=1-Math.exp(-2*Math.PI*2200/sampleRate);
803 let bass=0,low=0,peak=0,total=0;
804 for(let frame=0;frame<count;frame++){
805  let b=0,m=0,h=0,n=0;
806  for(let i=frame*hop;i<Math.min(samples.length,(frame+1)*hop);i++){
807   const x=Number.isFinite(samples[i])?samples[i]:0;bass+=bassA*(x-bass);low+=trebleA*(x-low);
808   b+=bass*bass;m+=(low-bass)**2;h+=(x-low)**2;total+=x*x;peak=Math.max(peak,Math.abs(x));n++;
809  }
810  bands[frame*3]=Math.sqrt(b/n);bands[frame*3+1]=Math.sqrt(m/n);bands[frame*3+2]=Math.sqrt(h/n);
811 }
812 const scales=[];
813 for(let channel=0;channel<3;channel++){
814  const values=Array.from({length:count},(_,i)=>bands[i*3+channel]).sort((a,b)=>a-b);
815  scales[channel]=Math.max(.035,values[Math.floor(count*.95)]||0);
816  for(let i=0;i<count;i++)bands[i*3+channel]=Math.min(1.25,bands[i*3+channel]/scales[channel]);
817 }
818 return {rate:sampleRate/hop,count,bands,peak,rms:Math.sqrt(total/samples.length),seconds:samples.length/sampleRate};
819}
820function envelopeAt(envelope,seconds){
821 if(!envelope||seconds<0)return {bass:0,mid:0,high:0};
822 const f=Math.min(envelope.count-1,seconds*envelope.rate),i=Math.floor(f),j=Math.min(i+1,envelope.count-1),k=f-i;
823 const at=c=>envelope.bands[i*3+c]*(1-k)+envelope.bands[j*3+c]*k;
824 return {bass:at(0),mid:at(1),high:at(2)};
825}
826/** A checkpoint is allowed in the air when at least one ordinary input survives.
827 * This is a local safety guard, not proof that the rest of a level is completable. */
828function checkpointSafety(world,horizon=.20){
829 if(!world||world.dead||world.won)return {safe:false,reason:'No active run'};
830 const duration=Math.ceil(horizon*240),choices=[world.held,false,true];
831 for(const held of choices){
832  const copy=world.copy();
833  for(let n=0;n<duration&&!copy.dead&&!copy.won;n++)copy.step(held,n===0&&held&&!world.held);
834  if(!copy.dead)return {safe:true,held,horizon};
835 }
836 return {safe:false,reason:'Too close to a collision. Move clear before saving.'};
837}
838function quantile(values,q){if(!values.length)return 0;const a=[...values].sort((x,y)=>x-y);return a[Math.min(a.length-1,Math.floor((a.length-1)*q))];}
839/** Tap calibration includes human response time; it is an adjustable estimate. */
840function calibrationEstimate(offsets){
841 const values=offsets.filter(x=>Number.isFinite(x)&&Math.abs(x)<.35);
842 if(values.length<6)return null;
843 const median=quantile(values,.5),deviations=values.map(x=>Math.abs(x-median));
844 return {milliseconds:Math.round(Math.max(-.15,Math.min(.25,median))*1000),spreadMs:Math.round(quantile(deviations,.5)*1000),samples:values.length};
845}
846
847
848// score.mjs
849
850/** Source transport, visual calibration and deterministic audio-reactive envelopes. */
851const hz=n=>440*2**((n-69)/12);
852/** Enemy sounds (events from core.mjs): MIDI notes, spacing and length in seconds, peak gain, oscillator. */
853const FOE_FX=Object.freeze({
854 stomp:Object.freeze({notes:[79,86],gap:.035,len:.12,amp:.08,wave:'triangle'}),
855 bonk:Object.freeze({notes:[81,74],gap:.04,len:.1,amp:.07,wave:'square'}),
856 bossdown:Object.freeze({notes:[67,71,74,79,86],gap:.07,len:.45,amp:.075,wave:'triangle'}),
857 shot:Object.freeze({notes:[57],gap:0,len:.07,amp:.045,wave:'triangle'})
858});
859class AudioEngine{
860 constructor(baseURL=null){this.baseURL=baseURL;this.ctx=null;this.playing=false;this.music=.75;this.sfx=.55;this.muted=false;this.offset=0;this.buffer=null;this.bufferId=null;this.serial=0;this.rate=1;this.syncMs=0;this.envelope=null;this.customBlob=null;this.fxNodes=new Set();}
861 async unlock(){
862  if(!this.ctx){const C=globalThis.AudioContext||globalThis.webkitAudioContext;if(!C)throw Error('This browser does not support Web Audio.');
863   this.ctx=new C({latencyHint:'interactive'});this.master=this.ctx.createGain();this.master.gain.value=.82;
864   this.musicOut=this.ctx.createGain();this.fxOut=this.ctx.createGain();this.musicOut.connect(this.master);this.fxOut.connect(this.master);this.master.connect(this.ctx.destination);this.mix();
865  }
866  if(this.ctx.state!=='running'){let timer;try{await Promise.race([this.ctx.resume(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Tap again to enable audio')),3500);})]);}finally{clearTimeout(timer);}}
867  if(this.ctx.state!=='running')throw Error('Audio unavailable');
868 }
869 mix(){if(!this.ctx)return;const t=this.ctx.currentTime;this.master.gain.setTargetAtTime(this.muted?0:.82,t,.018);this.musicOut.gain.setTargetAtTime(this.music,t,.018);this.fxOut.gain.setTargetAtTime(this.sfx,t,.018);}
870 async prepare(level){
871  const ticket=++this.serial;this.pending?.abort();await this.unlock();
872  if(ticket!==this.serial)throw new DOMException('Superseded soundtrack request','AbortError');
873  const track=level.trackId||level.id,key=track==='local'?'local:'+this.customBlob?.name+':'+this.customBlob?.size:track;
874  if(this.bufferId===key)return;
875  const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),20000);this.pending=controller;
876  try{
877   let bytes;
878   if(track==='local'){if(!this.customBlob)throw Error('Select your local song in Workshop first.');bytes=await this.customBlob.arrayBuffer();}
879   else{const path=globalThis.RUSH_AUDIO_ASSETS?.[track]||new URL(track+'.mp3',this.baseURL||new URL('audio/',document.baseURI)).href;
880    const response=await fetch(path,{signal:controller.signal});if(!response.ok)throw Error('Soundtrack download failed. Tap Retry.');bytes=await response.arrayBuffer();}
881   const decoded=await this.ctx.decodeAudioData(bytes);
882   if(ticket!==this.serial)throw new DOMException('Superseded soundtrack request','AbortError');
883   if(decoded.duration>300)throw Error('Please use a song shorter than five minutes.');
884   this.envelope=decoded.getChannelData?analysePCM(decoded.getChannelData(0),decoded.sampleRate):null;
885   this.buffer=decoded;this.bufferId=key;
886  }finally{clearTimeout(timeout);if(this.pending===controller)this.pending=null;}
887 }
888 start(offset=0){
889  this.stop();if(!this.buffer)throw Error('Soundtrack is not ready');const c=this.ctx;
890  this.offset=finite(offset,0,Math.max(0,this.buffer.duration-.001),0);this.source=c.createBufferSource();this.source.buffer=this.buffer;
891  this.activeRate=finite(this.rate,.5,1,1);this.activeSync=finite(this.syncMs,-150,250,0)/1000;this.source.playbackRate.value=this.activeRate;
892  this.source.connect(this.musicOut);this.started=c.currentTime+.045;this.epoch=this.started-this.offset;
893  this.source.start(this.started,this.offset);this.playing=true;this.mix();
894 }
895 now(){return this.playing?Math.max(this.offset,this.offset+(this.ctx.currentTime-this.started-this.activeSync)*this.activeRate):this.offset;}
896 energy(time=this.now()){return envelopeAt(this.envelope,time);}
897 stop(){if(this.playing)this.offset=this.now();this.playing=false;try{this.source?.stop();this.source?.disconnect();}catch{}this.source=null;}
898 tone(note,at,duration,amplitude,type='sine'){
899  const c=this.ctx,o=c.createOscillator(),e=c.createGain();o.type=type;o.frequency.value=hz(note);
900  e.gain.setValueAtTime(.0001,at);e.gain.linearRampToValueAtTime(amplitude,at+.005);e.gain.exponentialRampToValueAtTime(.0001,at+duration);
901  o.connect(e);e.connect(this.fxOut);this.fxNodes.add(o);o.start(at);o.stop(at+duration+.02);o.onended=()=>{this.fxNodes.delete(o);o.disconnect();e.disconnect();};return o;
902 }
903 fx(kind){
904  if(!this.ctx||this.ctx.state!=='running')return;const t=this.ctx.currentTime;
905  // Critters: a stomp springs up, a bonk knocks down, a boss falls to a short fanfare; a throw leaving a hand is a
906  // quiet low tick, the audible half of the screen-edge arrow for a shot that starts off screen.
907  const foe=Object.hasOwn(FOE_FX,kind)?FOE_FX[kind]:null;
907if(foe){foe.notes.forEach((n,i)=>this.tone(n,t+i*foe.gap,foe.len,foe.amp,foe.wave));return;}
908  const notes=kind==='coin'?[87,94]:kind==='portal'?[68,75,80]:kind==='win'?[72,76,79,84]:kind==='death'?[43,35]:[75];
909  notes.forEach((n,i)=>this.tone(n,t+i*.045,kind==='win'?.6:.16,kind==='death'?.12:.075,kind==='death'?'sawtooth':'sine'));
910  if(kind==='death'||kind==='portal'){
911   const c=this.ctx,len=Math.floor(c.sampleRate*.18),buffer=c.createBuffer(1,len,c.sampleRate),d=buffer.getChannelData(0);
912   let seed=971;for(let i=0;i<len;i++){seed=(Math.imul(seed,1664525)+1013904223)|0;d[i]=((seed>>>0)/2147483648-1)*(1-i/len)**2;}
913   const source=c.createBufferSource(),filter=c.createBiquadFilter(),gain=c.createGain();source.buffer=buffer;
914   filter.type=kind==='death'?'lowpass':'highpass';filter.frequency.value=kind==='death'?1400:3200;gain.gain.value=.14;
915   source.connect(filter);filter.connect(gain);gain.connect(this.fxOut);this.fxNodes.add(source);source.start();source.onended=()=>{this.fxNodes.delete(source);source.disconnect();filter.disconnect();gain.disconnect();};
916  }
917 }
918 close(){this.serial++;this.pending?.abort();this.stop();for(const node of this.fxNodes){try{node.stop();}catch{}}this.fxNodes.clear();return this.ctx?.close().catch(()=>{});}
919}
920
921
922// stagecraft.mjs
923/**
924 * Stagecraft: authored background staging (roll, gravity flips, zoom, palette, parallax
925 * speed and flow layers) for official and Workshop stages.
926 *
927 * Presentation only. Every motion value is a pure function of (level, world.t / world.x,
928 * decoded score envelope, tier). Nothing here writes World or enters exportLevel /
929 * replayLevelContent. The camera acts on the low-resolution background raster only:
930 * hazards, player, ground, roof and the DOM HUD are never rotated, zoomed or tinted.
931 * Tier off, and reduced motion (the in-game setting or the system preference, read live every
932 * frame), resolve to null, which is the V2.12 drawing path.
933 * Outside a run (menu, stage select, settings, loading, calibration) the background only sways
934 * +-1.5 deg (Subtle +-.6 deg) on a 16 s sine: no layers, zoom or phrase colour. Any change of pose
935 * source (menu -> run, arming probe -> run, run -> menu, off -> on, a new run after death / retry /
936 * checkpoint) hands over through a blend of at least 0.5 s, and every shown frame then passes a rate
937 * guard, so roll <= 60 deg/s, zoom <= .3/s and light <= .2/s hold on every path, not only inside plans
938 * (stageShow). The audio kick (a <= .02 zoom pulse) rides on top and is not choreography.
939 * Top-level names are prefixed (stage / STAGE_) because build.mjs concatenates modules.
940 */
941
942
943const STAGE_VERSION='stagecraft-1.0';
944/** settings.stage index -> tier name. 2 (full) is the default. */
945const STAGE_LEVELS=Object.freeze(['off','subtle','full']);
946/** Hard ceilings. Compile-time rate solving keeps authored scripts inside them; tests re-check by sampling. */
947const STAGE_LIMITS=Object.freeze({
948 rollRateDegS:60,sustainedDegS:15,swayRateDegS:12,flipRateDegS:60,flipAccelDegS2:60,turnRateDegS:30,
949 zoomMin:.97,zoomMax:1.10,zoomRatePerS:.30,lightMin:.8,lightMax:1.08,lightRatePerS:.2,tintMax:.05,
950 redBand:Object.freeze([340,15]),pivotY:214,coverBottom:428,portalHold:.25,
951 windupBeats:2,windupDeg:3,windupZoom:.03,crossfadeBeats:1,settleBeats:4,
952 kickAttackS:.075,kickDecayS:.14,
953 govEveryFrames:30,govWindow:45,govDrawP90Ms:14,govFrameFloorMs:22,govGainDrawMs:.5,govGainTailMs:1.5,govGainFrameMs:4,govStrikes:2,govRecoverChecks:4,govRecoverBackoffMax:32,
954 govHoldChecks:32,govHoldMaxChecks:128,govHoldRiseMs:1,govBlendS:1.5,govFlipHeadroomMs:10,govBlendGapDeg:40,govDropLog:20,
955 govHardRiseMs:1.5,govHardDrawMs:11.2,govRecoverFrameMs:2,govFrameFloorLevel:1,
956 handoffS:.5,layerRatePerS:4,probeRollDeg:1,autoAmp:.6
957});
958const STAGE_TIERS=Object.freeze({
959 subtle:Object.freeze({name:'subtle',amp:.5,rollCap:5,vMove:12,vDrop:18,dropEase:'inOutSine',flips:false,windup:false,layers:false,kickZoom:.008,zoomAmp:.5,hueAmp:.6,lightAmp:.5,swayAmp:.5,menuSway:.6}),
960 full:Object.freeze({name:'full',amp:1,rollCap:12,vMove:24,vDrop:48,dropEase:'outCubic',flips:true,windup:true,layers:true,kickZoom:.02,zoomAmp:1,hueAmp:1,lightAmp:1,swayAmp:1,menuSway:1.5}),
961 // Full choreography whose flips/turns become leans: used for a run whose pre-flight check found no frame headroom.
962 fullLean:Object.freeze({name:'fullLean',amp:1,rollCap:12,vMove:24,vDrop:48,dropEase:'outCubic',flips:false,windup:true,layers:true,kickZoom:.02,zoomAmp:1,hueAmp:1,lightAmp:1,swayAmp:1,menuSway:1.5})
963});
964/** Difficulty caps the authored phrase roll so EASY never gets DEMON choreography: phrase roll <= cap, and the
965 * visible roll is phrase roll + sway, so it can exceed the cap by the sway (at most about +2 deg).
966 * Events (flips/turns) have their own limits. Workshop / imported stages: phrase roll <= cap x autoAmp (0.6). */
967const STAGE_ROLL_CAP=Object.freeze({EASY:5,NORMAL:7,HARD:8,HARDER:10,INSANE:12,DEMON:12});
968/** Native tempo of each score (tools/render_audio.py TRACKS). A chart at another bpm maps sections in seconds. */
969const STAGE_TRACK_BPM=Object.freeze({'prism-break':132,'velvet-current':120,'solar-fracture':144});
970/** Section roles of the three scores, copied from tools/render_audio.py (sec=bar//4):
971 * intro=0, build=1/5, drop=2/6, air=3 (+4 on velvet-current), final>=7, anything else groove. */
972const STAGE_FORM=Object.freeze({
973 'prism-break':Object.freeze(['intro','build','drop','air','groove','build','drop','final']),
974 'velvet-current':Object.freeze(['intro','build','drop','air','air','build','drop']),
975 'solar-fracture':Object.freeze(['intro','build','drop','air','groove','build','drop','final'])
976});
977/** Role presets (full tier). roll/sway/raySpin in degrees (raySpin per beat), hue = shift from the cue theme,
978 * light = sky lightness factor, speed = background parallax speed multiplier, kick = audio kick weight. */
979const STAGE_ROLES=Object.freeze({
980 intro: Object.freeze({roll:0, zoom:1,   sway:1,  swayBeats:16,beats:4,hue:0,  light:.94,tint:.025,rays:0, raySpin:.5, streaks:0,mid:.5,speed:1,   kick:.5,prism:0}),
981 build: Object.freeze({roll:3, zoom:1.02,sway:1.5,swayBeats:8, beats:4,hue:0,  light:1,  tint:.025,rays:.5,raySpin:2,  streaks:0,mid:.8,speed:1.15,kick:.8,prism:0}),
982 drop:  Object.freeze({roll:8, zoom:1.06,sway:2,  swayBeats:8, beats:2,hue:12, light:1.05,tint:.035,rays:1,raySpin:4,  streaks:8,mid:1, speed:1.5, kick:1, prism:0}),
983 air:   Object.freeze({roll:0, zoom:.97, sway:0,  swayBeats:16,beats:4,hue:-10,light:.86,tint:.012,rays:0, raySpin:-1, streaks:0,mid:.4,speed:.6,  kick:.3,prism:0}),
984 groove:Object.freeze({roll:-3,zoom:1.02,sway:1.5,swayBeats:8, beats:4,hue:6,  light:1,  tint:.025,rays:.4,raySpin:1.5,streaks:0,mid:.8,speed:1,   kick:.8,prism:0}),
985 final: Object.freeze({roll:3, zoom:1.03,sway:1,  swayBeats:16,beats:4,hue:0,  light:1.02,tint:.03,rays:.6,raySpin:2,  streaks:2,mid:.8,speed:1.1, kick:.8,prism:0})
986});
987/** One directive per cue (same order as levels.mjs cues). Omitted fields inherit the role.
988 * events: flip = 180-degree background turn anchored on the gravity portal nearest `at`
989 * (trapezoid, <= 60 deg/s, <= 60 deg/s^2); turn = authored sine turn over `beats`. */
990const STAGE_SCRIPTS=Object.freeze({
991 'low-tide':{phrases:[
992  {light:.92,speed:.8,heroRise:true},
993  {roll:2,sway:1.2,swayBeats:16,rays:.3},
994  {roll:-4,zoom:1.04,rays:.8,raySpin:2,streaks:4},
995  {zoom:.98,light:.88},
996  {roll:1.5,sway:1,swayBeats:16,light:.84,speed:.5},
997  {roll:3,zoom:1.03,rays:.5},
998  {roll:-5,zoom:1.05,light:1.06,rays:1,raySpin:2.5,streaks:6,hue:8}],events:[]},
999 'prism-break':{phrases:[
1000  {},
1001  {roll:3},
1002  {roll:-7,zoom:1.06,rays:1,streaks:8},
1003  {},
1004  {roll:7,light:.9,hue:-8},
1005  {roll:-3,rays:.6},
1006  {roll:7,zoom:1.07,rays:1,raySpin:6,streaks:8},
1007  {roll:-2}],events:[]},
1008 'crystal-pulse':{phrases:[
1009  {},
1010  {roll:-3},
1011  {roll:8,zoom:1.06,rays:.8,streaks:6},
1012  {},
1013  {roll:-3,prism:.5},
1014  {roll:3,prism:1,rays:.4},
1015  {roll:-8,zoom:1.07,prism:1,rays:1,raySpin:3}],events:[]},
1016 'velvet-current':{phrases:[
1017  {light:.9,speed:.8},
1018  {roll:3,sway:1.5},
1019  {roll:-8,zoom:1.06,rays:1,streaks:6},
1020  {speed:.6},
1021  {roll:2,light:.88},
1022  {roll:-3,speed:2.4,streaks:6,mid:1},
1023  {roll:8,zoom:1.07,rays:1,light:1.06,speed:1.4}],events:[]},
1024 'solar-fracture':{phrases:[
1025  {light:.88,heroSpin:3},
1026  {roll:3,speed:1.3},
1027  {roll:-9,zoom:1.07,rays:1,streaks:8},
1028  {},
1029  {roll:0},
1030  {roll:3},
1031  {roll:-10,zoom:1.08,rays:1,raySpin:8,streaks:10},
1032  {roll:3}],events:[{at:64,add:180,kind:'flip'},{at:80,add:-180,kind:'flip'}]},
1033 'gravity-well':{phrases:[
1034  {sway:2,swayBeats:16},
1035  {roll:-3},
1036  {roll:9,zoom:1.06,rays:1,streaks:6},
1037  {},
1038  {roll:-3},
1039  {roll:0},
1040  {roll:0,zoom:1.08,beats:16,rays:1,raySpin:-3,light:.95,windup:false},
1041  {roll:3}],events:[{at:80,add:180,kind:'flip'},{at:91,add:-180,kind:'flip'},{at:100.2,add:40,kind:'turn',beats:11.5},{at:112.35,add:-40,kind:'turn',beats:10}]},
1042 'flipside':{phrases:[
1043  {sway:1.5},
1044  {roll:4,rays:.5,raySpin:3},
1045  {roll:-12,zoom:1.08,rays:1,streaks:10,sway:3},
1046  {},
1047  {roll:-4,rays:.6,raySpin:10,heroSpin:4},
1048  {roll:4},
1049  {roll:0,rays:1,streaks:10,windup:false},
1050  {roll:0}],events:[{at:96,add:180,kind:'flip'},{at:112,add:180,kind:'flip'}]},
1051 // Stage 9: a NORMAL enemy-reading stage on velvet-current (7 score sections, two drops). Calm on purpose: the
1052 // critters carry the phrase, so rolls stay small (cap 7), light never dips far and no gravity gate means no flip.
1053 'critter-crossing':{phrases:[
1054  {light:.92,speed:.8},
1055  {roll:2,sway:1.2,swayBeats:16},
1056  {roll:-5,zoom:1.04,rays:.8,streaks:4},
1057  {zoom:.98,light:.9},
1058  {roll:1.5,light:.88,speed:.6},
1059  {roll:3,zoom:1.02,rays:.4},
1060  {roll:-5,zoom:1.05,rays:.8,streaks:4,light:1.04}],events:[]},
1061 'nova-cascade':{phrases:[
1062  {from:{roll:-4},beats:16,light:.9},
1063  {roll:4,sway:2},
1064  {roll:-12,zoom:1.08,rays:1,streaks:10},
1065  {},
1066  {roll:3,zoom:1.04,beats:16},
1067  {roll:0},
1068  {roll:12,zoom:1.1,rays:1,raySpin:8,streaks:10,light:1.06,hue:10},
1069  {roll:-3}],events:[{at:80,add:180,kind:'flip'},{at:92.5,add:-180,kind:'flip'}]}
1070});
1071
1072const stageClamp=(v,a,b)=>Math.max(a,Math.min(b,Number.isFinite(v)?v:0));
1073const STAGE_EASE=Object.freeze({
1074 linear:u=>u,
1075 inOutSine:u=>(1-Math.cos(Math.PI*u))/2,
1076 outCubic:u=>1-(1-u)**3,
1077 inQuad:u=>u*u,
1078 // Trapezoid velocity profile with a quarter of the time accelerating and a quarter braking:
1079 // peak slope 1/(1-.25) and constant acceleration, so a flip is bounded in both deg/s and deg/s^2.
1080 trap:u=>{const r=.25,k=r*(1-r);return u<r?.5*u*u/k:u<1-r?(u-r/2)/(1-r):1-.5*(1-u)**2/k;}
1081});
1082/** Peak |d ease/du| per ease: used to stretch a move until it respects its rate ceiling. */
1083const STAGE_EASE_PEAK=Object.freeze({linear:1,inOutSine:Math.PI/2,outCubic:3,inQuad:2,trap:4/3});
1084/** Never rest a large sky area in saturated red (WCAG red-flash precondition R/(R+G+B)>=.8). */
1085function stageSafeHue(h){const x=((h%360)+360)%360;if(x>=340||x<15)return x>=340&&x<357.5?340:15;return x;}
1086/** Painter hue chase (time constant ~1/3 s, same as V2.12) that jumps over the red band instead of sweeping through it. */
1087function stageChaseHue(current,target,dt){
1088 const d=(((target-current)%360)+540)%360-180,h=((current+d*Math.min(1,dt*3))%360+360)%360;
1089 if(h>=340||h<15)return Math.abs(d)<1e-6?stageSafeHue(h):(d>0?15:340);return h;
1090}
1091/** Stateless kick from the decoded score: positive bass flux, 75 ms linear attack, 140 ms decay.
1092 * A pure function of world time, so pause/retry/replay and 60/120 Hz displays agree. */
1093function stageKick(env,t){
1094 if(!env||!(t>0)||!env.count)return 0;
1095 const r=env.rate,bands=env.bands,A=STAGE_LIMITS.kickAttackS,D=STAGE_LIMITS.kickDecayS,last=Math.min(env.count-1,Math.floor(t*r)),first=Math.max(6,Math.ceil((t-.5)*r));let k=0;
1096 for(let j=first;j<=last;j++){const on=bands[j*3]-bands[(j-6)*3];if(on<=0)continue;const tau=t-j/r,w=tau<A?tau/A:Math.exp(-(tau-A)/D);k=Math.max(k,Math.min(1,on*2.4)*w);}
1097 return k;
1098}
1099/** Default direction for Workshop / imported stages. Official scores use their section form, one phrase per
1100 * 16-beat score section; when the chart bpm differs from the score's own tempo the sections are mapped in
1101 * seconds (a section spans 16*bpm/native chart beats), so a drop still lands on the music's drop. Local songs
1102 * rank 16-beat phrases by the decoded envelope; otherwise a fixed cycle. Unreviewed charts get autoAmp (60%)
1103 * of the role amplitude, phrase roll <= difficulty cap x autoAmp, and never flip. */
1104function stageAutoScript(level,env=null){
1105 const form=STAGE_FORM[level.trackId],native=STAGE_TRACK_BPM[level.trackId],bpm=level.bpm||60/(level.beat||.5),A=STAGE_LIMITS.autoAmp;
1106 const span=form&&native?16*bpm/native:16,n=Math.max(1,Math.ceil(level.beats/span-1e-9)),bounds=Array.from({length:n},(_,i)=>i*span);let roles;
1107 if(form)roles=bounds.map((_,i)=>form[i]||'groove');
1108 else if(env&&n>=3){
1109  const score=[];for(let p=0;p<n;p++){let s=0,k=0;for(let b=p*16;b<Math.min(level.beats,p*16+16);b+=.25){const e=envelopeAt(env,b*level.beat);s+=e.high+.5*e.bass;k++;}score.push(k?s/k:0);}
1110  roles=Array(n).fill('groove');roles[0]='intro';
1111  const inner=[...Array(n).keys()].filter(p=>p>0),drops=[...inner].sort((a,b)=>score[b]-score[a]||a-b).slice(0,Math.max(1,Math.round(n/3.5)));
1112  for(const p of drops)roles[p]='drop';if(n>=4&&roles[n-1]!=='drop')roles[n-1]='final';
1113  const quiet=inner.filter(p=>roles[p]==='groove').sort((a,b)=>score[a]-score[b]);if(quiet.length)roles[quiet[0]]='air';
1114  for(const p of drops)if(p>1&&roles[p-1]==='groove')roles[p-1]='build';
1115 }else roles=Array.from({length:n},(_,i)=>i===0?'intro':n>2&&i===n-1?'final':['build','drop','air','groove'][(i-1)%4]);
1116 let sign=1;
1117 return {auto:true,roles,bounds,events:[],phrases:roles.map(r=>{const R=STAGE_ROLES[r];if(!R.roll)return {};sign=-sign;return {roll:Math.abs(R.roll)*sign*A,zoom:1+(R.zoom-1)*A,rays:R.rays*A,streaks:Math.round(R.streaks*.5)};})};
1118}
1119function stageSegValue(s,b){const u=stageClamp((b-s.b)/s.len,0,1);return s.from+(s.to-s.from)*STAGE_EASE[s.ease](u);}
1120function stageChannel(list,b){let s=null;for(const x of list){if(x.b>b)break;s=x;}return s?stageSegValue(s,b):list.length?list[0].from:0;}
1121function stageResolve(list,init){list.sort((a,b)=>a.b-b.b);let prev=null;for(const s of list){s.from=prev?stageSegValue(prev,s.b):init;prev=s;}return list;}
1122/** Minimum move length (beats) so that ease(peak slope) * delta / length stays under rate (units/s). */
1123function stageMinBeats(delta,rate,ease,beat){return rate>0?STAGE_EASE_PEAK[ease]*Math.abs(delta)/(rate*beat):0;}
1124
1125/** Compile one level for one tier into channel segment lists. Pure; cached per (level, tier, envelope). */
1126function stageCompile(level,tierName,env=null){
1127 const tier=STAGE_TIERS[tierName],L=STAGE_LIMITS,beat=level.beat||.5,beats=level.beats,unit=(level.speed||340)*beat;
1128 const authored=!level.custom&&STAGE_SCRIPTS[level.id],script=authored||stageAutoScript(level,env);
1129 const bounds=authored?level.cues.map(c=>c.b):script.bounds;
1130 const form=authored?STAGE_FORM[level.trackId||level.id]:null,roles=script.roles||bounds.map((_,i)=>form?.[i]||'groove');
1131 const cap=Math.min(STAGE_ROLL_CAP[level.difficulty]??8,tier.rollCap)*(authored?1:L.autoAmp);
1132 const objects=level.objects||[],portals=objects.filter(o=>o.type==='portal').map(o=>({b:o.x/unit,gravity:o.gravity||0})).sort((a,b)=>a.b-b.b);
1133 const cueTheme=b=>{let c=level.cues[0];for(const q of level.cues){if(q.b>b)break;c=q;}return THEMES[c.theme]||THEMES[0];};
1134 // --- events: flips anchor on a gravity portal; turns are authored. Subtle tier converts both into small leans.
1135 const events=[];let flipOrient=0,turnSum=0,leanNow=0;
1136 for(const e of (authored?script.events:[])||[]){
1137  let at=e.at;if(e.kind==='flip'){const g=portals.find(p=>p.gravity&&Math.abs(p.b-e.at)<=.5);if(!g)throw Error('Flip without a gravity portal at beat '+e.at+' in '+level.id);at=g.b+L.portalHold;}
1138  let delta,ease,len;
1139  if(e.kind==='flip')flipOrient+=e.add;else turnSum+=e.add;
1140  if(tier.flips){
1141   if(e.kind==='flip'){const v=L.flipRateDegS,a=L.flipAccelDegS2,T=Math.abs(e.add)/v+v/a;delta=e.add;ease='trap';len=T/beat;}
1142   else{delta=e.add;ease='inOutSine';len=Math.max(e.beats||4,stageMinBeats(delta,L.turnRateDegS,ease,beat));}
1143  }else if(e.kind==='flip'){
1144   // Subtle: a flipped world becomes a lean of `cap` degrees towards the flip direction.
1145   // The lean variant (slow device) leans half as far: smaller rolls also need a smaller raster region.
1146   const leanDeg=tier.name==='fullLean'?cap*.5:cap,odd=((Math.round(flipOrient/180)%2)+2)%2===1,next=odd?Math.sign(e.add)*leanDeg:0;delta=next-leanNow;leanNow=next;
1147   ease='inOutSine';len=Math.max(2,stageMinBeats(delta,tier.vMove,ease,beat));
1148  }else{
1149   const lim=tier.name==='fullLean'?cap*.5:cap,target=stageClamp(turnSum*tier.amp,-lim,lim),from=stageClamp((turnSum-e.add)*tier.amp,-lim,lim);
1150   delta=target-from;ease='inOutSine';len=Math.max(e.beats||4,stageMinBeats(delta,tier.vMove,ease,beat));
1151  }
1152  events.push({b:at,len,delta,ease,kind:e.kind,add:e.add});
1153 }
1154 events.sort((a,b)=>a.b-b.b);
1155 for(let i=1;i<events.length;i++)if(events[i].b<events[i-1].b+events[i-1].len-1e-6)throw Error('Overlapping stage events in '+level.id+' at beat '+events[i].b.toFixed(2));
1156 if(tier.flips&&Math.round(flipOrient+turnSum)%360!==0)throw Error('Stage '+level.id+' does not end upright');
1157 const inEvent=b=>events.find(e=>b>=e.b-1e-9&&b<e.b+e.len);
1158 const deferred=b=>{let x=b,e;while((e=inEvent(x)))x=e.b+e.len;return x;};
1159 const evSegs=[];{let o=0;for(const e of events){evSegs.push({b:e.b,len:e.len,from:o,to:o+e.delta,ease:e.ease});o+=e.delta;}}
1160 const orientAt=b=>stageChannel(evSegs,b);
1161 const flippedDuring=(b0,b1)=>{if(!tier.flips)return false;for(let b=b0;b<b1;b+=.5){const m=((orientAt(b)%360)+360)%360;if(m>=90&&m<=270)return true;}return false;};
1162 // --- phrases
1163 const phrases=bounds.map((b,i)=>{
1164  const role=roles[i]||'groove',R=STAGE_ROLES[role]||STAGE_ROLES.groove,o=(script.phrases||[])[i]||{},d={...R,...o};
1165  const end=bounds[i+1]??beats,span=Math.max(1,end-b),theme=cueTheme(b);
1166  const hazards=objects.filter(x=>(x.type==='spike'||x.type==='block'||x.type==='saw'||x.type==='slope')&&x.x>=b*unit&&x.x<end*unit).length;
1167  const density=stageClamp(1.2-.3*hazards/(span*beat),.45,1);
1168  const swayP=d.swayBeats*beat,swayMax=L.swayRateDegS*swayP/(2*Math.PI);
1169  const gate=portals.filter(p=>p.b>=b-.25&&p.b<=b+1).reduce((m,p)=>Math.max(m,p.b),-Infinity);
1170  const style=(theme.style==='crystal'&&tier.flips&&flippedDuring(b,end))?'city':theme.style;
1171  return {i,b,end,role,theme,style,drop:role==='drop',windup:d.windup!==false,
1172   roll:stageClamp(d.roll*tier.amp,-cap,cap),zoom:stageClamp(1+(d.zoom-1)*tier.zoomAmp,L.zoomMin,L.zoomMax),
1173   sway:Math.min(d.sway*tier.swayAmp*density,swayMax,cap),swayBeats:d.swayBeats,beats:d.beats,
1174   hue:stageSafeHue(theme.hue+d.hue*tier.hueAmp),light:stageClamp(1+(d.light-1)*tier.lightAmp,L.lightMin,L.lightMax),
1175   tint:stageClamp(d.tint,0,L.tintMax),rays:tier.layers?d.rays:0,raySpin:d.raySpin,streaks:tier.layers?d.streaks:0,mid:tier.layers?d.mid:0,
1176   prism:tier.layers?(d.prism||0):0,speed:d.speed,kick:d.kick,heroRise:!!d.heroRise,heroSpin:d.heroSpin||1,
1177   start:Number.isFinite(gate)?Math.max(b,gate+L.portalHold):b,from:d.from||null,density};
1178 });
1179 const ch={roll:[],zoom:[],sway:[],light:[],tint:[],rays:[],streaks:[],mid:[],prism:[],event:[]};
1180 const push=(name,b,len,to,ease='inOutSine')=>ch[name].push({b,len:Math.max(1e-3,len),to,ease});
1181 const first=phrases[0];
1182 // Phrase layer: roll/zoom moves (portal hold, rate-solved, deferred out of events), windups, settle.
1183 const moves=[];
1184 phrases.forEach((p,i)=>{
1185  const next=phrases[i+1];
1186  moves.push({b:deferred(p.start),o:0,kind:'phrase',p});
1187  // The wind-up ends exactly where the (portal-held) hit starts, so the counter-lean flows into the snap.
1188  if(next&&next.drop&&next.windup&&tier.windup){const hit=deferred(next.start),wb=hit-L.windupBeats;if(hit===next.start&&!inEvent(wb)&&!inEvent(hit-1e-3))moves.push({b:wb,o:1,kind:'windup',p,next});}
1189 });
1190 moves.push({b:deferred(beats-L.settleBeats),o:2,kind:'settle'});
1191 moves.sort((a,b)=>a.b-b.b||a.o-b.o);
1192 let roll=first.from?.roll??first.roll,zoom=first.from?.zoom??first.zoom;
1193 const rollInit=roll,zoomInit=zoom;
1194 for(const m of moves){
1195  const cur=stageChannel(stageResolve(ch.roll,rollInit),m.b),curZ=stageChannel(stageResolve(ch.zoom,zoomInit),m.b);
1196  if(m.kind==='windup'){const target=-Math.sign(m.next.roll||1)*Math.min(L.windupDeg*tier.amp,cap),len=L.windupBeats,dz=Math.min(L.zoomMax,curZ+L.windupZoom*tier.zoomAmp);
1197   if(stageMinBeats(target-cur,tier.vMove,'inQuad',beat)<=len)push('roll',m.b,len,target,'inQuad');push('zoom',m.b,len,dz,'inQuad');continue;}
1198  if(m.kind==='settle'){push('roll',m.b,L.settleBeats,0);push('zoom',m.b,L.settleBeats,1);push('sway',m.b,2,0);continue;}
1199  const p=m.p;if(p.i===0&&!p.from)continue;
1200  const ease=p.drop?tier.dropEase:'inOutSine',vmax=p.drop?tier.vDrop:tier.vMove,zEase=p.drop&&ease==='outCubic'?'outCubic':'inOutSine';
1201  const len=Math.max(p.beats,stageMinBeats(p.roll-cur,vmax,ease,beat),stageMinBeats(p.zoom-curZ,L.zoomRatePerS,zEase,beat));
1202  push('roll',m.b,len,p.roll,ease);push('zoom',m.b,len,p.zoom,zEase);
1203 }
1204 stageResolve(ch.roll,rollInit);stageResolve(ch.zoom,zoomInit);
1205 // Palette and layers follow the phrase line itself (no portal hold): colour is not motion.
1206 let light=first.light;
1207 phrases.forEach(p=>{
1208  const lb=Math.max(2,stageMinBeats(p.light-light,L.lightRatePerS,'inOutSine',beat));push('light',p.b,lb,p.light);light=p.light;
1209  const fb=Math.max(1,Math.min(4,p.beats));push('tint',p.b,fb,p.tint);push('rays',p.b,fb,p.rays);push('streaks',p.b,1,p.streaks);push('mid',p.b,2,p.mid);push('prism',p.b,4,p.prism);
1210  push('sway',deferred(p.b),2,p.sway);
1211 });
1212 for(const [name,init] of [['light',first.light],['tint',first.tint],['rays',0],['streaks',0],['mid',first.mid],['prism',0],['sway',0]])stageResolve(ch[name],init);
1213 ch.event=evSegs;
1214 // Integrated positions: ray angle (deg per beat) and background scroll offset (speed ramps over 2 beats, in x).
1215 const spinAt=[];let spin=0;phrases.forEach(p=>{spinAt.push(spin);spin+=p.raySpin*(p.end-p.b);});
1216 const speedSegs=[];let sPrev=first.speed,off=0,xPrev=0;
1217 phrases.forEach((p,i)=>{const x0=p.b*unit,x1=x0+2*unit;if(i===0){sPrev=p.speed;return;}off+=(sPrev-1)*(x0-xPrev);speedSegs.push({x0,x1,s0:sPrev,s1:p.speed,off});off+=((sPrev+p.speed)/2-1)*(x1-x0);xPrev=x1;sPrev=p.speed;});
1218 const maxPhrase=Math.max(...phrases.map(p=>Math.abs(p.roll)+p.sway),Math.abs(rollInit),L.windupDeg)+.5;
1219 let maxEvent=0;for(const e of evSegs)maxEvent=Math.max(maxEvent,Math.abs(e.from),Math.abs(e.to));
1220 const zoomMin=Math.min(1,...ch.zoom.map(s=>Math.min(s.from,s.to)));
1221 return {id:level.id,tier:tierName,cap,phrases,events,ch,spinAt,speedSegs,firstSpeed:first.speed,unit,beat,beats,
1222  maxRoll:Math.min(180,maxPhrase+maxEvent),zoomMin:Math.max(L.zoomMin-.01,zoomMin)};
1223}
1224function stageSpeedOffset(plan,x){
1225 const S=plan.speedSegs;if(!S.length)return (plan.firstSpeed-1)*x;
1226 let k=-1;for(let i=0;i<S.length;i++){if(S[i].x0<=x)k=i;else break;}
1227 if(k<0)return (plan.firstSpeed-1)*x;
1228 const s=S[k];if(x<=s.x1){const u=x-s.x0,w=s.x1-s.x0;return s.off+(s.s0-1)*u+(s.s1-s.s0)*u*u/(2*w);}
1229 return s.off+((s.s0+s.s1)/2-1)*(s.x1-s.x0)+(s.s1-1)*(x-s.x1);
1230}
1231/** Sample one compiled plan at beat b / world x. Pure. Roll in degrees. */
1232function stageSample(plan,b,x){
1233 const P=plan.phrases;let i=0;while(i+1<P.length&&P[i+1].b<=b)i++;const p=P[i],prev=P[i-1],ch=plan.ch;
1234 let dist=1;for(const e of plan.events)dist=Math.min(dist,b<e.b?e.b-b:b>e.b+e.len?b-e.b-e.len:0);const gateSway=stageClamp(dist,0,1);
1235 const swayAmp=stageChannel(ch.sway,b)*gateSway,sway=swayAmp*Math.sin(2*Math.PI*(b-p.b)/p.swayBeats);
1236 const event=stageChannel(ch.event,b),phrase=stageChannel(ch.roll,b);
1237 const fade=prev&&prev.style!==p.style&&b-p.b<STAGE_LIMITS.crossfadeBeats?{style:prev.style,a:(b-p.b)/STAGE_LIMITS.crossfadeBeats}:null;
1238 const active=!!plan.events.find(e=>b>=e.b&&b<e.b+e.len),m=((event%360)+360)%360;
1239 return {phrase:i,role:p.role,roll:phrase+sway+event,event,eventActive:active,upright:!active&&(m<1e-6||m>360-1e-6),zoom:stageChannel(ch.zoom,b),hue:p.hue,light:stageChannel(ch.light,b),
1240  tint:stageChannel(ch.tint,b),rays:stageChannel(ch.rays,b),rayAngle:(plan.spinAt[i]+p.raySpin*(b-p.b))*Math.PI/180,
1241  streaks:Math.round(stageChannel(ch.streaks,b)),mid:stageChannel(ch.mid,b),prism:stageChannel(ch.prism,b),
1242  heroY:p.heroRise?(1-STAGE_EASE.inOutSine(stageClamp((b-p.b)/16,0,1)))*90:0,heroSpin:p.heroSpin,kickWeight:p.kick,
1243  style:p.style,fade,speedOffset:stageSpeedOffset(plan,x)};
1244}
1245/** Buffer half-extents (logical px, even) so the rolled/zoomed background covers [0,W]x[0,coverBottom] for every roll up to maxRoll. */
1246function stageExtents(W,rollDeg,zoom=1){
1247 const hw=W/2,hh=STAGE_LIMITS.coverBottom/2,a=Math.abs(rollDeg)*Math.PI/180,c=Math.abs(Math.cos(a)),s=Math.abs(Math.sin(a));
1248 const even=v=>Math.ceil(v/2)*2;return {ex:even((hw*c+hh*s)/zoom+4),ey:even((hw*s+hh*c)/zoom+4)};
1249}
1250function stageAlloc(W,maxRoll,zoomMin){let hx=0,hy=0;for(let d=0;d<=Math.min(180,maxRoll)+1e-9;d+=.5){const e=stageExtents(W,d,zoomMin);hx=Math.max(hx,e.ex);hy=Math.max(hy,e.ey);}return {hx,hy};}
1251
1252function stageAllocCached(p,W,maxRoll,zoomMin){const k=W+':'+maxRoll+':'+zoomMin;if(p._stageAllocKey!==k){p._stageAllocKey=k;p._stageAlloc=stageAlloc(W,maxRoll,zoomMin);}return p._stageAlloc;}
1253const stageWrap=v=>((v+180)%360+360)%360-180;
1254/** Order-sensitive digest of a chart (type, position, gravity, mode). Workshop edits objects in place, so a
1255 * plan cache keyed on the level object alone would outlive an edit. */
1256function stageObjectsSig(objects){let h=(objects.length*2654435761)>>>0;
1257 for(const o of objects){const k=String(o.type||'')+'|'+(o.gravity||0)+'|'+(o.mode||'');for(let i=0;i<k.length;i++)h=Math.imul(h^k.charCodeAt(i),16777619)>>>0;
1258  h=Math.imul(h^Math.round((o.x||0)*8),16777619)>>>0;h=Math.imul(h^Math.round((o.y||0)*8),16777619)>>>0;}
1259 return h;}
1260const stagePlans=new WeakMap(),stageEnvIds=new WeakMap();let stageEnvSeq=0;
1261const stageEnvId=env=>{if(!env)return 0;let id=stageEnvIds.get(env);if(!id)stageEnvIds.set(env,id=++stageEnvSeq);return id;};
1262/** Plans are cached per level object and tier. Official stages are frozen. A Workshop / imported chart is
1263 * re-keyed once per run on everything the compiler reads (tempo, length, speed, track, difficulty, the object
1264 * digest and, for a local song, the decoded envelope), and a changed key drops that level's old plans. */
1265function stagePlanFor(level,tierName,env,token){
1266 let m=stagePlans.get(level);if(!m)stagePlans.set(level,m={plans:new Map(),key:null,token:undefined});
1267 if(level.custom&&(token===undefined||m.token!==token)){
1268  const key=[level.bpm,level.beat,level.beats,level.speed,level.trackId,level.difficulty,stageObjectsSig(level.objects||[]),STAGE_FORM[level.trackId]?0:stageEnvId(env)].join(':');
1269  m.token=token;if(key!==m.key){m.key=key;m.plans.clear();}
1270 }
1271 let plan=m.plans.get(tierName);
1272 if(!plan){plan=stageCompile(level,tierName,level.custom?env:null);m.plans.set(tierName,plan);}return plan;
1273}
1274const stageQ=(a,q)=>{const v=[...a].sort((x,y)=>x-y);return v[Math.floor((v.length-1)*q)]||0;},stageQ90=a=>stageQ(a,.9);
1275/** Trial (or frame hold) without measurable help: back to the level it started from, and no new trial for 32 checks,
1276 * doubling to 128 (a draw miss whose p90 climbs another 1 ms may start one sooner). */
1277function stageRestore(g,from,dq,entry,wt){const L=STAGE_LIMITS;
1278 g.want=from;g.restores++;g.hold=Math.min(L.govHoldMaxChecks,L.govHoldChecks*2**(g.restores-1));g.holdDq=dq;g.trial=null;g.heldBy=null;g.frameHold=null;
1279 entry.outcome='restored';entry.at=+wt.toFixed(2);}
1280/** Frame-budget governor. Levels: 3 full+layers, 2 full motion, 1 subtle, 0 off.
1281 * Runs. Every run (a new app.runToken: a new attempt, retry, checkpoint or resume) starts at the player's own level (Stage
1282 * motion, Low graphics) with a clean slate: no level, cap, hold or trial carries over. Across runs the painter keeps only
1283 * the trial log and a hint (prior: earlier runs, and how many of them produced hard evidence). The hint is read by the
1284 * pre-flight check alone (a prior hard verdict makes it choose leans); it never sets a level, so no run starts Off.
1285 * Measurement. Every 30 play frames, once the last 45 frames were all drawn at the level in force (a level change or a
1286 * blend restarts that count, so no decision rests on frames of the previous level), it reads the draw p90 (dq) and median
1287 * (dm) and the median frame interval (fm) of those 45 frames. The run's first check records its opening baselines: the
1288 * frame median (fiBase, never above 60 Hz) and the draw median (dmBase). The frame line is max(22 ms, 1.5 x fiBase + 4 ms):
1289 * a 120 -> 60 Hz switch (16.7 ms) never crosses it, a halved 60 Hz frame rate (33 ms) does.
1290 * Trial. Over budget (dq > 14 ms, or fm over the line) steps down one level and measures again there, one level at a time
1291 * while the miss lasts. Frame intervals alone never take the stage below Subtle: a frame-only miss at Subtle starts no
1292 * trial, a frame-only trial stops at Subtle, and any trial only steps from Subtle to Off while the draw is still over
1293 * budget. The stage is only blamed when a lower level measurably helped against the level that missed:
1294 * - hard (draw misses only): the missing level's draw median had risen >= 1.5 ms above dmBase and was above 11.2 ms, and
1295 *   the lower level cut it by >= 0.5 ms. The only evidence that strikes: two hard verdicts against a level cap the run below it.
1296 * - draw (the median fell >= 0.5 ms, but the cost was already there at the opening, or the median 
1296was <= 11.2 ms) and tail
1297 *   (the p90 fell >= 1.5 ms back within budget while the median held: a costly layer phrase, or the end of a spike): the
1298 *   lower level is kept and the level above retried after 8 checks with the draw within budget (doubling to 32; frame
1299 *   intervals do not hold a retry back: a host that stays slow must not keep the stage down). Never a cap.
1300 * - frame (the frame median fell >= 4 ms from above the line): outcome 'held', never 'kept', never hard, never a cap: frame
1301 *   intervals also stretch for reasons that have nothing to do with the stage (the host, other tabs, heat), and a step
1302 *   down can coincide with the host recovering. The lower level is held for 32 checks (doubling to 128 on each later
1303 *   frame hold in the run), then comes back one level at the first check whose 45-frame median is within 2 ms of fiBase
1304 *   (a display faster than 60 Hz counts as 60 Hz here). A hold ends early, restored like a trial without help, as soon as
1305 *   the held level stops beating the missed level by 4 ms while frames are over the line (or once the hold has run).
1306 * - No measurable help (for a frame-only miss only a frame gain counts), even at the floor: the trial restores the level
1307 *   it started from and holds off new trials for 32 checks (doubling to 128); only a draw miss whose p90 climbs another
1308 *   1 ms may start one sooner.
1309 * A switch between the full and subtle plans only starts when the two plans stay within 40 degrees of each other for the
1310 * whole 1.5 s blend, so a governor change never cuts a flip in half. User-visible result of a step-down: 3->2 the flow
1311 * layers (rays, streaks, mid skyline, prism) fade out within 0.25 s; 2->1 motion blends to Subtle over 1.5 s; 1->0 blends
1312 * to a still background. Every trial, its evidence and its outcome is logged in the DEVICE CHECK report (stageReport). */
1313function stageGovern(p,app,active,userLevel,t,nameOf,gapOk){
1314 const L=STAGE_LIMITS,fresh=()=>({level:userLevel,user:userLevel,cap:userLevel,want:null,blend:null,trial:null,hold:0,holdDq:0,restores:0,strikes:[0,0,0,0],tries:[0,0,0,0],
1315  need:L.govRecoverChecks,good:0,heldBy:null,frameHold:null,upHold:0,frameKeeps:0,hard:0,trials:0,count:0,n:0,token:-1,fiBase:0,fiOpe
1315n:0,dmBase:0,flips:null,flipCheck:null,leanPending:false,
1316  drops:[],dropsTotal:0,retries:0,prior:{runs:0,hard:0}});
1317 let g=p.stageGov;if(!g||g.user!==userLevel)g=p.stageGov={...fresh(),drops:g?g.drops:[],dropsTotal:g?g.dropsTotal:0};
1318 if(g.token!==app.runToken){
1319  // A new run starts again from the player's level; its unfinished trial proves nothing.
1320  if(g.trial){g.trial.entry.outcome='restored';g.trial.entry.note='new run';}
1321  const prior=g.token===-1?g.prior:{runs:g.prior.runs+1,hard:g.prior.hard+(g.hard>0?1:0)};
1322  g=p.stageGov={...fresh(),token:app.runToken,drops:g.drops,dropsTotal:g.dropsTotal,retries:g.retries,prior};
1323 }
1324 const on=app.settings.stageGovernor!==false&&active&&app.state==='play';
1325 if(on&&g.want===null&&!g.blend)g.count++;
1326 if(on&&++g.n%L.govEveryFrames===0&&g.count>=L.govWindow){
1327  const d=app.metrics.drawMs,f=app.metrics.frameMs||[],W=L.govWindow;
1328  // Opening baselines. The frame median is never taken above 60 Hz: it adapts the line to faster displays, not to a stage
1329  // that already halves the frame rate from the first frame. The draw median is only taken from the opening frames at the
1330  // player's level (this frame's draw is logged after this call, so the first check sees 59): never after a trial began.
1331  if(!g.fiBase&&f.length>=60){const open=stageQ(f.slice(-60),.5);g.fiOpen=open;g.fiBase=Math.min(1000/60,open);}
1332  if(!g.dmBase&&d.length>=W&&!g.trials&&g.level===g.user)g.dmBase=stageQ(d.slice(-60),.5);
1333  const dw=d.slice(-W),dq=stageQ90(dw),dm=stageQ(dw,.5),fm=f.length>=W?stageQ(f.slice(-W),.5):0,cur=g.level;
1334  const line=Math.max(L.govFrameFloorMs,g.fiBase*1.5+4),drawBad=dq>L.govDrawP90Ms,frameBad=!!g.fiBase&&fm>line,bad=drawBad||frameBad;
1335  // Recovery compares against the run's real opening median (fiOpen), not the 60 Hz-capped fiBase: a steady
1336  // 40/48/50 Hz device would otherwise never read as recovered and a frame hold would last the whole run.
1337  const recovered=!!g.fiBase&&fm<=Math.max(g.fiOpen||g.fiBase,1000/60)+L.govRecoverFrameMs;
1338  if(g.trial){const T=g.trial,e=T.entry,gainD=T.dm-dm,gainF=T.fm-fm,gainT=T.dq-dq,floor=T.frameOnly||!drawBad?L.govFrameFloorLevel:0;
1339   const drawGain=!T.frameOnly&&gainD>=L.govGainDrawMs,frameGain=T.fm>line&&gainF>=L.govGainFrameMs;
1340   const hard=drawGain&&g.dmBase>0&&T.dm-g.dmBase>=L.govHardRiseMs&&T.dm>L.govHardDrawMs;
1341   const tail=!T.frameOnly&&!drawBad&&T.dq>L.govDrawP90Ms&&gainT>=L.govGainTailMs;
1342   const evidence=hard?'hard':drawGain?'draw':tail?'tail':frameGain?'frame':null;
1343   e.to=cur;e.gainDrawMs=+gainD.toFixed(2);e.gainTailMs=+gainT.toFixed(1);e.gainFrameMs=+gainF.toFixed(1);
1344   // Keep stepping down while the miss lasts: to the still background while the draw misses (only there is every stage
1345   // cost gone), never below Subtle on frame intervals alone.
1346   if(bad&&cur>floor){g.want=cur-1;}
1347   else if(evidence){const k=cur+1;
1348    if(hard){g.hard++;if(++g.strikes[k]>=L.govStrikes)g.cap=Math.min(g.cap,cur);}
1349    if(evidence==='frame'){g.frameKeeps++;g.upHold=Math.min(L.govHoldMaxChecks,L.govHoldChecks*2**(g.frameKeeps-1));g.frameHold={from:T.from,at:cur,ref:T.fm,dq:T.dq,entry:e};}
1350    else{g.tries[k]++;g.need=Math.min(L.govRecoverBackoffMax,L.govRecoverChecks*2**g.tries[k]);g.frameHold=null;}
1351    g.heldBy=evidence==='frame'?'frame':'draw';g.good=0;g.trial=null;e.outcome=evidence==='frame'?'held':'kept';e.evidence=evidence;e.capped=g.cap===cur;e.at=+app.world.t.toFixed(2);}
1352   else stageRestore(g,T.from,T.dq,e,app.world.t);
1353  }else if(g.heldBy==='frame'&&g.frameHold&&cur===g.frameHold.at&&g.frameHold.ref-fm<L.govGainFrameMs&&(frameBad||g.upHold<=0)){
1354   // A frame hold has to keep beating the level that missed; when it stops (frames over the line again, or the hold has
1355   // run without the frames recovering), the step down was the host's doing, not the stage's: restore.
1356   const H=g.frameHold;H.entry.note='held level stopped helping';stageRestore(g,H.from,H.dq,H.entry,app.world.t);
1357  }else if(bad&&cur>(drawBad?0:L.govFrameFloorLevel)&&(g.hold<=0||drawBad&&dq>g.holdDq+L.govHoldRiseMs)){
1358   const entry={t:+app.world.t.toFixed(2),cause:drawBad&&frameBad?'draw+frame':drawBad?'draw':'frame',from:cur,to:cur-1,draw:+dq.toFixed(1),frame:+fm.toFixed(1),outcome:null,capped:false};
1359   g.trial={from:cur,dq,dm,fm,frameOnly:!drawBad,entry};g.want=cur-1;g.good=0;g.hold=0;g.trials++;g.dropsTotal++;g.drops.push(entry);if(g.drops.length>L.govDropLog)g.drops.shift();
1360  }else{if(g.hold>0)g.hold--;if(g.upHold>0)g.upHold--;
1361   if(cur>=g.cap){g.heldBy=null;g.frameHold=null;g.good=0;}
1362   else if(g.heldBy==='frame'){if(g.upHold<=0&&recovered&&!drawBad){g.want=cur+1;g.retries++;g.heldBy=null;g.frameHold=null;}}
1363   else if(drawBad)g.good=0;else if(++g.good>=g.need){g.want=cur+1;g.good=0;g.retries++;}
1364  }
1365 }
1366 if(g.want!==null&&g.want!==g.level){
1367  const a=nameOf(g.level),b=nameOf(g.want);
1368  if(a===b){g.level=g.want;g.want=null;g.count=0;}
1369  else if(gapOk(a,b)){g.blend={from:a,to:b,t0:t};g.level=g.want;g.want=null;g.count=0;}
1370 }else if(g.want===g.level)g.want=null;
1371 return g;
1372}
1373/** Pre-flight check before a run's first flip/turn: the event plan only runs when the governor is at a
1374 * full level with nothing pending or on trial, the recent draw p90 leaves headroom, and n
1374o earlier run of this session
1375 * produced hard evidence (the prior hint); otherwise the run uses the lean variant (flips become leans). Normally decided
1376 * one beat before the first event, where both plans agree, so the switch is seamless. A late decision (the governor only
1377 * reached a full level after the first event had begun) never jumps: a blend in flight is retargeted, otherwise the flip
1378 * plan keeps playing until the two plans come within the blend gap and then blends (leanPending). */
1379function stageFlipLatch(g,app,plan,beat){
1380 if(g.flips!==null||!plan.events.length||beat<plan.events[0].b-1)return;
1381 if(app.settings.stageGovernor===false){g.flips=true;return;}
1382 const d=app.metrics?.drawMs||[],p90=d.length>=30?stageQ90(d.slice(-STAGE_LIMITS.govWindow)):0,prior=!!(g.prior&&g.prior.hard>0);
1383 const allowed=!prior&&g.level>=2&&g.want===null&&!g.trial&&p90<=STAGE_LIMITS.govFlipHeadroomMs;
1384 g.flips=allowed;g.flipCheck={beat:+beat.toFixed(2),p90:+p90.toFixed(1),allowed,late:beat>plan.events[0].b,prior};
1385 if(allowed)return;
1386 if(g.blend&&g.blend.to==='full')g.blend.to='lean';else g.leanPending=true;
1387}
1388const STAGE_IDENTITY=Object.freeze({roll:0,zoom:1,light:1,tint:.025,rays:0,streaks:0,mid:0,prism:0,heroY:0,heroSpin:1,speedOffset:0,kickWeight:0,event:0});
1389const STAGE_SHOWN_IDENTITY=Object.freeze({key:null,t:null,wt:0,dir:0,rollDeg:0,zoom:1,light:1,tint:.025,rays:0,mid:0,prism:0,heroY:0,streaks:0});
1390const stageThemeCache=new Map();
1391function stageThemeWithStyle(theme,style){if(!style||theme.style===style)return theme;const k=theme.name+':'+style;let v=stageThemeCache.get(k);if(!v)stageThemeCache.set(k,v=Object.freeze({...theme,style}));return v;}
1392function stageOff(p){p.stageShown=null;p.stageHand=null;return null;}
1393/** What the player sees: every pose passes here. src 'menu' | 'probe' | 'plan'; token = app.runToken.
1394 * Hand-over: a new source, or a new run (retry, restart, checkpoint; a resume keeps its pose and needs none), starts a
1395 * smoothstep blend from the pose last shown, at least handoffS long and stretched so roll, zoom and light each stay
1396 * under their ceilings. The target roll is unwrapped once, at the start, to the turn nearest the shown roll, and then
1397 * followed continuously: a target crossing the antipode can never reverse the blend.
1398 * Rate guard: then shown roll, zoom and light move at most ceiling x max(frame time, world-time advance of the same
1399 * run), and layer weights at most layerRatePerS, so nothing (a superseded governor blend, a layer switch, a plan
1400 * change) can jump in one frame. Plans are rate-solved under the same ceilings, so in steady play the guard is a
1401 * no-op; the audio kick (a <= .02 zoom pulse, stageFrame) is added after it and is not part of the .3/s choreography rate. */
1402function stageShow(p,st,src,t,token,wt){
1403 const L=STAGE_LIMITS,key=src==='menu'?'menu':src+':'+token,prev=p.stageShown;
1404 if(!prev||prev.key!==key){const f=prev||STAGE_SHOWN_IDENTITY,o=-360*Math.round((st.rollDeg-f.rollDeg)/360),d=Math.abs(st.rollDeg+o-f.rollDeg),dz=Math.abs(st.zoom-f.zoom),dl=Math.abs(st.light-f.light);
1405  const same=d<.01&&dz<1e-4&&dl<1e-4&&Math.abs(st.rays-f.rays)<.01&&Math.abs(st.mid-f.mid)<.01&&Math.abs(st.prism-f.prism)<.01&&Math.abs(st.heroY-f.heroY)<.1;
1406  p.stageHand=same?null:{from:{...f},o,t0:t,len:Math.max(L.handoffS,1.5*d/L.rollRateDegS,1.5*dz/L.zoomRatePerS,1.5*dl/L.lightRatePerS)};}
1407 let out=st;const h=p.stageHand;
1408 if(h){let u=(t-h.t0)/h.len;
1409  if(!(u<1))p.stageHand=null;
1410  else{u=Math.max(0,u);u=u*u*(3-2*u);const f=h.from,mix=(a,b)=>a+(b-a)*u;
1411   out={...st,rollDeg:f.rollDeg+(st.rollDeg+h.o-f.rollDeg)*u,zoom:mix(f.zoom,st.zoom),light:mix(f.light,st.light),tint:mix(f.tint,st.tint),rays:mix(f.rays,st.rays),mid:mix(f.mid,st.mid),prism:mix(f.prism,st.prism),heroY:mix(f.heroY,st.heroY),streaks:u<.5?f.streaks:st.streaks,handoff:u};}}
1412 let roll=out.rollDeg,zoom=out.zoom,light=out.light,rays=out.rays,mid=out.mid,prism=out.prism,dir=prev?.dir||0;
1413 if(prev){const span=Math.max(stageClamp(t-prev.t,0,.1),prev.key===key&&wt>prev.wt?wt-prev.wt:0),lim=rate=>rate*span*1.001+1e-9,step=(v,from,rate)=>from+stageClamp(v-from,-lim(rate),lim(rate));
1414  let dr=stageWrap(roll-prev.rollDeg);if(Math.abs(dr)>150&&dir&&Math.sign(dr)!==dir)dr-=Math.sign(dr)*360;
1415  const r=stageClamp(dr,-lim(L.rollRateDegS),lim(L.rollRateDegS));if(Math.abs(r)>1e-9)dir=Math.sign(r);
1416  roll=out.rollDeg+stageWrap(prev.rollDeg+r-out.rollDeg);
1417  zoom=step(zoom,prev.zoom,L.zoomRatePerS);light=step(light,prev.light,L.lightRatePerS);
1418  rays=step(rays,prev.rays,L.layerRatePerS);mid=step(mid,prev.mid,L.layerRatePerS);prism=step(prism,prev.prism,L.layerRatePerS);}
1419 p.stageShown={key,t,wt,dir,rollDeg:roll,zoom,light,tint:out.tint,rays,mid,prism,heroY:out.heroY,streaks:out.streaks};
1420 return {...out,roll:roll*Math.PI/180,rollDeg:roll,zoom,light,rays,mid,prism};
1421}
1422function stageMenuPose(p,userLevel,theme,t,W){
1423 const tierName=userLevel>=2?'full':'subtle',roll=Math.sin(t*2*Math.PI/16)*STAGE_TIERS[tierName].menuSway;
1424 return {menu:true,tier:tierName,gov:userLevel,rollDeg:roll,zoom:1,hue:theme.hue,light:1,tint:.025,rays:0,rayAngle:0,streaks:0,mid:0,prism:0,heroY:0,heroSpin:1,
1425  bgTheme:theme,bgScroll:null,fade:null,alloc:stageAllocCached(p,W,STAGE_TIERS.full.menuSway,1),pivotY:STAGE_LIMITS.pivotY,kick:0,role:'menu',phrase:-1};
1426}
1427/** Per-frame stage state for Painter.draw, or null (tier off / reduced motion / governor off): draw V2.12 exactly.
1428 * Arming probe (app.arm sets painter.stageProbe while it chooses renderScale): the probe draws the run's own first
1429 * frame, so the player sees no extra motion and play starts from the very pose that was priced. renderScale only
1430 * changes the main-canvas raster, and that raster costs more once the background blit is rotated than for an
1431 * axis-aligned copy; an upright first frame (roll exactly 0) would price the cheap copy, so it is nudged to
1432 * probeRollDeg (1 deg) and handed back to the plan over handoffS. The larger fill of big rolls and the flow layers are
1433 * 0.5x-buffer draw time that no renderScale buys back: that is the governor's job, not the probe's. */
1434function stageFrame(p,app,active,t,motion,theme){
1435 if(!motion||app.motionQuery?.matches)return stageOff(p);const s=app.settings||{};
1436 const want=Number.isInteger(s.stage)?stageClamp(s.stage,0,2):2;if(!want)return stageOff(p);
1437 const userLevel=s.low?Math.min(1,want===2?3:1):want===2?3:1;
1438 const level=app.level,w=app.world,W=p.W,env=app.audio?.envelope||null,token=app.runToken;
1439 if(!active)return stageShow(p,stageMenuPose(p,userLevel,theme,t,W),'menu',t,token,w.t);
1440 const full=stagePlanFor(level,'full',env,token);
1441 const beat=Math.max(0,w.t/level.beat);
1442 const planOf=name=>name==='full'?full:name==='lean'?stagePlanFor(level,'fullLean',env,token):stagePlanFor(level,'subtle',env,token);
1443 const nameOf=l=>{const g=p.stageGov;return l>=2?(g&&g.flips===false&&!g.leanPending?'lean':'full'):l===1?'subtle':'off';};
1444 const rollAt=(name,b)=>
1444name==='off'?0:stageSample(planOf(name),b,b*level.speed*level.beat).roll;
1445 const gapOk=(a,b)=>{for(let k=0;k<=6;k++){const bb=beat+k/6*STAGE_LIMITS.govBlendS/level.beat;if(Math.abs(stageWrap(rollAt(a,bb))-stageWrap(rollAt(b,bb)))>STAGE_LIMITS.govBlendGapDeg)return false;}return true;};
1446 const g=stageGovern(p,app,active,userLevel,t,nameOf,gapOk);
1447 if(g.level>=2)stageFlipLatch(g,app,full,beat);
1448 if(g.leanPending&&!g.blend){
1449  const A=stageSample(full,beat,w.x),B=stageSample(planOf('lean'),beat,w.x);
1450  if(Math.abs(stageWrap(A.roll-B.roll))<.01&&Math.abs(A.zoom-B.zoom)<1e-4)g.leanPending=false;
1451  else if(gapOk('full','lean')){g.blend={from:'full',to:'lean',t0:t};g.leanPending=false;}
1452 }
1453 const cur=nameOf(g.level);
1454 const sample=name=>name==='off'?null:stageSample(planOf(name),beat,w.x);
1455 let st=sample(cur),u=1;
1456 if(g.blend){u=stageClamp((t-g.blend.t0)/STAGE_LIMITS.govBlendS,0,1);u=u*u*(3-2*u);if(u>=1)g.blend=null;}
1457 if(g.blend){const A=sample(g.blend.from)||{...STAGE_IDENTITY,hue:theme.hue,style:theme.style,fade:null,phrase:-1,role:'off'},B=sample(g.blend.to)||{...STAGE_IDENTITY,hue:theme.hue,style:theme.style,fade:null,phrase:-1,role:'off'};
1458  const mix={roll:stageWrap(A.roll)+(stageWrap(B.roll)-stageWrap(A.roll))*u};for(const k of ['zoom','light','tint','rays','mid','prism','heroY','speedOffset'])mix[k]=A[k]+(B[k]-A[k])*u;
1459  st={...B,...mix,streaks:u<.5?A.streaks:B.streaks,hue:B.hue};
1460 }else if(!st)return stageOff(p);
1461 const layers=g.level>=3&&!s.low,Lf=layers?1:0,tier=STAGE_TIERS[cur==='subtle'?'subtle':'full'];
1462 const alloc=stageAllocCached(p,W,Math.max(full.maxRoll,STAGE_TIERS.full.menuSway),Math.min(full.zoomMin,1));
1463 const pose={menu:false,tier:cur,gov:g.level,rollDeg:st.roll,zoom:stageClamp(st.zoom,STAGE_LIMITS.zoomMin-.01,STAGE_LIMITS.zoomMax),hue:st.hue,light:st.light,tint:st.tint,
1464  rays:st.rays*Lf,rayAngle:st.rayAngle||0,streaks:Lf?st.streaks:0,mid:st.mid*Lf,prism:st.prism*Lf,heroY:st.heroY,heroSpin:st.heroSpin,
1465  bgTheme:stageThemeWithStyle(theme,st.style),bgScroll:w.x+st.speedOffset,fade:Lf?st.fade:null,alloc,pivotY:STAGE_LIMITS.pivotY,kick:0,role:st.role,phrase:st.phrase,event:st.event||0,flips:g.flips};
1466 if(p.stageProbe){pose.probe=true;if(Math.abs(pose.rollDeg)<STAGE_LIMITS.probeRollDeg)pose.rollDeg=(pose.rollDeg<0?-1:1)*STAGE_LIMITS.probeRollDeg;}
1467 const out=stageShow(p,pose,p.stageProbe?'probe':'plan',t,token,w.t);
1468 const kick=app.state==='play'&&s.reactive!==false?stageKick(env,w.t):0;
1469 return kick?{...out,kick,zoom:stageClamp(out.zoom+tier.kickZoom*kick*(st.kickWeight||0),STAGE_LIMITS.zoomMin-.01,STAGE_LIMITS.zoomMax+.03)}:out;
1470}
1471/** Stage state for the DEVICE CHECK report / export: setting, what is drawn, the governor's trial log (each step-down
1472 * with its cause, how far it went, its outcome (kept = the stage cost the budget; restored = it did not) and, when kept, its
1473 * evidence: hard / draw / tail / frame), the pre-flight check and the hint earlier runs left (prior). */
1474function stageReport(p,app){
1475 const s=app.settings||{},g=p.stageGov||null,sys=!!app.motionQuery?.matches,want=Number.isInteger(s.stage)?stageClamp(s.stage,0,2):2;
1476 const off=s.reduced?'reduced motion':sys?'system reduced motion':!want?'setting':g&&g.level===0?'governor':null;
1477 const names=['off','subtle','full','full+layers'];
1478 return {version:STAGE_VERSION,setting:STAGE_LEVELS[want],lowGraphics:!!s.low,reducedMotion:!!s.reduced,systemReducedMotion:sys,offBecause:off,
1479  level:off?'off':g?names[g.level]:names[s.low?1:want===2?3:1],cap:g?names[g.cap]:null,flips:g?g.flips:null,flipCheck:g?.flipCheck??null,
1480  stepDowns:g?g.dropsTotal:0,retries:g?g.retries:0,drops:g?g.drops.map(d=>({...d})):[],prior:g&&g.prior?{...g.prior}:null};
1481}
1482
1483/** Inspection helper (docs/tests): the resolved directive table for one stage. */
1484function stageTable(level,tierName='full'){
1485 const plan=stageCompile(level,tierName,null);
1486 return {id:level.id,tier:tierName,cap:plan.cap,maxRoll:plan.maxRoll,zoomMin:plan.zoomMin,
1487  phrases:plan.phrases.map(p=>({b:p.b,cue:level.cues.find(c=>c.b===p.b)?.name??null,role:p.role,start:+p.start.toFixed(2),roll:+p.roll.toFixed(2),zoom:+p.zoom.toFixed(3),sway:+p.sway.toFixed(2),
1488   hue:Math.round(p.hue),light:p.light,rays:p.rays,raySpin:p.raySpin,streaks:p.streaks,mid:p.mid,prism:p.prism,speed:p.speed,style:p.style})),
1489  events:plan.events.map(e=>({kind:e.kind,b:+e.b.toFixed(2),beats:+e.len.toFixed(2),seconds:+(e.len*plan.beat).toFixed(2),delta:e.delta}))};
1490}
1491
1492
1493// paint.mjs
1494
1495
1496
1497const COLORS=['#c7ff3e','#58f6ff','#ff61d4','#ffe165','#ff8353','#b28cff','#ffffff','#6bf9bb'];
1498function polygon(c,pts,fill,stroke=null,width=2){c.beginPath();
1498pts.forEach(([x,y],i)=>i?c.lineTo(x,y):c.moveTo(x,y));c.closePath();if(fill){c.fillStyle=fill;c.fill();}if(stroke){c.strokeStyle=stroke;c.lineWidth=width;c.stroke();}}
1499function line(c,x,y,X,Y,color,width=1){c.strokeStyle=color;c.lineWidth=width;c.beginPath();c.moveTo(x,y);c.lineTo(X,Y);c.stroke();}
1500function circle(c,x,y,r,fill,stroke=null,w=1){c.beginPath();c.arc(x,y,r,0,Math.PI*2);if(fill){c.fillStyle=fill;c.fill();}if(stroke){c.lineWidth=w;c.strokeStyle=stroke;c.stroke();}}
1501function gear(c,x,y,r,angle,fill,stroke,teeth=12){const a=[];for(let i=0;i<teeth*4;i++){const rot=angle+i*Math.PI*2/(teeth*4),s=(i%4===0||i%4===3)?.77:1;a.push([x+Math.cos(rot)*r*s,y+Math.sin(rot)*r*s]);}polygon(c,a,fill,stroke,1.6);circle(c,x,y,r*.39,null,stroke,1.5);circle(c,x,y,r*.12,stroke);}
1502function diamond(c,x,y,r,color){polygon(c,[[x,y-r],[x+r*.55,y],[x,y+r],[x-r*.55,y]],color);}
1503// Integer hash: stable window lights and genuinely varied silhouette heights.
1504function scenerySeed(index){let n=(index|0)+0x6d2b79f5;n=Math.imul(n^(n>>>15),n|1);n^=n+Math.imul(n^(n>>>7),n|61);return ((n^(n>>>14))>>>0)/4294967296;}
1505function player(c,x,y,mode='cube',angle=0,color=COLORS[0],size=32,icon=0,t=0){
1506 c.save();c.translate(x,y);c.rotate(angle);c.scale(size/32,size/32);c.lineJoin='round';
1507 const ink='#081827',ice='#cfffff',blue='#2ee3f5';
1508 if(mode==='ship'){
1509  polygon(c,[[-28,-4],[-52-Math.sin(t*50)*10,4],[-28,10]],'#ffa342');polygon(c,[[-27,0],[-44,4],[-27,6]],'#ffff9a');
1510  polygon(c,[[-25,-5],[5,-7],[26,3],[6,15],[-19,13],[-28,7]],color,ink,3);polygon(c,[[-14,0],[5,-2],[16,4],[-3,8],[-15,6]],blue,ink,2);
1511  polygon(c,[[-18,12],[-9,20],[6,13]],color,ink,2);circle(c,-3,-8,11,blue,ink,3);line(c,-9,-10,1,-14,ice,2);
1512 }else if(mode==='wave'){
1513  polygon(c,[[-18,-13],[20,0],[-18,13],[-9,0]],color,ink,3);polygon(c,[[-10,-6],[11,0],[-10,6],[-5,0]],ice);
1514 }else if(mode==='ufo'){
1515  c.beginPath();c.ellipse(0,-2,14,13,0,Math.PI,0);c.fillStyle=blue;c.fill();c.strokeStyle=ink;c.lineWidth=3;c.stroke();
1516  polygon(c,[[-24,2],[-16,-3],[16,-3],[24,2],[14,12],[-14,12]],color,ink,3);for(let i=-1;i<=1;i++)circle(c,i*11,4,3,ice,ink);
1517 }else if(mode==='ball')gear(c,0,0,19,0,color,ink,9);
1518 else if(mode==='robot'||mode==='spider'){
1519  if(mode==='robot'){const k=Math.sin(t*23)*4;polygon(c,[[-14,7],[-6,7],[-5,18+k],[-19,18+k]],color,ink,3);polygon(c,[[4,7],[13,7],[19,18-k],[7,18-k]],blue,ink,3);}
1520  else for(let j of [-1,1])for(let i=0;i<3;i++)line(c,j*5,0,j*(20+i*3),12-i*7,color,4);
1521  polygon(c,[[-16,-16],[12,-16],[17,2],[9,11],[-15,8]],color,ink,3);polygon(c,[[-8,-8],[10,-8],[10,0],[-8,0]],blue,ink,2);line(c,-7,-7,7,-7,ice,1);
1522 }else{
1523  c.fillStyle=color;c.fillRect(-16,-16,32,32);c.strokeStyle=ink;c.lineWidth=3;c.strokeRect(-16,-16,32,32);
1524  c.strokeStyle='rgba(255,255,255,.7)';c.lineWidth=1;c.strokeRect(-13,-13,26,26);
1525  if(icon%3===1){polygon(c,[[0,-11],[11,0],[0,11],[-11,0]],blue,ink,2);circle(c,0,0,3,ink);}
1526  else if(icon%3===2){polygon(c,[[-11,-8],[-1,-3],[-10,1]],blue,ink,2);polygon(c,[[11,-8],[1,-3],[10,1]],blue,ink,2);c.fillStyle=ink;c.fillRect(-7,7,14,3);}
1527  else{c.fillStyle=blue;c.fillRect(-10,-8,7,8);c.fillRect(3,-8,7,8);c.fillRect(-9,6,18,5);c.strokeStyle=ink;c.lineWidth=2;c.strokeRect(-10,-8,7,8);c.strokeRect(3,-8,7,8);c.strokeRect(-9,6,18,5);}
1528 }
1529 c.restore();
1530}
1531class Painter{
1532 constructor(canvas){this.canvas=canvas;this.c=canvas.getContext('2d',{alpha:false,desynchronized:true});this.W=960;this.hue=208;this.trail=[];this.particles=[];this.rings=[];this.last=0;this.shake=0;}
1533 resize(width,height){const d=Math.min(devicePixelRatio||1,2)*(this.renderScale||1);const pw=Math.round(width*d),ph=Math.round(height*d);if(this.canvas.width!==pw)this.canvas.width=pw;if(this.canvas.height!==ph)this.canvas.height=ph;this.W=(width>0&&height>0)?width/height*540:960;}
1534 event(e,color){
1535  if(e.kind==='jump'||e.kind==='land'){for(let i=0;i<7;i++)this.particles.push({x:e.x-12,y:e.y+16,vx:-30-i*7,vy:-25-i*7,ttl:.3+i*.02,max:.5,size:2,color});return;}
1536  if(['death','coin','portal','orb','pad','spider','win'].includes(e.kind)){
1537   const n=e.kind==='death'?48:e.kind==='win'?65:18;
1538   for(let i=0;i<n;i++){const a=i*2.3999,speed=(e.kind==='death'?80:40)+(i%9)*20;this.particles.push({x:e.x,y:e.y,vx:Math.cos(a)*speed,vy:Math.sin(a)*speed,ttl:.5+(i%7)*.07,max:1,size:2+i%5,color:e.kind==='coin'?'#ffec7d':i%4?color:'#fff'});}
1539   this.rings.push({x:e.x,y:e.y,ttl:.6,color,kind:e.kind});if(e.kind==='death')this.shake=.3;
1540  }
1541 }
1542 background(theme,scroll,t,pulse,low){
1543  const c=this.c,W=this.W,h=this.hue;
1544  const bg=c.createLinearGradient(0,0,0,540);bg.addColorStop(0,`hsl(${h},86%,14%)`);bg.addColorStop(.58,`hsl(${h},87%,30%)`);bg.addColorStop(1,`hsl(${h},90%,42%)`);c.fillStyle=bg;c.fillRect(0,0,W,540);
1545  const halo=c.createRadialGradient(W*.62,270,0,W*.62,270,W*.66);halo.addColorStop(0,`hsla(${h+25},100%,65%,.14)`);halo.addColorStop(1,'transparent');c.fillStyle=halo;c.fillRect(0,0,W,540);
1546  // Distant architectural rings are scenery, not hazards.
1547  if(!low){const fx=W*.73-(scroll*.025)%(W+700);c.save();c.globalAlpha=.17+pulse*.04;
1548   if(theme.style==='city'){circle(c,fx,180,105,null,theme.accent,1);circle(c,fx,180,94,null,theme.accent,.6);for(let i=0;i<12;i++){const a=i*Math.PI/6;line(c,fx+Math.cos(a)*100,180+Math.sin(a)*100,fx+Math.cos(a)*109,180+Math.sin(a)*109,theme.accent,2);}}
1549   else if(theme.style==='factory')gear(c,fx,210,128,t*.025,'rgba(6,7,16,.3)',theme.accent,18);
1550   else if(theme.style==='crystal'){diamond(c,fx,180,96,'rgba(234,134,255,.16)');circle(c,fx,180,122,null,theme.accent,.7);}
1551   c.restore();}
1552  c.save();c.globalAlpha=.045;for(let i=-1;i<5;i++){const xx=i*300-scroll*.055%300;polygon(c,[[xx,-60],[xx+70,-60],[xx+510,470],[xx+210,470]],'#befcff');}c.restore();
1553  for(let layer=0;layer<2;layer++){
1554   const spacing=layer?90:145,move=scroll*(layer?.13:.06),start=Math.floor(move/spacing)-1;
1555   for(let i=start;i<(move+W)/spacing+1;i++){
1556    const x=i*spacing-move,seed=scenerySeed(i+layer*901),height=65+seed*(layer?155:255),y=FLOOR-height;
1557    if(theme.style==='city'){
1558     c.fillStyle=layer?'rgba(1,11,30,.31)':'rgba(4,18,40,.28)';c.fillRect(x,y,spacing-8,height+25);
1559     c.strokeStyle=layer?'rgba(73,169,238,.13)':'rgba(89,186,246,.07)';c.lineWidth=1;c.strokeRect(x+3,y+3,spacing-14,height);
1560     for(let row=0;row<(height-25)/23;row++)for(let col=0;col<(spacing-26)/19;col++)if(scenerySeed(i*101+row*17+col+layer*999)>.3){c.fillStyle=layer?'rgba(132,239,255,.22)':'rgba(127,194,254,.11)';c.fillRect(x+12+col*19,y+15+row*23,5,9);}
1561     line(c,x+spacing*.5,y,x+spacing*.5,y-15-seed*15,'rgba(117,213,250,.19)',1);
1562    }else if(theme.style==='crystal'){
1563     const p=[[x+spacing*.4,y-40],[x+spacing*.8,y+40],[x+spacing*.62,FLOOR+40],[x-12,FLOOR+40]];polygon(c,p,layer?'rgba(45,17,67,.5)':'rgba(26,10,56,.26)','rgba(221,135,255,.10)',1);
1564     line(c,x+spacing*.4,y-40,x+spacing*.4,FLOOR+40,'rgba(201,114,255,.12)',1);line(c,x+spacing*.4,y-40,x+spacing*.8,y+40,'rgba(244,184,255,.22)',1);
1565    }else if(theme.style==='factory'){
1566     c.fillStyle='rgba(27,5,16,.28)';c.fillRect(x,y,spacing-8,height);c.strokeStyle='rgba(255,126,63,.15)';c.lineWidth=4;c.strokeRect(x+14,y+15,spacing-36,height-30);
1567     if(layer)gear(c,x+spacing*.5,y+45,25,((i%2?1:-1)*t*.1),'rgba(21,6,15,.3)','rgba(255,162,75,.18)',9);
1568    }else{
1569     c.strokeStyle='rgba(108,255,212,.075)';c.lineWidth=2;c.beginPath();c.moveTo(x,FLOOR+20);c.bezierCurveTo(x+90,y+30,x-40,y,x+spacing*.5,y-60);c.stroke();
1570     diamond(c,x+spacing*.5,y-70,15+seed*20,'rgba(135,255,228,.07)');
1571    }
1572   }
1573  }
1574  if(!low)for(let i=0;i<38;i++){const x=((i*153.3-scroll*.07)% (W+40)+W+40)%(W+40)-20,y=55+(i*87.7)%335+Math.sin(t*.4+i)*8;circle(c,x,y,i%3?1:2,`rgba(174,240,255,${.13+(i%5)*.025})`);}
1575  if(pulse>0){c.fillStyle=`rgba(128,221,255,${pulse*.025})`;c.fillRect(0,0,W,FLOOR);}
1576 }
1577 ground(scroll,theme,top=false){const c=this.c,W=this.W,y=top?0:FLOOR,hh=top?ROOF:120;
1578  const g=c.createLinearGradient(0,y,0,y+hh);g.addColorStop(0,top?'#030918':`hsl(${this.hue} 80% 21%)`);g.addColorStop(1,top?`hsl(${this.hue} 75% 13%)`:'#020816');c.fillStyle=g;c.fillRect(0,y,W,hh);
1579  const base=top?ROOF:FLOOR;line(c,0,base,W,base,'#ebffff',2);line(c,0,base+(top?-4:4),W,base+(top?-4:4),theme.accent,1);
1580  const offset=scroll%64;for(let x=-offset-64;x<W+64;x+=64){c.fillStyle='rgba(0,0,0,.2)';c.fillRect(x+6,y+14,52,hh-27);c.strokeStyle='rgba(109,219,252,.15)';c.lineWidth=1;c.strokeRect(x+6,y+14,52,hh-27);line(c,x+14,y+21,x+47,y+21,'rgba(171,248,255,.14)',1);c.fillStyle=theme.accent;c.globalAlpha=.1;c.fillRect(x+26,y+29,11,hh-53);c.globalAlpha=1;}
1581 }
1582 object(raw,cam,t,theme,used,low,pulse=0){const c=this.c,o=shapeAt(raw,t),x=o.x-cam,y=o.y; if(x+(o.w||o.r)<-100||x>this.W+100)return;
1583  c.save();c.globalAlpha=raw.alpha??1;c.lineJoin='miter';const accent=theme.accent;
1584  if(o.type==='block'){
1585   const face=c.createLinearGradient(x,y,x+o.w,y+o.h);face.addColorStop(0,'#041b33');face.addColorStop(1,'#020817');c.fillStyle=face;c.fillRect(x,y,o.w,o.h);c.strokeStyle=accent;if(!low){c.globalAlpha=(raw.alpha??1)*.16;c.lineWidth=6;c.strokeRect(x-1.5,y-1.5,o.w+3,o.h+3);c.globalAlpha=raw.alpha??1;}c.lineWidth=2;c.strokeRect(x+.5,y+.5,o.w-1,o.h-1);
1586   c.strokeStyle='#fff';c.lineWidth=1;line(c,x+2,y+1,x+o.w-2,y+1,'#fff',1.1);
1587   c.strokeStyle=`hsla(${this.hue},100%,75%,.24)`;c.strokeRect(x+5,y+5,o.w-10,o.h-10);
1588   for(let yy=y+10;yy<y+o.h-8;yy+=36)for(let xx=x+10;xx<x+o.w-8;xx+=36){const s=Math.min(23,x+o.w-xx-5,y+o.h-yy-5);if(s>6){c.fillStyle=`hsla(${this.hue},80%,55%,.09)`;c.fillRect(xx,yy,s,s);line(c,xx,yy+s,xx+s,yy,'rgba(180,235,255,.08)',1);}}
1589   for(const xx of [x+5,x+o.w-8])for(const yy of [y+5,y+o.h-8]){c.fillStyle=accent;c.fillRect(xx,yy,3,3);}
1590   c.fillStyle=accent;c.globalAlpha=.15;c.fillRect(x+2,y+o.h-6,o.w-4,4);c.globalAlpha=1;
1591  }else if(o.type==='spike'){
1592   const p=o.flip?[[x,y],[x+o.w,y],[x+o.w/2,y+o.h]]:[[x,y+o.h],[x+o.w,y+o.h],[x+o.w/2,y]];
1593   const g=c.createLinearGradient(0,y,0,y+o.h);g.addColorStop(0,'#03020a');g.addColorStop(1,`hsl(${this.hue} 65% 16%)`);c.shadowColor=accent;c.shadowBlur=low?0:4;polygon(c,p,g,'#eeffff',1.7);c.shadowBlur=0;
1594   if(!o.flip){polygon(c,[[x+8,y+o.h-5],[x+o.w-8,y+o.h-5],[x+o.w/2,y+11]],null,accent,1);circle(c,x+o.w/2,y+o.h-8,1.5,'#fff');}
1595  }else if(o.type==='slope'){polygon(c,o.reverse?[[x,y],[x+o.w,y+o.h],[x,y+o.h]]:[[x,y+o.h],[x+o.w,y],[x+o.w,y+o.h]],'#020711',accent,2);}
1596  else if(o.type==='saw')gear(c,x,y,o.r,t*1.6,'#040713',accent);
1597  else if(o.type==='coin'&&!used.has(o.id)){
1598   c.translate(x,y);c.scale(.55+.45*Math.abs(Math.cos(t*1.7)),1);c.shadowColor='#ffe566';c.shadowBlur=low?0:12;circle(c,0,0,16,'#e5ac20','#fffaad',2);c.shadowBlur=0;circle(c,0,0,11,'#ffda40','#9e690f',1.5);diamond(c,0,0,7,'#fff7c4');
1599  }else if(o.type==='orb'&&!used.has(o.id)){
1600   circle(c,x,y,25+pulse*7,null,'rgba(255,236,100,.3)',1);circle(c,x,y,17,'#ffe751','#fffebd',2);circle(c,x,y,10,'#fffbbb');
1601  }else if(o.type==='pad'){
1602   c.beginPath();c.ellipse(x+o.w/2,y+8,o.w/2,10,0,Math.PI,Math.PI*2);c.fillStyle='#ffdc37';c.fill();c.strokeStyle='#fff4b4';c.lineWidth=2;c.stroke();
1603  }else if(o.type==='portal'){
1604   const colors={ship:'#f998ff',wave:'#59faff',cube:'#96ff68',ufo:'#ffb35f',robot:'#f4efff',ball:'#ff7158',spider:'#c691ff'};
1605   const col=o.gravity?'#ffe644':colors[o.mode]||accent,cy=(ROOF+FLOOR)/2;
1606   c.translate(x+10,cy);const r=145;
1607   const halo=c.createRadialGradient(0,0,5,0,0,100);halo.addColorStop(0,col+'25');halo.addColorStop(1,'transparent');c.fillStyle=halo;c.fillRect(-100,-r,200,r*2);
1608   c.beginPath();c.ellipse(0,0,24,r,0,0,Math.PI*2);c.fillStyle='#02080fc9';c.fill();c.strokeStyle=col;if(!low){c.globalAlpha=(raw.alpha??1)*(.14+pulse*.04);c.lineWidth=11+pulse*4;c.stroke();c.globalAlpha=raw.alpha??1;}c.lineWidth=5;c.stroke();
1609   c.beginPath();c.ellipse(6,0,23,r-3,0,0,Math.PI*2);c.strokeStyle='#fff';c.lineWidth=1.3;c.stroke();
1610   for(let i=0;i<16;i++){const a=t*(i%2?-.7:.7)+i*Math.PI/8;circle(c,Math.cos(a)*27,Math.sin(a)*r,2.7,col);}
1611   for(let i=-1;
1611i<2;i++){c.globalAlpha=.65;polygon(c,[[-7,-8+i*30],[4,i*30],[-7,8+i*30]],null,col,2);}c.globalAlpha=1;
1612   c.font='900 10px Arial';c.textAlign='center';c.fillStyle=col;c.fillText(o.label||o.mode.toUpperCase(),0,-r-14);
1613  }
1614  c.restore();
1615 }
1616 draw(app,ms){
1617  const t=ms/1000,dt=Math.min(.05,t-this.last||.016);this.last=t;const c=this.c,W=this.W;
1618  c.setTransform(this.canvas.width/W,0,0,this.canvas.height/540,0,0);
1619  const w=app.world,active=['play','pause','death','complete','arming'].includes(app.state),level=app.level;
1620  const cue=active?cueAt(level,w.x):level.cues[0],theme=THEMES[cue.theme],motion=!app.settings.reduced;
1621  // Stagecraft (background-only staging). null = tier off / reduced motion: the V2.12 path, unchanged.
1622  const stage=stageFrame(this,app,active,t,motion,theme);this.lastStage=stage;
1623  if(stage)this.hue=stageChaseHue(this.hue,stage.hue,dt);else this.hue+=(((theme.hue-this.hue+540)%360)-180)*Math.min(1,dt*3);
1624  const scroll=active?w.x:t*34,anchor=Math.min(340,W*.28),cam=active?w.x-anchor:scroll;
1625  const pulse=motion&&active&&app.settings.reactive!==false?Math.min(1,app.energy?.bass||0):0;
1626  c.save();if(motion&&this.shake>0)c.translate(Math.sin(t*130)*this.shake*14,Math.cos(t*91)*this.shake*9);this.shake=Math.max(0,this.shake-dt);
1627  this._stage=stage;this.background(stage?stage.bgTheme:theme,stage&&stage.bgScroll!==null?stage.bgScroll:scroll,t,pulse,app.settings.low);
1628  if(active){
1629   for(const d of level.decor){const xx=d.x-cam;if(xx<-150||xx>W+150)continue;c.save();c.globalAlpha=d.type==='guide'?.18:.11;
1630    if(d.type==='gear')gear(c,xx,d.y,60,t*.3,'#010c18',theme.accent,14);
1631    if(d.type==='monolith'){polygon(c,[[xx,d.y],[xx+42,d.y+20],[xx+42,d.y+d.h],[xx,d.y+d.h+22]],'#031627',theme.accent,1);line(c,xx+12,d.y+22,xx+12,d.y+d.h-12,theme.accent,2);}
1632    if(d.type==='guide'){circle(c,xx,d.y,24,null,theme.accent,1);polygon(c,[[xx-4,d.y-6],[xx+3,d.y],[xx-4,d.y+6]],null,theme.accent,2);}
1633    c.restore();
1634   }
1635   if(app.state==='play'&&!w.dead){this.trail.push({x:w.x,y:w.y,angle:w.angle,mode:w.mode});if(this.trail.length>40)this.trail.shift();}
1636   if(app.settings.trails){
1637    if(w.mode==='wave'){const points=this.trail.filter(z=>z.mode==='wave');c.beginPath();points.forEach((p,i)=>i?c.lineTo(p.x-cam,p.y):c.moveTo(p.x-cam,p.y));c.strokeStyle=app.color;c.lineWidth=6+pulse*3;c.stroke();c.strokeStyle='#edffff';c.lineWidth=2;c.stroke();}
1638    else for(let i=0,step=((devicePixelRatio||1)>1.5?10:6);i<this.trail.length;i+=step){const p=this.trail[i];c.globalAlpha=(i/this.trail.length)*.16;player(c,p.x-cam,p.y,p.mode,p.angle,app.color,44*w.size,app.settings.icon,t);}
1639    c.globalAlpha=1;
1640   }
1641   for(const o of level.objects)this.object(o,cam,w.t,theme,w.used,app.settings.low,pulse);
1642   if(!w.dead)player(c,anchor,w.y,w.mode,w.angle,app.color,SIZE*1.375*w.size,app.settings.icon,t);
1643   if(w.x>level.length-W){const x=level.length-cam;for(let i=0;i<7;i++){const size=40+i*16;circle(c,x,240,size,null,theme.accent+(i%2?'77':'22'),i%2?2:4);}line(c,x,ROOF,x,FLOOR,'#fff',4);}
1644   if(w.t<1.4){c.font='900 48px Arial';c.textAlign='center';c.strokeStyle='#092135';c.lineWidth=6;c.strokeText('ATTEMPT '+app.attempt,W*.5,220);c.fillStyle='#fff';c.fillText('ATTEMPT '+app.attempt,W*.5,220);}
1645  }else{
1646   for(let i=-1;i<8;i++){const xx=i*270-scroll%270;this.object({id:-1,type:'block',x:xx,y:FLOOR-(i%2?60:30),w:110,h:i%2?60:30},0,t,theme,new Set(),app.settings.low);if(i%2===0)this.object({type:'spike',x:xx+146,y:FLOOR-34,w:34,h:34},0,t,theme,new Set(),true);}
1647   const phase=t%2.9,j=phase<.61?Math.sin(phase/.61*Math.PI)*105:0;player(c,W*.16,FLOOR-16-j,'cube',j?phase*8:0,app.color,32,app.settings.icon,t);
1648   c.save();c.globalAlpha=.18;for(let i=0;i<3;i++)circle(c,W*.52,245,115+i*24,null,theme.accent,1);c.restore();
1649  }
1650  for(let i=this.particles.length-1;i>=0;i--){const p=this.particles[i];p.ttl-=dt;if(p.ttl<=0){this.particles.splice(i,1);continue;}p.x+=p.vx*dt;p.y+=p.vy*dt;p.vy+=130*dt;if(!app.settings.low){c.globalAlpha=Math.min(1,p.ttl/p.max);c.fillStyle=p.color;c.fillRect(p.x-cam,p.y,p.size,p.size);}}
1651  c.globalAlpha=1;for(let i=this.rings.length-1;i>=0;i--){const r=this.rings[i];r.ttl-=dt;if(r.ttl<=0){this.rings.splice(i,1);continue;}if(motion){circle(c,r.x-cam,r.y,(.6-r.ttl)*140,null,r.color+Math.floor(r.ttl/.6*140).toString(16).padStart(2,'0'),2);if(r.kind==='portal'&&r.ttl>.28&&!app.settings.low){c.globalAlpha=(r.ttl-.28)*1.8;
1651for(let k=0;k<5;k++){const a=k*Math.PI*2/5,rr=(.6-r.ttl)*130;line(c,r.x-cam+Math.cos(a)*rr,r.y+Math.sin(a)*rr,r.x-cam+Math.cos(a+.13)*(rr+34),r.y+Math.sin(a+.13)*(rr+34),'#d9ffff',1.5);}c.globalAlpha=1;}}}
1652  this.ground(scroll,theme);if(active&&(['ship','wave','ufo'].includes(w.mode)||w.gravity<0))this.ground(scroll,theme,true);
1653  this.vignette();
1654  c.restore();this._stage=null;
1655 }
1656}
1657
1658
1659// foe-paint.mjs
1660/** Enemy drawing, kept out of paint.mjs so it merges beside other paint work. Bodies are rasterised
1661 * once per (kind, frame, facing, telegraph, low, scale) and blitted; shots are one arc each. Every pose
1662 * comes from the pure functions core.mjs collides with, and every drawn body covers its 3px-inset hurt
1663 * box (rounded corners of radius 8 stay outside it), so what is drawn is at least what kills.
1664 * Colour roles (readability contract): crimson = an enemy or a roller you jump; cobalt = a high shot,
1665 * stay down; white flash = it acts within one beat. None of these is a portal, coin, pad, orb or theme
1666 * accent colour. Cobalt #294cff replaced the V2.14 draft's violet, which sat 10 deg from the spider gate
1667 * (#c691ff): cobalt is >= 52 CIELAB dE from the spider gate for normal, protan and deutan vision (violet
1668 * was 35) and >= 114 from the crimson roller (violet 99). Both shot fills sit on skies of similar
1669 * luminance, so each shot carries a white 2.5px ring (>= 3.45:1 against every theme sky at shot height,
1670 * stage hue offsets included) and the high shot a white down-arrow (5.9:1 on cobalt).
1671 * Presentation only; the page also highlights, without any blinking, the enemy or shot named by
1672 * world.death once the run is over. Prefixed names: build.mjs concatenates every module into one scope.
1673 */
1674
1675
1676const foeVectorObject=Painter.prototype.object,foeDraw=Painter.prototype.draw,foeEvent=Painter.prototype.event;
1677const FOE_FRAMES=4,FOE_CACHE_COUNT=40,FOE_CACHE_BYTES=6*1024*1024,FOE_WARN_BEATS=1;
1678// The white body flash toggles twice per throw cycle; below 2/3 s per cycle (> 3 toggles/s, e.g. every <= 2 beats at
1679// 180 bpm) only the raised arm telegraphs. Boss actions are >= 2 beats apart (parseLevel), i.e. <= 3 toggles/s at 180 bpm.
1680const FOE_FLASH_MIN_CYCLE_S=2/3;
1681const FOE_THREAT='#ff3b5c',FOE_DUCK='#294cff';
1682const FOE_INK='#14040c',FOE_BOSS='#4a1238';
1683// The run being drawn: level (bpm/speed for poses) and world (death highlight). The editor sets its own.
1684Painter.prototype.draw=function(app,...rest){this.foeLevel=app.level;this.foeWorld=app.world;this.foeEditor=false;return foeDraw.call(this,app,...rest);};
1685// paint.mjs only knows its own event kinds; a stomp or bonk gets a squash ring, a boss defeat a burst.
1686// 'shot' (a throw leaving a hand) is heard, not drawn: the telegraph and the edge chevron show it.
1687Painter.prototype.event=function(e,color){
1688 if(e.kind==='shot')return;
1689 if(!['stomp','bonk','bossdown'].includes(e.kind))return foeEvent.call(this,e,color);
1690 const n=e.kind==='bossdown'?40:10;for(let i=0;i<n;i++){const a=i*2.3999,v=50+(i%7)*18;this.particles.push({x:e.x,y:e.y+14,vx:Math.cos(a)*v,vy:-Math.abs(Math.sin(a))*v,ttl:.35+(i%5)*.06,max:.7,size:2+i%3,color:i%3?'#ffffff':FOE_THREAT});}
1691 this.rings.push({x:e.x,y:e.y+16,ttl:.45,color:'#ffffff',kind:e.kind});if(e.kind==='bossdown')this.shake=.25;
1692};
1693function foeRound(c,x,y,w,h,r){c.beginPath();c.moveTo(x+r,y);c.arcTo(x+w,y,x+w,y+h,r);c.arcTo(x+w,y+h,x,y+h,r);c.arcTo(x,y+h,x,y,r);c.arcTo(x,y,x+w,y,r);c.closePath();}
1694function foeBody(c,kind,w,h,frame,face,warn,low,flash=warn){
1695 const boss=kind==='boss',fill=flash?'#fff4f6':boss?FOE_BOSS:FOE_THREAT,k=frame%2?2:-2;c.save();
1696 if(!low){c.shadowColor=flash?'#ffffff':FOE_THREAT;c.shadowBlur=flash?14:6;}
1697 foeRound(c,0,0,w,h,8);c.fillStyle=fill;c.fill();c.shadowBlur=0;c.lineWidth=2.5;c.strokeStyle=boss?FOE_THREAT:FOE_INK;c.stroke();
1698 // Features are drawn facing -x (face=-1: standing, or walking toward the player) and mirrored for face=+1,
1699 // so the eyes, jaw and throwing arm face the way it walks; a boss glares crimson.
1700 c.translate(w/2,h/2);c.scale(-face,1);
1701 c.fillStyle='#fff';c.beginPath();c.arc(-w*.2,-h*.12,Math.min(w,h)*.16,0,Math.PI*2);c.fill();c.fillStyle=boss?FOE_THREAT:FOE_INK;c.beginPath();
1701c.arc(-w*.24,-h*.1,Math.min(w,h)*.08,0,Math.PI*2);c.fill();
1702 if(kind==='walker'){c.strokeStyle=FOE_INK;c.lineWidth=3;c.beginPath();c.moveTo(-w*.28,h/2-2);c.lineTo(-w*.28+k,h/2+3);c.moveTo(w*.28,h/2-2);c.lineTo(w*.28-k,h/2+3);c.stroke();
1703  c.fillStyle=FOE_INK;c.fillRect(-w*.5+4,-2,6,3);}                                   // bulldog jaw
1704 else if(kind==='flyer'){c.fillStyle='rgba(235,250,255,.85)';c.beginPath();c.ellipse(w*.1,-h/2-2+k,w*.24,h*.2,0,0,Math.PI*2);c.fill();}
1705 else if(kind==='thrower'){c.strokeStyle=FOE_INK;c.lineWidth=4;c.beginPath();c.moveTo(-w*.3,-h*.05);c.lineTo(-w*.62,warn?-h*.6:h*.1);c.stroke();}
1706 else{c.fillStyle='#ffe0ea';for(let i=-1;i<=1;i++){c.beginPath();c.moveTo(i*w*.2-6,-h/2+3);c.lineTo(i*w*.2,-h/2-9);c.lineTo(i*w*.2+6,-h/2+3);c.fill();}}
1707 c.restore();
1708}
1709function foeSprite(p,kind,w,h,frame,face,warn,low,scale,flash=warn){
1710 p.foeCache??=new Map();p.foeBytes??=0;const key=[kind,w,h,frame,face,warn,flash,low,scale].join('|');let s=p.foeCache.get(key);
1711 if(s){p.foeCache.delete(key);p.foeCache.set(key,s);return s;}
1712 const m=16,canvas=p.canvas.ownerDocument.createElement('canvas');canvas.width=Math.ceil((w+m*2)*scale);canvas.height=Math.ceil((h+m*2)*scale);
1713 const ctx=canvas.getContext('2d');ctx.setTransform(scale,0,0,scale,m*scale,m*scale);foeBody(ctx,kind,w,h,frame,face,warn,low,flash);
1714 s={canvas,m,bytes:canvas.width*canvas.height*4};
1715 while(p.foeCache.size&&(p.foeCache.size>=FOE_CACHE_COUNT||p.foeBytes+s.bytes>FOE_CACHE_BYTES)){const k=p.foeCache.keys().next().value;p.foeBytes-=p.foeCache.get(k).bytes;p.foeCache.delete(k);}
1716 p.foeCache.set(key,s);p.foeBytes+=s.bytes;p.foeBuilds=(p.foeBuilds||0)+1;return s;
1717}
1718/** Beats until this enemy next throws or dives: drives the one-beat white telegraph. */
1719function foeNextAct(raw,t,level){const beat=60/level.bpm,b=t/beat;
1720 if(raw.kind==='thrower'&&raw.shot){const s=raw.shot,k=Math.max(0,Math.ceil((b-s.at)/s.every));if(s.count!==undefined&&k>=s.count)return Infinity;return s.at+k*s.every-b;}
1721 if(raw.kind==='boss'){let best=Infinity;for(const a of bossPlan(raw,level)){const start=a.c==='D'?(a.T+.55)/beat-2.5:(a.T+(a.c==='r'?.3:-.04))/beat-2;if(start>=b)best=Math.min(best,start-b);}return best;}
1722 return Infinity;}
1723function foeChevron(c,y,color){c.fillStyle=color;c.beginPath();c.moveTo(-18,y-11);c.lineTo(-4,y);c.lineTo(-18,y+11);c.lineTo(-13,y);c.closePath();c.fill();c.lineWidth=1.5;c.strokeStyle='#ffffff';c.stroke();}
1724/** Which drawn thing ended the run: {id} for a body, {id,k} for a shot, null otherwise. */
1725function foeCulprit(world){const d=world?.dead?world.death:null;if(d===null||d===undefined)return null;
1726 if(typeof d==='number')return {id:d};const m=/^shot:(\d+):(\d+)$/.exec(String(d));return m?{id:+m[1],k:+m[2]}:null;}
1727/** One line for the death panel naming what ended the run and how to beat it; '' when it was not an enemy. */
1728function foeCause(world,level){const c=foeCulprit(world),o=c&&level?.objects?.find(x=>x.id===c.id&&x.type==='enemy');if(!o)return '';
1729 if(c.k!==undefined){const high=o.kind==='boss'?o.pattern?.[c.k]==='h':o.shot?.style==='high';return high?'A blue ball got you. Stay down under it.':'A red ball got you. Jump it.';}
1730 return {walker:'A walker got you. Land on its head.',flyer:'A buzzer got you. Land on it from above.',thrower:'A thrower got you. Land on its head.',boss:'The boss got you. Stomp it while it sits.'}[o.kind]||'';}
1731// A steady (never blinking) white frame around the culprit: the death screen names what hit you.
1732function foeMark(c,x,y,w,h){c.save();c.lineWidth=3;c.strokeStyle='#ffffff';foeRound(c,x-5,y-5,w+10,h+10,10);c.stroke();c.lineWidth=2;c.strokeStyle=FOE_INK;foeRound(c,x-8,y-8,w+16,h+16,12);c.stroke();c.restore();}
1733/** Editor preview (t=0, no motion): a boss arena as a dashed band with its beats spelled out, and a
1734 *  thrower's shot lane, the two spaces findShotConflicts keeps clear of solids. */
1735function foeEditorLane(p,raw,cam,level){const c=p.c,beat=60/level.bpm,unit=level.speed*beat;c.save();c.setLineDash([6,6]);c.lineWidth=2;
1736 if(raw.kind==='boss'){const x=raw.x-cam;c.fillStyle='rgba(255,59,92,.07)';c.fillRect(x,ROOF,raw.w,FLOOR-ROOF);c.strokeStyle='rgba(255,59,92,.6)';c.strokeRect(x,ROOF,raw.w,FLOOR-ROOF);
1737  c.strokeStyle='rgba(255,59,92,.3)';c.strokeRect(x+raw.w,ROOF,BOSS_LEAD,FLOOR-ROOF);c.setLineDash([]);c.font='900 11px Arial';c.textAlign='center';
1738  for(let j=0;j<raw.pattern.length;j++){const ch=raw.pattern[j],bx=x+j*unit;if(ch==='.')continue;
1739   if(ch==='r'){c.fillStyle=FOE_THREAT;c.beginPath();
1739c.arc(bx,FLOOR-9,9,0,Math.PI*2);c.fill();}
1740   else if(ch==='h'){c.fillStyle=FOE_DUCK;c.beginPath();c.arc(bx,FLOOR-60,9,0,Math.PI*2);c.fill();}
1741   else{c.fillStyle=FOE_BOSS;c.fillRect(bx-16,FLOOR-24,32,24);c.strokeStyle=FOE_THREAT;c.lineWidth=2;c.strokeRect(bx-16,FLOOR-24,32,24);}
1742   c.fillStyle='#ffffff';c.fillText(ch,bx,FLOOR-30);}
1743  c.fillStyle=FOE_THREAT;c.textAlign='left';c.fillText('BOSS · '+raw.hp+' HIT'+(raw.hp>1?'S':''),x+6,ROOF+16);}
1744 else if(raw.kind==='thrower'&&raw.shot){const r=raw.shot.r||8,len=raw.shot.speed*raw.shot.life*beat,y=raw.shot.style==='high'?FLOOR-60:FLOOR-r;
1745  c.strokeStyle=raw.shot.style==='high'?'rgba(41,76,255,.7)':'rgba(255,59,92,.6)';c.beginPath();c.moveTo(raw.x-cam,y);c.lineTo(raw.x-len-cam,y);c.stroke();}
1746 c.restore();}
1747Painter.prototype.object=function(raw,cam,t,theme,used,low,pulse=0){
1748 if(raw.type!=='enemy')return foeVectorObject.call(this,raw,cam,t,theme,used,low,pulse);
1749 if(used.has(raw.id))return;
1750 const level=this.foeLevel??{bpm:132,speed:340},beat=60/level.bpm,boss=raw.kind==='boss',culprit=foeCulprit(this.foeWorld),mine=culprit?.id===raw.id;
1751 if(this.foeEditor){foeEditorLane(this,raw,cam,level);if(boss)return;}
1752 // Cull by everything the enemy can reach: patrol sweep, the shot lane left of a thrower, a boss arena.
1753 if(boss){const px=level.speed*t;if(px<raw.x-800||px>raw.x+raw.w+200)return;}
1754 else if(raw.x+raw.w-cam<-100||raw.x-(raw.shot?raw.shot.speed*raw.shot.life*beat:0)-cam>this.W+100)return;
1755 const e=boss?bossAt(raw,t,level):enemyAt(raw,t,beat),frame=Math.floor(t/beat*2)%FOE_FRAMES,warn=!mine&&foeNextAct(raw,t,level)<=FOE_WARN_BEATS,flash=warn&&(raw.kind!=='thrower'||raw.shot.every*beat>=FOE_FLASH_MIN_CYCLE_S);
1756 // A patrol shows its whole beat as a dashed lane 6px above its floor line (the 2px floor stroke would hide it):
1757 // the space it claims is the space it will walk.
1758 if(raw.leg&&!low){const ly=raw.y+raw.h-6;this.c.save();this.c.strokeStyle='rgba(255,59,92,.75)';this.c.lineWidth=2;this.c.setLineDash([6,6]);this.c.beginPath();this.c.moveTo(raw.x-cam,ly);this.c.lineTo(raw.x+raw.w-cam,ly);this.c.stroke();this.c.restore();}
1759 const scale=Math.round(Math.min(3,Math.max(.5,this.canvas.height/540))*4)/4,s=foeSprite(this,raw.kind,e.w,e.h,frame,e.face??-1,warn,!!low,scale,flash),c=this.c;
1760 c.drawImage(s.canvas,0,0,s.canvas.width,s.canvas.height,e.x-cam-s.m,e.y-s.m,e.w+s.m*2,e.h+s.m*2);
1761 if(mine&&culprit.k===undefined)foeMark(c,e.x-cam,e.y,e.w,e.h);
1762 const shots=boss?bossShotsAt(raw,t,level):shotsAt(raw,t,beat),life=boss?3*beat:raw.shot?.life*beat;
1763 for(const p of shots){const x=p.x-cam,high=p.y<FLOOR-40,col=high?FOE_DUCK:FOE_THREAT;
1764  if(x-p.r>this.W){if((x-this.W)/(level.speed+Math.abs(boss?BOSS_LEAD/(2*beat)-level.speed:raw.shot.speed))<beat){c.save();c.translate(this.W,0);foeChevron(c,p.y,col);c.restore();}continue;}
1765  c.globalAlpha=Math.max(0,Math.min(1,(life-(t-p.launch))/.15));c.fillStyle=col;c.beginPath();c.arc(x,p.y,p.r,0,Math.PI*2);c.fill();
1766  c.lineWidth=2.5;c.strokeStyle='#ffffff';c.stroke();if(high){c.lineWidth=2;c.beginPath();c.moveTo(x-4,p.y-2);c.lineTo(x,p.y+3);c.lineTo(x+4,p.y-2);c.stroke();}c.globalAlpha=1;
1767  if(mine&&culprit.k===p.k)foeMark(c,x-p.r,p.y-p.r,p.r*2,p.r*2);}
1768 if(boss){const hits=bossPlan(raw,level).filter(a=>a.c==='D'&&used.has('boss:'+raw.id+':'+a.j)).length,x0=e.x-cam+e.w/2-raw.hp*14;
1769  for(let i=0;i<raw.hp;i++){c.globalAlpha=i<raw.hp-hits?1:.25;c.fillStyle=FOE_THREAT;c.fillRect(x0+i*28,e.y-30,24,17);c.strokeStyle='#ffffff';c.lineWidth=2;c.strokeRect(x0+i*28,e.y-30,24,17);}c.globalAlpha=1;}
1770};
1771
1772
1773// render-cache.mjs
1774/** Reuse rasterized obstacle faces instead of re-running glow filters every frame.
1775 * Positions and collision time still come from the live world. This changes only
1776 * rendering cost, not geometry, physics, or low-frame-rate safety thresholds.
1777 *
1778 * Important: adaptive render-scale changes must not flush the entire cache. A
1779 * cold slow frame can legitimately lower renderScale; clearing every sprite at
1780 * that boundary creates a rebuild feedback loop (slow -> lower scale -> rebuild
1781 * -> slow). Scale therefore belongs in the bounded cache key instead.
1782 */
1783
1784// Enemies draw through foe-paint's own sprite cache; importing it first keeps them inside the profiler below.
1785
1786
1787const vectorObject = Painter.prototype.object;
1788const CACHEABLE = new Set(['block','spike','slope','pad']);
1789const SPRITE_CACHE_COUNT=96;
1790const SPRITE_CACHE_BYTES=12*1024*1024;
1791Painter.prototype.object = function(raw,cam,t,theme,used,low,pulse=0) {
1792 if(!CACHEABLE.has(raw.type)) return vectorObject.call(this,raw,cam,t,theme,used,low,pulse);
1793 const o=shapeAt(raw,t),x=o.x-cam,y=o.y;
1794 if(x+o.w < -100 || x>this.W+100)return;
1795 const scale=Math.min(3,Math.max(.5,this.canvas.height/540));
1796 this.spriteCache??=new Map();this.spriteBytes??=0;
1797 // Six-degree buckets retain the live colour movement while avoiding cache churn
1798 // from tiny audio-reactive hue changes. Foreground geometry remains unchanged.
1799 const hue=Math.round(this.hue/6)*6;
1800 const scaleKey=Math.round(scale*100)/100;
1801 const key=[scaleKey,o.type,o.w,o.h,!!o.flip,!!o.reverse,theme.accent,hue,!!low].join('|');
1802 let sprite=this.spriteCache.get(key);
1803 if(!sprite){
1804  const margin=18,width=Math.ceil(o.w+margin*2),height=Math.ceil(o.h+margin*2);
1805  const canvas=this.canvas.ownerDocument.createElement('canvas');
1806  canvas.width=Math.ceil(width*scale);canvas.height=Math.ceil(height*scale);
1807  const ctx=canvas.getContext('2d');ctx.setTransform(scale,0,0,scale,0,0);
1808  const before=this.c,previousHue=this.hue;
1809  try{
1810   this.c=ctx;this.hue=hue;
1811   vectorObject.call(this,{...o,x:margin,y:margin,motion:null,alpha:1},0,0,theme,used,low,0);
1812  }finally{this.c=before;this.hue=previousHue;}
1813  sprite={canvas,width,height,margin,bytes:canvas.width*canvas.height*4};
1814  while(this.spriteCache.size&&(this.spriteCache.size>=SPRITE_CACHE_COUNT||this.spriteBytes+sprite.bytes>SPRITE_CACHE_BYTES)){
1815   const first=this.spriteCache.keys().next().value;this.spriteBytes-=this.spriteCache.get(first).bytes;this.spriteCache.delete(first);this.spriteEvictions=(this.spriteEvictions||0)+1;
1816  }
1817  this.spriteCache.set(key,sprite);this.spriteBytes+=sprite.bytes;this.spriteBuilds=(this.spriteBuilds||0)+1;
1818 }else{
1819  // Touch on hit so the byte/count bound evicts the least-recently-used sprite,
1820  // not merely the oldest inserted one across adaptive scale transitions.
1821  this.spriteCache.delete(key);this.spriteCache.set(key,sprite);this.spriteHits=(this.spriteHits||0)+1;
1822 }
1823 const c=this.c;c.save();c.globalAlpha*=raw.alpha??1;
1824 c.drawImage(sprite.canvas,0,0,sprite.canvas.width,sprite.canvas.height,x-sprite.margin,y-sprite.margin,sprite.width,sprite.height);
1825 c.restore();
1826};
1827
1828// Quality-only phase profiler. It is dormant in production and activates only
1829// when the acceptance harness explicitly sets globalThis.__OVERDRIVE_PROFILE__.
1830// Prefix every top-level symbol because build.mjs concatenates modules into one
1831// scope before syntax-checking the standalone bundle.
1832const rushProfileQuantile=(values,p)=>{if(!values?.length)return 0;const a=[...values].sort((x,y)=>x-y);return a[Math.floor((a.length-1)*p)]||0;};
1833const rushPushProfile=(p,name,value)=>{const a=p._profileSamples[name]??=[];a.push(value);if(a.length>360)a.shift();};
1834const rushProfiledBackground=Painter.prototype.background;
1835Painter.prototype.background=function(...args){
1836 if(!globalThis.__OVERDRIVE_PROFILE__)return rushProfiledBackground.apply(this,args);
1837 const t=performance.now(),out=rushProfiledBackground.apply(this,args);this._profileFrame.background+=performance.now()-t;return out;
1838};
1839const rushProfiledGround=Painter.prototype.ground;
1840Painter.prototype.ground=function(...args){
1841 if(!globalThis.__OVERDRIVE_PROFILE__)return rushProfiledGround.apply(this,args);
1842 const t=performance.now(),out=rushProfiledGround.apply(this,args);this._profileFrame.ground+=performance.now()-t;return out;
1843};
1844const rushProfiledObject=Painter.prototype.object;
1845Painter.prototype.object=function(...args){
1846 if(!globalThis.__OVERDRIVE_PROFILE__)return rushProfiledObject.apply(this,args);
1847 const t=performance.now(),out=rushProfiledObject.apply(this,args);this._profileFrame.objects+=performance.now()-t;this._profileFrame.objectCalls++;return out;
1848};
1849const rushProfiledDraw=Painter.prototype.draw;
1850Painter.prototype.draw=function(...args){
1851 if(!globalThis.__OVERDRIVE_PROFILE__)return rushProfiledDraw.apply(this,args);
1852 this._profileSamples??={background:[],ground:[],objects:[],other:[],total:[],objectCalls:[]};
1853 this._profileFrame={background:0,ground:0,objects:0,objectCalls:0};
1854 const t=performance.now(),out=rushProfiledDraw.apply(this,args),total=performance.now()-t;
1855 const f=this._profileFrame;rushPushProfile(this,'background',f.background);rushPushProfile(this,'ground',f.ground);rushPushProfile(this,'objects',f.objects);rushPushProfile(this,'other',Math.max(0,total-f.background-f.ground-f.objects));rushPushProfile(this,'total',total);rushPushProfile(this,'objectCalls',f.objectCalls);return out;
1856};
1857Painter.prototype.resetProfile=function(){this._profileSamples={background:[],ground:[],objects:[],other:[],total:[],objectCalls:[]};this._profileFrame={background:0,ground:0,objects:0,objectCalls:0};this._profileCacheStart={builds:this.spriteBuilds||0,hits:this.spriteHits||0,evictions:this.spriteEvictions||0};};
1858Painter.prototype.profileReport=function(){const out={};for(const [name,a] of Object.entries(this._profileSamples||{}))out[name]={samples:a.length,p50:rushProfileQuantile(a,.5),p95:rushProfileQuantile(a,.95),total:a.reduce((x,y)=>x+y,0)};const start=this._profileCacheStart||{builds:0,hits:0,evictions:0};out.spriteCache={builds:(this.spriteBuilds||0)-start.builds,hits:(this.spriteHits||0)-start.hits,evictions:(this.spriteEvictions||0)-start.evictions,entries:this.spriteCache?.size||0,bytes:this.spriteBytes||0};out.foeCache={entries:this.foeCache?.size||0,bytes:this.foeBytes||0,builds:this.foeBuilds||0};return out;};
1859
1860
1861// scenery-cache.mjs
1862/**
1863 * Low-resolution background raster for the single opaque gameplay Canvas.
1864 *
1865 * WebKit paid heavily both for full-resolution software-Canvas scenery and for
1866 * a transparent gameplay canvas composited over DOM/SVG parallax layers. Keep
1867 * one alpha:false Canvas, render slow scenery into a 0.5x offscreen buffer, then
1868 * upscale that buffer once per frame. Foreground objects/player/particles remain
1869 * at the normal gameplay resolution and simulation/collision code is untouched.
1870 */
1871
1872
1873
1874const RBG_SCALE=.5;
1875const RBG_CACHE_LIMIT=6*1024*1024;
1876const RBG_CACHE_COUNT=96;
1877function rushSceneryProfilePush(p,name,value){if(!p._profileSamples)return;const a=p._profileSamples[name]??=[];a.push(value);if(a.length>360)a.shift();}
1878
1879function rbgPolygon(c,points,fill,stroke=null,width=1){c.beginPath();points.forEach(([x,y],i)=>i?c.lineTo(x,y):c.moveTo(x,y));c.closePath();if(fill){c.fillStyle=fill;c.fill();}if(stroke){c.strokeStyle=stroke;c.lineWidth=width;c.stroke();}}
1880function rbgLine(c,x,y,X,Y,color,width=1){c.strokeStyle=color;c.lineWidth=width;c.beginPath();c.moveTo(x,y);c.lineTo(X,Y);c.stroke();}
1881function rbgCircle(c,x,y,r,fill,stroke=null,width=1){c.beginPath();
1881c.arc(x,y,r,0,Math.PI*2);if(fill){c.fillStyle=fill;c.fill();}if(stroke){c.strokeStyle=stroke;c.lineWidth=width;c.stroke();}}
1882function rbgDiamond(c,x,y,r,fill){rbgPolygon(c,[[x,y-r],[x+r*.55,y],[x,y+r],[x-r*.55,y]],fill);}
1883function rbgGear(c,x,y,r,angle,fill,stroke,teeth=12){const points=[];for(let i=0;i<teeth*4;i++){const a=angle+i*Math.PI*2/(teeth*4),s=i%4===0||i%4===3?.77:1;points.push([x+Math.cos(a)*r*s,y+Math.sin(a)*r*s]);}rbgPolygon(c,points,fill,stroke,1.5);rbgCircle(c,x,y,r*.39,null,stroke,1.2);}
1884function rbgMakeCanvas(p,width,height,scale=RBG_SCALE,alpha=true){const canvas=p.canvas.ownerDocument.createElement('canvas');canvas.width=Math.max(1,Math.ceil(width*scale));canvas.height=Math.max(1,Math.ceil(height*scale));const c=canvas.getContext('2d',{alpha,desynchronized:true});c.setTransform(scale,0,0,scale,0,0);return {canvas,c,width,height,scale,bytes:canvas.width*canvas.height*4};}
1885function rbgCache(p,key,build){p.rbgCache??=new Map();p.rbgBytes??=0;if(p.rbgCache.has(key))return p.rbgCache.get(key);const value=build();while(p.rbgCache.size&&(p.rbgBytes+value.bytes>RBG_CACHE_LIMIT||p.rbgCache.size>=RBG_CACHE_COUNT)){const first=p.rbgCache.keys().next().value;p.rbgBytes-=p.rbgCache.get(first).bytes;p.rbgCache.delete(first);}p.rbgCache.set(key,value);p.rbgBytes+=value.bytes;p.sceneryBytes=p.rbgBytes+(p.rbgFrame?.bytes||0)+(p.stageBuf?.bytes||0);return value;}
1886function rbgFrame(p){const width=p.W,height=540,pw=Math.ceil(width*RBG_SCALE),ph=Math.ceil(height*RBG_SCALE);if(p.rbgFrame&&p.rbgFrame.canvas.width===pw&&p.rbgFrame.canvas.height===ph)return p.rbgFrame;p.rbgFrame=rbgMakeCanvas(p,width,height,RBG_SCALE,false);p.sceneryBytes=(p.rbgBytes||0)+p.rbgFrame.bytes;return p.rbgFrame;}
1887function rbgVignette(p,c,W){const cached=p.rbgVignetteGradient;if(cached&&cached.c===c&&cached.W===W)return cached.gradient;const gradient=c.createRadialGradient(W*.5,260,120,W*.5,260,Math.max(W*.7,440));gradient.addColorStop(0,'transparent');gradient.addColorStop(1,'rgba(0,4,16,.35)');p.rbgVignetteGradient={c,W,gradient};return gradient;}
1888function rbgCell(p,theme,layer,index,height,spacing){const rounded=Math.round(height),key=['rbg-cell',theme.style,layer,index,rounded].join(':');return rbgCache(p,key,()=>{const city=theme.style==='city',cropX=city?38:0,cropY=city?52:0,surface=rbgMakeCanvas(p,city?spacing-4:spacing+80,city?rounded+60:rounded+135,RBG_SCALE,true),c=surface.c,x=40-cropX,y=85-cropY,bottom=rounded+85-cropY,seed=scenerySeed(index+layer*901);surface.offsetX=cropX;surface.offsetY=cropY;
1889 if(theme.style==='city'){
1890  c.fillStyle=layer?'rgba(1,11,30,.31)':'rgba(4,18,40,.28)';c.fillRect(x,y,spacing-8,rounded+25);c.strokeStyle=layer?'rgba(73,169,238,.13)':'rgba(89,186,246,.07)';c.lineWidth=1;c.strokeRect(x+3,y+3,spacing-14,rounded);
1891  for(let row=0;row<(rounded-25)/23;row++)for(let col=0;col<(spacing-26)/19;col++)if(scenerySeed(index*101+row*17+col+layer*999)>.3){c.fillStyle=layer?'rgba(132,239,255,.22)':'rgba(127,194,254,.11)';c.fillRect(x+12+col*19,y+15+row*23,5,9);}rbgLine(c,x+spacing*.5,y,x+spacing*.5,y-15-seed*15,'rgba(117,213,250,.19)');
1892 }else if(theme.style==='crystal'){
1893  rbgPolygon(c,[[x+spacing*.4,y-40],[x+spacing*.8,y+40],[x+spacing*.62,bottom+40],[x-12,bottom+40]],layer?'rgba(45,17,67,.5)':'rgba(26,10,56,.26)','rgba(221,135,255,.10)');rbgLine(c,x+spacing*.4,y-40,x+spacing*.4,bottom+40,'rgba(201,114,255,.12)');rbgLine(c,x+spacing*.4,y-40,x+spacing*.8,y+40,'rgba(244,184,255,.22)');
1894 }else if(theme.style==='factory'){
1895  c.fillStyle='rgba(27,5,16,.28)';c.fillRect(x,y,spacing-8,rounded);c.strokeStyle='rgba(255,126,63,.15)';c.lineWidth=4;c.strokeRect(x+14,y+15,spacing-36,rounded-30);
1896 }else{
1897  c.strokeStyle='rgba(108,255,212,.075)';c.lineWidth=2;c.beginPath();c.moveTo(x,bottom+20);c.bezierCurveTo(x+90,y+30,x-40,y,x+spacing*.5,y-60);c.stroke();rbgDiamond(c,x+spacing*.5,y-70,15+seed*20,'rgba(135,255,228,.07)');
1898 }return surface;});}
1899function rbgBlitLogical(c,s,x,y,w=s.width,h=s.height){c.drawImage(s.canvas,0,0,s.canvas.width,s.canvas.height,x,y,w,h);}
1900
1901Painter.prototype.background=function(theme,scroll,t,pulse,low){
1902 const main=this.c,W=this.W,h=this.hue,frame=rbgFrame(this),c=frame.c,rushProfile=!!globalThis.__OVERDRIVE_PROFILE__;let rushPhase=rushProfile?performance.now():0;
1903 c.setTransform(RBG_SCALE,0,0,RBG_SCALE,0,0);c.globalAlpha=1;c.globalCompositeOperation='copy';
1904 const bg=c.createLinearGradient(0,0,0,540);bg.addColorStop(0,`hsl(${h},86%,14%)`);bg.addColorStop(.58,`hsl(${h},87%,30%)`);bg.addColorStop(1,`hsl(${h},90%,42%)`);c.fillStyle=bg;c.fillRect(0,0,W,540);c.globalCompositeOperation='source-over';
1905 const halo=c.createRadialGradient(W*.62,270,0,W*.62,270,W*.66);halo.addColorStop(0,`hsla(${h+25},100%,65%,.14)`);halo.addColorStop(1,'transparent');c.fillStyle=halo;c.fillRect(0,0,W,540);
1906 if(rushProfile){const now=performance.now();rushSceneryProfilePush(this,'scenerySky',now-rushPhase);rushPhase=now;}
1907 if(!low){const fx=W*.73-(scroll*.025)%(W+700);c.save();c.globalAlpha=.17+pulse*.04;
1907if(theme.style==='city'){rbgCircle(c,fx,180,105,null,theme.accent);rbgCircle(c,fx,180,94,null,theme.accent,.6);for(let i=0;i<12;i++){const a=i*Math.PI/6;rbgLine(c,fx+Math.cos(a)*100,180+Math.sin(a)*100,fx+Math.cos(a)*109,180+Math.sin(a)*109,theme.accent,2);}}else if(theme.style==='factory')rbgGear(c,fx,210,128,t*.025,'rgba(6,7,16,.3)',theme.accent,18);else if(theme.style==='crystal'){rbgDiamond(c,fx,180,96,'rgba(234,134,255,.16)');rbgCircle(c,fx,180,122,null,theme.accent,.7);}c.restore();}
1908 c.save();c.globalAlpha=.045;for(let i=-1;i<5;i++){const xx=i*300-scroll*.055%300;rbgPolygon(c,[[xx,-60],[xx+70,-60],[xx+510,470],[xx+210,470]],'#befcff');}c.restore();
1909 if(rushProfile){const now=performance.now();rushSceneryProfilePush(this,'sceneryHero',now-rushPhase);rushPhase=now;}
1910 for(let layer=0;layer<2;layer++){
1911  const spacing=layer?90:145,move=scroll*(layer?.13:.06),start=Math.floor(move/spacing)-1,end=(move+W)/spacing+1;
1912  // Cached scenery cells are already rasterized at the same 0.5x resolution as
1913  // the background frame. On WebKit, using the nine-argument drawImage form under
1914  // a 0.5 transform still routes through a scale/sample path even though the final
1915  // physical size is unchanged. Draw those rasters directly in frame pixels so the
1916  // common city/crystal/neon path is a true 1:1 copy. City cells additionally crop
1917  // transparent raster margins while preserving their original world-space origin.
1918  // Factory layer 1 retains the original per-cell ordering because animated gears
1919  // are intentionally interleaved.
1920  if(!(theme.style==='factory'&&layer)){
1921   c.save();c.setTransform(1,0,0,1,0,0);
1922   for(let i=start;i<end;i++){
1923    const x=i*spacing-move,seed=scenerySeed(i+layer*901),height=65+seed*(layer?155:255),y=FLOOR-height,cell=rbgCell(this,theme,layer,i,height,spacing);
1924    c.drawImage(cell.canvas,(x-40+(cell.offsetX||0))*RBG_SCALE,(y-85+(cell.offsetY||0))*RBG_SCALE);
1925   }
1926   c.restore();
1927  }else{
1928   for(let i=start;i<end;i++){
1929    const x=i*spacing-move,seed=scenerySeed(i+layer*901),height=65+seed*(layer?155:255),y=FLOOR-height,cell=rbgCell(this,theme,layer,i,height,spacing);
1930    rbgBlitLogical(c,cell,x-40,y-85);rbgGear(c,x+spacing*.5,y+45,25,(i%2?1:-1)*t*.1,'rgba(21,6,15,.3)','rgba(255,162,75,.18)',9);
1931   }
1932  }
1933 }
1934 if(rushProfile){const now=performance.now();rushSceneryProfilePush(this,'sceneryCells',now-rushPhase);rushPhase=now;}
1935 if(!low)for(let i=0;i<38;i++){const x=((i*153.3-scroll*.07)%(W+40)+W+40)%(W+40)-20,y=55+(i*87.7)%335+Math.sin(t*.4+i)*8;rbgCircle(c,x,y,i%3?1:2,`rgba(174,240,255,${.13+(i%5)*.025})`);}
1936 if(pulse>0){c.fillStyle=`rgba(128,221,255,${pulse*.025})`;c.fillRect(0,0,W,FLOOR);}
1937 // Apply the vignette inside the low-resolution background so foreground stays
1938 // crisp and we avoid a second full-screen alpha blit on the main Canvas.
1939 c.fillStyle=rbgVignette(this,c,W);c.fillRect(0,0,W,540);
1940 if(rushProfile){const now=performance.now();rushSceneryProfilePush(this,'sceneryFx',now-rushPhase);rushPhase=now;}
1941 const previousSmoothing=main.imageSmoothingEnabled,previousComposite=main.globalCompositeOperation;main.globalAlpha=1;main.imageSmoothingEnabled=false;main.globalCompositeOperation='copy';main.drawImage(frame.canvas,0,0,frame.canvas.width,frame.canvas.height,0,0,W,540);main.globalCompositeOperation=previousComposite;main.imageSmoothingEnabled=previousSmoothing;
1942 if(rushProfile){rushSceneryProfilePush(this,'sceneryBlit',performance.now()-rushPhase);}
1943 this.sceneryBytes=(this.rbgBytes||0)+frame.bytes;
1944};
1945
1946// ---- Stagecraft path (see stagecraft.mjs). The function above stays the V2.12 path for
1947// stage=null (tier off / reduced motion), Workshop and the onboarding prototype. The stage path
1948// renders the same scenery into a padded 0.5x buffer sized once per level for its largest roll,
1949// fills only the region the current roll/zoom can show, and rolls/zooms it inside the single
1950// nearest-neighbour copy blit. The gameplay corridor (objects, player, ground, roof) is never
1951// transformed. New layers live in the 0.5x buffer: a quarter of the fill cost, and they roll for free.
1952function stageBuf(p,st){
1953 const {hx,hy}=st.alloc,W=p.W;let b=p.stageBuf;
1954 if(!b||b.W!==W||b.hx<hx||b.hy<hy){const HX=Math.max(hx,b&&b.W===W?b.hx:0),HY=Math.max(hy,b&&b.W===W?b.hy:0);
1955  b=rbgMakeCanvas(p,2*HX,2*HY,RBG_SCALE,false);b.W=W;b.hx=HX;b.hy=HY;p.stageBuf=b;
1955p.sceneryBytes=(p.rbgBytes||0)+(p.rbgFrame?.bytes||0)+b.bytes;}
1956 return b;
1957}
1958function stageRays(c,x,y,angle,amount,hue,len){c.save();c.globalAlpha=Math.min(.09,.075*amount);c.fillStyle=`hsl(${hue+40},100%,78%)`;c.beginPath();for(let i=0;i<12;i++){const a=angle+i*Math.PI/6;c.moveTo(x,y);c.lineTo(x+Math.cos(a-.05)*len,y+Math.sin(a-.05)*len);c.lineTo(x+Math.cos(a+.05)*len,y+Math.sin(a+.05)*len);c.closePath();}c.fill();c.restore();}
1959// V02 K05 prism framing (7 hexagons + 6 framing rays, guide colour, upper field only), opened by amount.
1960function stagePrism(c,x,y,open,angle){c.save();c.strokeStyle='#85bfd0';c.lineWidth=1.5;for(let i=0;i<7;i++){const r=32+i*24+open*(24+i*18);c.globalAlpha=(.04+i*.012)*open;c.beginPath();for(let k=0;k<6;k++){const a=angle+k*Math.PI/3,px=x+Math.cos(a)*r,py=y+Math.sin(a)*r;k?c.lineTo(px,py):c.moveTo(px,py);}c.closePath();c.stroke();}
1961 c.globalAlpha=.09*open;c.beginPath();for(let k=0;k<6;k++){const a=angle+Math.PI/6+k*Math.PI/3;c.moveTo(x+Math.cos(a)*40,y+Math.sin(a)*40);c.lineTo(x+Math.cos(a)*560,y+Math.sin(a)*560);}c.stroke();c.restore();}
1962function stageMid(c,style,scroll,X0,X1,amount,accent){
1963 const move=scroll*.35,step=style==='ocean'?48:style==='crystal'?70:style==='factory'?96:60,base=FLOOR+6,i0=Math.floor((X0+move)/step)-1,i1=Math.ceil((X1+move)/step)+1;
1964 c.save();c.globalAlpha=amount;c.beginPath();c.moveTo(i0*step-move,base+30);
1965 for(let i=i0;i<=i1;i++){const x=i*step-move,r=scenerySeed(i*7+3);
1966  if(style==='city'){const h=26+r*40;c.lineTo(x,base-h);c.lineTo(x+step,base-h);}
1967  else if(style==='crystal'){const h=30+r*36;c.lineTo(x+step*.22,base-h);c.lineTo(x+step*.78,base-h);c.lineTo(x+step,base-h*.62);}
1968  else if(style==='factory'){const h=30+r*20,k=14+r*16;c.lineTo(x,base-h);c.lineTo(x+step*.62,base-h);c.lineTo(x+step*.62,base-h-k);c.lineTo(x+step*.82,base-h-k);c.lineTo(x+step*.82,base-h);c.lineTo(x+step,base-h);}
1969  else c.quadraticCurveTo(x+step*.5,base-34-Math.sin(i*1.3)*10-r*8,x+step,base-28-Math.sin((i+1)*1.3)*10);
1970 }
1971 c.lineTo(i1*step+step-move,base+30);c.closePath();c.fillStyle='rgba(2,8,24,.24)';c.fill();c.globalAlpha=amount*.18;c.strokeStyle=accent;c.lineWidth=2;c.stroke();c.restore();}
1972function stageStreaks(c,scroll,X0,X1,count){const span=X1-X0+420;c.save();c.strokeStyle='rgba(220,255,255,.10)';c.lineWidth=2;c.beginPath();for(let i=0;i<count;i++){const len=120+scenerySeed(i+77)*160,x=((i*397.3-scroll*1.6)%span+span)%span+X0-210,y=92+scenerySeed(i+31)*210;c.moveTo(x,y);c.lineTo(x+len,y);}c.stroke();c.restore();}
1973function stageCells(p,c,style,scroll,t,X0,X1,ox,oy,alpha){
1974 const theme={style};
1975 for(let layer=0;layer<2;layer++){
1976  const spacing=layer?90:145,move=scroll*(layer?.13:.06),start=Math.floor((move+X0)/spacing)-1,end=(move+X1)/spacing+1;
1977  if(!(style==='factory'&&layer)){c.save();c.setTransform(1,0,0,1,ox,oy);c.globalAlpha=alpha;
1978   for(let i=start;i<end;i++){const x=i*spacing-move,seed=scenerySeed(i+layer*901),height=65+seed*(layer?155:255),y=FLOOR-height,cell=rbgCell(p,theme,layer,i,height,spacing);c.drawImage(cell.canvas,(x-40+(cell.offsetX||0))*RBG_SCALE,(y-85+(cell.offsetY||0))*RBG_SCALE);}
1979   c.restore();
1980  }else{c.save();c.globalAlpha=alpha;for(let i=start;i<end;i++){const x=i*spacing-move,seed=scenerySeed(i+layer*901),height=65+seed*(layer?155:255),y=FLOOR-height,cell=rbgCell(p,theme,layer,i,height,spacing);rbgBlitLogical(c,cell,x-40,y-85);rbgGear(c,x+spacing*.5,y+45,25,(i%2?1:-1)*t*.1,'rgba(21,6,15,.3)','rgba(255,162,75,.18)',9);}c.restore();}
1981 }
1982}
1983function stageEarth(p,c){const v=p.stageEarthGradient;if(v&&v.c===c)return v.gradient;const gradient=c.createLinearGradient(0,FLOOR,0,FLOOR+72);gradient.addColorStop(0,'rgba(3,10,26,0)');gradient.addColorStop(1,'rgba(3,10,26,.9)');p.stageEarthGradient={c,gradient};return gradient;}
1984function stageVignette(p,c,W){const v=p.stageVignetteGradient;if(v&&v.c===c&&v.W===W)return v.gradient;const gradient=c.createRadialGradient(W*.5,260,120,W*.5,260,Math.max(W*.7,440));gradient.addColorStop(0,'transparent');gradient.addColorStop(1,'rgba(0,4,16,.35)');p.stageVignetteGradient={c,W,gradient};return gradient;}
1985function stageBackground(p,st,theme,scroll,t,pulse,low){
1986 const main=p.c,W=p.W,h=p.hue,buf=stageBuf(p,st),c=buf.c,cx=W/2,cy=st.pivotY,HX=buf.hx,HY=buf.hy,prof=!!globalThis.__OVERDRIVE_PROFILE__;let ph=prof?performance.now():0;
1987 const e=stageExtents(W,st.rollDeg,st.zoom),ex=Math.min(HX,e.ex),ey=Math.min(HY,e.ey),X0=cx-ex,Y0=cy-ey,RW=2*ex,RH=2*ey,X1=X0+RW,Y1=Y0+RH,ox=(HX-cx)*RBG_SCALE,oy=(HY-cy)*RBG_SCALE,Lf=st.light;
1988 const lap=()=>{if(!prof)return 0;const now=performance.now(),d=now-ph;ph=now;return d;};
1989 c.setTransform(RBG_SCALE,0,0,RBG_SCALE,ox,oy);c.globalAlpha=1;c.globalCompositeOperation='copy';
1990 const bg=c.createLinearGradient(0,0,0,540);bg.addColorStop(0,`hsl(${h},86%,${14*Lf}%)`);bg.addColorStop(.58,`hsl(${h},87%,${30*Lf}%)`);bg.addColorStop(1,`hsl(${h},90%,${Math.min(60,42*Lf)}%)`);c.fillStyle=bg;c.fillRect(X0,Y0,RW,RH);c.globalCompositeOperation='source-over';
1991 const halo=c.createRadialGradient(W*.62,270,0,W*.62,270,W*.66);halo.addColorStop(0,`hsla(${h+25},100%,65%,.14)`);halo.addColorStop(1,'transparent');c.fillStyle=halo;c.fillRect(X0,Y0,RW,RH);
1992 if(prof)rushSceneryProfilePush(p,'scenerySky',lap());
1993 if(!low){const fx=W*.73-(scroll*.025)%(W+700),hy=st.heroY||0;c.save();c.globalAlpha=.17+pulse*.04;
1994  if(theme.style==='city'){rbgCircle(c,fx,180+hy,105,null,theme.accent);rbgCircle(c,fx,180+hy,94,null,theme.accent,.6);for(let i=0;i<12;i++){const a=i*Math.PI/6;rbgLine(c,fx+Math.cos(a)*100,180+hy+Math.sin(a)*100,fx+Math.cos(a)*109,180+hy+Math.sin(a)*109,theme.accent,2);}}
1995  else if(theme.style==='factory')rbgGear(c,fx,210+hy,128,t*.025*(st.heroSpin||1),'rgba(6,7,16,.3)',theme.accent,18);
1996  else if(theme.style==='crystal'){rbgDiamond(c,fx,180+hy,96,'rgba(234,134,255,.16)');rbgCircle(c,fx,180+hy,122,null,theme.accent,.7);}
1997  else{rbgCircle(c,fx,200+hy,96,null,theme.accent,.8);rbgCircle(c,fx,200+hy,70,null,theme.accent,.5);for(let i=0;i<3;i++)rbgLine(c,fx-120+i*18,262+hy+i*14,fx+120-i*18,262+hy+i*14,theme.accent,1);}
1998  c.restore();}
1999 if(st.rays>0)stageRays(c,W*.58,176,st.rayAngle,st.rays*(1+.35*(st.kick||0)),h,Math.max(640,ex+ey+200));
2000 const yTop=Y0< -60?Math.max(-221,Y0):-60,kl=210/530,kr=440/530;
2001 c.save();c.globalAlpha=.045;for(let i=Math.floor((X0-520)/300);i<=Math.ceil(X1/300)+1;i++){const xx=i*300-scroll*.055%300;rbgPolygon(c,[[xx+(yTop+60)*kl,yTop],[xx+70+(yTop+60)*kr,yTop],[xx+510,470],[xx+210,470]],'#befcff');}c.restore();
2002 if(st.prism>0)stagePrism(c,W*.58,150,st.prism,st.rayAngle*.5);
2003 if(prof)rushSceneryProfilePush(p,'sceneryHero',lap());
2004 if(st.fade){stageCells(p,c,st.fade.style,scroll,t,X0,X1,ox,oy,1-st.fade.a);stageCells(p,c,theme.style,scroll,t,X0,X1,ox,oy,st.fade.a);}
2005 else stageCells(p,c,theme.style,scroll,t,X0,X1,ox,oy,1);
2006 // Ground mass under the skyline: invisible at small rolls, a dark tilted horizon at large ones.
2007 if(Y1>FLOOR+2){c.fillStyle=stageEarth(p,c);c.fillRect(X0,FLOOR,RW,Y1-FLOOR);}
2008 if(prof)rushSceneryProfilePush(p,'sceneryCells',lap());
2009 if(st.mid>0)stageMid(c,theme.style,scroll,X0,X1,st.mid,theme.accent);
2010 if(st.streaks>0)stageStreaks(c,scroll,X0,X1,st.streaks);
2011 if(prof)rushSceneryProfilePush(p,'sceneryStage',lap());
2012 if(!low){const SW=RW+40;for(let i=0;i<38;i++){const x=((i*153.3-scroll*.07)%SW+SW)%SW-20+X0,y=55+(i*87.7)%335+Math.sin(t*.4+i)*8;rbgCircle(c,x,y,i%3?1:2,`rgba(174,240,255,${.13+(i%5)*.025})`);}}
2013 if(pulse>0){c.fillStyle=`rgba(128,221,255,${pulse*st.tint})`;c.fillRect(X0,Y0,RW,Math.max(0,FLOOR-Y0));}
2014 c.fillStyle=stageVignette(p,c,W);c.fillRect(X0,Y0,RW,RH);
2015 if(prof)rushSceneryProfilePush(p,'sceneryFx',lap());
2016 main.save();main.globalAlpha=1;main.imageSmoothingEnabled=false;main.globalCompositeOperation='copy';
2017 if(st.roll||st.zoom!==1){main.translate(cx,cy);if(st.roll)main.rotate(st.roll);if(st.zoom!==1)main.scale(st.zoom,st.zoom);main.translate(-cx,-cy);}
2018 // Arming probe (stagecraft stageFrame): the first frame fills as usual but blits the whole buffer, so its rotated copy
2019 // rasters the full canvas the way every rolled in-run frame does, not just the sky of an upright first frame. The rows
2020 // outside the filled region land above the canvas or under the opaque ground, so nothing visible changes.
2021 if(st.probe)main.drawImage(buf.canvas,0,0,2*HX*RBG_SCALE,2*HY*RBG_SCALE,cx-HX,cy-HY,2*HX,2*HY);
2022 else main.drawImage(buf.canvas,(HX-ex)*RBG_SCALE,(HY-ey)*RBG_SCALE,RW*RBG_SCALE,RH*RBG_SCALE,X0,Y0,RW,RH);
2023 main.restore();
2024 if(prof)rushSceneryProfilePush(p,'sceneryBlit',lap());
2025 p.sceneryBytes=(p.rbgBytes||0)+(p.rbgFrame?.bytes||0)+buf.bytes;
2026}
2027const rushStageBaseBackground=Painter.prototype.background;
2028Painter.prototype.background=function(theme,scroll,t,pulse,low){
2029 const st=this._stage;if(st)return stageBackground(this,st,theme,scroll,t,pulse,low);
2030 const out=rushStageBaseBackground.call(this,theme,scroll,t,pulse,low);if(this.stageBuf)this.sceneryBytes+=this.stageBuf.bytes;return out;
2031};
2032
2033// Reuse crisp 1x tiles for the foreground ground. The tile is fully opaque, so
2034// an alpha:false cache avoids unnecessary source-alpha composition without changing pixels.
2035Painter.prototype.ground=function(scroll,theme,top=false){
2036 const c=this.c,W=this.W,y=top?0:FLOOR,hh=top?ROOF:120,hue=Math.round(this.hue/6)*6,key=['rbg-ground',top,hue,theme.accent].join(':');
2037 const tile=rbgCache(this,key,()=>{const s=rbgMakeCanvas(this,64,hh,1,false),x=s.c,g=x.createLinearGradient(0,0,0,hh);g.addColorStop(0,top?'#030918':`hsl(${hue} 80% 21%)`);g.addColorStop(1,top?`hsl(${hue} 75% 13%)`:'#020816');x.fillStyle=g;x.fillRect(0,0,64,hh);x.fillStyle='rgba(0,0,0,.2)';x.fillRect(6,14,52,hh-27);x.strokeStyle='rgba(109,219,252,.15)';x.lineWidth=1;x.strokeRect(6,14,52,hh-27);rbgLine(x,14,21,47,21,'rgba(171,248,255,.14)');x.fillStyle=theme.accent;x.globalAlpha=.1;x.fillRect(26,29,11,hh-53);return s;});
2038 for(let x=-(scroll%64)-64;x<W+64;x+=64)rbgBlitLogical(c,tile,x,y);const base=top?ROOF:FLOOR;rbgLine(c,0,base,W,base,'#ebffff',2);rbgLine(c,0,base+(top?-4:4),W,base+(top?-4:4),theme.accent);
2039};
2040
2041// Vignette is already baked into the low-resolution background frame.
2042Painter.prototype.vignette=function(){};
2043
2044// render-cache.mjs loads before this module, so scenery overrides its first
2045// background/ground wrappers. Re-wrap the final implementations here for the
2046// acceptance-only profiler. Normal gameplay takes the direct fast path.
2047const rushSceneryProfiledBackground=Painter.prototype.background;
2048Painter.prototype.background=function(...args){
2049 if(!globalThis.__OVERDRIVE_PROFILE__)return rushSceneryProfiledBackground.apply(this,args);
2050 const t=performance.now(),out=rushSceneryProfiledBackground.apply(this,args),dt=performance.now()-t;
2051 if(this._profileFrame)this._profileFrame.background+=dt;
2052 return out;
2053};
2054const rushSceneryProfiledGround=Painter.prototype.ground;
2055Painter.prototype.ground=function(...args){
2056 if(!globalThis.__OVERDRIVE_PROFILE__)return rushSceneryProfiledGround.apply(this,args);
2057 const t=performance.now(),out=rushSceneryProfiledGround.apply(this,args),dt=performance.now()-t;
2058 if(this._profileFrame)this._profileFrame.ground+=dt;
2059 return out;
2060};
2061
2062
2063// replays.mjs
2064/** Visible rehearsal only; geometry-only QA solver; standard collisions, never records. */
2065const REPLAYS=[[[442,1],[459,0],[813,1],[830,0],[1205,1],[1222,0],[1514,1],[1531,0],[1957,1],[1974,0],[2340,1],[2357,0],[2789,1],[2806,0],[3647,1],[3664,0],[3681,1],[3726,0],[3743,1],[3762,0],[3782,1],[3800,0],[3822,1],[3839,0],[3861,1],[3878,0],[3900,1],[3917,0],[3940,1],[3975,0],[4012,1],[4029,0],[4061,1],[4078,0],[4096,1],[4135,0],[4183,1],[4200,0],[4232,1],[4249,0],[4274,1],[4291,0],[4314,1],[4331,0],[4354,1],[4371,0],[4428,1],[4457,0],[4474,1],[4495,0],[4516,1],[4533,0],[4582,1],[4609,0],[4626,1],[4646,0],[4666,1],[4684,0],[4706,1],[4723,0],[4744,1],[4761,0],[4784,1],[4801,0],[4824,1],[4865,0],[4909,1],[4926,0],[4958,1],[4975,0],[5000,1],[5017,0],[5040,1],[5057,0],[5080,1],[5097,0],[5120,1],[5137,0],[5160,1],[5177,0],[5200,1],[5217,0],[5668,1],[5685,0],[5993,1],[6010,0],[6423,1],[6440,0],[7522,1],[7539,0],[7950,1],[7967,0],[8405,1],[8422,0],[9266,1],[9283,0],[9704,1],[9721,0],[10132,1],[10149,0],[10526,1],[10578,0],[10630,1],[10670,0],[10722,1],[10774,0],[10826,1],[10878,0],[10930,1],[10994,0],[11046,1],[11122,0],[11174,1],[11226,0],[11278,1],[11330,0],[11406,1],[11458,0],[11510,1],[11537,0],[11589,1],[11641,0],[11693,1],[11745,0],[11797,1],[11874,0],[11926,1],[11978,0],[12030,1],[12082,0],[12134,1],[12186,0],[12649,1],[12666,0],[13030,1],[13047,0],[13405,1],[13422,0]],[[475,1],[492,0],[1025,1],[1042,0],[1426,1],[1443,0],[2007,1],[2024,0],[2106,1],[2123,0],[2217,1],[2234,0],[2316,1],[2333,0],[2415,1],[2432,0],[2498,1],[2515,0],[2597,1],[2614,0],[2671,1],[2688,0],[2770,1],[2787,0],[2869,1],[2886,0],[2984,1],[3001,0],[3097,1],[3114,0],[3196,1],[3213,0],[3295,1],[3312,0],[3390,1],[3407,0],[3475,1],[3492,0],[3574,1],[3591,0],[3673,1],[3690,0],[3772,1],[3789,0],[4313,1],[4330,0],[4735,1],[4752,0],[5896,1],[5940,0],[5957,1],[5974,0],[6001,1],[6023,0],[6043,1],[6061,0],[6082,1],[6099,0],[6120,1],[6137,0],[6160,1],[6177,0],[6200,1],[6217,0],[6239,1],[6256,0],[6273,1],[6293,0],[6321,1],[6338,0],[6366,1],[6383,0],[6406,1],[6423,0],[6440,1],[6485,0],[6542,1],[6559,0],[6590,1],[6607,0],[6632,1],[6649,0],[6672,1],[6689,0],[6712,1],[6729,0],[6794,1],[6829,0],[6846,1],[6866,0],[6885,1],[6902,0],[6949,1],[6975,0],[6992,1],[7011,0],[7031,1],[7049,0],[7071,1],[7089,0],[7112,1],[7129,0],[7152,1],[7169,0],[7191,1],[7208,0],[7230,1],[7267,0],[7307,1],[7324,0],[7356,1],[7373,0],[7398,1],[7415,0],[7438,1],[7455,0],[7478,1],[7495,0],[7518,1],[7535,0],[7558,1],[7575,0],[7598,1],[7615,0],[7638,1],[7655,0],[7678,1],[7695,0],[8146,1],[8163,0],[8585,1],[8602,0],[8993,1],[9010,0],[9661,1],[9715,0],[9769,1],[9807,0],[9861,1],[9915,0],[9969,1],[10023,0],[10077,1],[10147,0],[10201,1],[10255,0],[10280,1],[10338,0],[10392,1],[10446,0],[10500,1],[10554,0],[10641,1],[10695,0],[10749,1],[10783,0],[10837,1],[10891,0],[10945,1],[10999,0],[11053,1],[11127,0],[11181,1],[11235,0],[11289,1],[11343,0],[11397,1],[11451,0],[11505,1],[11522,0],[11910,1],[11927,0],[12382,1],[12399,0]],[[405,1],[422,0],[695,1],[712,0],[994,1],[1011,0],[1387,1],[1404,0],[1859,1],[1876,0],[2137,1],[2154,0],[2495,1],[2512,0],[3356,1],[3401,0],[3418,1],[3437,0],[3456,1],[3474,0],[3496,1],[3513,0],[3534,1],[3551,0],[3574,1],[3591,0],[3614,1],[3631,0],[3662,1],[3681,0],[3701,1],[3718,0],[3739,1],[3795,0],[3864,1],[3881,0],[3909,1],[3926,0],[3949,1],[3966,0],[3990,1],[4007,0],[4077,1],[4114,0],[4131,1],[4148,0],[4176,1],[4199,0],[4217,1],[4235,0],[4256,1],[4274,0],[4296,1],[4313,0],[4336,1],[4353,0],[4375,1],[4392,0],[4415,1],[4444,0],[4475,1],[4492,0],[4522,1],[4539,0],[4563,1],[4580,0],[4603,1],[4620,0],[4643,1],[4660,0],[4683,1],[4700,0],[4723,1],[4740,0],[4763,1],[4780,0],[5187,1],[5204,0],[5494,1],[5511,0],[5818,1],[5835,0],[6895,1],[6912,0],[7287,1],[7304,0],[7695,1],[7712,0],[8395,1],[8412,0],[8694,1],[8711,0],[8987,1],[9004,0],[9295,1],[9312,0],[9647,1],[9697,0],[9744,1],[9803,0],[9853,1],[9903,0],[9953,1],[10003,0],[10065,1],[10115,0],[10144,1],[10213,0],[10263,1],[10313,0],[10363,1],[10413,0],[10503,1],[10545,0],[10595,1],[10645,0],[10695,1],[10745,0],[10795,1],[10853,0],[10903,1],[10953,0],[11003,1],[11053,0],[11103,1],[11153,0],[11587,1],[11604,0],[11894,1],[11911,0],[12287,1],[12304,0]],[[431,1],[448,0],[824,1],[841,0],[1193,1],[1210,0],[1514,1],[1531,0],[2026,1],[2043,0],[2340,1],[2357,0],[2721,1],[2738,0],[3584,1],[3601,0],[3693,1],[3710,0],[3792,1],[3809,0],[3891,1],[3908,0],[3977,1],[3994,0],[4076,1],[4093,0],[4137,1],[4154,0],[4236,1],[4253,0],[4335,1],[4352,0],[4454,1],[4471,0],[4566,1],[4583,0],[4665,1],[4682,0],[4764,1],[4781,0],[4844,1],[4861,0],[4943,1],[4960,0],[5042,1],[5059,0],[5141,1],[5158,0],[5668,1],[5685,0],[5993,1],[6010,0],[6423,1],[6440,0],[7304,1],[7321,0],[7684,1],[7701,0],[8059,1],[8076,0],[8969,1],[8986,0],[10904,1],[10921,0],[11284,1],[11301,0],[11659,1],[11676,0]],[[966,1],[983,0],[1435,1],[1452,0],[2106,1],[2123,0],[2453,1],[2470,0],[2886,1],[2903,0],[3295,1],[3312,0],[3976,1],[4020,0],[4037,1],[4055,0],[4074,1],[4092,0],[4113,1],[4130,0],[4151,1],[4168,0],[4191,1],[4208,0],[4230,1],[4247,0],[4270,1],[4287,0],[4310,1],[4327,0],[4350,
20651],[4368,0],[4391,1],[4408,0],[4432,1],[4449,0],[4473,1],[4490,0],[4512,1],[4548,0],[4587,1],[4604,0],[4635,1],[4652,0],[4676,1],[4693,0],[4717,1],[4734,0],[4756,1],[4773,0],[4797,1],[4814,0],[4861,1],[4886,0],[4904,1],[4923,0],[4943,1],[4960,0],[4981,1],[4998,0],[5040,1],[5063,0],[5081,1],[5099,0],[5119,1],[5137,0],[5160,1],[5177,0],[5199,1],[5216,0],[5238,1],[5255,0],[5278,1],[5295,0],[5312,1],[5339,0],[5374,1],[5391,0],[5420,1],[5437,0],[5461,1],[5478,0],[5502,1],[5519,0],[5541,1],[5558,0],[5582,1],[5599,0],[5621,1],[5638,0],[5662,1],[5679,0],[5701,1],[5718,0],[5742,1],[5759,0],[6305,1],[6322,0],[6846,1],[6863,0],[7313,1],[7330,0],[7926,1],[7943,0],[8395,1],[8412,0],[8945,1],[8962,0],[9606,1],[9623,0],[9775,1],[9792,0],[10086,1],[10103,0],[10255,1],[10272,0],[10553,1],[10570,0],[10986,1],[11003,0],[11653,1],[11694,0],[11711,1],[11739,0],[11762,1],[11779,0],[11803,1],[11820,0],[11843,1],[11860,0],[11883,1],[11900,0],[11923,1],[11940,0],[11963,1],[11980,0],[12003,1],[12020,0],[12050,1],[12068,0],[12088,1],[12105,0],[12126,1],[12143,0],[12165,1],[12182,0],[12232,1],[12257,0],[12274,1],[12294,0],[12315,1],[12333,0],[12354,1],[12371,0],[12394,1],[12411,0],[12433,1],[12450,0],[12473,1],[12490,0],[12510,1],[12546,0],[12587,1],[12604,0],[12635,1],[12652,0],[12677,1],[12719,0],[12766,1],[12783,0],[12814,1],[12831,0],[12856,1],[12873,0],[12896,1],[12913,0],[12935,1],[12952,0],[12976,1],[12993,0],[13047,1],[13075,0],[13092,1],[13112,0],[13133,1],[13151,0],[13172,1],[13189,0],[13211,1],[13228,0],[13251,1],[13268,0],[13291,1],[13308,0],[13331,1],[13348,0],[13370,1],[13387,0],[13411,1],[13428,0]],[[431,1],[448,0],[757,1],[774,0],[1132,1],[1149,0],[1522,1],[1539,0],[1889,1],[1906,0],[2107,1],[2124,0],[2314,1],[2331,0],[2533,1],[2550,0],[2742,1],[2759,0],[2960,1],[2977,0],[3632,1],[3668,0],[3685,1],[3706,0],[3726,1],[3744,0],[3766,1],[3783,0],[3805,1],[3822,0],[3844,1],[3861,0],[3940,1],[3986,0],[4003,1],[4023,0],[4043,1],[4060,0],[4080,1],[4097,0],[4120,1],[4137,0],[4159,1],[4230,0],[4331,1],[4349,0],[4370,1],[4387,0],[4409,1],[4426,0],[4448,1],[4465,0],[4488,1],[4505,0],[4584,1],[4630,0],[4647,1],[4667,0],[4687,1],[4705,0],[4727,1],[4744,0],[4766,1],[4783,0],[4805,1],[4822,0],[4839,1],[4872,0],[4912,1],[4929,0],[4961,1],[4978,0],[5002,1],[5019,0],[5042,1],[5059,0],[5083,1],[5100,0],[5122,1],[5139,0],[5162,1],[5179,0],[5203,1],[5220,0],[5449,1],[5466,0],[5735,1],[5752,0],[6060,1],[6077,0],[6431,1],[6448,0],[7125,1],[7142,0],[7343,1],[7360,0],[7551,1],[7568,0],[7769,1],[7786,0],[7998,1],[8015,0],[8216,1],[8233,0],[8886,1],[8903,0],[9060,1],[9077,0],[9205,1],[9222,0],[9377,1],[9394,0],[9551,1],[9568,0],[9695,1],[9712,0],[10086,1],[10103,0],[10241,1],[10258,0],[10561,1],[10578,0],[10698,1],[10715,0],[10823,1],[10840,0],[10960,1],[10977,0],[11088,1],[11105,0],[11224,1],[11241,0],[11380,1],[11397,0],[11516,1],[11533,0],[11642,1],[11659,0],[11778,1],[11795,0],[12272,1],[12337,0],[12389,1],[12441,0],[12493,1],[12545,0],[12633,1],[12685,0],[12737,1],[12789,0],[12841,1],[12893,0],[12922,1],[12996,0],[13047,1],[13098,0],[13149,1],[13200,0],[13274,1],[13326,0],[13378,1],[13430,0],[13482,1],[13528,0],[13677,1],[13694,0]],[[405,1],[422,0],[795,1],[812,0],[1037,1],[1054,0],[1294,1],[1311,0],[1605,1],[1622,0],[1770,1],[1787,0],[1937,1],[1954,0],[2144,1],[2161,0],[2345,1],[2362,0],[2609,1],[2626,0],[3332,1],[3360,0],[3378,1],[3398,0],[3418,1],[3435,0],[3520,1],[3574,0],[3591,1],[3610,0],[3628,1],[3704,0],[3812,1],[3829,0],[3912,1],[3969,0],[3987,1],[4006,0],[4026,1],[4090,0],[4171,1],[4188,0],[4212,1],[4229,0],[4314,1],[4364,0],[4381,1],[4400,0],[4419,1],[4492,0],[4592,1],[4610,0],[4668,1],[4701,0],[4718,1],[4738,0],[4758,1],[4776,0],[4797,1],[4814,0],[5005,1],[5022,0],[5195,1],[5212,0],[5387,1],[5404,0],[5759,1],[5776,0],[5994,1],[6011,0],[6428,1],[6445,0],[6578,1],[6595,0],[6718,1],[6735,0],[6868,1],[6885,0],[7010,1],[7027,0],[7160,1],[7177,0],[7595,1],[7612,0],[7794,1],[7811,0],[8028,1],[8045,0],[8203,1],[8220,0],[8368,1],[8385,0],[8568,1],[8585,0],[8778,1],[8795,
20650],[8953,1],[8970,0],[9730,1],[9747,0],[9887,1],[9904,0],[10055,1],[10072,0],[10212,1],[10229,0],[10505,1],[10522,0],[10662,1],[10679,0],[10830,1],[10847,0],[10987,1],[11004,0],[11330,1],[11347,0],[11487,1],[11504,0],[11655,1],[11672,0],[11812,1],[11829,0],[12103,1],[12120,0],[12294,1],[12311,0],[12512,1],[12529,0]],[[405,1],[422,0],[787,1],[804,0],[970,1],[987,0],[1144,1],[1161,0],[1324,1],[1341,0],[1795,1],[1812,0],[1987,1],[2004,0],[2194,1],[2211,0],[2395,1],[2412,0],[2587,1],[2604,0],[2805,1],[2822,0],[3361,1],[3410,0],[3427,1],[3446,0],[3465,1],[3482,0],[3503,1],[3521,0],[3544,1],[3561,0],[3583,1],[3600,0],[3624,1],[3681,0],[3751,1],[3768,0],[3870,1],[3987,0],[4072,1],[4089,0],[4168,1],[4214,0],[4231,1],[4308,0],[4454,1],[4497,0],[4514,1],[4556,0],[4591,1],[4608,0],[4639,1],[4656,0],[4697,1],[4719,0],[4738,1],[4756,0],[4777,1],[4794,0],[4928,1],[4945,0],[5228,1],[5245,0],[5518,1],[5535,0],[5818,1],[5835,0],[6528,1],[6545,0],[6728,1],[6745,0],[6918,1],[6935,0],[7143,1],[7160,0],[7360,1],[7377,0],[7610,1],[7627,0],[8187,1],[8204,0],[8370,1],[8387,0],[8555,1],[8572,0],[8712,1],[8729,0],[8895,1],[8912,0],[9062,1],[9079,0],[9647,1],[9697,0],[9748,1],[9797,0],[9846,1],[9895,0],[9944,1],[9993,0],[10028,1],[10091,0],[10141,1],[10178,0],[10249,1],[10299,0],[10328,1],[10388,0],[10438,1],[10478,0],[10556,1],[10606,0],[10628,1],[10684,0],[10734,1],[10778,0],[10849,1],[10899,0],[10929,1],[10979,0],[11029,1],[11078,0],[11139,1],[11189,0],[11387,1],[11404,0],[11545,1],[11562,0],[11694,1],[11711,0],[11874,1],[11891,0],[12030,1],[12047,0],[12172,1],[12189,0],[12319,1],[12336,0],[12499,1],[12516,0],[12655,1],[12672,0]],[[486,1],[503,0],[960,1],[977,0],[1375,1],[1392,0],[1680,1],[1697,0],[2033,1],[2050,0],[2401,1],[2418,0],[2766,1],[2783,0],[3121,1],[3138,0],[3475,1],[3492,0],[3976,1],[4020,0],[4037,1],[4055,0],[4074,1],[4092,0],[4113,1],[4130,0],[4151,1],[4168,0],[4191,1],[4208,0],[4230,1],[4247,0],[4270,1],[4287,0],[4310,1],[4327,0],[4350,1],[4368,0],[4391,1],[4408,0],[4432,1],[4449,0],[4473,1],[4490,0],[4512,1],[4548,0],[4587,1],[4604,0],[4635,1],[4652,0],[4676,1],[4693,0],[4717,1],[4734,0],[4756,1],[4773,0],[4797,1],[4814,0],[4861,1],[4886,0],[4904,1],[4923,0],[4943,1],[4960,0],[4981,1],[4998,0],[5040,1],[5063,0],[5081,1],[5099,0],[5119,1],[5137,0],[5160,1],[5177,0],[5199,1],[5216,0],[5238,1],[5255,0],[5278,1],[5295,0],[5312,1],[5339,0],[5374,1],[5391,0],[5420,1],[5437,0],[5461,1],[5478,0],[5502,1],[5519,0],[5541,1],[5558,0],[5582,1],[5599,0],[5621,1],[5638,0],[5662,1],[5679,0],[5701,1],[5718,0],[5742,1],[5759,0],[6120,1],[6137,0],[6545,1],[6562,0],[7020,1],[7037,0],[7435,1],[7452,0],[7801,1],[7818,0],[8166,1],[8183,0],[8521,1],[8538,0],[8873,1],[8890,0],[9241,1],[9258,0],[9721,1],[9738,0],[10075,1],[10092,0],[10381,1],[10398,0],[10741,1],[10758,0],[11095,1],[11112,0],[11761,1],[11778,0],[12241,1],[12258,0],[12721,1],[12738,0],[12961,1],[12978,0],[13201,1],[13218,0]]];
2066
2067
2068// workshop.mjs
2069
2070
2071
2072
2073
2074
2075const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
2076const KEY='geometrylite.overdrive.workshop.v2';
2077// Cloud identity lives BESIDE the draft, never inside the level JSON: exportLevel
2078// output and its content hash must stay byte-identical to an offline draft.
2079const CLOUD_KEY='geometrylite.overdrive.workshop.cloud.v1';
2080// The community hub is a site page, not part of this bundle: an embedded build must
2081// reach it by opening the site, never by navigating the frame it is playing in.
2082const COMMUNITY_URL='https://geometrylite.net/community';
2083const CLOUD_UUID=/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2084const CLOUD_STATUS=['unsaved','draft','pending','published','rejected','archived'];
2085const CLOUD_MESSAGES={
2086 PLATFORM_DISABLED:'Community services are not enabled yet.',
2087 SIGN_IN_REQUIRED:'Sign in at geometrylite.net/community first, then save again.',
2088 REVISION_CONFLICT:'A newer cloud version exists. Reload the page before saving.',
2089 ACCOUNT_CHANGED:'The signed-in account changed. Reload the page.',
2090 RATE_LIMITED:'Too many saves. Try again in a minute.',
2091 INVALID_TRANSITION:'This level is already under review.',
2092};
2093// Long enough to read a sign-in step or a revision conflict before it goes.
2094const CLOUD_TOAST_MS=4500;
2095// One sentence the author can act on, used by every path that refuses a stacked solid.
2096// Far enough that a tap with a shaky finger still reads as a tap.
2097const DRAG_SLOP=6;
2098const OVERLAP_MESSAGE="Overlaps another object. Solid objects can't stack.";
2099const ENEMY_LANE_MESSAGE="An enemy keeps the lane of its kind. Move it along the beat.";
2100const SHOT_LANE_MESSAGE="An enemy shot would pass through it. Keep throw lanes and boss arenas clear.";
2101const CLOUD_UNAVAILABLE='Cloud service is unavailable right now. Your level is saved on this device.';
2102// A code names something the author can do; a bare status or a failed fetch names only our
2103// own trouble. Never show the number — say the one thing that matters: nothing is lost.
2104const cloudMessage=e=>CLOUD_MESSAGES[e?.code]
2105 ||(!e?.code||String(e.code).startsWith('HTTP_')?CLOUD_UNAVAILABLE:e.message)
2106 ||CLOUD_UNAVAILABLE;
2107const cloudId=()=>{
2108 const id=globalThis.crypto?.randomUUID?.();
2109 if(typeof id!=='string'||!CLOUD_UUID.test(id))throw Object.assign(Error('This browser cannot create a cloud level ID.'),{code:'UUID_UNAVAILABLE'});
2110 return id;
2111};
2112const cloudRevision=saved=>{
2113 if(!Number.isSafeInteger(saved?.revision)||saved.revision<1)throw Object.assign(Error('The cloud returned an unexpected response.'),{code:'INVALID_RESPONSE'});
2114 return saved.revision;
2115};
2116/** Patrol spans the panel offers: only those parseLevel accepts at this tempo (<= 200 units/s over one leg), plus the current one. */
2117function patrolSpans(o,level){const leg=o.leg??2,beat=60/level.bpm,fits=[64,96,128,192].filter(v=>v/(leg*beat)<=200),now=Math.round((o.w-o.bw)*1000)/1000;return [0,...fits,...(now&&!fits.includes(now)?[now]:[])];}
2118const TYPES=[['block','■'],['spike','▲'],['saw','✹'],['pad','⌒'],['orb','◎'],['coin','◆'],['portal','◉'],['slope','◢'],['enemy','☻']];
2119class Workshop{
2120 constructor(app){this.app=app;this.level=emptyLevel();this.tool='block';this.x=0;this.selected=null;this.undo=[];this.redo=[];this.drag=null;this.clearArmed=false;this.mode='place';this.songName='';this.moreOpen=false;this.propsOpen=false;this.rotateHinted=false;
2121  try{const text=localStorage.getItem(KEY);
2121if(text)this.level=parseLevel(JSON.parse(text));}catch{}
2122  this.cloud=this.readCloud();this.cloudBusy=false;this.client=new LevelClient();
2123 }
2124 snapshot(){return JSON.stringify(exportLevel(this.level));}
2125 // A stacked solid is neither playable nor readable, so no edit may create one.
2126 refuse(){this.app.toast(OVERLAP_MESSAGE);return true;}
2127 blocked(objects,candidate,ignoreId){if(collides(objects,candidate,ignoreId))return this.refuse();if(blocksShots(objects,candidate,ignoreId,this.level)){this.app.toast(SHOT_LANE_MESSAGE);return true;}return false;}
2128 // Where a drop actually lands: flush against the neighbour it met, if one step gets it clear.
2129 settled(candidate,ignoreId){const at=snapClear(this.level.objects,candidate,ignoreId);return at&&at.x>=120?at:null;}
2130 settle(candidate,ignoreId){
2131  const at=snapClear(this.level.objects,candidate,ignoreId);
2132  if(!at){this.refuse();return null;}
2133  if(at.x<120){this.app.toast('Keep objects past the start line.');return null;}
2134  if(blocksShots(this.level.objects,at,ignoreId,this.level)){this.app.toast(SHOT_LANE_MESSAGE);return null;}
2135  return at;
2136 }
2137 cancelDrag(){this.drag=null;}
2138 // A level that arrives already stacked is still the author's to keep, but it cannot be
2139 // published. Name the damage and put the first offender under the cursor to fix it.
2140 overlapCount(){return new Set(findOverlaps(this.level.objects).flatMap(p=>[p.a,p.b])).size;}
2141 reportOverlaps(){
2142  const pairs=findOverlaps(this.level.objects);if(!pairs.length)return 0;
2143  const count=new Set(pairs.flatMap(p=>[p.a,p.b])).size;
2144  this.choose(pairs[0].a);this.app.view();this.app.toast(count+' overlapping objects — fix them before publishing.',CLOUD_TOAST_MS);
2145  return count;
2146 }
2147 // Same for a solid in a throw lane or boss arena: kept, named, the first shooter selected (publishing refuses it).
2148 reportShotConflicts(){
2149  const pairs=findShotConflicts(this.level.objects,this.level);if(!pairs.length)return 0;
2150  const n=pairs.length;
2151  this.choose(pairs[0].a);this.app.view();this.app.toast(n+' enemy shot'+(n>1?'s':'')+' would pass through a solid — fix '+(n>1?'them':'it')+' before publishing.',CLOUD_TOAST_MS);
2152  return n;
2153 }
2154 // The drawer and a half-confirmed CLEAR belong to the moment that raised them. Carrying
2155 // either across an entry, a turn or a dismissal leaves a destructive tap waiting unseen.
2156 resetChrome(){this.moreOpen=false;this.clearArmed=false;}
2157 // The sheet opens itself for a newly selected object and otherwise stays where the author
2158 // put it, so a re-render never re-opens a summary they just collapsed.
2159 choose(id,reveal=true){const next=id??null;if(next!==this.selected){this.propsOpen=reveal&&next!==null;this.autoFlip=false;}this.selected=next;}
2160 // The compact sheet is pinned 58px up and capped at 34%, so it covers roughly the lower
2161 // half of the scene. Opening it over the object just picked would hide what it edits.
2162 occluded(o){return !!o&&this.compact()&&o.y+(['coin','saw','orb'].includes(o.type)?o.r:o.h)>270;}
2163 // A re-render replaces the whole panel: blur first so a half-typed field fires its change
2164 // event into the level instead of vanishing with the node that held it.
2165 commitFocus(){const el=this.app.root?.activeElement;
2166  if(el&&el!==this.app.ui&&this.app.ui?.contains?.(el)&&['INPUT','SELECT','TEXTAREA'].includes(el.tagName))el.blur();}
2167 push(){this.undo.push(this.snapshot());if(this.undo.length>LIMITS.history)this.undo.shift();this.redo=[];}
2168 apply(data){this.level=parseLevel(data);this.app.level=this.level;this.save();this.reportOverlaps();}
2169 save(){try{localStorage.setItem(KEY,this.snapshot());this.saved=true;}catch{this.saved=false;}}
2170 readCloud(){
2171  try{
2172   const raw=localStorage.getItem(CLOUD_KEY);if(!raw)return null;
2173   const value=JSON.parse(raw);
2174   if(value&&typeof value==='object'&&!Array.isArray(value)&&CLOUD_UUID.test(String(value.uuid))&&Number.isSafeInteger(value.revision)&&value.revision>=0)
2175    return {uuid:String(value.uuid).toLowerCase(),revision:value.revision,status:CLOUD_STATUS.includes(value.status)?value.status:'draft'};
2176   localStorage.removeItem(CLOUD_KEY);return null;
2177  }catch{return null;}
2178 }
2179 writeCloud(){try{if(this.cloud)localStorage.setItem(CLOUD_KEY,JSON.stringify(this.cloud));else localStorage.removeItem(CLOUD_KEY);}catch{}}
2180 // A replaced level is a different work; it must never inherit another level's cloud slot.
2181 forgetCloud(){this.cloud=null;this.writeCloud();}
2182 // Returns how many problems it named, so the caller's own toast does not replace that warning.
2183 importLevel(level){this.push();this.level=level;this.x=0;this.choose(null);this.forgetCloud();this.changed();return this.reportOverlaps()||this.reportShotConflicts();}
2184 // One cloud write at a time: a second click would repeat expectedRevision and the
2185 // server would answer the author's own request with a revision conflict.
2186 async cloudSave(){
2187  const app=this.app;if(this.cloudBusy)return;
2188  if(this.reportOverlaps())return;
2189  this.cloudBusy=true;app.view();let message;
2190  try{
2191   // Reserve the id BEFORE the first write: a retry must reuse it, or a lost
2192   // response leaves an orphan row nothing on this device can ever address.
2193   if(!this.cloud){this.cloud={uuid:cloudId(),revision:0,status:'unsaved'};this.writeCloud();}
2194   const {uuid,revision}=this.cloud;
2195   const saved=await this.client.saveDraft({uuid,expectedRevision:revision,level:JSON.parse(this.snapshot())});
2196   this.cloud={uuid,revision:cloudRevision(saved),status:'draft'};this.writeCloud();
2197   message='Saved to cloud as a draft.';
2198  }catch(e){message=await this.resync(e);}
2199  finally{this.cloudBusy=false;app.view();app.toast(message,CLOUD_TOAST_MS);}
2200 }
2201 // A conflict means the cloud moved on. Re-read its revision so the author can decide
2202 // to overwrite with one more click; this never silently discards either version.
2203 async resync(error){
2204  if(error?.code!=='REVISION_CONFLICT'||!this.cloud)return cloudMessage(error);
2205  try{
2206   const current=await this.client.get(this.cloud.uuid);
2207   const revision=cloudRevision(current);
2208   this.cloud={...this.cloud,revision,status:CLOUD_STATUS.includes(current.status)?current.status:this.cloud.status};
2209   this.writeCloud();
2210   return 'Cloud had a newer revision; local version updated — save again to overwrite it.';
2211  }catch{return cloudMessage(error);}
2212 }
2213 async cloudSubmit(){
2214  const app=this.app;if(this.cloudBusy)return;
2215  if(!this.cloud?.revision)return app.toast('Save to cloud before submitting.',CLOUD_TOAST_MS);
2216  if(this.reportOverlaps())return;
2217  this.cloudBusy=true;app.view();let message;
2218  try{
2219   const saved=await this.client.submit({uuid:this.cloud.uuid,expectedRevision:this.cloud.revision});
2220   // Early access publishes the first levels on submit. Anything else is queued: only the
2221   // server's own 'published' may claim a public level, never a hopeful local guess.
2222   const status=saved.status==='published'?'published':'pending';
2223   this.cloud={...this.cloud,revision:cloudRevision(saved),status};this.writeCloud();
2224   message=status==='published'?'Published to the community!':'Submitted for review. It stays unranked.';
2225  }catch(e){message=cloudMessage(e);}
2226  finally{this.cloudBusy=false;app.view();app.toast(message,CLOUD_TOAST_MS);}
2227 }
2228 // Chromium answers a 'noopener' open with null even when it succeeded, so ask for the
2229 // window and sever the opener afterwards: a null here really is a blocked pop-up.
2230 openWindow(url,target){
2231  const opened=globalThis.open?.(url,target);
2232  if(!opened)return false;
2233  if(target==='_blank'){try{opened.opener=null;}catch{}}
2234  return true;
2235 }
2236 /** Where PUBLISH ON COMMUNITY leads, decided by who owns the frame this game runs in. */
2237 communityTarget(){
2238  try{
2239   const top=globalThis.top,embedded=top&&top!==globalThis;
2240   // Another site's frame: its top is not ours to navigate, so open the production page.
2241   if(embedded&&top.location.origin!==globalThis.location.origin)return {url:COMMUNITY_URL,target:'_blank'};
2242   const href=(embedded?top:globalThis).location.href;
2243   // Already the community page's own embedded editor: its workspace is right there.
2244   if(new URL(href).pathname.replace(/\/+$/,'').endsWith('/community'))return {here:true};
2245   // Same host as whatever serves this game: localhost, preview and production all work.
2246   return {url:new URL('/community',href).href,target:'_top'};
2247  }catch{return {url:COMMUNITY_URL,target:'_blank'};}
2248 }
2249 // Reading top's origin is the same permission as navigating it: a frame that throws here
2250 // can only ever reach the community page in a new tab of its own.
2251 topReachable(){try{const top=globalThis.top;return !top||top===globalThis||typeof top.location.origin==='string';}catch{return false;}}
2252 // This site's own frames are sandboxed without allow-top-navigation, so a '_top' open is
2253 // refused while window.open still answers with a window. Ask the host first; only a host
2254 // that answers handled counts, and silence falls through to opening the page ourselves.
2255 askHost(){
2256  const parent=globalThis.parent;
2257  if(!parent||parent===globalThis||!globalThis.addEventListener)return Promise.resolve(false);
2258  return new Promise(resolve=>{
2259   let timer;
2260   const finish=handled=>{globalThis.removeEventListener('message',hear);clearTimeout(timer);resolve(handled);};
2261   const hear=e=>{if(e.source===parent&&e.data?.type===OPEN_COMMUNITY_RESULT_TYPE&&e.data.handled===true)finish(true);};
2262   globalThis.addEventListener('message',hear);
2263   timer=setTimeout(()=>finish(false),OPEN_COMMUNITY_TIMEOUT_MS);
2264   try{parent.postMessage({type:OPEN_COMMUNITY_TYPE},'*');}catch{finish(false);}
2265  });
2266 }
2267 // A refused '_top' leaves the page exactly where it was. Watching for the move that never
2268 // came is the only signal the frame gets, so tell the author instead of doing nothing.
2269 navigated(){
2270  const doc=globalThis.document;
2271  if(!doc)return Promise.resolve(true);
2272  const was=globalThis.location?.href,visible=doc.visibilityState;
2273  return new Promise(resolve=>setTimeout(()=>resolve(doc.visibilityState!==visible||globalThis.location?.href!==was),NAVIGATION_CHECK_MS));
2274 }
2275 // Keep the draft before leaving: an unsaved name or object would be lost by the navigation.
2276 async openCommunity(){
2277  this.save();
2278  // Only a framed editor whose top we can read has a host worth asking. Everyone else —
2279  // a standalone page, a third party's frame — must not spend even a microtask here: the
2280  // open below has to stay inside the same call stack as the tap that authorised it.
2281  const host=globalThis.parent&&globalThis.parent!==globalThis&&this.topReachable();
2282  if(host&&await this.askHost())return this.app.toast('Opening the Community page…');
2283  const to=this.communityTarget();
2284  if(to.here)return this.app.toast('Use the Creator workspace on this page to save and submit.');
2285  if(!this.openWindow(to.url,to.target))return this.app.toast('Pop-up blocked. Open geometrylite.net/community in your browser.');
2286  // A '_blank' that answered with a window did open. Only '_top' can be refused in silence,
2287  // so only '_top' is worth watching for the move that never came.
2288  if(to.target==='_top'&&!await this.navigated())this.app.toast('Open geometrylite.net/community in your browser.');
2289 }
2290 changed(){this.app.level=this.level;this.save();this.app.view();}
2291 // A portrait phone rotates the whole arena, so the editor chrome eats the screen's short
2292 // side. Compact mode trades the full toolbars for a drawer; landscape keeps every button.
2293 compact(){return !!this.app.arena?.classList?.contains('rotated');}
2294 enter(){this.app.audio.stop();this.app.release();this.app.customRun=false;this.app.externalRun=false;this.app.level=this.level;
2295  // Before the render, never after: every way out of the editor comes back through here, and
2296  // chrome drawn from flags that are about to be cleared is chrome the DOM no longer means.
2297  this.resetChrome();this.app.setState('editor');
2298  if(this.compact()&&!this.rotateHinted){this.rotateHinted=true;this.app.toast('Rotate your phone or tap ⛶ for fullscreen.');}
2299 }
2300 point(e){
2301  const box=this.app.arena.getBoundingClientRect(),rotated=this.app.arena.classList.contains('rotated');
2302  let x,y;if(rotated){x=e.clientY-box.top;y=box.right-e.clientX;}else{x=e.clientX-box.left;y=e.clientY-box.top;}
2303  const scale=540/(rotated?box.width:box.height);return {x:x*scale+this.x,y:y*scale};
2304 }
2305 pick(point){return [...this.level.objects].reverse().find(o=>['coin','saw','orb'].includes(o.type)?Math.hypot(point.x-o.x,point.y-o.y)<o.r+10:point.x>=o.x-5&&point.x<=o.x+o.w+5&&point.y>=o.y-5&&point.y<=o.y+o.h+5);}
2306 down(e){
2307  if(this.moreOpen){this.resetChrome();this.app.view();return;}
2308  if(this.drag)return;
2309  const p=this.point(e),cx=e.clientX??0,cy=e.clientY??0;if(p.y<85||p.y>FLOOR+8)return;
2310  // The compact sheet floats over the scene. A tap on bare ground dismisses it instead of
2311  // dropping an object under it; PAN is exempt, since dragging is how the scene scrolls here.
2312  if(this.mode!=='pan'&&this.compact()&&this.propsOpen&&!this.pick(p)){this.propsOpen=false;this.app.view();return;}
2313  if(this.mode==='pan'){this.drag={kind:'pan',at:p.x,origin:this.x};return;}
2314  if(this.mode==='select'){
2315   const o=this.pick(p);this.choose(o?.id,!this.occluded(o));
2316   if(o&&!this.occluded(o))this.propsOpen=true;
2317   if(o)this.drag={kind:'move',id:o.id,spec:{...o},cx,cy,moved:false,ghost:null,fresh:false};
2318   this.app.view();return;
2319  }
2320  if(this.mode==='erase'){const o=this.pick(p);if(o){this.push();this.level.objects=this.level.objects.filter(a=>a.id!==o.id);this.choose(null);this.changed();}return;}
2321  if(this.level.objects.length>=LIMITS.objects){this.app.toast('Object limit reached');return;}
2322  const o=this.settle(this.toolSpec(this.tool,p));
2323  if(!o)return;
2324  const data=exportLevel(this.level);data.objects=[...data.objects,o];
2325  try{const validated=parseLevel(data);this.push();this.level=validated;
2326   const placed=this.level.objects.find(a=>a.x===o.x&&a.type===o.type);
2327   this.choose(placed?.id,!this.compact());this.changed();
2328   // The placing tap already took its undo step, so dragging on from here relocates the new
2329   // object inside that same step rather than stacking a second one on top of it.
2330   if(placed)this.drag={kind:'move',id:placed.id,spec:{...placed},cx,cy,moved:false,ghost:null,fresh:true};
2331  }catch(err){this.app.toast(err.message);}
2332 }
2333 /** The object a tool makes at a tapped point; the long-standing default y is kept here. */
2334 toolSpec(tool,p){
2335  const x=Math.max(120,Math.round(p.x/24)*24),y=FLOOR-Math.ceil((FLOOR-p.y)/24)*24;
2336  const o={id:Math.max(-1,...this.level.objects.map(a=>a.id))+1,type:tool,x,y,w:48,h:48,r:20};
2337  if(tool==='spike'){o.w=34;o.h=34;o.y=FLOOR-34;}
2338  if(tool==='pad'){o.w=38;o.h=9;o.y=FLOOR-8;}
2339  if(['coin','saw','orb'].includes(tool)){o.y=y+24;o.w=40;o.h=40;}
2340  if(tool==='portal'){o.mode='ship';o.y=ROOF;o.h=FLOOR-ROOF;o.w=36;o.label='SHIP';}
2341  if(tool==='slope'){o.w=120;o.h=72;o.y=FLOOR-72;}
2342  if(tool==='enemy')return enemyEdit(o,'kind','walker',this.level);
2343  return o;
2344 }
2345 // Where a drag would drop: the same 24px grid a tap uses, but free of the floor. core.mjs
2346 // judges a spike by its triangle, a pad and a saw by their own box wherever they sit, so
2347 // any row is playable; a portal is a full-height gate and has no row to choose.
2348 snap(point,spec){
2349  const x=Math.max(120,Math.round(point.x/24)*24);
2350  if(spec.type==='portal')return {x,y:ROOF};
2351  // An enemy's lane is part of its kind (parseLevel holds it there): a drag moves it along the beat only.
2352  if(spec.type==='enemy')return {x,y:spec.y};
2353  const row=FLOOR-Math.ceil((FLOOR-point.y)/24)*24,ceiling=row<=ROOF+8;
2354  if(['coin','saw','orb'].includes(spec.type)){const r=spec.r??20;return {x,y:ceiling?ROOF+r:clamp(row+24,ROOF+r,FLOOR-r)};}
2355  return {x,y:ceiling?ROOF:clamp(row,ROOF,FLOOR-(spec.h??0))};
2356 }
2357 /** A pointer that has travelled far enough to mean 'move this' rather than 'select this'. */
2358 dragging(e,d){return d.moved||(d.moved=Math.hypot((e.clientX??0)-d.cx,(e.clientY??0)-d.cy)>DRAG_SLOP);}
2359 // Dragging a tool out of the palette is a mouse idiom: on a touch screen the palette has to
2360 // scroll sideways, and a drag there would fight the scroll it needs.
2361 paletteDown(tool,e){
2362  if(this.drag||!globalThis.matchMedia?.('(pointer:fine)')?.matches)return;
2363  this.drag={kind:'create',tool,spec:this.toolSpec(tool,{x:120,y:FLOOR}),cx:e.clientX??0,cy:e.clientY??0,moved:false,ghost:null};
2364 }
2365 // One edit per gesture: a fresh object moves inside the step its placement already took.
2366 moveTo(id,to,fresh){
2367  const data=exportLevel(this.level),item=data.objects.find(z=>z.id===id);if(!item)return;
2368  const original={...item};item.x=to.x;item.y=to.y;if(to.flip!==undefined)item.flip=to.flip;
2369  try{
2370   const next=parseLevel(data);if(!fresh)this.push();this.level=next;
2371   // parseLevel re-sorts by x and re-numbers, so the same object answers to a new id. The
2372   // sheet is untouched: it is still the object the author was already looking at.
2373   this.selected=next.objects.find(a=>a.x===to.x&&a.y===to.y&&a.type===item.type)?.id??null;
2374   this.changed();
2375  }catch(e){Object.assign(item,original);this.app.toast(e.message);}
2376 }
2377 placeAt(spec){
2378  if(this.level.objects.length>=LIMITS.objects)return this.app.toast('Object limit reached');
2379  const data=exportLevel(this.level);data.objects=[...data.objects,spec];
2380  try{const next=parseLevel(data);this.push();this.level=next;
2381   this.choose(next.objects.find(a=>a.x===spec.x&&a.y===spec.y&&a.type===spec.type)?.id,!this.compact());this.changed();
2382  }catch(e){this.app.toast(e.message);}
2383 }
2384 move(e){
2385  const d=this.drag;if(!d)return;
2386  if(d.kind==='pan'){const p=this.point(e);this.x=clamp(d.origin-(p.x-this.x-d.at+d.origin),0,Math.max(0,this.level.length-this.app.paint.W));this.updateScroll();return;}
2387  if(!this.dragging(e,d))return;
2388  // One render, on the first step: the sheet would otherwise cover the ground being aimed at.
2389  // It stays down afterwards; EDIT ▸ is how the author asks for it back.
2390  if(!d.lowered){
2391   d.lowered=true;d.autoFlip=this.autoFlip;
2392   // Dragging a tool out of the palette is itself the request to place it, whatever mode the
2393   // editor was in. A plain click still only switches tools, through the click handler.
2394   const took=d.kind==='create'&&(this.mode!=='place'||this.tool!==d.tool);
2395   if(took){this.mode='place';this.tool=d.tool;}
2396   if(this.compact()&&this.propsOpen)this.propsOpen=false;
2397   if(took||this.compact())this.app.view();
2398  }
2399  const p=this.point(e);
2400  // Outside the scene there is nowhere to drop, so there is nothing to show. A drag may
2401  // reach the roof itself, which a tap deliberately cannot: that top row is how a ceiling
2402  // spike gets made, and the snap clamps whatever the pointer does to a legal row anyway.
2403  if(p.y<ROOF||p.y>FLOOR+8){d.ghost=null;d.outside=true;return;}
2404  d.outside=false;
2405  const raw={...d.spec,...this.snap(p,d.spec)};
2406  // On the ceiling a spike hangs; off it, it goes back to whatever the author chose. A flip
2407  // this feature set on its own is not a choice, so it does not survive leaving the roof.
2408  if(raw.type==='spike')raw.flip=raw.y===ROOF||(!!d.spec.flip&&!d.autoFlip);
2409  const at=this.settled(raw,d.id);
2410  d.ghost=at?{...at,blocked:false}:{...raw,blocked:true};
2411 }
2412 up(cancelled){
2413  const d=this.drag;this.drag=null;
2414  if(cancelled||!d||d.kind==='pan'||!d.moved)return;
2415  if(!d.ghost)return d.outside?this.app.toast('Drop inside the level area.'):undefined;
2416  const {blocked,...landing}=d.ghost;
2417  if(blocked)return this.refuse();
2418  this.autoFlip=landing.type==='spike'&&landing.y===ROOF&&!d.spec.flip;
2419  if(d.kind==='move')this.moveTo(d.id,landing,d.fresh);else this.placeAt(landing);
2420 }
2421 updateScroll(){const s=this.app.ui.querySelector('[data-edit="scroll"]');if(s)s.value=this.x;}
2422 mutate(fn){this.push();fn();this.changed();}
2423 async action(a){
2424  const action=a.slice(3),app=this.app;
2425  // Two full-width panels at once leave no scene to edit, so raising the drawer lowers the
2426  // sheet. Closing it does not raise the sheet again: only a selection speaks for that.
2427  if(action==='more'){if(this.moreOpen)this.resetChrome();else{this.moreOpen=true;this.propsOpen=false;}app.view();return;}
2428  // Close the drawer here, not in each branch: export, import and save never re-render.
2429  // CLEAR is the exception: its confirm step is the button's own label, so arming it behind
2430  // a drawer that just slid shut would hide a destructive tap until the author reopened it.
2431  if(this.moreOpen&&action!=='clear'){this.resetChrome();app.view();}
2432  if(action==='props'){this.propsOpen=!this.propsOpen;app.view();return;}
2433  if(action.startsWith('tool:')){this.tool=action.split(':')[1];this.mode='place';app.view();return;}
2434  if(['select','pan','erase'].includes(action)){this.mode=action;app.view();return;}
2435  if(action==='undo'&&this.undo.length){this.redo.push(this.snapshot());this.level=parseLevel(JSON.parse(this.undo.pop()));this.choose(null);this.changed();return;}
2436  if(action==='redo'&&this.redo.length){this.undo.push(this.snapshot());this.level=parseLevel(JSON.parse(this.redo.pop()));this.choose(null);this.changed();return;}
2437  if(action==='save'){this.save();app.toast(this.saved?'Level saved on this device':'Storage unavailable; use Export to keep your level');return;}
2438  if(action==='clear'){
2439   if(!this.clearArmed){this.clearArmed=true;app.view();return;}
2440   this.moreOpen=false;this.mutate(()=>{this.level=emptyLevel();this.choose(null);this.clearArmed=false;this.forgetCloud();});return;
2441  }
2442  if(action==='copy-stage'){this.push();const l=levelData(app.index);this.level=parseLevel({...exportLevel(l),trackId:l.trackId||l.id});this.choose(null);this.forgetCloud();this.changed();return;}
2443  if(action==='test'||action==='practice'){
2444   const count=this.overlapCount(),run=app.begin({custom:true,practice:action==='practice'});
2445   if(count)setTimeout(()=>app.toast(count+' overlapping objects — fix them before publishing.',CLOUD_TOAST_MS),0
2445);
2446   return run;
2447  }
2448  if(action==='export'){
2449   const file=new File([this.snapshot()],(this.level.name.replace(/[^\w-]+/g,'-')||'overdrive-level')+'.json',{type:'application/json'});
2450   if(navigator.canShare?.({files:[file]})){try{await navigator.share({files:[file],title:this.level.name});return;}catch(e){if(e.name==='AbortError')return;}}
2451   this.download(file,file.name);return;
2452  }
2453  if(action==='import'){const input=document.createElement('input');input.type='file';input.accept='.json,application/json';input.onchange=async()=>{const f=input.files[0];if(!f)return;if(f.size>LIMITS.jsonBytes)return app.toast('Level files must be smaller than 1 MB');try{const l=parseLevel(JSON.parse(await f.text()));if(!this.importLevel(l))app.toast(l.trackId==='local'?'Level loaded. Choose its local song before testing.':'Level imported');}catch(e){app.toast(e.message);}};input.click();return;}
2454  if(action==='cloud-save')return this.cloudSave();
2455  if(action==='cloud-submit')return this.cloudSubmit();
2456  if(action==='community')return this.openCommunity();
2457  if(action==='song'){
2458   const input=document.createElement('input');input.type='file';input.accept='audio/*';input.onchange=async()=>{const f=input.files[0];if(!f)return;if(f.size>LIMITS.audioBytes)return app.toast('Songs must be smaller than 20 MB');this.songName=f.name;app.audio.customBlob=f;app.audio.bufferId=null;this.mutate(()=>this.level.trackId='local');app.toast('Local song selected; it stays in this browser session.');};input.click();return;
2459  }
2460  const o=this.level.objects.find(o=>o.id===this.selected);if(!o)return;
2461  if(action==='delete'){this.mutate(()=>{this.level.objects=this.level.objects.filter(z=>z!==o);this.choose(null);});return;}
2462  if(action==='duplicate'){
2463   const copy=this.settle({...o,x:o.x+72});if(!copy)return;
2464   try{const data=exportLevel(this.level);data.objects=[...data.objects,copy];const next=parseLevel(data);this.push();this.level=next;this.choose(null);this.changed();}catch(e){app.toast(e.message);}return;
2465  }
2466  if(action.startsWith('nudge:'))return this.nudge(action.split(':')[1]);
2467 }
2468 // exportLevel hands back the live objects, so every path out of here restores the copy.
2469 nudge(key,steps=1){
2470  const o=this.level.objects.find(z=>z.id===this.selected);if(!o)return;
2471  if(o.type==='enemy'&&(key==='up'||key==='down')){this.app.toast(ENEMY_LANE_MESSAGE);return;}
2472  const data=exportLevel(this.level),item=data.objects.find(z=>z.id===o.id),original={...item},step=24*steps;
2473  if(key==='left')item.x-=step;if(key==='right')item.x+=step;if(key==='up')item.y-=step;if(key==='down')item.y+=step;
2474  const to={x:item.x,y:item.y,type:item.type};
2475  if(this.blocked(data.objects,item,item.id)){Object.assign(item,original);return;}
2476  try{
2477   const next=parseLevel(data);Object.assign(item,original);this.push();this.level=next;
2478   // parseLevel re-sorts by x, so a step past a neighbour hands the object a new id.
2479   this.selected=next.objects.find(a=>a.x===to.x&&a.y===to.y&&a.type===to.type)?.id??this.selected;
2480   this.changed();
2481  }catch(e){Object.assign(item,original);this.app.toast(e.message);}
2482 }
2483 // Keyboard equivalents of the nudge panel, live only while an object is selected so the
2484 // keys stay out of the way the rest of the time. app.mjs has already excluded typing.
2485 key(e){
2486  if(e.code==='Escape'){if(this.selected===null)return false;this.choose(null);this.app.view();return true;}
2487  if(this.selected===null)return false;
2488  if(['Delete','Backspace'].includes(e.code)){this.action('ed:delete');return true;}
2489  const way={ArrowLeft:'left',ArrowRight:'right',ArrowUp:'up',ArrowDown:'down'}[e.code];
2490  if(!way)return false;
2491  this.nudge(way,e.shiftKey?4:1);return true;
2492 }
2493 download(blob,name){const a=document.createElement('a'),url=URL.createObjectURL(blob);a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),3000);}
2494 input(el){
2495  const key=el.dataset.edit;if(key==='scroll'){this.x=+el.value;return;}
2496  const data=JSON.parse(this.snapshot());
2497  if(['name','startMode','trackId'].includes(key))data[key]=el.value;
2498  if(['bpm','beats','speed','theme'].includes(key))data[key]=+el.value;
2499  const o=data.objects.find(z=>z.id===this.selected);
2500  if(key==='portal'&&o){delete o.mode;delete o.gravity;delete o.speed;delete o.size;const[type,value]=el.value.split(':');o[type]=type==='mode'?value:+value;}
2501  if(['width','height'].includes(key)&&o)o[key==='width'?'w':'h']=+el.value;
2502  if(key==='alpha'&&o)o.alpha=+el.value;
2503  if(key==='motion'&&o){if(+el.value)o.motion={range:+el.value,speed:1.8,phase:0};else delete o.motion;}
2504  if(key==='flip'&&o){o.flip=!!+el.value;this.autoFlip=false;}
2505  if(key.startsWith('foe:')&&o){const next=enemyEdit(o,key.slice(4),el.value,this.level);data.objects=data.objects.map(z=>z.id===o.id?next:z);
2506   if(this.blocked(data.objects,next,next.id))return this.app.view();}
2507  // Width, height and motion all change the space an object claims, so all three are judged.
2508  if(['width','height','motion'].includes(key)&&o&&this.blocked(data.objects,o,o.id))return this.app.view();
2509  try{const next=parseLevel(data);this.push();this.level=next;this.changed();}catch(e){this.app.toast(e.message);}
2510 }
2511 draw(paint,ms){
2512  const c=paint.c,W=paint.W,theme=THEMES[this.level.cues[0].theme];c.setTransform(paint.canvas.width/W,0,0,paint.canvas.height/540,0,0);paint.hue=theme.hue;paint.background(theme,this.x,ms/1000,0,true);
2513  c.fillStyle='rgba(0,10,27,.4)';c.fillRect(0,0,W,540);c.strokeStyle='rgba(137,205,229,.16)';c.lineWidth=.7;
2514  for(let x=-(this.x%24);x<W;x+=24){c.beginPath();c.moveTo(x,82);c.lineTo(x,FLOOR);c.stroke();}
2515  for(let y=FLOOR;y>=ROOF;y-=24){c.beginPath();c.moveTo(0,y);c.lineTo(W,y);c.stroke();}
2516  const unit=this.level.speed*this.level.beat;c.font='10px Arial';
2517  for(let b=Math.floor(this.x/unit);b<(this.x+W)/unit;b++){const x=b*unit-this.x;c.fillStyle='rgba(149,239,254,.65)';c.fillText(''+(b+1),x+3,98);if(b%4===0){c.fillStyle='rgba(182,238,255,.12)';c.fillRect(x,104,1,FLOOR-104);}}
2518  // Enemies pose from this level's tempo (not the last run's), with no death mark, plus boss arenas and throw lanes.
2519  paint.foeLevel=this.level;paint.foeWorld=null;paint.foeEditor=true;
2520  try{for(const o of this.level.objects)paint.object(o,this.x,0,theme,new Set(),true,0);}finally{paint.foeEditor=false;}
2521  paint.ground(this.x,theme);
2522  const o=this.level.objects.find(o=>o.id===this.selected);if(o){const centered=['coin','orb','saw'].includes(o.type);c.strokeStyle='#efff5f';c.lineWidth=2;const x=o.x-this.x-(centered?o.r:0),y=o.y-(centered?o.r:0);c.setLineDash([5,3]);c.strokeRect(x-4,y-4,o.w+8,o.h+8);c.setLineDash([]);}
2523  // Drawn, never rendered: rebuilding the panel mid-drag would fight the pointer holding it.
2524  const g=this.drag?.ghost;
2525  if(g){const centered=['coin','orb','saw'].includes(g.type),gx=g.x-this.x-(centered?g.r:0),gy=g.y-(centered?g.r:0);
2526   c.strokeStyle=g.blocked?'#ff6b6b':'#9dff7a';c.lineWidth=2;c.setLineDash([6,4]);c.strokeRect(gx,gy,g.w,g.h);c.setLineDash([]);}
2527 }
2528 render(){
2529  const o=this.level.objects.find(o=>o.id===this.selected),select=(key,options,value)=>`<select data-edit="${key}" aria-label="${key}">${options.map(([v,l])=>`<option value="${esc(v)}" ${String(v)===String(value)?'selected':''}>${esc(l)}</option>`).join('')}</select>`;
2530  const compact=this.compact();
2531  // Turning back to the full toolbar removes the drawer, so its open flag must not outlive
2532  // it: a stale one would swallow the next canvas tap once the bars are back.
2533  if(!compact)this.moreOpen=false;
2534  const open=this.propsOpen?'open':'';
2535  const
2536   back=`<button data-action="back" aria-label="Exit workshop">‹ STAGES</button>`,
2537   title=`<input class="level-name-input" data-edit="name" aria-label="Level name" maxlength="32" value="${esc(this.level.name)}">`,
2538   test=`<button class="accent" data-action="ed:test">▶ TEST</button>`,
2539   practice=`<button data-action="ed:practice">PRACTICE</button>`,
2540   save=`<button data-action="ed:save">SAVE</button>`,
2541   undo=`<button data-action="ed:undo" ${this.undo.length?'':'disabled'} aria-label="Undo">↶</button>`,
2542   redo=`<button data-action="ed:redo" ${this.redo.length?'':'disabled'} aria-label="Redo">↷</button>`,
2543   send=`<button data-action="ed:export">EXPORT</button>`,
2544   receive=`<button data-action="ed:import">IMPORT</button>`,
2545   cloudSave=`<button data-action="ed:cloud-save" ${this.cloudBusy?'disabled':''}>SAVE TO CLOUD</button>`,
2546   cloudSubmit=`<button data-action="ed:cloud-submit" ${this.cloud?.revision&&!['pending','published'].includes(this.cloud.status)&&!this.cloudBusy?'':'disabled'}>SUBMIT FOR REVIEW</button>`,
2547   community=`<button data-action="ed:community">PUBLISH ON COMMUNITY</button>`,
2548   clear=`<button ${this.clearArmed?'class="danger" ':''}data-action="ed:clear">${this.clearArmed?'CONFIRM CLEAR':'CLEAR'}</button>`,
2549   loadStage=`<button data-action="ed:copy-stage">LOAD STAGE</button>`,
2550   props=`<button data-action="ed:props" aria-label="Edit properties">${o?'EDIT ▸':'SETTINGS'}</button>`,
2551   full=`<button data-action="fullscreen" aria-label="Fullscreen">⛶</button>`;
2552  // Compact keeps only what a playtest loop needs on the bar; the rest waits in the drawer.
2553  const top=compact
2554   ?`<div class="edit-top">${back}${title}${test}${save}${props}${full}<button data-action="ed:more" aria-expanded="${this.moreOpen}" aria-controls="edit-more">MORE ▾</button></div><div class="edit-more" id="edit-more" ${this.moreOpen?'':'hidden'}>${practice}${undo}${redo}${send}${receive}
2554${cloudSave}${cloudSubmit}${community}${clear}${loadStage}</div>`
2555   :`<div class="edit-top">${back}${title}${test}${practice}${save}${undo}${redo}${send}${receive}${cloudSave}${cloudSubmit}${community}${clear}</div>`;
2556  // PAN already drags the scene, so compact drops the slider row and its 35px of height.
2557  const scroll=compact?'':`<div class="edit-scroll"><span>VIEW</span><input type="range" aria-label="Scroll level" data-edit="scroll" min="0" max="${Math.max(0,this.level.length-this.app.paint.W)}" step="24" value="${this.x}"><span>${this.level.objects.length} / 1500</span>${loadStage}</div>`;
2558  // Compact renders the sheet only while it is open: a collapsed bar would stand in the
2559  // scene costing a whole column, and the EDIT / SETTINGS control on the bar raises it.
2560  const sheet=compact&&!this.propsOpen?'':`<details class="edit-properties" ${open}><summary>${o?'EDIT '+o.type.toUpperCase():'LEVEL SETTINGS'}</summary><div class="edit-property-body">${o?`<p>Object ${o.id} · ${Math.round(o.x)}, ${Math.round(o.y)}</p><div class="nudge">${(o.type==='enemy'?[['left','←'],['right','→']]:[['left','←'],['up','↑'],['down','↓'],['right','→']]).map(([k,l])=>`<button data-action="ed:nudge:${k}" aria-label="Move ${k}">${l}</button>`).join('')}</div>${['block','slope'].includes(o.type)?`<label>Width <input type="number" data-edit="width" aria-label="Object width" min="8" max="400" value="${o.w}"></label><label>Height <input type="number" data-edit="height" aria-label="Object height" min="8" max="400" value="${o.h}"></label>`:''}<button data-action="ed:duplicate">DUPLICATE</button><button data-action="ed:delete">DELETE</button><label>Opacity ${select('alpha',[[1,'100%'],[.75,'75%'],[.5,'50%']],o.alpha??1)}</label>${o.type==='spike'?`<label>Facing ${select('flip',[[0,'Ground'],[1,'Ceiling']],o.flip?1:0)}</label>`:''}${['saw','block'].includes(o.type)?`<label>Vertical motion ${select('motion',[[0,'None'],[24,'24 units'],[48,'48 units'],[72,'72 units']],o.motion?.range??0)}</label>`:''}${o.type==='enemy'?`<label>Enemy ${select('foe:kind',[['walker','WALKER'],['flyer','FLYER'],['thrower','THROWER'],['boss','BOSS']],o.kind)}</label>${['walker','flyer'].includes(o.kind)?`<label>Patrol ${select('foe:span',patrolSpans(o,this.level).map(v=>[v,v?v+' units':'Stand still']),o.w-o.bw)}</label>`:''}${o.kind==='thrower'?`<label>Throw ${select('foe:shot',[['roll','ROLLER (jump)'],['high','HIGH (stay down)']],o.shot?.style)}</label><label>Every ${select('foe:every',[[2,'2 beats'],[4,'4 beats'],[8,'8 beats']],o.shot?.every)}</label>`:''}${o.kind==='boss'?`<label>Hits ${select('foe:hp',[[1,'1'],[2,'2'],[3,'3']],o.hp)}</label><label>Attack beats <input data-edit="foe:pattern" aria-label="Boss pattern" maxlength="32" value="${esc(o.pattern)}"></label>`:''}`:''}${o.type==='portal'?`<label>Portal ${select('portal',[...MODES.map(k=>['mode:'+k,k.toUpperCase()]),['gravity:-1','INVERT'],['gravity:1','RESTORE'],['speed:260','SLOW'],['speed:420','FAST'],['size:0.6','MINI'],['size:1','NORMAL SIZE']],o.mode?'mode:'+o.mode:o.gravity?'gravity:'+o.gravity:o.speed?'speed:'+o.speed:'size:'+o.size)}</label>`:''}`:`<label>Start ${select('startMode',MODES.map(k=>[k,k.toUpperCase()]),this.level.startMode)}</label><label>Track ${select('trackId',[...TRACKS.map(k=>[k,k.replaceAll('-',' ')]),['local',this.songName||'Your local song']],this.level.trackId)}</label><button data-action="ed:song">CHOOSE LOCAL MUSIC</button><label>BPM <input data-edit="bpm" type="number" min="80" max="180" value="${this.level.bpm}" aria-label="BPM"></label><label>Length ${select('beats',[[32,'32 beats'],[64,'64 beats'],[96,'96 beats'],[128,'128 beats']],this.level.beats)}</label><label>Speed ${select('speed',[[260,'Slow'],[340,'Normal'],[420,'Fast']],this.level.speed)}</label><label>Scene ${select('theme',THEMES.map((t,i)=>[i,t.name]),this.level.cues[0].theme)}</label>`}</div></details>`;
2561  return `${top}
2562  <div class="edit-bottom"><div class="edit-palette">${[['select','↖'],['pan','↔'],['erase','×']].map(([k,i])=>`<button class="${this.mode===k?'chosen':''}" data-action="ed:${k}" aria-label="${k}"><b>${i}</b><span>${k}</span></button>`).join('')}${TYPES.map(([k,i])=>`<button class="${this.tool===k&&this.mode==='place'?'chosen':''}" data-action="ed:tool:${k}" aria-label="Place ${k}"><b>${i}</b><span>${k}</span></button>`).join('')}</div>${scroll}</div>
2563  ${sheet}<div class="edit-hint">${this.mode==='place'?'Tap to place on the ground, drag to place anywhere.':this.mode==='pan'?'Drag the grid or use the VIEW slider.':this.mode==='select'?'Tap an object to edit its properties.':'Tap an object to erase it.'}</div>`;
2564 }
2565}
2566
2567
2568// app.mjs
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583const VERSION='2.14.0-rc1';
2584const SAVE='geometrylite.overdrive.v2';
2585const BUNDLE_BASE=(()=>{try{return new URL('.',document.currentScript?.src||document.baseURI).href;}catch{return null;}})();
2586const svgs={play:'<path d="M19 10L51 32 19 54Z" fill="#f9ffdc" stroke="#254f27" stroke-width="4" stroke-linejoin="round"/>',back:'<path d="M43 13L22 32l21 19M24 32h27" fill="none" stroke="currentColor" stroke-width="7" stroke-linejoin="round" stroke-linecap="round"/>',pause:'<path d="M22 11v42M42 11v42" stroke="currentColor" stroke-width="10"/>',retry:'<path d="M49 21a22 22 0 1 0 3 22" fill="none" stroke="currentColor" stroke-width="7"/><path d="M35 22l19 6-1-21" fill="currentColor"/>',home:'<path d="M8 29L32 9l24 20M16 25v29h32V25" fill="none" stroke="currentColor" stroke-width="6" stroke-linejoin="round"/>
2586<path d="M27 39h10v15H27z" fill="currentColor"/>',trophy:'<path d="M17 10h30v16c0 10-8 16-15 16s-15-6-15-16zM17 15H7v9c0 7 8 10 13 10m27-19h10v9c0 7-8 10-13 10M32 42v11M18 56h28" fill="#fff099" stroke="#27536a" stroke-width="4" stroke-linejoin="round"/>',arrow:'<path d="M17 9L49 32 17 55l7-23z" fill="currentColor" stroke="#042443" stroke-width="3"/>',settings:'<path d="M25 7h14l2 9 9 5 8-1 5 12-8 7-1 9-10 8-10-4-10 2-9-9 2-10-6-9 7-11 9 1z" fill="currentColor"/><circle cx="34" cy="32" r="10" fill="#135574"/>'};
2587const svg=k=>`<svg viewBox="0 0 64 64" aria-hidden="true">${svgs[k]||svgs.play}</svg>`;
2588const round=(action,icon,label,extra='')=>`<button class="round ${extra}" data-action="${action}" aria-label="${label}">${svg(icon)}</button>`;
2589function cube(color,icon=0){return `<svg viewBox="0 0 64 64" aria-hidden="true"><rect x="7" y="7" width="50" height="50" rx="1" fill="${color}" stroke="#061d2b" stroke-width="5"/><rect x="11" y="11" width="42" height="42" fill="none" stroke="#e5ffff" stroke-opacity=".45"/>${icon===0?'<path d="M18 20h10v12H18zm18 0h10v12H36zM19 42h26v7H19z" fill="#55edff" stroke="#09263a" stroke-width="3"/>':icon===1?'<path d="M32 16l16 16-16 16-16-16z" fill="#57eaf4" stroke="#09263a" stroke-width="3"/><rect x="28" y="28" width="8" height="8" fill="#09263a"/>':'<path d="M16 21l15 8-15 6zm32 0l-15 8 15 6zM23 43h18v5H23z" fill="#59e8f1" stroke="#09263a" stroke-width="2"/>'}</svg>`;}
2590const num=(v,lo,hi,fallback)=>Number.isFinite(v)?clamp(v,lo,hi):fallback;
2591function load(){const d={version:2,settings:{color:0,icon:0,music:.75,sfx:.55,syncMs:0,practiceRate:1,reactive:true,autoQuality:true,muted:false,autoRetry:true,trails:true,low:false,stage:2,reduced:matchMedia('(prefers-reduced-motion: reduce)').matches},stats:Array.from({length:OFFICIAL_STAGE_COUNT},()=>({best:0,practice:0,attempts:0,coins:0})),jumps:0};
2592 try{const s=JSON.parse(localStorage.getItem(SAVE));if(s?.version!==2)return d;for(const k of ['color','icon','music','sfx'])d.settings[k]=num(s.settings?.[k],0,k==='color'?7:k==='icon'?2:1,d.settings[k]);for(const k of ['muted','autoRetry','trails','low','reduced','reactive','autoQuality'])if(typeof s.settings?.[k]==='boolean')d.settings[k]=s.settings[k];for(let i=0;i<OFFICIAL_STAGE_COUNT;i++)for(const k of ['best','practice','attempts','coins'])d.stats[i][k]=num(s.stats?.[i]?.[k],0,k==='coins'?7:k==='attempts'?1e7:100,0);d.jumps=num(s.jumps,0,1e9,0);d.settings.stage=Math.round(num(s.settings?.stage,0,2,2));d.settings.syncMs=num(s.settings?.syncMs,-150,250,0);d.settings.practiceRate=num(s.settings?.practiceRate,.5,1,1);}catch{}return d;}
2593class Game{
2594 constructor(root=document,host=null){this.root=root;this.host=host;this.focused=!host;this.arena=this.root.getElementById('arena');this.canvas=this.root.getElementById('game');this.ui=this.root.getElementById('ui');this.store=load();this.settings=this.store.settings;this.motionQuery=typeof matchMedia==='function'?matchMedia('(prefers-reduced-motion: reduce)'):null;this.index=MENU_ORDER[0];this.level=levelData(this.index);this.world=new World(this.level);this.state='menu';this.attempt=0;this.practice=false;this.watch=false;this.audio=new AudioEngine(BUNDLE_BASE?new URL('audio/',BUNDLE_BASE).href:null);this.paint=new Painter(this.canvas);this.held=false;this.queue=[];this.pointers=new Set();this.keys=new Set();this.checkpoints=[];this.lastCheckpoint=-5;this.runToken=0;this.metrics={frames:0,steps:0,maxLag:0,pausesForLag:0,drawMs:[]};this.energy={bass:0,mid:0,high:0};this.customRun=false;this.externalLevel=null;this.externalRun=false;this.workshop=new Workshop(this);this.seenModes=new Set();this.metrics.portals=[];this.metrics.inputMs=[];this.metrics.frameMs=[];this.lastFrame=0;this.tick=0;this.replayIndex=0;this.abort=new AbortController();this.bind();if(host){this.resizeObserver=new ResizeObserver(()=>this.resize());this.resizeObserver.observe(host);}this.resize();this.view();this.frame=ms=>{try{this.update(ms);}finally{if(!this.destroyed)this.raf=requestAnimationFrame(this.frame);}};this.raf=requestAnimationFrame(this.frame);}
2595 get color(){return COLORS[this.settings.color|0];}
2596 save(){try{localStorage.setItem(SAVE,JSON.stringify(this.store));}catch{}}
2597 setState(state){this.state=state;this.resize();this.view();}
2598 resize(){const rect=this.host?.getBoundingClientRect(),w=rect?.width||window.innerWidth,h=rect?.height||window.innerHeight,active=['play','pause','death','complete','arming','editor'].includes(this.state),rotate=h>w&&active;
2599  const turned=rotate!==this.arena.classList.contains('rotated'),showing=!!this.ui.querySelector('.edit-top');
2600  this.arena.classList.toggle('rotated',rotate);this.arena.style.setProperty('--long',h+'px');this.arena.style.setProperty('--short',w+'px');
2601  this.paint.resize(rotate?h:w,rotate?w:h);
2602  // The editor draws a compact portrait layout, so a turn has to rebuild it. This fires on a
2603  // real turn while the editor is up, and once more as the editor is left for the menu or a
2604  // playtest, where setState renders the new screen again — harmless, and it still commits a
2605  // field the author was typing in and drops chrome the next visit must not inherit.
2606  if(turned&&showing){this.workshop.commitFocus();this.workshop.resetChrome();this.view();}
2607 }
2608 bind(){const o={signal:this.abort.signal};
2609  this.ui.addEventListener('click',e=>{const button=e.target.closest('[data-action]');if(button&&!button.disabled){e.preventDefault();this.act(button.dataset.action);}},o);
2610  this.ui.addEventListener('change',e=>{if(e.target.dataset.edit)this.workshop.input(e.target);},{signal:this.abort.signal});
2611  // A palette button is a drag handle on a mouse: the workshop decides whether to take it.
2612  this.ui.addEventListener('pointerdown',e=>{const b=e.target.closest?.('.edit-palette [data-action^="ed:tool:"]');
2613   if(b&&this.state==='editor')this.workshop.paletteDown(b.dataset.action.slice(8),e);},o);
2614  this.ui.addEventListener('toggle',e=>{if(!e.target.classList?.contains('edit-properties'))return;this.workshop.propsOpen=e.target.open;
2615   // Compact draws no collapsed bar, so closing the summary has to take the panel away.
2616   if(!e.target.open&&this.workshop.compact())this.view();},{signal:this.abort.signal,capture:true});
2617  this.ui.addEventListener('input',e=>{if(e.target.dataset.edit==='scroll'){this.workshop.input(e.target);return;}const k=e.target.dataset.setting;if(!k)return;this.settings[k]=e.target.type==='checkbox'?e.target.checked:Number(e.target.value);
2617if(k==='autoQuality'){this.paint.renderScale=1;this.paint.tunedKey=null;this.resize();}if(k==='stage'||k==='low'||k==='reduced')this.paint.stageGov=null;this.mix();this.save();if(k==='syncMs'){const label=this.root.getElementById('sync-value');if(label)label.textContent=this.settings.syncMs+' ms';}this.arena.classList.toggle('no-motion',this.settings.reduced);},o);
2618  this.arena.addEventListener('pointerdown',e=>{this.focused=true;if(e.target.closest('button,a,input,select,details,.panel'))return;if(this.state==='editor'){e.preventDefault();this.workshop.down(e);try{this.arena.setPointerCapture(e.pointerId);}catch{}return;}if(this.state==='death'){e.preventDefault();this.retry();return;}if(this.state!=='play'||this.watch||(e.pointerType==='mouse'&&e.button!==0))return;e.preventDefault();this.pointers.add(e.pointerId);this.input(true);try{this.arena.setPointerCapture(e.pointerId);}catch{}},o);
2619  this.arena.addEventListener('pointermove',e=>{if(this.state==='editor')this.workshop.move(e);},o);
2620  // A cancelled pointer is the system taking the gesture away, not the author finishing it:
2621  // it must drop the drag rather than commit wherever the ghost happened to be.
2622  const up=cancelled=>e=>{this.workshop.up(cancelled);this.pointers.delete(e.pointerId);if(!this.pointers.size&&!this.keys.size)this.input(false);};
2623  window.addEventListener('pointerup',up(false),o);window.addEventListener('pointercancel',up(true),o);// lostpointercapture also follows an ordinary pointerup; by then up() has already cleared
2624  // the drag, so this second call is a no-op and only a real capture loss reaches a live one.
2625  this.arena.addEventListener('lostpointercapture',up(true),o);
2626  this.arena.addEventListener('contextmenu',e=>e.preventDefault(),o);
2627  window.addEventListener('keydown',e=>{if(!this.focused)return;if(e.composedPath()[0] instanceof HTMLInputElement||e.composedPath()[0] instanceof HTMLSelectElement)return;if(this.state==='editor'){if((e.ctrlKey||e.metaKey)&&e.code==='KeyZ'){e.preventDefault();this.workshop.action(e.shiftKey?'ed:redo':'ed:undo');return;}
2628    if(this.workshop.key(e))e.preventDefault();return;}const k=e.code;
2629   if(['Space','ArrowUp','KeyW'].includes(k)){e.preventDefault();if(e.repeat)return;if(this.state==='death'){this.retry();return;}if(this.state==='play'&&!this.watch){this.keys.add(k);this.input(true);}return;}
2630   if(e.repeat)return;
2631   if(k==='Escape')this.act(this.state==='play'?'pause':this.state==='pause'?'resume':'back');if(k==='KeyR'&&['play','pause','death','complete'].includes(this.state))this.retry();if(k==='KeyM')this.act('mute');if(k==='KeyF')this.fullscreen();if(k==='KeyZ')this.checkpoint(true);if(k==='KeyX')this.checkpoints.pop();
2632  },o);
2633  window.addEventListener('keyup',e=>{this.keys.delete(e.code);if(!this.keys.size&&!this.pointers.size)this.input(false);},o);
2634  window.addEventListener('resize',()=>{const portrait=window.innerHeight>window.innerWidth;if(this.lastPortrait!==undefined&&this.lastPortrait!==portrait&&this.state==='play')this.pause();this.lastPortrait=portrait;this.resize();},o);
2635  this.lastPortrait=window.innerHeight>window.innerWidth;
2636  if(this.host)document.addEventListener('pointerdown',e=>{if(!e.composedPath().includes(this.host)){this.focused=false;if(this.state==='play')this.pause();}},{signal:this.abort.signal});
2637  window.addEventListener('blur',()=>{if(this.state==='play')this.pause();this.release();},o);
2638  document.addEventListener('visibilitychange',()=>{if(document.hidden&&this.state==='play')this.pause();},o);
2639  window.addEventListener('pagehide',()=>{this.save();this.audio.stop();this.release();if(this.state==='play')this.setState('pause');},o);
2640 }
2641 mix(){Object.assign(this.audio,{music:this.settings.music,sfx:this.settings.sfx,muted:this.settings.muted,syncMs:this.settings.syncMs,rate:this.practice?this.settings.practiceRate:1});this.audio.mix();}
2642 release(){this.held=false;this.queue=[];this.pointers.clear();this.keys.clear();this.workshop?.cancelDrag();}
2643 input(down){if(this.state!=='play'||this.watch)return;this.resumeUntil=-1;this.queue.push({at:this.audio.now(),down,perf:performance.now()});}
2644 /** External levels are played as data: no draft is touched and no record is written. */
2645 loadLevel(data,{practice=false}={}){const {level}=parseLoadRequest({type:LOAD_LEVEL_TYPE,level:data,practice});this.externalLevel=level;
2646  // Publishing refuses a solid in a throw lane or boss arena (validateLevel); a loaded level that has one still plays, unranked, and says why.
2647  const lanes=findShotConflicts(level.objects,level).length;const run=this.begin({external:true,practice});
2648  return lanes?run.then(r=>{if(this.externalLevel===level&&this.state==='play')this.toast('Heads up: '+lanes+' enemy shot'+(lanes>1?'s pass':' passes')+' through a solid here. Publishing would refuse it.',4200);return r;}):run;}
2649 async begin({practice=false,watch=false,retry=false,custom=false,external=false}={}){
2650  const token=++this.runToken;this.audio.stop();this.release();this.practice=practice;this.watch=watch;this.customRun=custom||external;this.externalRun=external;
2651  this.level=external?this.externalLevel:custom?this.workshop.level:levelData(this.index);this.world=new World(this.level);
2652  if(!retry){this.checkpoints=[];this.attempt=0;this.lastCheckpoint=0;}else if(practice&&this.checkpoints.length)this.world.reset(this.checkpoints.at(-1));
2653  this.seenModes.clear();this.tick=Math.round(this.world.t/DT);this.replayIndex=0;this.replayHeld=false;this.attempt++;
2654  if(!watch&&!custom&&!external){this.store.stats[this.index].attempts++;this.save();}
2655  const cp=practice&&retry?this.checkpoints.at(-1):null;if(cp){this.held=!!cp.resumeHeld;this.resumeUntil=this.world.t+.15;}else this.resumeUntil=-1;
2656  this.paint.trail=[];this.paint.particles=[];this.paint.rings=[];this.setState('loading');this.mix();
2657  try{await this.audio.prepare(this.level);if(token!==this.runToken)return;if(this.audio.buffer.duration<this.level.duration-.1)throw Error('The selected song is shorter than this level. Reduce the length in Workshop.');
2658   if(!this.audioBound){this.audioBound=true;this.audio.ctx.addEventListener('statechange',()=>{if(this.state==='play'&&this.audio.ctx.state!=='running')this.pause();},{signal:this.abort.signal});}
2659   if(!await this.arm(token))return;this.metrics.frameMs=[];this.metrics.drawMs=[];this.metrics.inputMs=[];this.metrics.interruptions=[];this.metrics.pausesForLag=0;this.lastFrame=0;this.audio.start(this.world.t);this.enterPlay();this.canvas.focus({preventScroll:true});if(document.hidden)this.pause();
2660  }catch(error){if(token!==this.runToken)return;this.error=error.message||'Audio did not initialize';this.setState('error');}
2661 }
2662 async arm(token){
2663  // Warm the actual HUD and scene, not a different loading-screen composition.
2664  this.setState('arming');const key=this.paint.W+':'+this.arena.clientHeight+':'+devicePixelRatio;
2665  const shouldTune=this.settings.autoQuality&&this.paint.tunedKey!==key;
2666  // While renderScale is chosen the stage draws the run's own first frame with its background blit rotated (an upright
2667  // first frame is nudged to 1 deg; stagecraft stageFrame), so the choice prices the rolled blit the run will draw, and
2668  // play starts from the pose that was priced. enterPlay() clears the flag on every path into play.
2669  this.paint.stageProbe=shouldTune;
2670  const qualityCandidates=shouldTune?[1,.75,.5]:[this.paint.renderScale||1],probes=[];
2671  for(const scale of qualityCandidates){
2672   if(token!==this.runToken||this.destroyed)return false;
2673   if(this.paint.renderScale!==scale){this.paint.renderScale=scale;this.resize();}
2674   for(let settle=0;settle<2;settle++){await new Promise(requestAnimationFrame);if(token!==this.runToken||this.destroyed)return false;}
2675   let last=0,samples=[],frames=0,drawStart=this.metrics.drawCount|0,probeBegan=performance.now();
2676   const target=shouldTune?12:4,maxMs=shouldTune?520:180;
2677   while(samples.length<target&&performance.now()-probeBegan<maxMs){
2678    const stamp=await new Promise(requestAnimationFrame);if(token!==this.runToken||this.destroyed)return false;
2679    if(last&&frames>1)samples.push(stamp-last);last=stamp;frames++;
2680   }
2681   const drawn=Math.min((this.metrics.drawCount|0)-drawStart,this.metrics.drawMs.length),draws=drawn>0?this.metrics.drawMs.slice(-drawn):[],median=samples.length?quantile(samples,.5):1e9,frameP95=samples.length?quantile(samples,.95):1e9,drawP95=draws.length?quantile(draws,.95):1e9;
2682   const safe=median<=22&&frameP95<=40&&drawP95<=16,score=Math.max(median/22,frameP95/40,drawP95/16);
2683   probes.push({scale,frameMedian:median,frameP95,drawP95,safe,score});
2684  }
2685  let best=probes.filter(p=>p.safe).sort((a,b)=>b.scale-a.scale)[0];
2686  if(!best)best=[...probes].sort((a,b)=>a.score-b.score||b.scale-a.scale)[0];
2687  if(this.paint.renderScale!==best.scale){this.paint.renderScale=best.scale;this.resize();for(let settle=0;settle<2;settle++){await new Promise(requestAnimationFrame);if(token!==this.runToken||this.destroyed)return false;}}
2688  this.metrics.qualityProbe={frameMedian:best.frameMedian,frameP95:best.frameP95,drawP95:best.drawP95,scale:best.scale,candidates:probes};
2689  this.metrics.qualityProbe.stagePoseDeg=this.paint.stageProbe&&this.paint.lastStage?.probe?+this.paint.lastStage.rollDeg.toFixed(1):null;this.paint.stageProbe=false;
2690  this.paint.tunedKey=key;return true;
2691 }
2692 enterPlay(){this.state='play';this.paint.stageProbe=false;this.ui.querySelector('.arming-overlay')?.remove();this.hud();}
2693 retry(){if(this.state==='loading'||this.state==='arming')return;return this.begin({practice:this.practice,watch:this.watch,custom:this.customRun,external:this.externalRun,retry:true});}
2694 pause(){if(this.state!=='play')return;this.audio.stop();this.release();this.save();this.setState('pause');}
2695 async resume(){if(this.state!=='pause')return;const token=++this.runToken;
2696  try{await this.audio.unlock();if(token!==this.runToken)return;if(!await this.arm(token))return;this.audio.start(this.world.t);this.release();this.enterPlay();this.canvas.focus({preventScroll:true});}catch{this.toast('Tap Resume again to enable audio');}
2697 }
2698 checkpoint(manual=false){
2699  if(!this.practice||this.watch||this.state!=='play')return;
2700  const safe=checkpointSafety(this.world);
2701  if(!safe.safe){if(manual)this.toast(safe.reason);return;}
2702  const snapshot=this.world.snapshot();snapshot.resumeHeld=safe.held;this.checkpoints.push(snapshot);
2703  if(this.checkpoints.length>30)this.checkpoints.shift();this.lastCheckpoint=this.world.t;
2704  if(manual)this.toast('Checkpoint saved · '+this.world.mode.toUpperCase());
2705 }
2706 update(ms){const now=ms/1000;this.metrics.frames++;if(this.lastFrame&&this.state==='play'){this.metrics.frameMs.push(ms-this.lastFrame);if(this.metrics.frameMs.length>1200)this.metrics.frameMs.shift();}this.lastFrame=ms;
2707  if(this.state==='play'){
2708   const target=this.audio.now(),w=this.world;
2709   if(target-w.t>.3){this.metrics.interruptions??=[];this.metrics.interruptions.push({gameTime:w.t,audioTime:target,lag:target-w.t,frameMs:this.metrics.frameMs.slice(-20),drawMs:this.metrics.drawMs.slice(-20),visibility:document.visibilityState});this.metrics.pausesForLag++;this.pause();this.toast('Paused after a frame interruption');}
2710   else{
2711    let n=0;while(w.t+DT<=target&&!w.dead&&!w.won&&n++<90){let pressed=false;
2712     if(this.watch){const replay=REPLAYS[this.index];while(this.replayIndex<replay.length&&replay[this.replayIndex][0]<=this.tick){const d=!!replay[this.replayIndex++][1];pressed||=d&&!this.held;this.held=d;}}
2713     else while(this.queue.length&&this.queue[0].at<=w.t+DT){const q=this.queue.shift();pressed||=q.down&&!this.held;this.held=q.down;if(q.down&&q.perf!==undefined){this.metrics.inputMs.push(performance.now()-q.perf);if(this.metrics.inputMs.length>200)this.metrics.inputMs.shift();}}
2714     if(this.resumeUntil>0&&w.t>=this.resumeUntil){this.held=false;this.resumeUntil=-1;}w.step(this.held,pressed);this.seenModes.add(w.mode);this.tick++;this.metrics.steps++;
2715    }
2716    this.metrics.maxLag=Math.max(this.metrics.maxLag,Math.max(0,target-w.t));
2717    for(const e of w.events){if(e.kind==='portal')this.metrics.portals.push({t:e.t,before:e.before,after:e.after});this.paint.event(e,this.color);if(['coin','portal','pad','orb','win','death','stomp','bonk','bossdown','shot'].includes(e.kind))this.audio.fx(e.kind);if(e.kind==='jump'&&!this.watch&&!this.customRun)this.store.jumps++;}
2718    w.events.length=0;
2719    const cue=cueAt(this.level,w.x);if(cue.name!==this.lastCue){this.lastCue=cue.name;const el=this.root.getElementById('section');if(el){el.textContent=cue.name;el.classList.add('on');clearTimeout(this.cueTimer);this.cueTimer=setTimeout(()=>el.classList.remove('on'),1600);}}
2720    if(w.dead){this.audio.stop();this.deathAt=now;const s=this.store.stats[this.index],k=this.practice?'practice':'best',p=Math.floor(w.x/this.level.length*100);this.newBest=!this.watch&&!this.customRun&&p>s[k];
2720if(!this.watch&&!this.customRun){s[k]=Math.max(s[k],p);this.save();}this.release();this.setState('death');}
2721    else if(w.won){this.audio.stop();if(!this.watch&&!this.customRun){const s=this.store.stats[this.index];s[this.practice?'practice':'best']=100;if(!this.practice)s.coins|=w.coins;this.save();}this.release();this.setState('complete');}
2722    else if(this.practice&&w.t-this.lastCheckpoint>5)this.checkpoint();
2723    if(!this.lastHUD||now-this.lastHUD>.05){this.hud();this.lastHUD=now;}
2724   }
2725  }else if(this.state==='death'&&this.settings.autoRetry&&!this.watch&&now-this.deathAt>.85)this.retry();
2726  const raw=this.state==='play'&&this.settings.reactive?this.audio.energy(this.world.t):{bass:0,mid:0,high:0};for(const k of ['bass','mid','high'])this.energy[k]+=(raw[k]-this.energy[k])*(raw[k]>this.energy[k]?.5:.15);
2727  if(this.state==='calibrate')this.calibrationFrame();
2728  const t=performance.now();if(this.state==='editor')this.workshop.draw(this.paint,ms);else this.paint.draw(this,ms);this.metrics.drawMs.push(performance.now()-t);this.metrics.drawCount=(this.metrics.drawCount|0)+1;if(this.metrics.drawMs.length>600)this.metrics.drawMs.shift();
2729 }
2730 hud(){const tips={cube:'CUBE · TAP TO JUMP',ship:'SHIP · HOLD TO RISE, RELEASE TO FALL',wave:'WAVE · HOLD UP / RELEASE DOWN',ufo:'UFO · TAP TO HOP IN THE AIR',robot:'ROBOT · HOLD FOR A HIGHER JUMP',ball:'BALL · TAP ON A SURFACE TO FLIP GRAVITY',spider:'SPIDER · TAP ON A SURFACE TO SWITCH SIDES'};
2731 const tip=this.root.getElementById('control-tip');if(tip){const gate=this.world.nearby(650).find(o=>o.type==='portal'&&o.mode&&o.mode!==this.world.mode&&o.x>this.world.x),text=gate&&gate.x-this.world.x<650?'UP NEXT: '+tips[gate.mode]:this.world.t-this.world.portalAt<2?tips[this.world.mode]:this.world.t<2?'TAP TO JUMP · HOLD TO KEEP JUMPING':'';if(tip.textContent!==text)tip.textContent=text;}
2732 const w=this.world,p=clamp(w.x/this.level.length,0,1),bar=this.root.getElementById('progress'),pct=this.root.getElementById('percent');if(bar)bar.style.transform=`scaleX(${p})`;if(pct)pct.textContent=Math.floor(p*100)+'%';for(const el of this.ui.querySelectorAll('[data-coin]'))el.classList.toggle('on',!!(w.coins&(1<<+el.dataset.coin)));}
2733 toast(text,ms=2300){this.ui.querySelector('.notice')?.remove();const e=document.createElement('div');e.className='notice';e.role='status';e.textContent=text;this.ui.append(e);clearTimeout(this.toastTimer);this.toastTimer=setTimeout(()=>e.remove(),ms);}
2734 async fullscreen(){try{if(document.fullscreenElement)await document.exitFullscreen();else if((this.host||this.arena).requestFullscreen){await (this.host||this.arena).requestFullscreen();try{await screen.orientation.lock('landscape');}catch{}}else this.toast('Rotate your phone. This game already fills the browser.');}catch{this.toast('Rotate your phone for full-width play');}}
2735 act(a){
2736  if(a==='workshop')return this.workshop.enter();if(a.startsWith('ed:'))return this.workshop.action(a);
2737  if(a==='calibrate'){this.audio.stop();this.setState('calibrate');return;}if(a==='cal-start')return this.startCalibration();if(a==='cal-tap')return this.calibrationTap();if(a==='cal-apply')return this.applyCalibration();if(a==='diagnostics')return this.showDiagnostics();if(a==='diagnostics-export')return this.exportDiagnostics();
2738  if(a==='choose'){this.setState('levels');return;}if(a==='play')return this.begin();if(a==='practice')return this.begin({practice:true});if(a==='watch')return this.begin({watch:true});
2739  if(a==='prev'||a==='next'){this.index=nextIndex(MENU_ORDER,this.index,a==='next'?1:-1);this.level=levelData(this.index);this.setState('levels');return;}
2740  if(['settings','icons','stats','help'].includes(a)){this.setState(a);return;}
2741  if(a==='back'||a==='home'){this.stopCalibration();++this.runToken;this.audio.stop();this.release();if(this.customRun&&!this.externalRun&&['play','pause','death','complete','error'].includes(this.state))return this.workshop.enter();this.customRun=false;this.externalRun=false;this.level=levelData(this.index);this.setState(['play','pause','death','complete'].includes(this.state)?'levels':'menu');return;}
2742  if(a==='pause')return this.pause();if(a==='resume')return this.resume();if(a==='retry')return this.retry();
2743  if(a==='mute'){this.settings.muted=!this.settings.muted;this.mix();this.save();const b=this.ui.querySelector('[data-action="mute"]');
2743if(b)b.textContent=this.settings.muted?'SOUND OFF':'♫ SOUND';return;}
2744  if(a==='fullscreen')return this.fullscreen();if(a==='checkpoint')return this.checkpoint(true);if(a==='remove-cp'){this.checkpoints.pop();this.toast('Checkpoint removed');return;}
2745  if(a.startsWith('color:')||a.startsWith('icon:')){const[k,v]=a.split(':'),u=unlocks(this.store);if(!(k==='color'?u.colors:u.icons).has(+v)){const rule=UNLOCKS.find(x=>x.kind===k&&x.index===+v);this.toast('Locked · '+(rule?rule.label:'keep playing'));return;}this.settings[k]=+v;this.save();this.view();}
2746 }
2747 records(){if(this.externalRun)return '<p>COMMUNITY LEVEL · unranked, normal records stay unchanged.</p>';if(this.customRun)return '<p>WORKSHOP PLAYTEST · normal records stay unchanged.</p>';const s=this.store.stats[this.index];return `<div class="records">${[['best','NORMAL'],['practice','PRACTICE']].map(([k,name])=>`<div><div class="record-head"><span>${name}</span><b>${s[k]}%</b></div><div class="bar ${k==='practice'?'practicebar':''}"><i style="width:${s[k]}%"></i></div></div>`).join('')}</div>`;}
2748 coins(mask,live=false){return `<div class="hud-coins">${[0,1,2].map(i=>`<span class="coin ${mask&(1<<i)?'on':''}" ${live?'data-coin="'+i+'"':''}>◆</span>`).join('')}</div>`;}
2749 view(){this.arena.classList.toggle('no-motion',this.settings.reduced);let html='';const l=this.level,back=round('back','back','Back','back'),u=unlocks(this.store),pendingList=u.pending.length?`<div class="unlock-list">${u.pending.map(r=>`<span><b>${r.have}/${r.need}</b> ${r.label}</span>`).join('')}</div>`:'';
2750  if(this.state==='menu')html=`<div class="top"><span class="wordmark"><i class="tiny-square"></i>GEOMETRYLITE</span><button class="text-btn" data-action="fullscreen">FULLSCREEN ⛶</button></div><section class="screen"><div class="eyebrow">${['','ONE','TWO','THREE','FOUR','FIVE','SIX','SEVEN','EIGHT','NINE'][OFFICIAL_STAGE_COUNT]||OFFICIAL_STAGE_COUNT} WORLDS. ONE RHYTHM.</div><h1 class="logo"><span class="geo">GEOMETRYLITE</span><span class="over">OVERDRIVE</span></h1><div class="menu-copy">Find the flow. Break through.</div><div class="menu-primary"><div class="main-side"><button class="round" data-action="icons" aria-label="Customize your icon">${cube(this.color,this.settings.icon)}</button><span>ICON KIT</span></div><button class="big-play" data-action="choose" aria-label="Choose a stage">${svg('play')}</button><div class="main-side">${round('stats','trophy','Your progress')}<span>PROGRESS</span></div></div><div class="menu-tools"><button data-action="settings">SETTINGS</button><button data-action="workshop">WORKSHOP</button><button data-action="help">HOW TO PLAY</button></div></section><div class="chapter">${'<i></i>'.repeat(OFFICIAL_STAGE_COUNT)}</div><div class="footer"><span>ORIGINAL STAGES & SOUNDTRACKS</span><span class="keys">TAP / <b class="key">SPACE</b> TO JUMP</span><span>${VERSION}</span></div>`;
2751  if(this.state==='levels'){
2752   const art=THEMES[l.cues[0].theme],t=art.name,grad=art.grad,slot=MENU_ORDER.indexOf(this.index);
2753   html=`${back}<section class="screen"><div class="eyebrow">CHOOSE YOUR JOURNEY</div><div class="level-layout"><button class="arrow prev" data-action="prev" aria-label="Previous stage">${svg('arrow')}</button><div class="level-card"><div class="level-art" style="background:linear-gradient(125deg,${grad})"><span class="art-label">${t}</span><span class="art-number">0${slot+1}</span><svg class="art-shapes" viewBox="0 0 600 150" preserveAspectRatio="xMidYMax slice" aria-hidden="true"><path d="M0 145H600" stroke="#cdffff" stroke-width="2"/><path d="M245 145V109h49v-40h50v76m25 0l18-35 18 35m45 0V73h62v72" fill="#031429" stroke="#84eaff" stroke-width="2"/><g transform="translate(151 81) rotate(24)"><rect x="-18" y="-18" width="36" height="36" fill="${this.color}" stroke="#062438" stroke-width="3"/><path d="M-10-8h7v8h-7zm13 0h7v8H3zM-9 7H9v5H-9z" fill="#4aeffa" stroke="#082235" stroke-width="2"/></g><path d="M102 109l24-14m-39 5l24-14m-8 31l23-13" stroke="#75fff4" stroke-width="2" opacity=".6"/></svg></div><div class="level-info"><div class="level-line"><span class="difficulty"><i class="face">${l.stars>=10?'»«':l.stars>=8?'›‹':'⌣'}</i>${l.difficulty}</span><span class="stars">${l.stars} ★</span></div><h2 class="level-title">${l.name}</h2><p class="level-desc">${l.tag}</p>${this.records()}<div class="actions"><button class="primary" data-action="play">▶ PLAY</button><button class="secondary" data-action="practice">◆ PRACTICE</button></div><div class="music-credit"><span>♫ ${l.bpm} BPM · ORIGINAL SCORE</span><span>${Math.round(l.duration)} SEC · ${slot+1} / ${OFFICIAL_STAGE_COUNT}</span></div></div></div><button class="arrow next" data-action="next" aria-label="Next stage">${svg('arrow')}</button><div class="dots">${Array.from({length:OFFICIAL_STAGE_COUNT},(_,i)=>`<i class="dot ${i===slot?'on':''}"></i>`).join('')}</div><button class="link-btn" data-action="watch">WATCH A VERIFIED RUN · NO RECORDS</button></div></section>`;
2754  }
2755  if(this.state==='settings')html=`${back}<section class="screen"><div class="panel"><h2>MAKE IT FEEL RIGHT</h2>${[['music','Music'],['sfx','Sound effects']].map(([k,name])=>`<label class="row"><span>${name}</span><input type="range" min="0" max="1" step=".01" data-setting="${k}" value="${this.settings[k]}" aria-label="${name}"></label>`).join('')}${[['reactive','Music-reactive visuals','Respond to the actual score, not a metronome'],['autoQuality','Adaptive resolution','Choose detail from measured frame delivery before play'],['autoRetry','Auto retry','Back into the rhythm after a crash'],['trails','Player trails','Keep your movement visible'],['low','Low graphics','Fewer particles and s
2755ofter glow'],['reduced','Reduced motion','No camera shake, screen pulses or stage motion']].map(([k,name,desc])=>`<label class="row"><span>${name}<small>${desc}</small></span><input type="checkbox" data-setting="${k}" ${this.settings[k]?'checked':''} aria-label="${name}"></label>`).join('')}<label class="row"><span>Stage motion<small>Background roll, gravity flips, zoom and colour. Reduced motion, here or in system settings, turns it off.</small></span><select data-setting="stage" aria-label="Stage motion">${[[2,'Full'],[1,'Subtle'],[0,'Off']].map(([v,label])=>`<option value="${v}" ${this.settings.stage===v?'selected':''}>${label}</option>`).join('')}</select></label><label class="row"><span>Practice speed<small>Normal records always use 100% speed.</small></span><select data-setting="practiceRate" aria-label="Practice speed">${[[1,'100%'],[.75,'75%'],[.5,'50%']].map(([v,label])=>`<option value="${v}" ${this.settings.practiceRate===v?'selected':''}>${label}</option>`).join('')}</select></label><label class="row"><span>Audio alignment<small>Positive = delay the visible game</small><output id="sync-value">${this.settings.syncMs} ms</output></span><input type="range" min="-150" max="250" step="5" data-setting="syncMs" value="${this.settings.syncMs}" aria-label="Audio alignment"></label><div class="actions"><button class="secondary" data-action="calibrate">CALIBRATE</button><button class="secondary" data-action="diagnostics">DEVICE CHECK</button></div><button class="primary" data-action="back">DONE</button></div></section>`;
2756  if(this.state==='icons')html=`${back}<section class="screen"><div class="panel"><h2>YOUR SIGNATURE</h2><div class="custom-hero">${cube(this.color,this.settings.icon)}</div><span class="eyebrow">ICON KIT</span><div class="icons">${[0,1,2].map(i=>{const lk=u.icons.has(i)?'':(UNLOCKS.find(r=>r.kind==='icon'&&r.index===i)?.label||'LOCKED');return `<button class="icon ${i===this.settings.icon?'on':''} ${lk?'locked':''}" data-action="icon:${i}" aria-label="Icon ${i+1}${lk?' (locked: '+lk+')':''}" title="${lk}">${cube(this.color,i)}</button>`;}).join('')}</div><span class="eyebrow">PRIMARY COLOR</span><div class="swatches">${COLORS.map((c,i)=>{const lk=u.colors.has(i)?'':(UNLOCKS.find(r=>r.kind==='color'&&r.index===i)?.label||'LOCKED');return `<button class="swatch ${i===this.settings.color?'on':''} ${lk?'locked':''}" style="--swatch:${c}" data-action="color:${i}" aria-label="Color ${i+1}${lk?' (locked: '+lk+')':''}" title="${lk}"></button>`;}).join('')}</div>${pendingList||'<p class="unlock-note">Everything is unlocked.</p>'}<p class="unlock-note">Cosmetic unlocks come from your stage records and jumps. They never affect records or verified runs.</p><button class="primary" data-action="back">LOOKING GOOD</button></div></section>`;
2757  if(this.state==='stats')html=`${back}<section class="screen"><div class="panel"><span class="eyebrow">EVERY ATTEMPT COUNTS</span><h2>YOUR PROGRESS</h2><div class="big-number">${this.store.stats.filter(s=>s.best===100).length}<span style="font-size:.45em;color:#749cb7"> / ${OFFICIAL_STAGE_COUNT}</span></div><div class="stat-list"><div><span>Stars</span><b>${u.summary.stars}</b></div><div><span>Coins</span><b>${u.summary.coins} / ${u.summary.stages*3}</b></div><div><span>Jumps</span><b>${this.store.jumps}</b></div>${this.store.stats.map((s,i)=>`<div><span>${levelData(i).name}</span><b>${s.best}%</b></div>`).join('')}<div><span>Total attempts</span><b>${this.store.stats.reduce((a,s)=>a+s.attempts,0)}</b></div></div>${pendingList}<p>Saved on this device. Rehearsals never change your normal records. Cosmetic unlocks never affect records.</p><button class="primary" data-action="back">KEEP GOING</button></div></section>`;
2758  if(this.state==='help')html=`${back}<section class="screen"><div class="panel"><h2>FIND YOUR FLOW</h2><div class="help"><b>CUBE</b><span>Tap to jump. Hold to jump again on landing.</span><b>SHIP</b><span>Hold to rise. Release to descend.</span><b>WAVE</b><span>Hold diagonally up; release diagonally down.</span><b>UFO</b><span>Tap again in the air to hop.</span><b>ROBOT</b><span>Tap for a short jump; hold briefly for a higher jump.</span><b>BALL</b><span>Tap while touching a surface to reverse gravity.</span><b>SPIDER</b><span>Tap from a surface to switch instantly to the opposite one.</span><b>CRITTERS</b><span>Land on a critter to bounce off it. Jumping up into one knocks it out, except the boss: it falls only to stomps. Any other touch, or any touch while flying, ends the run.</span><b>SHOTS</b><span>Jump a red ball. Stay down under a blue ball with a white arrow. A white flash means a throw or dive within one beat;
2758 an arrow at the screen edge means a shot is coming.</span><b>PRACTICE</b><span>Use SAVE or Z for a checkpoint, including safe flight. X removes it. Slow practice in Settings does not set normal records.</span><b>KEYBOARD</b><span>Space / ↑ · Escape pauses · R retries · M mutes.</span></div><p>Rotate your phone for a wider view. In portrait, gameplay turns sideways to use your whole display.</p><p>Independent original game. Not affiliated with RobTop Games or 1Games.</p><button class="primary" data-action="back">LET'S GO</button></div></section>`;
2759  if(['play','pause','death','complete','arming'].includes(this.state)){
2760   const w=this.world;
2761   html=`<div class="hud"><div class="hud-top"><span class="hud-label">${this.watch?'REHEARSAL':this.practice?'PRACTICE':l.name}<br><small>${this.watch?'NO RECORDS':l.bpm+' BPM'}</small></span><div class="progress"><i id="progress"></i></div><span class="percent" id="percent">0%</span><button class="hud-pause" data-action="pause" aria-label="Pause">${svg('pause')}</button></div><div class="section-flash" id="section"></div><div class="control-tip" id="control-tip" aria-live="polite"></div><div class="hud-bottom"><div class="hud-tools"><button data-action="mute" aria-label="Toggle sound">${this.settings.muted?'SOUND OFF':'♫ SOUND'}</button><button data-action="fullscreen" aria-label="Fullscreen">⛶</button>${this.practice?'<button data-action="checkpoint">◆ SAVE</button><button data-action="remove-cp">✕</button>':''}</div>${this.coins(w.coins,true)}</div></div>`;
2762   if(this.state==='pause')html+=`<section class="screen overlay"><div class="panel"><span class="eyebrow">TAKE A BREATH</span><h2>PAUSED</h2><p>${l.name} · Attempt ${this.attempt}</p>${this.records()}<div class="pause-actions">${round('home','home',this.customRun&&!this.externalRun?'Back to editor':'Stage select')}${round('resume','play','Resume')}${round('retry','retry','Restart')}</div><span class="deaths">${this.practice?'Practice progress stays separate':'One more try. One step further.'}</span></div></section>`;
2763   if(this.state==='death')html+=`<section class="screen"><div class="panel" style="width:320px;background:#082442c9"><span class="eyebrow">${this.newBest?'A LITTLE FURTHER':'GET BACK IN THE FLOW'}</span><h2>${this.newBest?'NEW BEST!':'TRY AGAIN'}</h2><div class="big-number">${Math.floor(w.x/l.length*100)}%</div>${foeCause(w,l)?`<p>${foeCause(w,l)}</p>`:''}<div class="actions"><button class="secondary" data-action="home">STAGES</button><button class="primary" data-action="retry">↻ RETRY</button></div></div></section>`;
2764   if(this.state==='complete')html+=`<section class="screen overlay"><div class="panel"><span class="eyebrow">${this.watch?'REHEARSAL FINISHED':'YOU FOUND THE FLOW'}</span><h2>${this.watch?'RUN COMPLETE':'LEVEL COMPLETE!'}</h2><div class="big-number">100%</div><p>${l.name} · ${this.attempt} attempt${this.attempt>1?'s':''}</p><div style="display:flex;justify-content:center">${this.coins(w.coins)}</div><p>${this.watch?'Verified replay, normal collision rules. No records changed.':this.practice?'Practice clear. Your normal record is unchanged.':'Your progress is saved on this device.'}</p><div class="actions"><button class="secondary" data-action="home">STAGES</button><button class="primary" data-action="retry">PLAY AGAIN</button></div></div></section>`;
2765  }
2766  if(this.state==='arming')html+=`<div class="arming-overlay"><b>GET READY</b><span>Preparing smooth play…</span></div>`;
2767  if(this.state==='loading')html=`<section class="screen loading"><div class="spinner"></div><span class="eyebrow">${l.name}</span><h2>${this.state==='arming'?'GET READY':'LOADING THE BEAT'}</h2><p>${this.state==='arming'?'Preparing the play surface…':'Loading your original soundtrack…'}</p><button class="link-btn" data-action="back">BACK</button></section>`;
2768  if(this.state==='error')html=`<section class="screen loading"><div class="panel"><h2>ONE MORE TAP</h2><p>${String(this.error||'Tap Retry to start the soundtrack.').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))}</p><p>Check your connection and tap Retry.</p><div class="actions"><button class="secondary" data-action="back">BACK</button><button class="primary" data-action="retry">RETRY</button></div></div></section>`;
2769  if(this.state==='editor')html=this.workshop.render();
2770  if(this.state==='calibrate')html=this.calibrationView();
2771  if(this.state==='diagnostics')html=this.diagnosticsView();
2772  this.ui.innerHTML=html;this.hud();
2773 }
2774 async startCalibration(){
2775  this.stopCalibration();try{await this.audio.unlock();}catch(e){this.toast(e.message);return;}
2776  this.cal={start:this.audio.ctx.currentTime+.5,interval:.6,scheduled:0,offsets:[],running:true};this.setState('calibrate');
2777 }
2778 stopCalibration(){if(this.cal)this.cal.running=false;for(const o of this.calNodes||[]){try{o.stop();}catch{}}this.calNodes=[];}
2779 calibrationFrame(){
2780  const s=this.cal;if(!s?.running)return;const t=this.audio.ctx.currentTime;
2781  while(s.scheduled<24&&s.start+s.scheduled*s.interval<t+.15){this.calNodes??=[];this.calNodes.push(this.audio.tone(88,s.start+s.scheduled*s.interval,.04,.10));s.scheduled++;}
2782  const circle=this.root.getElementById('cal-circle'),phase=(t-s.start)%s.interval;if(circle)circle.style.transform=`scale(${phase>=0&&phase<.09?1.18:1})`;
2783  if(t>s.start+24*s.interval){s.running=false;this.view();}
2784 }
2785 calibrationTap(){
2786  const s=this.cal;if(!s?.running)return;const elapsed=this.audio.ctx.currentTime-s.start,index=Math.round(elapsed/s.interval);if(index<2||index>=24||s.last===index)return;s.last=index;
2787  s.offsets.push(elapsed-index*s.interval);const count=this.root.getElementById('cal-count');if(count)count.textContent=s.offsets.length+' taps collected';
2788  if(s.offsets.length>=12){this.stopCalibration();this.view();}
2789 }
2790 applyCalibration(){const estimate=calibrationEstimate(this.cal?.offsets||[]);if(!estimate)return;this.settings.syncMs=estimate.milliseconds;this.mix();this.save();this.toast('Alignment saved. Fine-tune in Settings for your headphones.');}
2791 calibrationView(){
2792  const estimate=calibrationEstimate(this.cal?.offsets||[]);
2793  return `${round('back','back','Back','back')}<section class="screen"><div class="panel sync-panel"><span class="eyebrow">AUDIO ALIGNMENT</span><h2>FIND YOUR TIMING</h2><p>Tap with each click. Ignore the first two warm-up beats.<br>This estimates your timing preference, not hardware latency.</p><button id="cal-circle" class="cal-circle" data-action="cal-tap" aria-label="Tap with the beat">TAP</button><p id="cal-count">${this.cal?.offsets?.length||0} taps collected</p>${estimate?`<p>Suggested offset <b>${estimate.milliseconds} ms</b> · variation ${estimate.spreadMs} ms</p><button class="primary" data-action="cal-apply">USE THIS OFFSET</button>`:''}<button class="secondary" data-action="cal-start">${this.cal?'RESTART':'START CLICKS'}</button></div></section>`;
2794 }
2795 deviceReport(){return {version:VERSION,time:new Date().toISOString(),viewport:{width:innerWidth,height:innerHeight,pixelRatio:devicePixelRatio},userAgent:navigator.userAgent,samples:this.metrics.frameMs.length,frameIntervalP50:quantile(this.metrics.frameMs,.5),frameIntervalP95:quantile(this.metrics.frameMs,.95),drawP95:quantile(this.metrics.drawMs,.95),inputProcessingP95:quantile(this.metrics.inputMs,.95),inputSamples:this.metrics.inputMs.length,defensivePauses:this.metrics.pausesForLag,audioContextLatency:this.audio.ctx?.base
2795Latency??null,audioOutputLatency:this.audio.ctx?.outputLatency??null,alignmentMs:this.settings.syncMs,renderScale:this.paint.renderScale||1,adaptiveResolution:this.settings.autoQuality,rasterCacheBytes:(this.paint.spriteBytes||0)+(this.paint.sceneryBytes||0)+(this.paint.foeBytes||0),scope:'Browser timing measurements only; not physical touch-to-display or speaker latency',stage:stageReport(this.paint,this)};}
2796 showDiagnostics(){this.lastDeviceReport=this.deviceReport();this.audio.stop();this.setState('diagnostics');}
2797 diagnosticsView(){const r=this.lastDeviceReport||this.deviceReport();return `${round('back','back','Back','back')}<section class="screen"><div class="panel"><span class="eyebrow">THIS DEVICE · THIS SESSION</span><h2>DEVICE CHECK</h2><p>Play a stage first. These are browser timings, not lab measurements.</p><div class="stat-list"><div><span>Frame samples</span><b>${r.samples}</b></div><div><span>Frame interval · 95th percentile</span><b>${r.samples?r.frameIntervalP95.toFixed(1)+' ms':'Not measured'}</b></div><div><span>Drawing · 95th percentile</span><b>${r.drawP95.toFixed(1)} ms</b></div><div><span>Input processing · ${r.inputSamples} taps</span><b>${r.inputSamples?r.inputProcessingP95.toFixed(1)+' ms':'Not measured'}</b></div><div><span>Interruption pauses</span><b>${r.defensivePauses}</b></div><div><span>Stage motion · ${r.stage.stepDowns} step-downs</span><b>${r.stage.offBecause?'Off · '+r.stage.offBecause:r.stage.level}</b></div></div><button class="primary" data-action="diagnostics-export">EXPORT REPORT</button></div></section>`;}
2798 exportDiagnostics(){const data=this.lastDeviceReport||this.deviceReport();this.workshop.download(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}),'overdrive-device-check.json');}
2799 destroy(){if(this.destroyed)return;this.destroyed=true;this.resizeObserver?.disconnect();this.stopCalibration();++this.runToken;cancelAnimationFrame(this.raf);clearTimeout(this.toastTimer);clearTimeout(this.cueTimer);this.abort.abort();this.audio.close();this.paint.spriteCache?.clear();this.paint.sceneryCache?.clear();this.save();}
2800}
2801if(document.getElementById('arena')){window.rush=new Game();
2802 // Same-origin only. An external level is data for this page; it never reaches the Workshop draft or records.
2803 window.addEventListener('message',event=>{
2804  if(event.origin!==location.origin||!event.data||typeof event.data!=='object'||event.data.type!==LOAD_LEVEL_TYPE)return;
2805  const reply=body=>{try{event.source?.postMessage({type:LOAD_RESULT_TYPE,...body},event.origin);}catch{}};
2806  const failed=error=>reply({ok:false,error:error?.message||'The level could not be loaded'});
2807  try{window.rush.loadLevel(event.data.level,{practice:event.data.practice}).then(()=>reply({ok:true,name:window.rush.level.name}),failed);}catch(error){failed(error);}
2808 });
2809}
2810window.rushVersion=VERSION;
2811
2812
2813// verified-game.mjs
2814/** Opt-in adapter: keep the existing Game and physics implementation unchanged.
2815 * Only <geometry-overdrive-player verified-runs> uses this subclass.
2816 */
2817
2818
2819
2820
2821class VerifiedGame extends Game {
2822  constructor(root,host){super(root,host);this.runClient=new RunClient({enabled:host?.hasAttribute('verified-runs')===true});this.verifiedAttempt=null;this.runStatus='local';}
2823  invalidateRun(reason){this.verifiedAttempt?.recorder.invalidate(reason);if(this.verifiedAttempt)this.runStatus='interrupted-local';}
2824  async begin(options={}){
2825    this.invalidateRun('RUN_RESTARTED');this.verifiedAttempt=null;this.runStatus='local';
2826    const gate=++this.runToken,stage=this.index;
2827    this.audio.stop();this.release();this.setState('loading');
2828    let attempt=null;
2829    try {attempt=await this.runClient.start({stage,level:levelData(stage),practice:!!options.practice,watch:!!options.watch,custom:!!options.custom||!!options.external});}
2830    catch(error){if(gate===this.runToken&&!this.destroyed)this.toast(error.message||'This run stays local.');}
2831    if(gate!==this.runToken||this.destroyed||stage!==this.index){attempt?.recorder.invalidate('RUN_SUPERSEDED');return;}
2832    this.verifiedAttempt=attempt;this.runStatus=attempt?'recording':'local';
2833    await super.begin(options);
2834    if(!attempt||this.verifiedAttempt!==attempt||this.destroyed)return;
2835    if(this.state!=='play'||this.tick!==0||this.world.t!==0){this.invalidateRun('RECORDER_LATE_ATTACH');return;}
2836    const world=this.world,step=world.step;
2837    // Observe the exact input consumed by the real physics. No replacement
2838    // simulation, wall-clock timestamps, injected state, or changed dt.
2839    world.step=(held,pressed,dt)=>{
2840      if(dt!==undefined&&dt!==DT)attempt.recorder.invalidate('RECORDER_CLOCK_MISMATCH');
2841      attempt.recorder.capture(this.tick,held,pressed);
2842      return step.call(world,held,pressed,dt);
2843    };
2844  }
2845  update(ms){const playing=this.state==='play',world=this.world;super.update(ms);if(playing&&world===this.world&&(world.dead||world.won))this.finishVerifiedRun();}
2846  setState(state){if(this.state==='play'&&!['play','death','complete'].includes(state))this.invalidateRun(state==='pause'?'RUN_PAUSED':'RUN_INTERRUPTED');return super.setState(state);}
2847  pause(){if(this.state==='play')this.invalidateRun('RUN_PAUSED');return super.pause();}
2848  act(action){if(action==='back'||action==='home'){this.invalidateRun('RUN_ABANDONED');this.verifiedAttempt=null;}return super.act(action);}
2849  destroy(){this.invalidateRun('RUN_DESTROYED');this.verifiedAttempt=null;return super.destroy();}
2850  finishVerifiedRun(){
2851    const attempt=this.verifiedAttempt;if(!attempt||attempt.recorder.invalid)return;this.runStatus='verifying';
2852    this.runClient.finish(attempt,{completed:this.world.won&&!this.world.dead,coins:this.world.coins}).then(response=>{
2853      if(this.verifiedAttempt!==attempt||this.destroyed)return;
2854      this.runStatus=response.status==='accepted'?'replay-verified-unranked':response.status;
2855      this.toast(response.status==='accepted'?'Replay checked · rankings and rewards are not enabled.':'Replay was not accepted. Local progress is unchanged.');
2856    }).catch(()=>
2856{if(this.verifiedAttempt===attempt&&!this.destroyed){this.runStatus='submission-pending';this.toast('Verification unavailable. Local progress is unchanged.');}});
2857  }
2858}
2859
2860
2861if(!customElements.get('geometry-overdrive-player'))customElements.define('geometry-overdrive-player',class extends HTMLElement{
2862 connectedCallback(){if(this.game&&!this.game.destroyed)return;const root=this.shadowRoot||this.attachShadow({mode:'open'});root.innerHTML='<style>'+":host{color-scheme:dark;--lime:#c9ff40;--ice:#66f2ff;--ink:#061124;font-family:Arial,Helvetica,sans-serif}*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#081e39}body{overscroll-behavior:none;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}.arena{position:absolute;inset:0;overflow:hidden;touch-action:none;isolation:isolate}.arena.rotated{inset:auto;left:50%;top:50%;width:var(--long);height:var(--short);transform:translate(-50%,-50%) rotate(90deg)}canvas{display:block;width:100%;height:100%;position:absolute;inset:0;outline:none}.ui{position:absolute;inset:0;pointer-events:none}.ui button,.ui input,.ui a,.panel{pointer-events:auto}button{font:inherit;cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;color:inherit}button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid #fff;outline-offset:5px}button:disabled{cursor:wait;opacity:.5}a{color:inherit;text-decoration:none}.screen{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:24px;text-align:center;pointer-events:none}.screen>*{pointer-events:auto}.eyebrow{font:800 10px/1.4 Arial;letter-spacing:3.3px;color:#a8d5ed;text-transform:uppercase}.tiny-square{display:inline-block;width:6px;height:6px;background:var(--lime);margin:0 7px 1px 0;box-shadow:0 0 10px #c9ff4044}.top{position:absolute;top:max(23px,env(safe-area-inset-top));left:max(26px,env(safe-area-inset-left));right:max(26px,env(safe-area-inset-right));display:flex;align-items:center;justify-content:space-between;gap:20px}.wordmark{font-size:12px;font-weight:900;letter-spacing:2px;color:#d7f2ff}.version{font:700 9px Arial;letter-spacing:1.6px;color:#8ab9d4}.top .text-btn{padding:9px 14px;border:1px solid #80d5ff33;border-radius:7px;background:#04162e77;font-size:10px;letter-spacing:1px;min-height:40px}.logo{margin:0;display:flex;flex-direction:column;align-items:center;line-height:.9;transform:skew(-5deg);filter:drop-shadow(0 7px 0 #031126);font-family:'Arial Black',Arial,sans-serif;letter-spacing:-5px}.logo .over{font-size:clamp(52px,7.9vw,104px);font-weight:1000;color:var(--lime);paint-order:stroke fill;-webkit-text-stroke:6px #082127;text-shadow:0 2px #ffffab,0 6px #4d9b25}.logo .geo{font-size:clamp(17px,2.2vw,25px);font-weight:900;color:#fff;letter-spacing:9px;margin-bottom:12px;-webkit-text-stroke:2px #06223a;paint-order:stroke fill}.logo:after{content:'';margin-top:17px;width:70%;height:4px;background:linear-gradient(90deg,transparent,#49e7ff,transparent);transform:skew(5deg)}.menu-copy{font-size:12px;font-weight:500;color:#b2d2e7;letter-spacing:1px;margin-top:-3px}.menu-primary{display:flex;align-items:center;gap:35px;margin-top:0}.round{width:59px;height:59px;border-radius:50%;border:3px solid #061321;background:linear-gradient(155deg,#2eb8ca,#0c508a);box-shadow:inset 0 0 0 2px #84f7ff88,inset 0 -7px #08324f55,0 5px #04132a;display:grid;place-items:center;transition:transform .15s,filter .15s;position:relative;padding:0}.round svg{width:54%;height:54%;filter:drop-shadow(0 2px #072233)}.round:hover,.big-play:hover,.primary:hover{filter:brightness(1.15);transform:translateY(-2px)}.round:active,.big-play:active{transform:translateY(3px)}.big-play{width:122px;height:122px;border:5px solid #092329;border-radius:50%;background:linear-gradient(145deg,#efffa8,#bcf83d 48%,#77c125 50%,#8ce834);box-shadow:inset 0 0 0 3px #f7ffd5,inset 0 -10px #37591d55,0 7px #05162c,0 0 55px #75fbed22;position:relative;display:grid;place-items:center;transition:.16s;padding:0}.big-play:before{content:'';position:absolute;inset:-14px;border:1px solid #8ee6eb4a;border-radius:50%}.big-play svg{width:62%;height:62%;margin-left:7px;filter:drop-shadow(0 3px #476b1c)}.main-side{display:flex;flex-direction:column;align-items:center;gap:12px}.main-side span{font-weight:800;font-size:9px;letter-spacing:1.3px;color:#bfdfeb}.footer{position:absolute;bottom:max(20px,env(safe-area-inset-bottom));left:24px;right:24px;display:flex;align-items:center;justify-content:space-between;gap:14px;color:#84b4d0;font-size:9px;letter-spacing:1px}.keys{color:#a9d1df;font-size:11px;letter-spacing:.4px}.key{display:inline-block;border:1px solid #6cbdf344;background:#06314c44;padding:4px 7px;border-radius:4px;font-size:9px;color:#d1f8ff}.menu-tools{display:flex;gap:8px;margin-top:-3px}.menu-tools button{background:#02162f55;border:1px solid #69bcdb33;border-radius:6px;color:#bedbeb;padding:10px 17px;
2862font:800 9px Arial;letter-spacing:1.2px;min-height:36px}.menu-tools button:hover{background:#07446888}.chapter{position:absolute;bottom:calc(21% + 14px);right:22px;display:flex;gap:7px}.chapter i{width:5px;height:5px;border:1px solid #86ddf666;transform:rotate(45deg)}.chapter i:first-child{background:var(--lime);border-color:var(--lime)}.back{position:absolute;top:max(20px,env(safe-area-inset-top));left:max(22px,env(safe-area-inset-left));width:44px;height:44px;z-index:5}.back svg{width:60%}.level-layout{width:min(730px,82vw);position:relative;display:flex;flex-direction:column;gap:14px}.level-card{border:2px solid #639ace66;border-radius:14px;background:linear-gradient(140deg,#07152cee,#133e62c9);box-shadow:inset 0 1px #9be7ff66,0 8px #020d2055,0 0 70px #00122655;overflow:hidden;position:relative}.level-art{height:128px;position:relative;overflow:hidden;background:linear-gradient(110deg,#083665,#1e8fb9);border-bottom:1px solid #86bffb33}.level-art:before{content:'';position:absolute;inset:0;background:repeating-linear-gradient(90deg,transparent 0 58px,#01264d44 58px 65px);transform:skew(-20deg) scale(1.2)}.art-label{position:absolute;left:24px;top:24px;color:#bcf8ff;font-size:9px;font-weight:800;letter-spacing:3px}.art-number{position:absolute;right:24px;top:2px;font:1000 112px Arial;color:#e4ffff11;letter-spacing:-10px}.art-shapes{position:absolute;inset:0;width:100%;height:100%}.level-title{font:1000 clamp(23px,3.7vw,38px)/1 Arial;letter-spacing:-1px;margin:0;color:#fff;text-shadow:0 3px #061226}.level-info{padding:21px 28px 19px}.level-line{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:12px}.difficulty{font-size:9px;letter-spacing:1.2px;font-weight:900;color:#b2ff7d;display:flex;align-items:center;gap:8px}.face{width:27px;height:27px;background:linear-gradient(#92ecff,#24b0d2);border:2px solid #021b32;border-radius:50%;display:flex;align-items:center;justify-content:center;font:900 16px Arial;box-shadow:inset 0 0 0 1px #c2ffff}.stars{font-weight:900;font-size:15px;color:#ffe47d}
2862.level-desc{font:12px Arial;color:#92bfd4;margin:6px 0 20px;text-align:left}.records{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:18px}.record-head{display:flex;justify-content:space-between;color:#9cc0d5;font-size:9px;font-weight:900;letter-spacing:1px;margin-bottom:7px}.record-head b{color:#e2ffff}.bar{height:7px;background:#06152a;border:1px solid #20384b;border-radius:9px;overflow:hidden}.bar i{height:100%;background:linear-gradient(90deg,#58ad20,#c9ff40);display:block;border-radius:8px}.practicebar i{background:linear-gradient(90deg,#137782,#76f7ff)}.actions{display:flex;justify-content:center;gap:10px;margin-top:20px}.primary,.secondary{border:2px solid #082030;border-radius:9px;padding:13px 24px;min-height:43px;background:linear-gradient(#c9ff5a,#7bc52e);color:#0a2a24;font-weight:900;letter-spacing:1px;font-size:12px;box-shadow:inset 0 2px #efffa199,0 4px #041426;transition:.15s}.secondary{background:linear-gradient(#1488ac,#135077);color:#d5faff;box-shadow:inset 0 2px #97ddfb33,0 4px #041426}.music-credit{font-size:10px;color:#85adc4;letter-spacing:.3px;display:flex;justify-content:space-between;gap:15px;margin-top:17px}.arrow{position:absolute;top:44%;width:42px;height:56px;background:none;border:0;padding:0;color:#83edff;filter:drop-shadow(0 3px #071e2d)}.arrow svg{width:100%;height:100%}.arrow.prev{left:-54px;transform:rotate(180deg)}.arrow.next{right:-54px}.dots{display:flex;justify-content:center;gap:9px}.dot{width:7px;height:7px;border-radius:50%;border:1px solid #71a9c266;background:#236288}.dot.on{background:var(--lime);border-color:var(--lime)}.link-btn{border:0;background:none;color:#a6d1e1;font-size:10px;font-weight:700;letter-spacing:.6px;padding:10px;min-height:38px}.link-btn:hover{color:white;text-decoration:underline}.panel{padding:30px 34px;width:min(510px,88vw);border:2px solid #518eaa;border-radius:14px;background:linear-gradient(145deg,#123954ee,#071c37f5);box-shadow:inset 0 2px #a9e6ff22,0 8px #010e2355;max-height:90%;overflow:auto}.panel h2{font:1000 30px Arial;margin:0 0 20px;letter-spacing:-.5px}.panel p{font:12px/1.7 Arial;color:#a9c9db}.row{display:flex;justify-content:space-between;align-items:center;text-align:left;margin:20px 0;font-size:12px;font-weight:700}.row small{font-size:10px;color:#8ab7cd;display:block;margin-top:5px;font-weight:400}.row input[type=range]{width:45%;accent-color:var(--lime)}.row input[type=checkbox]{accent-color:var(--lime);width:22px;height:22px}.panel .primary{margin-top:15px}.swatches{display:flex;justify-content:center;flex-wrap:wrap;gap:11px;margin:18px 0 23px}.swatch{width:36px;height:36px;border:3px solid #03182c;border-radius:7px;background:var(--swatch);box-shadow:inset 0 0 0 1px #ffffff99}.swatch.on{outline:2px solid #e4fbff;outline-offset:3px}.icons{display:flex;justify-content:center;gap:12px;margin:15px 0}.icon{width:60px;height:60px;display:grid;place-items:center;background:#071e36;border:2px solid #254c63;border-radius:9px}.icon.on{border-color:#baff4b;background:#23452b}.icon svg{width:47px;height:47px}.custom-hero{width:78px;height:78px;margin:0 auto 20px;filter:drop-shadow(0 0 18px #68e8ee33)}.custom-hero svg{width:100%;height:100%}.hud{position:absolute;inset:0;pointer-events:none}.hud-top{position:absolute;top:max(17px,env(safe-area-inset-top));left:max(18px,env(safe-area-inset-left));right:max(18px,env(safe-area-inset-right));display:flex;align-items:center;justify-content:center;gap:12px}.progress{height:9px;width:35%;border:1px solid #caf7ff;border-radius:20px;padding:1px;box-shadow:0 0 0 2px #061b34;background:#051b37b8;overflow:hidden}.progress i{display:block;height:100%;width:100%;transform-origin:left;transform:scaleX(0);border-radius:7px;background:linear-gradient(90deg,#43d3e8,#ceff5e)}.percent{font:900 14px Arial;text-shadow:0 2px #052140;min-width:40px}.hud-label{position:absolute;left:0;top:0;font:900 9px/1.7 Arial;letter-spacing:1.4px;text-shadow:0 2px #082335;color:#d6effd}.hud-label small{color:#8dbdcc;letter-spacing:1px;font-size:8px}.hud-pause{position:absolute;right:0;top:-8px;width:44px;height:44px;background:#02162f66;border:1px solid #aae1ff55;border-radius:9px;color:#e7feff;padding:12px;pointer-events:auto}.hud-pause svg{width:100%;height:100%}.hud-bottom{position:absolute;bottom:max(16px,env(safe-area-inset-bottom));left:max(18px,env(safe-area-inset-left));right:max(18px,env(safe-area-inset-right));display:flex;align-items:center;justify-content:space-between;gap:12px}.hud-tools{display:flex;gap:5px}.hud-tools button{border:1px solid #5b94b144;background:#041e3866;border-radius:6px;
2862font:800 9px Arial;color:#c3dfe7;letter-spacing:.5px;padding:8px 10px;min-width:36px;min-height:32px;pointer-events:auto}.hud-coins{display:flex;gap:7px}.coin{width:21px;height:21px;border:2px solid #617f9355;border-radius:50%;display:grid;place-items:center;color:#62869e;font-size:11px;background:#10324977}.coin.on{background:#e9ae32;border-color:#ffe890;color:#fff7bd;box-shadow:0 0 8px #ffc50022}.overlay{background:#0108158c;backdrop-filter:blur(5px)}.overlay .panel{background:linear-gradient(#103659f0,#071b34f0)}.pause-actions{display:flex;justify-content:center;gap:19px;margin:24px 0}.pause-actions .round{width:70px;height:70px}.big-number{font:1000 65px/1 Arial;letter-spacing:-3px;color:var(--lime);text-shadow:0 5px #172e2a;margin:12px 0 15px}.stat-list{margin:15px 0}.stat-list div{display:flex;justify-content:space-between;gap:20px;padding:14px 0;border-bottom:1px solid #7cb3ce33;font:12px Arial;color:#b5d0e3}.stat-list b{color:#c3ff5e}.loading{background:#082443ed;z-index:20}.spinner{width:52px;height:52px;border:3px solid #458ba033;border-top-color:#a0ff4c;border-right-color:#58f1ee;border-radius:50%;animation:spin .7s linear infinite}.loading h2{font-size:26px;letter-spacing:-.5px;margin:0}.loading p{font-size:12px;color:#aacfe4}.notice{position:absolute;bottom:25%;left:50%;transform:translateX(-50%);background:#062338e8;border:1px solid #6ee7ff44;padding:8px 15px;border-radius:6px;font:800 10px/1.6 Arial;letter-spacing:.7px;color:#d7f8ff;pointer-events:none;text-align:center;max-width:80%;z-index:25}.section-flash{position:absolute;left:25px;top:110px;font:900 11px Arial;letter-spacing:5px;color:#d1f8ff;opacity:0;transition:opacity .4s;pointer-events:none}.section-flash.on{opacity:.8}
2862.deaths{font:12px Arial;color:#9abdce}.help{display:grid;grid-template-columns:1fr 1.5fr;gap:15px;font-size:12px;text-align:left}.help b{color:#caff53}.help span{color:#b4d2e3;line-height:1.5}.hidden{display:none!important}.rotate-note{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);font:9px Arial;color:#86b2c6;pointer-events:none}.no-motion *{animation:none!important;transition:none!important}@keyframes spin{to{transform:rotate(360deg)}}\n@media (max-width:600px) and (orientation:portrait){.top{top:24px;left:19px;right:19px}.screen{padding:18px;gap:26px}.logo .geo{font-size:18px;letter-spacing:6px}.logo .over{font-size:52px;letter-spacing:-3px;-webkit-text-stroke:4px}.menu-copy{font-size:10px;letter-spacing:.5px}.big-play{width:118px;height:118px}.menu-primary{gap:31px}.menu-tools{margin-top:2px}.footer{font-size:8px;left:16px;right:16px;bottom:22px;gap:10px}.footer .keys{font-size:9px}.level-layout{width:85vw;gap:18px}.level-art{height:155px}.level-info{padding:19px}.level-title{font-size:26px}.level-desc{font-size:11px;line-height:1.5}.records{gap:12px}.actions button{padding:12px 17px;font-size:10px}.music-credit{font-size:8px;gap:4px}.arrow{width:24px;height:48px}.arrow.prev{left:-26px}.arrow.next{right:-26px}.panel{width:94vw;padding:25px 22px}.panel h2{font-size:27px}.chapter{bottom:24%;right:18px}.back{top:24px;left:18px}}\n@media (max-height:480px){.screen{gap:11px;padding:12px}.logo .geo{font-size:14px;letter-spacing:6px;margin-bottom:7px}.logo .over{font-size:57px;-webkit-text-stroke:4px}.logo:after{margin-top:10px}.menu-copy{font-size:10px}.menu-primary{gap:33px}.big-play{width:83px;height:83px;border-width:4px}.big-play:before{inset:-9px}.round{width:46px;height:46px}.main-side{gap:7px}.main-side span{font-size:8px}.menu-tools button{min-height:30px;padding:8px 15px;font-size:8px}.top{top:12px;left:18px;right:18px}.footer{bottom:11px;left:18px;right:18px;font-size:8px}.keys{font-size:9px}.level-layout{width:min(600px,79vw);gap:10px}.level-card{display:grid;grid-template-columns:180px 1fr}.level-art{height:100%;min-height:245px;border-bottom:0;border-right:1px solid #86bffb33}.art-label{font-size:8px;letter-spacing:2px;top:20px;left:15px}.art-number{font-size:90px;top:40px;right:16px}.level-info{padding:16px 20px}.level-title{font-size:27px}.level-line{margin-bottom:7px}.level-desc{font-size:10px;line-height:1.4;margin:4px 0 15px}.records{gap:14px;margin-top:12px}.record-head{font-size:8px}.actions{margin-top:16px}.actions button{font-size:10px;padding:10px 19px;min-height:38px}.music-credit{font-size:8px;margin-top:16px;gap:6px}.panel{padding:19px 26px;max-height:92%}.panel h2{font-size:25px;margin-bottom:14px}.row{margin:13px 0}.custom-hero{width:53px;height:53px;margin-bottom:10px}.icons{margin:10px 0}.icon{width:45px;height:45px}.icon svg{width:35px}.swatches{gap:9px;margin:13px 0}.swatch{width:30px;height:30px}.panel .primary{margin-top:6px;padding:10px 22px}.pause-actions{margin:15px 0}.pause-actions .round{width:56px;height:56px}.big-number{font-size:49px;margin:7px 0}.help{gap:10px;font-size:10px}.back{top:12px;left:18px}.hud-top{top:max(14px,env(safe-area-inset-top))}.hud-bottom{bottom:max(12px,env(safe-area-inset-bottom))}.hud-tools button{font-size:8px;min-height:32px;padding:7px 9px}.section-flash{top:90px;font-size:9px}.loading h2{font-size:22px}}\n@media(pointer:coarse){.ui button{min-width:40px}.swatch{min-width:32px;min-height:32px}.hud-pause{min-height:44px;min-width:44px}.hud-tools button{min-height:40px}.link-btn{min-height:40px}.round.back{min-width:44px;min-height:44px}}\n/* Quality pass: accessible scrolling panels, compact touch editor and timing tools. */\n.panel{touch-action:pan-y;overscroll-behavior:contain}.row select{max-width:48%;padding:9px;border:1px solid #5da1bd;border-radius:6px;background:#103b57;color:white;font:700 12px Arial}.row output{display:block;color:#c9ff40;font-size:11px;margin-top:6px}.cal-circle{display:block;width:116px;height:116px;border-radius:50%;border:3px solid #e4ffaf;background:radial-gradient(#ccff5b,#589c1f);color:#072634;font:900 24px Arial;margin:16px auto;transition:transform .05s;touch-action:none}.sync-panel .secondary{margin:12px}.menu-tools{flex-wrap:wrap;justify-content:center}.edit-top,.edit-bottom,.edit-properties,.edit-hint{position:absolute;pointer-events:auto;font:700 10px Arial;color:#d5f8ff}.edit-top{left:10px;right:10px;top:max(10px,env(safe-area-inset-top));
2862display:flex;gap:5px;align-items:center;flex-wrap:wrap}.edit-top button,.edit-bottom button,.edit-property-body button{background:linear-gradient(#155170,#0b293e);border:1px solid #67c3d577;border-radius:5px;padding:8px;min-height:36px;color:#ddffff;font:800 10px Arial;touch-action:manipulation}.edit-top .accent{background:linear-gradient(#ccfc66,#86bc34);color:#072439}.level-name-input{background:#072437cc;border:1px solid #6594a788;border-radius:5px;color:white;width:150px;padding:9px;min-width:60px;font:700 11px Arial}.edit-bottom{left:10px;right:10px;bottom:max(7px,env(safe-area-inset-bottom));display:flex;flex-direction:column;gap:6px}.edit-palette{display:flex;justify-content:center;gap:5px;overflow-x:auto;background:#052132e8;padding:6px;border:1px solid #39798f;border-radius:7px}.edit-palette button{display:flex;flex-direction:column;gap:3px;align-items:center;min-width:48px;padding:5px;font-size:9px;text-transform:uppercase}.edit-palette button b{font-size:17px;line-height:18px}.edit-palette .chosen{border:2px solid #d6ff73;background:#4d7834;color:#fff}.edit-scroll{display:flex;align-items:center;gap:10px;background:#071f32ec;padding:3px 8px;border-radius:5px;font-size:9px}.edit-scroll input{flex:1;accent-color:#99eeff;min-width:50px}.edit-scroll button{font-size:8px;min-height:29px}.edit-properties{right:12px;top:82px;width:190px;max-height:calc(100% - 218px);overflow:auto;touch-action:pan-y;background:#0a2c44f0;border:1px solid #4386a2;border-radius:8px;z-index:4}.edit-properties summary{padding:11px;cursor:pointer;font-size:10px;color:#c9ff61}.edit-property-body{padding:5px 10px 10px;display:flex;flex-direction:column;gap:7px}.edit-property-body label{font-size:10px;display:flex;flex-direction:column;gap:4px}.edit-property-body select,.edit-property-body input{border:1px solid #47768a;background:#102c40;color:#d8f8ff;padding:7px;border-radius:4px;min-width:0;width:100%;font:10px Arial}.edit-property-body p{font:10px Arial;margin:0}.nudge{display:flex;gap:3px}.nudge button{flex:1;padding:6px;min-width:30px!important}.edit-hint{top:auto;bottom:calc(108px + max(7px,env(safe-area-inset-bottom)));left:14px;right:15px;pointer-events:none;font-size:9px;color:#b9deea;text-shadow:0 1px #092134}.arena.rotated .edit-top{top:7px;gap:4px}.arena.rotated .level-name-input{width:115px}.arena.rotated .edit-palette{justify-content:flex-start}.arena.rotated .edit-properties{top:78px}.arena.rotated .edit-top button{font-size:9px;min-height:36px}.arena.rotated .edit-bottom{bottom:8px}.arena.rotated .panel{width:min(510px,80%);max-height:93%;padding:18px 24px}.arena.rotated .panel h2{font-size:24px;margin-bottom:12px}.arena.rotated .row{margin:12px 0}.arena.rotated .screen{gap:12px}.edit-more{position:absolute;left:10px;right:10px;top:48px;z-index:5;display:flex;flex-wrap:wrap;gap:5px;align-items:center;padding:7px;background:#052132f2;border:1px solid #39798f;border-radius:7px;pointer-events:auto}.arena.rotated .edit-top{flex-wrap:nowrap;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}.arena.rotated .edit-top button{flex:0 0 auto;min-height:36px;min-width:36px;padding:6px 7px;white-space:nowrap}.arena.rotated .level-name-input{flex:0 0 auto;width:104px;min-height:36px;padding:6px}.edit-more[hidden]{display:none}.edit-more button{min-height:40px;min-width:36px}.arena.rotated .edit-palette{flex-wrap:nowrap;padding:4px;gap:4px}.arena.rotated .edit-palette button{min-width:40px;min-height:40px;padding:3px;gap:0;font-size:8px}.arena.rotated .edit-palette button b{font-size:15px;line-height:16px}.arena.rotated .edit-hint{display:none}.arena.rotated .edit-properties{left:10px;right:10px;bottom:58px;top:auto;width:auto;max-height:34%;touch-action:auto}.arena.rotated .edit-top::-webkit-scrollbar,.arena.rotated .edit-palette::-webkit-scrollbar{display:none}.arena.rotated .edit-property-body{flex-direction:row;flex-wrap:wrap;align-items:flex-end;overflow:auto;max-width:100%;padding:5px 8px 8px}.arena.rotated .edit-property-body>*{max-width:100%;flex:0 0 auto}.arena.rotated .edit-property-body label{flex:1 1 132px;min-width:0}.arena.rotated .edit-property-body p{flex:1 1 100%}.arena.rotated .nudge{flex:1 1 150px}.edit-top .danger,.edit-more .danger{background:linear-gradient(#ff9a6a,#c2331a);border-color:#ffc4ac;color:#2b0b03}@media(max-height:480px){.edit-hint{display:none}}\n@media(pointer:coarse){.edit-top button,.edit-palette button{min-height:40px}.edit-properties summary{min-height:40px}.cal-circle{min-height:116px}.panel select{min-height:40px}}\n\n/* MODE TEACHING: outside the collision lane */\n.control-tip{position:absolute;top:62px;left:8%;right:8%;text-align:center;color:#e8fcff;font:800 11px/1.5 Arial;letter-spacing:.6px;text-shadow:0 2px 4px #051323;pointer-events:none}.control-tip:empty{display:none}@media(max-height:500px){.control-tip{top:49px;font-size:9px}}\n\n/* PRE-START: real HUD is already composed before the score clock begins. */\n.arming-overlay{position:absolute;
2862left:50%;top:42%;transform:translate(-50%,-50%);display:flex;flex-direction:column;align-items:center;gap:10px;pointer-events:none;text-align:center;color:#e9ffff;text-shadow:0 2px 3px #001321;font:700 11px Arial}.arming-overlay b{font:900 30px Arial;letter-spacing:3px}.arming-overlay span{opacity:.8}\n/* COSMETIC PROGRESSION: locked cosmetics stay visible but muted; lists explain what unlocks them */\n.icon.locked,.swatch.locked{opacity:.32;filter:grayscale(.7)}\n.unlock-list{display:flex;flex-wrap:wrap;justify-content:center;gap:6px 12px;margin:4px 0 10px;font:700 10px/1.4 Arial;letter-spacing:.5px;color:#9fd4e6}\n.unlock-list b{color:var(--lime);margin-right:3px}\n.unlock-note{font:12px/1.5 Arial;color:#749cb7;margin:0 0 12px}\n"+'</style><main id="arena" class="arena"><canvas id="game" tabindex="0" aria-label="Tap or press Space to jump"></canvas><div id="ui" class="ui"></div></main>';this.game=this.hasAttribute('verified-runs')?new VerifiedGame(root,this):new Game(root,this);}
2863 loadLevel(level,options){if(!this.game||this.game.destroyed)throw Error('Player is not mounted');return this.game.loadLevel(level,options);}
2864 disconnectedCallback(){this.game?.destroy();}
2865});
2866window.RushCore={World,levelData,emptyLevel,parseLevel,exportLevel,DT,analysePCM,envelopeAt,checkpointSafety,policyVersion:'geometry-enemy-aim-v2.2'};})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.