The game has never had a board you simply clear out, and the request for one turned out to need a construction of its own rather than a denser deep lock. Filling cells at random can never work: a packed board has nothing that can move — measured over forty random fills of a 37-cell board, zero pieces free in all six orientations, every time, because a piece steps to the next cell before it can leave and on a full board that cell is taken. So packBoard builds backwards. Start empty, add only a piece that could leave right now, and the placement order reversed is a removal order — solvability becomes a property of the construction. Which cell to fill next decides everything: at random it stalls near half, while filling the most constrained cell first takes a 19-cell interior to 96% and fills it outright. The rim is deliberately empty. Those pieces leave first and ask nothing, and clearing from the rim inward gives a packed board its own difficulty curve inside one level — the outer cells read easily, and it tightens as it opens. Two things were tried against the ceiling and failed, and are recorded as such: depth-first backtracking made it worse on both counts (65-78% filled against greedy's 89%, at 2000-3000 seconds a board) because recomputing every cell's legal directions per node spends the budget three levels deep. And the audit's dead-turn gate could not be trusted here at all — on a packed board its breadth search truncates, so it certified levels the rule bot then walked through three blind turns in a row. The pool is filtered by playing it instead: a full pass takes one second and does not lie. The campaign is 32 packed levels to 18 deep locks, alternating, with the closing six at a completely full interior. Same rule bot, both editions: worst blind streak 1 either way, dead taps zero, and half again as much to do per level. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
266 lines
20 KiB
JavaScript
266 lines
20 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=640;
|
||
export function exitDuration(route,artStyle){const timing=exitTiming(route);return artStyle==='macaron'?timing.travelMs+MACARON_DISSOLVE_MS:timing.duration;}
|
||
// 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 ringIndex=(()=>{
|
||
if(!macaron)return ()=>0;
|
||
const 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 Math.max(Math.abs(dq),Math.abs(dr),Math.abs(dq+dr));};
|
||
})();
|
||
const ringTint=cell=>{const rings=materials.rings||[materials.board];return rings[ringIndex(cell)%rings.length];};
|
||
// Macaron: when the board is cleared, a ripple runs from the centre outward: each brick swells 8% and brightens, then settles.
|
||
const pulse=cell=>{if(!macaron||!(state.celebrate<1))return 0;const u=(state.celebrate-ringIndex(cell)*.07)/.45;return u>0&&u<1?Math.sin(u*Math.PI):0;};
|
||
// 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),ripple=pulse(cell),fill=blushCells.get(key(cell.q,cell.r))||ringTint(cell);if(sc>.02)base.polygon(roundedHex(x,y,size*.90*sc*(1+.08*ripple),.16),ripple>0?tint(fill,.35*ripple):fill);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: the token fills its brick, so the rails run under it and stay visible right up to its white rim.
|
||
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?.();
|
||
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 glides out through its port, slowing down as it crosses the tray edge (0.55s beyond the port
|
||
// over the first 45%), and dissolves mostly outside the board like a sugar cube: the solid face, rim and arrow fade
|
||
// while a lattice of dots in the token colour takes over; the dots erode from the rim inward, each shrinking, thinning
|
||
// towards white and drifting a little. Circles, polygons and alpha only; the noise is a fixed hash of the lattice position.
|
||
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 out=1-Math.pow(1-Math.min(1,t/.45),3),x=edge.x+dx*size*.55*out,y=edge.y+dy*size*.55*out,r=size*.90;
|
||
const solid=1-smoothMotion((t-.15)/.2);
|
||
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-(.16+.38*(1-rim)+.16*n1))/.28;
|
||
if(u>=1)continue;
|
||
if(u<=0){if(solid<1)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-.15)/.25);
|
||
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);}
|
||
}
|