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;} // 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{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!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=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);} }