feat: expand campaign and refine puzzle visuals and controls

This commit is contained in:
cabinw
2026-09-09 11:02:45 +08:00
parent 9f8990b579
commit 70b021d4cc
66 changed files with 41108 additions and 10553 deletions
+51 -10
View File
@@ -1,11 +1,11 @@
import { THEME } from './design-tokens.js';
import { exitTiming, smoothMotion } 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=[THEME.tileBlue,THEME.tilePink,THEME.tileYellow,THEME.tilePurple];
const DARK=COLORS.map(()=>THEME.arrowDark);
export const COLORS=PIGMENTS.map(p=>p.color);
// 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;
@@ -16,10 +16,11 @@ export function drawTile(p,x,y,size,tile,angle=-Math.PI/2+tile.dir*Math.PI/3,sca
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);
p.polygon(arrow,PIGMENTS[tile.color]?.arrow||THEME.arrowDark);
}
export function drawScene(p,level,tiles,view,state={}) {
const {size}=view;
const visualTile=tile=>({...tile,color:pigmentIndex(state.theme?Math.max(0,level.tiles.findIndex(t=>t.id===tile.id)):tile.color,state.theme)});
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)};
@@ -39,7 +40,7 @@ export function drawScene(p,level,tiles,view,state={}) {
// 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));
base.line([...edge,edge[0]],THEME.boardGrid,Math.max(1,size*.024));
}
const rails=[];
for(const cell of sorted) {
@@ -59,7 +60,7 @@ export function drawScene(p,level,tiles,view,state={}) {
}
}
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));
for(const points of rails)base.line(points,THEME.track,Math.max(2.5,size*.10));
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);
@@ -68,6 +69,10 @@ export function drawScene(p,level,tiles,view,state={}) {
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);}
if(state.queued?.includes(tile.id)){
const ring=hexPoints(c.x,c.y,size*1.03);
p.line([...ring,ring[0]],THEME.primary,3);
}
let dx=0;
if(tile.id===state.shake) dx=Math.sin(state.shakeTime*38)*5*(1-state.shakeTime);
let dy=0;
@@ -75,13 +80,17 @@ export function drawScene(p,level,tiles,view,state={}) {
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);
drawTile(p,c.x+dx,c.y+dy,size,visualTile(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(!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?1-phase.impact*.045: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);}
@@ -91,7 +100,7 @@ export function drawScene(p,level,tiles,view,state={}) {
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;
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);
@@ -102,3 +111,35 @@ export class CanvasPainter {
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 travel=exitTiming(route).travelFraction;
if(progress<travel){
const at=pointAlong(points,smoothMotion(progress/travel));
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=COLORS[tile.color],dx=Math.cos(angle),dy=Math.sin(angle);
// 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);
}
}
}