feat: add production campaigns, gameplay labs, and review documentation
This commit is contained in:
+31
-19
@@ -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--){
|
||||
|
||||
Reference in New Issue
Block a user