Files
HexTrails/src/renderer.js
T
cabinwandClaude Opus 5 7607f48cb1 feat(levels): boards whose interior is packed and whose rim is left empty
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>
2026-09-15 00:23:18 +08:00

266 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);}
}