feat(levels): gate the dead turn, and state the curve instead of hoping for it

Round 1 played all fifty levels and came back with the mechanics clean and one
complaint repeated across the reports: 短暂以为转错了方向 — a turn that changed
nothing. That one is mechanical rather than perceptual, so it can be gated.

The first attempt gated `doubleDead`, and it was wrong. That counts dead states,
which scales with the state space: three-turn levels have ~187 reachable stuck
states and five-turn ones ~739, so a raw count made deep levels look eight times
worse when the rate is 2% against 5%. It would have erased every level past
three turns — the opposite of the brief, where turning is the signature and is
not to be rationed. The gate is `maxDeadTurns`, the longest run of turns before
something opens, at 1: one beat of suspense, then a release. Eleven of the fifty
shipped at 2.

Selection could not express the curve either. Spreading picks evenly over the
ranked pool returns whatever the pool is made of, and the pool is 88%
three-turn, so no weighting moved the distribution off {3: 46}. A quota per
depth states the curve outright, deepest first so the scarce end picks before a
family cap is spent on levels the shallow end has hundreds of.

Same rule bot, both sets: blind rotations 0.24 → 0.12, worst blind streak 4 → 1,
decisions unchanged at 10.3. The same amount to do, half the wasted motion.
Rotation now runs 1.8 / 3.0 / 4.1 across the bands against 1.8 / 3.0 / 3.5.

Verdicts are keyed by the board rather than by its id, because a reselect
re-issues ids from the ordinal and `c50-01-v05` is not the same board before and
after — the review sheet was one reselect away from printing one level's verdict
under another's thumbnail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 17:13:02 +08:00
co-authored by Claude Opus 5
parent d374c208f0
commit ce7f6d30cf
24 changed files with 749603 additions and 60627 deletions
+20 -20
View File
@@ -247,27 +247,27 @@ export function drawExit(p,route,view,progress,tile,reduced=false){
}
}
// Macaron exit: the token reaches its port, swells like a bubble, pops, and drifts off as smaller bubbles with
// highlights that fade out. Circles with alpha only; every bubble's offsets are fixed so all frames agree.
// Macaron exit: the token stops at its port and dissolves like a sugar cube. The solid face, rim and arrow fade in the
// first fifth while a lattice of dots in the token colour takes over; the dots then erode from the rim inward, each
// shrinking, thinning towards white and drifting a little as it goes. Circles, polygons and alpha only; the noise is a
// fixed hash of the lattice position, so every frame agrees.
function bubbleDissolve(p,edge,angle,size,tile,color,t){
const dx=Math.cos(angle),dy=Math.sin(angle),alphaHex=v=>Math.round(Math.max(0,Math.min(1,v))*255).toString(16).padStart(2,'0'),base=color.slice(0,7);
const reach=smoothMotion(Math.min(1,t/.38)),at={x:edge.x+dx*size*.22*reach,y:edge.y+dy*size*.22*reach};
const swell=t<.38?1+.14*smoothMotion(t/.38):Math.max(0,1.14*(1-(t-.38)/.10));
if(swell>0)drawTile(p,at.x,at.y,size,tile,angle,swell);
const pop=(t-.36)/.30;
if(pop>0&&pop<1){const r=size*(.5+.9*(1-Math.pow(1-pop,3))),ring=Array.from({length:36},(_,i)=>({x:at.x+Math.cos(i/36*Math.PI*2)*r,y:at.y+Math.sin(i/36*Math.PI*2)*r}));p.line([...ring,ring[0]],tint(base,.45)+alphaHex(.8*(1-pop)),Math.max(.6,size*.09*(1-pop)));}
// Ten bubbles leave the popped face together: three large, the rest small, spread across the face and drifting
// outward past the port, upward, with a slow wobble, so the cloud stays on screen long enough to read.
for(let i=0;i<10;i++){
const s1=(i*7919%97)/97,s2=(i*104729%89)/89,s3=(i*15485863%83)/83;
const born=.34+.12*s1,life=.48+.18*s2,u=(t-born)/life;
if(u<=0||u>=1)continue;
const side=(s3-.5)*2.4,along=(s1-.5)*1.4,r0=size*(i<3?.22+.12*s2:.11+.12*s2);
const drift=size*(.05+(.35+.5*s2)*(1-Math.pow(1-u,2))),wobble=Math.sin(u*Math.PI*1.5+s1*6.28)*size*.12;
const bx=at.x+dx*(along*size*.35+drift)-dy*(side*size*.5+wobble),by=at.y+dy*(along*size*.35+drift)+dx*(side*size*.5+wobble)-size*.42*u;
const grow=Math.min(1,u/.18),fade=u>.6?1-(u-.6)/.4:1,r=r0*grow*fade;
if(r<=0)continue;
p.circle(bx,by,r,tint(base,.28)+alphaHex(.92*fade));
p.circle(bx-r*.32,by-r*.34,r*.28,'#FFFFFF'+alphaHex(.9*fade));
const x=edge.x+dx*size*.06,y=edge.y+dy*size*.06,r=size*.90,solid=1-smoothMotion(t/.18);
if(solid>0){const face=roundedHex(x,y,r,.16);p.polygon(face,base+alphaHex(solid));p.line([...face,face[0]],'#FFFFFF'+alphaHex(solid),Math.max(1,r*.10));}
const step=size*.17,inset=r*.82,rows=Math.ceil(inset/(step*.866))+1,cols=Math.ceil(inset/step)+1;
for(let j=-rows;j<=rows;j++)for(let i=-cols;i<=cols;i++){
const ox=(i+(j&1?.5:0))*step,oy=j*step*.866,ax=Math.abs(ox),ay=Math.abs(oy);
if(ay>inset*.866||ax*.866+ay*.5>inset*.866)continue;
let h=(i*374761393+j*668265263)|0;h=Math.imul(h^(h>>>13),1274126177);h^=h>>>16;
const n1=((h>>>0)%1000)/1000,n2=(((h>>>10)>>>0)%1000)/1000;
const rim=Math.max(ay,ax*.866+ay*.5)/(inset*.866);
const u=(t-(.05+.40*(1-rim)+.20*n1))/.33;
if(u>=1)continue;
if(u<=0){p.circle(x+ox,y+oy,size*.115,base);continue;}
const k=1-smoothMotion(u),drift=size*.14*u,a=n2*Math.PI*2;
p.circle(x+ox+Math.cos(a)*drift,y+oy+Math.sin(a)*drift-size*.10*u,size*.115*k,tint(base,.45*u)+alphaHex(.95*(1-u*.6)));
}
const arrow=1-smoothMotion(t/.3);
if(arrow>0){const rot=pts=>pts.map(([u,v])=>({x:x+r*(u*Math.cos(angle)-v*Math.sin(angle)),y:y+r*(u*Math.sin(angle)+v*Math.cos(angle))})),w=Math.max(1.5,r*.20),white='#FFFFFF'+alphaHex(arrow);p.line(rot([[-.34,0],[.30,0]]),white,w);p.line(rot([[.02,-.30],[.30,0],[.02,.30]]),white,w);}
}