feat: add production campaigns, gameplay labs, and review documentation

This commit is contained in:
cabinw
2026-09-13 12:27:10 +08:00
parent d1dffa9338
commit 4644e975b0
214 changed files with 98994 additions and 134 deletions
+31 -19
View File
@@ -1,3 +1,5 @@
import { drawStyledToken } 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';
@@ -9,6 +11,7 @@ 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;
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]);
@@ -20,27 +23,34 @@ export function drawTile(p,x,y,size,tile,angle=-Math.PI/2+tile.dir*Math.PI/3,sca
}
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 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 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;
// 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);
// 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);}
for(const cell of sorted){const {x,y}=center(cell,view);base.polygon(hexPoints(x,y,size+.2),materials.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,size*.024));
base.line([...edge,edge[0]],materials.boardGrid,Math.max(1,size*.024));
}
const rails=[];
for(const cell of sorted) {
@@ -56,17 +66,20 @@ export function drawScene(p,level,tiles,view,state={}) {
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);
rails.push({points,primary:!state.trackEmphasis||state.trackEmphasis(cell,a,b)});
}
}
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.5,size*.10));
// 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));
for(const {points,primary} of rails)if(primary)base.line(points,materials.trackHalo,Math.max(4,size*.16));
for(const {points,primary} of rails)if(primary)base.line(points,materials.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);
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)) {
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);
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)){
@@ -76,13 +89,12 @@ export function drawScene(p,level,tiles,view,state={}) {
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){
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;
}
drawTile(p,c.x+dx,c.y+dy,size,visualTile(tile));
}
if(state.animation) {
const {route,progress}=state.animation;
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);
@@ -92,7 +104,7 @@ export function drawScene(p,level,tiles,view,state={}) {
}
if(target&&phase.impact>0){
const x=(contact.x+target.x)/2,y=(contact.y+target.y)/2;
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)}],THEME.track,Math.max(1.5,size*.055));}
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));}
}
}
}
@@ -126,7 +138,7 @@ export function drawExit(p,route,view,progress,tile,reduced=false){
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);
const color=tile.fill||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--){