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>
274 lines
21 KiB
JavaScript
274 lines
21 KiB
JavaScript
import { drawStyledToken, tint } from './token-art.js';
|
||
import { appearanceTile } from './art-directions.js';
|
||
import { exitTiming, smoothMotion, travelProgress } from './exit-animation.js';
|
||
import { THEME, PIGMENTS, pigmentIndex } from './design-tokens.js';
|
||
import { DIRS, key } from './core.js';
|
||
import { tileSample, roundedHex } from './tile-shape.js';
|
||
import { collisionPhase, contactPath } from './collision-animation.js';
|
||
import { center, hexPoints, trackPoints, routePoints, pointAlong } from './geometry.js';
|
||
import { TILE_FRAMES, TILE_RADIUS, TILE_ASPECT } from './sprites.js';
|
||
export const COLORS=PIGMENTS.map(p=>p.color);
|
||
// Macaron's exit ends in a bubble dissolve that needs more time than the shared 180 ms effect window.
|
||
const MACARON_DISSOLVE_MS=560;
|
||
export function exitDuration(route,artStyle){const timing=exitTiming(route);return artStyle==='macaron'?timing.travelMs+MACARON_DISSOLVE_MS:timing.duration;}
|
||
// Four-point sparkle star used by the macaron win celebration.
|
||
const sparkle=(x,y,r)=>Array.from({length:8},(_,j)=>{const b=j*Math.PI/4-Math.PI/2,rr=j%2?r*.38:r;return {x:x+Math.cos(b)*rr,y:y+Math.sin(b)*rr};});
|
||
// Scene commands remain engine-independent. sprite() is an optional atlas adapter.
|
||
export function drawTile(p,x,y,size,tile,angle=-Math.PI/2+tile.dir*Math.PI/3,scale=1) {
|
||
const r=size*(tile.artStyle==='paper'?.88:tile.artStyle==='signpost'?.90:tile.artStyle==='macaron'?.90:TILE_RADIUS)*scale;
|
||
if(drawStyledToken(p,x,y,r,tile,angle))return;
|
||
const textured=p.sprite?.(tile.color,x,y,r)===true;
|
||
if(!textured){
|
||
p.polygon(roundedHex(x,y,r),COLORS[tile.color]);
|
||
}
|
||
const a=r*.58;
|
||
const shape=[[-.72,-.23],[.06,-.23],[.06,-.59],[.83,0],[.06,.59],[.06,.23],[-.72,.23]];
|
||
const arrow=shape.map(([u,v])=>({x:x+a*(u*Math.cos(angle)-v*Math.sin(angle)),y:y-1+a*(u*Math.sin(angle)+v*Math.cos(angle))}));
|
||
p.polygon(arrow,PIGMENTS[tile.color]?.arrow||THEME.arrowDark);
|
||
}
|
||
export function drawScene(p,level,tiles,view,state={}) {
|
||
const {size}=view;
|
||
const visualTile=tile=>appearanceTile({...tile,color:pigmentIndex(state.theme?Math.max(0,level.tiles.findIndex(t=>t.id===tile.id)):tile.color,state.theme)},state.theme);
|
||
const materials={...THEME,...state.theme?.materials};
|
||
const paper=state.theme?.artStyle==='paper',signpost=state.theme?.artStyle==='signpost',macaron=state.theme?.artStyle==='macaron';
|
||
const roadbedWidth=Math.min(18,Math.max(5,size*.30)),railWidth=Math.min(7,Math.max(2.5,size*.105)),gummyWidth=Math.min(6.5,Math.max(2.2,size*.11));
|
||
const angle=state.boardAngle||0,cos=Math.cos(angle),sin=Math.sin(angle);
|
||
const rotate=points=>points.map(pt=>{const x=pt.x-view.ox,y=pt.y-view.oy;return {x:view.ox+x*cos-y*sin,y:view.oy+x*sin+y*cos};});
|
||
const base={polygon:(pts,c)=>p.polygon(rotate(pts),c),line:(pts,c,w)=>p.line(rotate(pts),c,w)};
|
||
// Each accepted tap keeps its own path and clock. Legacy adapters may still
|
||
// supply one animation; the browser supplies all simultaneous departures.
|
||
const motions=(state.animations??(state.animation?[state.animation]:[])).map(animation=>{
|
||
const blocked=animation.route.status==='blocked';
|
||
const blocker=blocked?tiles.find(t=>t.id===animation.route.blocker):null;
|
||
const target=blocker?center(blocker,view):null;
|
||
const phase=blocked?collisionPhase(animation.progress,state.reduced):null;
|
||
const movingPath=contactPath(routePoints(animation.route,view,true),target,size*TILE_RADIUS);
|
||
const contact=movingPath[movingPath.length-1];
|
||
const impactAngle=target&&contact?Math.atan2(target.y-contact.y,target.x-contact.x):0;
|
||
return {...animation,blocker,target,phase,movingPath,contact,impactAngle};
|
||
});
|
||
const movingIds=new Set(motions.map(a=>a.route.tile.id));
|
||
const cellKeys=new Set(level.cells.map(c=>key(c.q,c.r)));
|
||
const sorted=[...level.cells].sort((a,b)=>center(a,view).y-center(b,view).y);
|
||
// Macaron: cells pop in as a wave from the top-left corner (state.intro 0–1); without an intro every cell is at scale 1.
|
||
const cellScale=(()=>{
|
||
if(!macaron||!(state.intro<1))return ()=>1;
|
||
const cs=level.cells.map(c=>({c,p:center(c,view)})),xs=cs.map(o=>o.p.x),ys=cs.map(o=>o.p.y),x0=Math.min(...xs),x1=Math.max(...xs),y0=Math.min(...ys),y1=Math.max(...ys);
|
||
const order=new Map(cs.map(({c,p})=>[key(c.q,c.r),((x1>x0?(p.x-x0)/(x1-x0):0)+(y1>y0?(p.y-y0)/(y1-y0):0))/2]));
|
||
return cell=>{const t=Math.max(0,Math.min(1,(state.intro-order.get(key(cell.q,cell.r))*.55)/.45)),u=t-1;return t<=0?0:1+2.2*u*u*u+1.2*u*u;};
|
||
})();
|
||
// Macaron: cell tints follow concentric rings from the board centre, so they stay put through every rotation.
|
||
const ringTint=(()=>{
|
||
if(!macaron)return ()=>materials.board;
|
||
const rings=materials.rings||[materials.board],cq=level.cells.reduce((a,c)=>a+c.q,0)/level.cells.length,cr=level.cells.reduce((a,c)=>a+c.r,0)/level.cells.length;
|
||
let origin=level.cells[0],best=Infinity;for(const c of level.cells){const d=Math.hypot(c.q-cq,c.r-cr);if(d<best){best=d;origin=c;}}
|
||
return cell=>{const dq=cell.q-origin.q,dr=cell.r-origin.r;return rings[Math.max(Math.abs(dq),Math.abs(dr),Math.abs(dq+dr))%rings.length];};
|
||
})();
|
||
// Macaron: cells along a previewed or travelling route blush in the token's own tint; a blocked cell blushes orange-cream.
|
||
// A tapped route carries its tile; a traced preview does not, so the tile is looked up at the route's first step.
|
||
const routeFill=route=>{const start=route.steps?.[0],tile=route.tile||(start&&tiles.find(t=>t.q===start.q&&t.r===start.r));return (tile&&visualTile(tile).fill)||materials.track;};
|
||
const blushCells=new Map();
|
||
if(macaron){
|
||
const blush=(steps,color)=>{for(const s of steps||[])blushCells.set(key(s.q,s.r),color);};
|
||
for(const m of motions)if(m.route.status==='exit')blush(m.route.steps,tint(routeFill(m.route),.84));
|
||
if(state.path)blush(state.path.steps,state.path.status==='exit'?tint(routeFill(state.path),.80):materials.blush);
|
||
for(const m of motions)if(m.blocker&&m.phase.impact>0)blushCells.set(key(m.blocker.q,m.blocker.r),materials.blush);
|
||
}
|
||
// Macaron: one white tray under everything, the rounded convex hull of the cells plus a margin, lifted by two opaque
|
||
// shadow steps. The tray turns with the board; the shadow offset stays vertical on screen.
|
||
if(macaron){
|
||
const pad=size*.34,cross=(o,a,b)=>(a.x-o.x)*(b.y-o.y)-(a.y-o.y)*(b.x-o.x);
|
||
const hull=pts=>{const s=[...pts].sort((a,b)=>a.x-b.x||a.y-b.y),lower=[],upper=[];for(const q of s){while(lower.length>=2&&cross(lower[lower.length-2],lower[lower.length-1],q)<=0)lower.pop();lower.push(q);}for(const q of s.reverse()){while(upper.length>=2&&cross(upper[upper.length-2],upper[upper.length-1],q)<=0)upper.pop();upper.push(q);}return [...lower.slice(0,-1),...upper.slice(0,-1)];};
|
||
const rounded=(poly,radius)=>poly.flatMap((v,i)=>{const prev=poly[(i+poly.length-1)%poly.length],next=poly[(i+1)%poly.length],ka=Math.min(.5,radius/Math.hypot(prev.x-v.x,prev.y-v.y)),kb=Math.min(.5,radius/Math.hypot(next.x-v.x,next.y-v.y)),a={x:v.x+(prev.x-v.x)*ka,y:v.y+(prev.y-v.y)*ka},b={x:v.x+(next.x-v.x)*kb,y:v.y+(next.y-v.y)*kb};return Array.from({length:6},(_,j)=>{const t=j/5,u=1-t;return {x:u*u*a.x+2*u*t*v.x+t*t*b.x,y:u*u*a.y+2*u*t*v.y+t*t*b.y};});});
|
||
const tray=grow=>rounded(hull(level.cells.flatMap(c=>{const {x,y}=center(c,view);return hexPoints(x,y,size+pad+grow);})),size*.9);
|
||
for(const [grow,dy,color] of [[size*.06,size*.20,materials.shadowSoft],[0,size*.10,materials.shadow]])p.polygon(rotate(tray(grow)).map(q=>({x:q.x,y:q.y+dy})),color);
|
||
base.polygon(tray(0),materials.tray);
|
||
}
|
||
// Signpost: a flat offset base and a rim under the cells lift the card off the world colour.
|
||
// The base offset stays vertical on screen, so it is applied after board rotation.
|
||
if(signpost){
|
||
const rimW=Math.max(1.5,size*.06),baseDy=size*.12;
|
||
for(const cell of sorted){const {x,y}=center(cell,view);p.polygon(rotate(hexPoints(x,y,size+.2+rimW)).map(q=>({x:q.x,y:q.y+baseDy})),materials.base);}
|
||
for(const cell of sorted){const {x,y}=center(cell,view);base.polygon(hexPoints(x,y,size+.2+rimW),materials.rim);}
|
||
}
|
||
// Paint cells before rails. Paper keeps the reference's narrow gaps; other
|
||
// styles overlap slightly to avoid antialiasing seams.
|
||
for(const cell of sorted){
|
||
const {x,y}=center(cell,view);
|
||
if(macaron){const sc=cellScale(cell);if(sc>.02)base.polygon(roundedHex(x,y,size*.90*sc,.16),blushCells.get(key(cell.q,cell.r))||ringTint(cell));continue;}
|
||
base.polygon(hexPoints(x,y,paper?size*.97:size+.2),materials.board);
|
||
}
|
||
// Cell boundaries stay beneath the connected track endpoints. Macaron separates cells with white gutters instead.
|
||
if(!macaron)for(const cell of sorted){
|
||
const {x,y}=center(cell,view),edge=hexPoints(x,y,paper?size*.97:size);
|
||
base.line([...edge,edge[0]],materials.boardGrid,paper?Math.max(.4,size*.025):signpost?Math.max(.6,size*.02):Math.max(1,size*.024));
|
||
}
|
||
const rails=[];
|
||
for(const cell of sorted) {
|
||
const {x,y}=center(cell,view),sc=cellScale(cell);
|
||
if(sc<=.15)continue;
|
||
for(let a=0;a<6;a++) if(a<cell.tracks[a]) {
|
||
// Inset the centerline by half the widest stroke at each hex side.
|
||
// Rounded line caps must also remain inside the visible cell.
|
||
const trackWidth=signpost?roadbedWidth:macaron?gummyWidth:Math.max(4,size*.16);
|
||
// Rails run continuously across macaron's white gutters; only board-edge ends are pulled inside the brick face.
|
||
const visualRadius=Math.max(0,(macaron?size*.90:size*.965)-trackWidth/Math.sqrt(3)-.5);
|
||
const b=cell.tracks[a],points=trackPoints(x,y,size,a,b);
|
||
const exterior=d=>!cellKeys.has(key(cell.q+DIRS[d][0],cell.r+DIRS[d][1]));
|
||
const first=points[0],last=points[points.length-1],shrink=1-visualRadius/size;
|
||
const dx0=exterior(a)?(first.x-x)*shrink:0,dy0=exterior(a)?(first.y-y)*shrink:0;
|
||
const dx1=exterior(b)?(last.x-x)*shrink:0,dy1=exterior(b)?(last.y-y)*shrink:0;
|
||
if(!paper)points.forEach((p,i)=>{const t=i/(points.length-1),u=1-t;p.x-=u*u*u*dx0+t*t*t*dx1;p.y-=u*u*u*dy0+t*t*t*dy1;});
|
||
if(sc<1)points.forEach(pt=>{pt.x=x+(pt.x-x)*sc;pt.y=y+(pt.y-y)*sc;});
|
||
rails.push({points,primary:!state.trackEmphasis||state.trackEmphasis(cell,a,b)});
|
||
}
|
||
}
|
||
// Pattern studies can emphasize real routes while retaining the other paired
|
||
// connections as secondary lines. Ordinary levels keep the same drawing passes.
|
||
for(const {points,primary} of rails)if(!primary)base.line(points,state.secondaryTrackColor||materials.boardGrid,Math.max(1,size*.025));
|
||
if(!paper&&!macaron)for(const {points,primary} of rails)if(primary)base.line(points,materials.trackHalo,signpost?roadbedWidth:Math.max(4,size*.16));
|
||
for(const {points,primary} of rails)if(primary)base.line(points,materials.track,paper?Math.max(.7,size*.055):signpost?railWidth:macaron?gummyWidth:Math.max(2.5,size*.10));
|
||
// Route preview: classic styles draw it under the tokens; signpost draws it over them, in two layers, ending at the rim.
|
||
// Macaron draws the line under the tokens but its blocked cross above them, so a short stub still shows its cross.
|
||
let afterTokens=null;
|
||
const drawPath=()=>{
|
||
if(!state.path)return;
|
||
if(macaron){
|
||
// One chunky line in the token's own colour over the rails; a blocked route stops at the contact point with an orange cross.
|
||
const exit=state.path.status==='exit',fill=routeFill(state.path);
|
||
let points=routePoints(state.path,view);if(exit&&!state.path.travelPoints&&points.length>2)points=points.slice(0,-1);
|
||
p.line(points,exit?fill:materials.blocked,gummyWidth*1.15);
|
||
if(!exit)afterTokens=()=>{const q=points[points.length-1],k=gummyWidth*1.2;p.line([{x:q.x-k,y:q.y-k},{x:q.x+k,y:q.y+k}],materials.blocked,gummyWidth);p.line([{x:q.x-k,y:q.y+k},{x:q.x+k,y:q.y-k}],materials.blocked,gummyWidth);};
|
||
return;
|
||
}
|
||
if(!signpost){p.line(routePoints(state.path,view),state.path.status==='exit'?THEME.primary:THEME.warning,Math.max(4,size*.12));return;}
|
||
const exit=state.path.status==='exit',route=state.theme.route||{};
|
||
let points=routePoints(state.path,view);if(exit&&!state.path.travelPoints&&points.length>2)points=points.slice(0,-1);
|
||
p.line(points,exit?route.exitGlow:route.blockedGlow,roadbedWidth*1.05);p.line(points,exit?route.exit:route.blocked,railWidth*1.5);
|
||
if(!exit){const q=points[points.length-1],k=railWidth*1.4;p.line([{x:q.x-k,y:q.y-k},{x:q.x+k,y:q.y+k}],route.blocked,railWidth);p.line([{x:q.x-k,y:q.y+k},{x:q.x+k,y:q.y-k}],route.blocked,railWidth);}
|
||
};
|
||
if(!signpost)drawPath();
|
||
if(state.selected) {
|
||
const cell=level.cells.find(c=>c.q===state.selected.q&&c.r===state.selected.r);
|
||
if(cell){const c=center(cell,view),pts=hexPoints(c.x,c.y,size*.98);p.line([...pts,pts[0]],THEME.reward,3);}
|
||
}
|
||
for(const tile of [...tiles].filter(t=>!movingIds.has(t.id)).sort((a,b)=>center(a,view).y-center(b,view).y)) {
|
||
const c=center(tile,view),sc=cellScale(tile);
|
||
// Macaron: a tray-white seat under every resting token covers the rail crossings in the gutters around its brick,
|
||
// so no rail end shows at the token's edge; when the token leaves, the continuous rail reappears.
|
||
if(macaron&&sc>=.3)p.polygon(roundedHex(c.x,c.y,size*1.12*sc,.16),materials.tray);
|
||
if(tile.id===state.hint) {
|
||
if(macaron){p.polygon(roundedHex(c.x,c.y,size*1.02,.16),materials.hintGlow);p.polygon(roundedHex(c.x,c.y,size*.94,.16),materials.hint);}
|
||
else if(signpost){const ring=Array.from({length:48},(_,i)=>({x:c.x+Math.cos(i/48*Math.PI*2)*size*1.1,y:c.y+Math.sin(i/48*Math.PI*2)*size*1.1}));p.line([...ring,ring[0]],state.theme.route?.hint||THEME.reward,Math.max(2.5,size*.08));}
|
||
else{p.circle(c.x,c.y,size*1.13,THEME.reward);p.circle(c.x,c.y,size*1.03,THEME.warningSurface);}
|
||
}
|
||
if(state.queued?.includes(tile.id)){
|
||
const ring=hexPoints(c.x,c.y,size*1.03);
|
||
p.line([...ring,ring[0]],macaron?materials.track:THEME.primary,3);
|
||
}
|
||
let dx=0;
|
||
if(tile.id===state.shake) dx=Math.sin(state.shakeTime*38)*5*(1-state.shakeTime);
|
||
let dy=0;
|
||
for(const {blocker,impactAngle,phase} of motions)if(tile.id===blocker?.id){
|
||
dx+=Math.cos(impactAngle)*phase.kick*size*.32;dy+=Math.sin(impactAngle)*phase.kick*size*.32;
|
||
}
|
||
if(sc>=.3)drawTile(p,c.x+dx,c.y+dy,size,visualTile(tile),undefined,sc);
|
||
}
|
||
if(signpost)drawPath();
|
||
afterTokens?.();
|
||
// Macaron: when the last token has left, twelve candy stars and a honey ring burst out of the tray before the result opens.
|
||
if(macaron&&state.celebrate!==undefined&&state.celebrate<1){
|
||
const t=state.celebrate,ease=1-Math.pow(1-t,3),cs=level.cells.map(c=>center(c,view)),cx=cs.reduce((a,c)=>a+c.x,0)/cs.length,cy=cs.reduce((a,c)=>a+c.y,0)/cs.length;
|
||
const reach=Math.max(...cs.map(c=>Math.hypot(c.x-cx,c.y-cy)))+size*1.3,colors=(state.theme.palette||[]).map(i=>state.theme.pigments[i].color);
|
||
const ringR=reach*(.45+.6*ease),ring=Array.from({length:48},(_,i)=>({x:cx+Math.cos(i/48*Math.PI*2)*ringR,y:cy+Math.sin(i/48*Math.PI*2)*ringR}));
|
||
p.line([...ring,ring[0]],materials.hint+Math.round((1-t)*200).toString(16).padStart(2,'0'),Math.max(1,size*.14*(1-t)));
|
||
for(let i=0;i<12;i++){const a=i/12*Math.PI*2-Math.PI/2+ease*.4,d=reach*(.3+.8*ease),r=size*.34*(1-smoothMotion(t)),sx=cx+Math.cos(a)*d,sy=cy+Math.sin(a)*d;if(r>0)p.polygon(sparkle(sx,sy,r),colors.length?colors[i%colors.length]:materials.hint);}
|
||
}
|
||
for(const {route,progress,target,phase,movingPath,contact,impactAngle} of motions) {
|
||
const blocked=route.status!=='exit';
|
||
if(!blocked){
|
||
drawExit(p,route,view,progress,visualTile(route.tile),state.reduced);
|
||
}else{
|
||
const loc=pointAlong(movingPath,phase?phase.distance:Math.sin(progress*Math.PI));
|
||
drawTile(p,loc.x,loc.y,size,visualTile(route.tile),loc.angle,phase&&!state.reduced?1-phase.impact*.12:1);
|
||
}
|
||
if(target&&phase.impact>0){
|
||
const x=(contact.x+target.x)/2,y=(contact.y+target.y)/2;
|
||
if(macaron){const k=size*.30*phase.impact;p.line([{x:x-k,y:y-k},{x:x+k,y:y+k}],materials.blocked,gummyWidth);p.line([{x:x-k,y:y+k},{x:x+k,y:y-k}],materials.blocked,gummyWidth);continue;}
|
||
for(const offset of [-1.95,-1.2,1.2,1.95]){const a=impactAngle+offset,d=size*(.12+phase.impact*.16),length=size*.26*phase.impact;p.line([{x:x+Math.cos(a)*d,y:y+Math.sin(a)*d},{x:x+Math.cos(a)*(d+length),y:y+Math.sin(a)*(d+length)}],materials.track,Math.max(1.5,size*.055));}
|
||
}
|
||
}
|
||
}
|
||
export class CanvasPainter {
|
||
constructor(context) { this.ctx=context; this.atlas=null; }
|
||
sprite(index,x,y,r) {
|
||
if(!TILE_FRAMES[index]||!this.atlas?.complete||!this.atlas.naturalWidth)return false;
|
||
const f=tileSample(TILE_FRAMES[index]),w=r*2,h=w*TILE_ASPECT;
|
||
const c=this.ctx,shape=roundedHex(x,y,r);c.save();c.beginPath();shape.forEach((p,i)=>i?c.lineTo(p.x,p.y):c.moveTo(p.x,p.y));c.closePath();c.clip();
|
||
this.ctx.drawImage(this.atlas,f.x,f.y,f.w,f.h,x-w/2,y-h/2,w,h);
|
||
c.restore();
|
||
return true;
|
||
}
|
||
polygon(points,fill) {const c=this.ctx;c.beginPath();points.forEach((p,i)=>i?c.lineTo(p.x,p.y):c.moveTo(p.x,p.y));c.closePath();c.fillStyle=fill;c.fill();}
|
||
line(points,color,width) {if(!points.length)return;const c=this.ctx;c.beginPath();points.forEach((p,i)=>i?c.lineTo(p.x,p.y):c.moveTo(p.x,p.y));c.strokeStyle=color;c.lineWidth=width;c.lineCap='round';c.lineJoin='round';c.stroke();}
|
||
circle(x,y,r,fill) {const c=this.ctx;c.beginPath();c.arc(x,y,r,0,Math.PI*2);c.fillStyle=fill;c.fill();}
|
||
}
|
||
|
||
export function exitGeometry(route,view){
|
||
const points=routePoints({...route,end:null},view);
|
||
const edge=points[points.length-1],before=points[points.length-2];
|
||
const angle=Math.atan2(edge.y-before.y,edge.x-before.x);
|
||
return {points,edge,angle};
|
||
}
|
||
export function drawExit(p,route,view,progress,tile,reduced=false){
|
||
const {points,edge,angle}=exitGeometry(route,view),size=view.size;
|
||
if(reduced){if(progress<.8){const at=pointAlong(points,progress/.8);drawTile(p,at.x,at.y,size,tile,at.angle);}return;}
|
||
const timing=exitTiming(route),travel=timing.travelMs/exitDuration(route,tile.artStyle);
|
||
if(progress<travel){
|
||
const at=pointAlong(points,travelProgress(progress/travel,timing.travelMs));
|
||
drawTile(p,at.x,at.y,size,tile,at.angle);return;
|
||
}
|
||
const t=Math.min(1,(progress-travel)/(1-travel)),ease=smoothMotion(t);
|
||
const color=tile.fill||COLORS[tile.color],dx=Math.cos(angle),dy=Math.sin(angle);
|
||
if(tile.artStyle==='macaron'){bubbleDissolve(p,edge,angle,size,tile,color,t);return;}
|
||
// Local exit plume: short enough to remain visible in the canvas gutter.
|
||
const at={x:edge.x+dx*size*.48*ease,y:edge.y+dy*size*.48*ease};
|
||
for(let i=3;i>=1;i--){
|
||
const d=size*(.10+i*.13)*ease,scale=(1-smoothMotion(t))*(.14-i*.025);
|
||
if(scale>0)p.circle(at.x-dx*d,at.y-dy*d,size*scale,color);
|
||
}
|
||
if(t<.82)drawTile(p,at.x,at.y,size,tile,angle,1-smoothMotion(t/.82));
|
||
const burst=Math.max(0,(t-.18)/.82);
|
||
if(burst>0&&burst<1){
|
||
for(let i=0;i<6;i++){
|
||
const a=angle+(i-2.5)*.58,d=size*(.12+.40*(1-Math.pow(1-burst,3))),r=size*.075*(1-smoothMotion(burst));
|
||
p.polygon(hexPoints(edge.x+dx*size*.18+Math.cos(a)*d,edge.y+dy*size*.18+Math.sin(a)*d,r),color);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 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);}
|
||
}
|