import { THEME } 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=[THEME.tileBlue,THEME.tilePink,THEME.tileYellow,THEME.tilePurple]; const DARK=COLORS.map(()=>THEME.arrowDark); // 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_RADIUS*scale; 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,tile.color===2?THEME.arrowDark:THEME.arrowLight); } export function drawScene(p,level,tiles,view,state={}) { const {size}=view; 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)}; const blocked=state.animation?.route.status==='blocked'; const blocker=blocked?tiles.find(t=>t.id===state.animation.route.blocker):null; const target=blocker?center(blocker,view):null; const phase=blocked?collisionPhase(state.animation.progress,state.reduced):null; const movingPath=state.animation?contactPath(routePoints(state.animation.route,view,true),target,size*TILE_RADIUS):null; const contact=movingPath?.[movingPath.length-1]; const impactAngle=target&&contact?Math.atan2(target.y-contact.y,target.x-contact.x):0; 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); // Complete the contiguous board before drawing any rails. A tiny overlap // prevents antialiasing seams between touching hexagons. for(const cell of sorted){const {x,y}=center(cell,view);base.polygon(hexPoints(x,y,size+.2),THEME.board);} // Subtle cell boundaries beneath the rails: retain the hex grid without // opening background gaps or painting over connected track endpoints. for(const cell of sorted){ const {x,y}=center(cell,view),edge=hexPoints(x,y,size); base.line([...edge,edge[0]],THEME.boardGrid,Math.max(1.25,size*.038)); } const rails=[]; for(const cell of sorted) { const {x,y}=center(cell,view); 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; 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;}); rails.push(points); } } for(const points of rails)base.line(points,THEME.trackHalo,Math.max(4,size*.16)); for(const points of rails)base.line(points,THEME.track,Math.max(2,size*.078)); if(state.path) p.line(routePoints(state.path,view),state.path.status==='exit'?THEME.primary:THEME.warning,Math.max(4,size*.12)); 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=>t.id!==state.animation?.route.tile.id).sort((a,b)=>center(a,view).y-center(b,view).y)) { const c=center(tile,view); if(tile.id===state.hint) {p.circle(c.x,c.y,size*1.13,THEME.reward);p.circle(c.x,c.y,size*1.03,THEME.warningSurface);} let dx=0; if(tile.id===state.shake) dx=Math.sin(state.shakeTime*38)*5*(1-state.shakeTime); let dy=0; if(tile.id===blocker?.id){ dx+=Math.cos(impactAngle)*phase.kick*size*.1;dy+=Math.sin(impactAngle)*phase.kick*size*.1; if(phase.impact>0){const ring=hexPoints(c.x+dx,c.y+dy,size*1.04);p.line([...ring,ring[0]],THEME.warning,2.5);} } drawTile(p,c.x+dx,c.y+dy,size,tile); } if(state.animation) { const {route,progress}=state.animation; const blocked=route.status!=='exit'; const loc=pointAlong(movingPath,phase?phase.distance:blocked?Math.sin(progress*Math.PI):progress); drawTile(p,loc.x,loc.y,size,route.tile,loc.angle,phase?1-phase.impact*.045:!blocked&&progress>.9?Math.max(.2,(1-progress)/.1):1); if(target&&phase.impact>0){ const x=(contact.x+target.x)/2,y=(contact.y+target.y)/2; for(const sign of [-1,1]){const a=impactAngle+sign*Math.PI/2,d=size*(.13+phase.impact*.12);p.line([{x:x+Math.cos(a)*d,y:y+Math.sin(a)*d},{x:x+Math.cos(a)*(d+size*.15),y:y+Math.sin(a)*(d+size*.15)}],THEME.warning,2.5);} } } } export class CanvasPainter { constructor(context) { this.ctx=context; this.atlas=null; } sprite(index,x,y,r) { if(!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();} }