Files
HexTrails/src/renderer.js
T

105 lines
6.5 KiB
JavaScript

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<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=Math.max(4,size*.16);
const visualRadius=Math.max(0,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;
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();}
}