fertig-classic-games/src/games/totalannihilation/TAArt.js

845 lines
34 KiB
JavaScript

// Total Annihilation — procedural art fallback.
//
// Every spritesheet declared in data/totalannihilation-artwork.json is optional. If its
// `path` is null (or the PNG 404s), we paint a canvas stand-in with the identical frame
// layout, so the view never knows the difference and the game is fully playable with zero
// art files present.
//
// The one structural change from the Advance Wars original this is modelled on
// (AdvanceWarsMapView.js ensureSheets/PROC_PAINTERS): painters are keyed by the sheet's
// `kind`, NOT by its name. That is what makes adding a third army or a fourth terrain theme
// a pure JSON edit — a new sheet with kind:"unit" automatically gets the unit painter.
const BODY = '#dcdcdc'; // neutral grey — the runtime army tint multiplies over this
const BODY_DARK = '#a8adb5';
const OUTLINE = '#26262e';
const ACCENT = '#8f939b';
const GLASS = '#3a4450';
const HOT = '#ffd27a';
/** Deterministic per-frame PRNG so speckles are stable across reloads. */
function frameRng(seed) {
let a = (seed * 2654435761) >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), a | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Create a canvas texture laid out as a spritesheet and register its frames.
* `at(frame, draw)` translates the context to that frame's origin; `finish()` uploads.
*/
function mkCanvasSheet(scene, key, wantW, wantH, wantCols, wantCount) {
// Guard every dimension. A NaN anywhere here reaches createCanvas as a zero-height texture
// and surfaces as an opaque WebGL "no canvas" error a long way from the actual mistake.
const size = (v, fallback) => (Number.isFinite(v) && v > 0 ? Math.floor(v) : fallback);
const frameW = size(wantW, 64);
const frameH = size(wantH, 64);
const cols = size(wantCols, 8);
const count = size(wantCount, 1);
if (frameW !== wantW || frameH !== wantH || cols !== wantCols || count !== wantCount) {
console.warn(`[TAArt] sheet "${key}" has a bad layout (${wantW}x${wantH}, cols ${wantCols}, `
+ `frames ${wantCount}) — falling back to ${frameW}x${frameH}, cols ${cols}, frames ${count}`);
}
const rows = Math.max(1, Math.ceil(count / cols));
const tex = scene.textures.createCanvas(key, cols * frameW, rows * frameH);
const ctx = tex.getContext();
return {
ctx,
at(frame, draw) {
const fx = (frame % cols) * frameW;
const fy = ((frame / cols) | 0) * frameH;
ctx.save();
ctx.translate(fx, fy);
draw(ctx, frameW, frameH);
ctx.restore();
},
finish() {
tex.refresh();
for (let f = 0; f < count; f++) {
const fx = (f % cols) * frameW;
const fy = ((f / cols) | 0) * frameH;
tex.add(f, 0, fx, fy, frameW, frameH);
}
return tex;
},
};
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function fillStroke(ctx, fill, stroke = OUTLINE, lw = 2) {
ctx.fillStyle = fill; ctx.fill();
if (stroke) { ctx.lineWidth = lw; ctx.strokeStyle = stroke; ctx.stroke(); }
}
// ---------------------------------------------------------------------------
// Unit shapes — all drawn FACING RIGHT (+x), centred, inside a rotation-safe circle
// ---------------------------------------------------------------------------
const UNIT_SHAPES = {
commander(ctx, S) {
const c = S / 2;
// legs behind
ctx.save(); ctx.translate(c, c);
ctx.fillStyle = BODY_DARK; ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2;
for (const sy of [-9, 9]) {
roundRect(ctx, -14, sy - 4, 14, 8, 3); ctx.fill(); ctx.stroke();
}
// torso
roundRect(ctx, -11, -11, 24, 22, 6); fillStroke(ctx, BODY);
// shoulder pods
for (const sy of [-12, 12]) {
ctx.beginPath(); ctx.arc(-2, sy, 5.5, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
}
// visor
ctx.fillStyle = GLASS;
roundRect(ctx, 6, -5, 6, 10, 2); ctx.fill();
// nanolathe arm
ctx.strokeStyle = ACCENT; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(10, -3); ctx.lineTo(21, -3); ctx.stroke();
ctx.fillStyle = HOT;
ctx.beginPath(); ctx.arc(22, -3, 2.5, 0, Math.PI * 2); ctx.fill();
ctx.restore();
},
commanderTurret(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
ctx.beginPath(); ctx.arc(0, 0, 7, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
roundRect(ctx, 5, -4.5, 17, 9, 3); fillStroke(ctx, BODY);
ctx.strokeStyle = '#8ad4ff'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(21, 0, 5, -0.9, 0.9); ctx.stroke();
ctx.restore();
},
infantry(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
roundRect(ctx, -8, -5, 16, 10, 5); fillStroke(ctx, BODY);
ctx.beginPath(); ctx.arc(1, 0, 4.5, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(4, -2); ctx.lineTo(14, -2); ctx.stroke();
ctx.restore();
},
sniper(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
roundRect(ctx, -9, -4, 17, 8, 4); fillStroke(ctx, BODY);
ctx.beginPath(); ctx.arc(0, 0, 4, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(3, -2); ctx.lineTo(20, -2); ctx.stroke();
ctx.fillStyle = GLASS;
ctx.beginPath(); ctx.arc(6, -4.5, 2.2, 0, Math.PI * 2); ctx.fill();
ctx.restore();
},
rocketTrooper(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
roundRect(ctx, -8, -5, 16, 10, 5); fillStroke(ctx, BODY);
ctx.beginPath(); ctx.arc(1, 0, 4.5, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
// shoulder-mounted launch tube, angled up off the body line so it reads distinct from
// the rifle silhouette even at a glance.
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 4.5;
ctx.beginPath(); ctx.moveTo(2, -3); ctx.lineTo(17, -8); ctx.stroke();
ctx.strokeStyle = ACCENT; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(2, -3); ctx.lineTo(17, -8); ctx.stroke();
ctx.fillStyle = HOT;
ctx.beginPath(); ctx.arc(17, -8, 2.2, 0, Math.PI * 2); ctx.fill();
ctx.restore();
},
jeep(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
// wheels first, so the body overlaps them
ctx.fillStyle = '#2f3238';
for (const [wx, wy] of [[-8, -9], [8, -9], [-8, 9], [8, 9]]) {
roundRect(ctx, wx - 4, wy - 3, 8, 6, 2.5); ctx.fill();
}
roundRect(ctx, -13, -8, 26, 16, 4); fillStroke(ctx, BODY);
// windscreen wedge
ctx.fillStyle = GLASS;
ctx.beginPath(); ctx.moveTo(4, -6); ctx.lineTo(12, -3); ctx.lineTo(12, 3); ctx.lineTo(4, 6);
ctx.closePath(); ctx.fill();
ctx.restore();
},
jeepTurret(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
ctx.beginPath(); ctx.arc(0, 0, 4.5, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(3, 0); ctx.lineTo(14, 0); ctx.stroke();
ctx.restore();
},
tank(ctx, S) { tankHull(ctx, S, 34, 24); },
rockettank(ctx, S) { tankHull(ctx, S, 36, 24); },
tankTurret(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
// trapezoid turret
ctx.beginPath();
ctx.moveTo(-8, -8); ctx.lineTo(7, -6); ctx.lineTo(7, 6); ctx.lineTo(-8, 8);
ctx.closePath(); fillStroke(ctx, BODY);
ctx.beginPath(); ctx.arc(-1, 0, 4, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(5, 0); ctx.lineTo(24, 0); ctx.stroke();
ctx.strokeStyle = BODY_DARK; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(5, 0); ctx.lineTo(24, 0); ctx.stroke();
ctx.restore();
},
rockettankTurret(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
roundRect(ctx, -8, -9, 18, 18, 3); fillStroke(ctx, BODY);
ctx.fillStyle = '#2f3238';
for (const [tx, ty] of [[3, -4.5], [3, 4.5], [-2, -4.5], [-2, 4.5]]) {
ctx.beginPath(); ctx.arc(tx, ty, 2.6, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
},
// Unarmed — no turret frame. The hull is the same tread-vehicle base as the combat tanks,
// topped with a folded nanolathe crane instead of a gun so it reads as a builder at a glance.
constructor(ctx, S) {
tankHull(ctx, S, 32, 26);
const c = S / 2;
ctx.save(); ctx.translate(c, c);
roundRect(ctx, -6, -8, 14, 16, 3); fillStroke(ctx, BODY_DARK);
ctx.strokeStyle = ACCENT; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(2, 0); ctx.lineTo(15, 0); ctx.stroke();
ctx.fillStyle = HOT;
ctx.beginPath(); ctx.arc(16, 0, 2.5, 0, Math.PI * 2); ctx.fill();
ctx.restore();
},
// Aircraft are drawn as a plan-view airframe: swept wings well behind the nose, a tail
// plane, and no tracks or wheels anywhere. That silhouette is the only thing telling the
// player at a glance that a unit is in a layer their tanks cannot shoot at.
fighter(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
// swept main wing
ctx.beginPath();
ctx.moveTo(2, 0); ctx.lineTo(-8, -17); ctx.lineTo(-14, -17); ctx.lineTo(-6, 0);
ctx.lineTo(-14, 17); ctx.lineTo(-8, 17);
ctx.closePath(); fillStroke(ctx, BODY_DARK);
// fuselage, nose to the right
ctx.beginPath();
ctx.moveTo(21, 0); ctx.lineTo(6, -6); ctx.lineTo(-16, -5);
ctx.lineTo(-16, 5); ctx.lineTo(6, 6);
ctx.closePath(); fillStroke(ctx, BODY);
// tail plane
ctx.beginPath();
ctx.moveTo(-13, 0); ctx.lineTo(-19, -9); ctx.lineTo(-21, -9);
ctx.lineTo(-21, 9); ctx.lineTo(-19, 9);
ctx.closePath(); fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
// canopy
ctx.fillStyle = GLASS;
ctx.beginPath(); ctx.ellipse(6, 0, 5, 3.2, 0, 0, Math.PI * 2); ctx.fill();
// exhaust
ctx.fillStyle = HOT;
ctx.beginPath(); ctx.arc(-17, 0, 2.4, 0, Math.PI * 2); ctx.fill();
ctx.restore();
},
bomber(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
// long straight wing — the visual opposite of the fighter's swept one
roundRect(ctx, -8, -24, 13, 48, 4); fillStroke(ctx, BODY_DARK);
// engine nacelles out on the wing
ctx.fillStyle = '#3b3f47';
for (const sy of [-16, 16]) {
roundRect(ctx, -9, sy - 4, 16, 8, 3); ctx.fill();
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 1.5; ctx.stroke();
}
// fuselage
ctx.beginPath();
ctx.moveTo(25, 0); ctx.lineTo(12, -8); ctx.lineTo(-20, -7);
ctx.lineTo(-20, 7); ctx.lineTo(12, 8);
ctx.closePath(); fillStroke(ctx, BODY);
// tail plane
roundRect(ctx, -22, -12, 6, 24, 3); fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
// glazed nose
ctx.fillStyle = GLASS;
ctx.beginPath(); ctx.ellipse(14, 0, 6, 4.5, 0, 0, Math.PI * 2); ctx.fill();
// bomb bay hatch down the belly
ctx.fillStyle = '#2f3238';
ctx.fillRect(-6, -4, 14, 8);
ctx.strokeStyle = HOT; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(1, -4); ctx.lineTo(1, 4); ctx.stroke();
ctx.restore();
},
// A builder with no tracks: a skirted hull over a plenum, so it reads as the thing that can
// cross the water a tracked Construction Vehicle has to drive around.
hoverConstructor(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
// inflated skirt
ctx.beginPath(); ctx.ellipse(0, 0, 22, 15, 0, 0, Math.PI * 2);
fillStroke(ctx, '#3b3f47', OUTLINE, 2);
ctx.beginPath(); ctx.ellipse(0, 0, 18, 11.5, 0, 0, Math.PI * 2);
fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
// hull
roundRect(ctx, -13, -8, 26, 16, 5); fillStroke(ctx, BODY);
// cab
ctx.fillStyle = GLASS;
roundRect(ctx, 3, -5, 7, 10, 2); ctx.fill();
// nanolathe arm, same language as the tracked constructor
ctx.strokeStyle = ACCENT; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(8, 0); ctx.lineTo(19, 0); ctx.stroke();
ctx.fillStyle = HOT;
ctx.beginPath(); ctx.arc(20, 0, 2.5, 0, Math.PI * 2); ctx.fill();
// lift fans
ctx.fillStyle = '#4a4e56';
for (const fx of [-8, 8]) {
ctx.beginPath(); ctx.arc(fx, 0, 3, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
},
};
function tankHull(ctx, S, len, wid) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
// tread rails
ctx.fillStyle = '#33363c'; ctx.strokeStyle = OUTLINE; ctx.lineWidth = 1.5;
for (const sy of [-wid / 2, wid / 2 - 6]) {
roundRect(ctx, -len / 2, sy, len, 6, 2); ctx.fill(); ctx.stroke();
}
ctx.fillStyle = '#4a4e56';
for (let i = 0; i < 6; i++) {
const bx = -len / 2 + 2 + i * ((len - 4) / 6);
ctx.fillRect(bx, -wid / 2 + 1, 3, 4);
ctx.fillRect(bx, wid / 2 - 5, 3, 4);
}
// hull
roundRect(ctx, -len / 2 + 3, -wid / 2 + 5, len - 6, wid - 10, 4); fillStroke(ctx, BODY);
// glacis plate
ctx.fillStyle = BODY_DARK;
ctx.beginPath();
ctx.moveTo(len / 2 - 3, -wid / 2 + 6); ctx.lineTo(len / 2 - 3, wid / 2 - 6);
ctx.lineTo(len / 2 - 9, wid / 2 - 6); ctx.lineTo(len / 2 - 9, -wid / 2 + 6);
ctx.closePath(); ctx.fill();
ctx.restore();
}
// ---------------------------------------------------------------------------
// Structure shapes — NOT rotated, drawn to fill the cell
// ---------------------------------------------------------------------------
const STRUCT_SHAPES = {
energyGen(ctx, S) {
const p = S * 0.12, d = S - p * 2;
roundRect(ctx, p, p, d, d, S * 0.10); fillStroke(ctx, BODY, OUTLINE, 3);
// vent slots
ctx.fillStyle = '#3b3f47';
for (let i = 0; i < 4; i++) ctx.fillRect(p + d * 0.14, p + d * (0.16 + i * 0.18), d * 0.24, d * 0.10);
// lightning glyph
ctx.fillStyle = HOT;
ctx.beginPath();
ctx.moveTo(p + d * 0.66, p + d * 0.14);
ctx.lineTo(p + d * 0.48, p + d * 0.52);
ctx.lineTo(p + d * 0.62, p + d * 0.52);
ctx.lineTo(p + d * 0.46, p + d * 0.90);
ctx.lineTo(p + d * 0.82, p + d * 0.44);
ctx.lineTo(p + d * 0.66, p + d * 0.44);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2; ctx.stroke();
},
massGen(ctx, S) {
const c = S / 2, r = S * 0.40;
// hex pad
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2 + Math.PI / 6;
const x = c + Math.cos(a) * r, y = c + Math.sin(a) * r;
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
ctx.closePath(); fillStroke(ctx, BODY, OUTLINE, 3);
// concentric rings
ctx.strokeStyle = ACCENT; ctx.lineWidth = 2.5;
for (const rr of [r * 0.62, r * 0.44, r * 0.26]) {
ctx.beginPath(); ctx.arc(c, c, rr, 0, Math.PI * 2); ctx.stroke();
}
// drill spindle
ctx.beginPath(); ctx.arc(c, c, r * 0.16, 0, Math.PI * 2); fillStroke(ctx, '#3b3f47', OUTLINE, 2);
// corner anchors
ctx.fillStyle = BODY_DARK;
for (const [ax, ay] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
roundRect(ctx, c + ax * r * 0.74 - 5, c + ay * r * 0.74 - 5, 10, 10, 2); ctx.fill();
}
},
barracks(ctx, S) {
const p = S * 0.10, w = S - p * 2, h = S - p * 2;
roundRect(ctx, p, p, w, h, S * 0.06); fillStroke(ctx, BODY, OUTLINE, 3);
// corrugated roof
ctx.strokeStyle = BODY_DARK; ctx.lineWidth = 2;
for (let i = 1; i < 7; i++) {
const y = p + (h * i) / 8;
ctx.beginPath(); ctx.moveTo(p + 4, y); ctx.lineTo(p + w - 4, y); ctx.stroke();
}
// arched door at the bay end
ctx.fillStyle = '#2f3238';
ctx.beginPath();
const dw = w * 0.34, dx = p + w / 2 - dw / 2, dy = p + h * 0.62;
ctx.moveTo(dx, p + h - 4);
ctx.lineTo(dx, dy + dw / 2);
ctx.arc(dx + dw / 2, dy + dw / 2, dw / 2, Math.PI, 0);
ctx.lineTo(dx + dw, p + h - 4);
ctx.closePath(); ctx.fill();
// marching chevron over the door
ctx.strokeStyle = HOT; ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(p + w / 2 - 10, dy - 12); ctx.lineTo(p + w / 2, dy - 4); ctx.lineTo(p + w / 2 + 10, dy - 12);
ctx.stroke();
},
vehiclePlant(ctx, S) {
const p = S * 0.07, w = S - p * 2, h = S - p * 2;
roundRect(ctx, p, p, w, h, S * 0.05); fillStroke(ctx, BODY, OUTLINE, 3);
// gantry rail across the roof
ctx.fillStyle = BODY_DARK;
ctx.fillRect(p + 4, p + h * 0.12, w - 8, h * 0.09);
ctx.fillStyle = '#3b3f47';
for (let i = 0; i < 5; i++) ctx.fillRect(p + 8 + i * ((w - 16) / 5), p + h * 0.12, 5, h * 0.09);
// roll-up bay door with slats
const dw = w * 0.62, dx = p + w / 2 - dw / 2, dy = p + h * 0.38, dh = h * 0.44;
ctx.fillStyle = '#2f3238';
ctx.fillRect(dx, dy, dw, dh);
ctx.strokeStyle = '#4a4e56'; ctx.lineWidth = 2;
for (let i = 1; i < 6; i++) {
const y = dy + (dh * i) / 6;
ctx.beginPath(); ctx.moveTo(dx + 2, y); ctx.lineTo(dx + dw - 2, y); ctx.stroke();
}
// floor chevron pointing out of the mouth — also shows where units will spawn
ctx.strokeStyle = HOT; ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(p + w / 2 - 14, p + h - 20); ctx.lineTo(p + w / 2, p + h - 8); ctx.lineTo(p + w / 2 + 14, p + h - 20);
ctx.stroke();
},
// Defences read as a squat base plus an obvious muzzle, so a glance at the minimap edge of a
// base tells you which structures shoot back. Both are drawn pointing UP: a tower's mount
// traverses freely in the sim, so its art never rotates and must not imply a firing arc.
laserTower(ctx, S) {
const c = S / 2;
// octagonal plinth
ctx.beginPath();
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2 + Math.PI / 8;
const x = c + Math.cos(a) * S * 0.36, y = c + Math.sin(a) * S * 0.36;
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
ctx.closePath(); fillStroke(ctx, BODY, OUTLINE, 3);
// sunken collar
ctx.beginPath(); ctx.arc(c, c, S * 0.24, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK, OUTLINE, 2);
// emitter housing with the barrel pointing up
roundRect(ctx, c - S * 0.11, c - S * 0.30, S * 0.22, S * 0.42, S * 0.05);
fillStroke(ctx, BODY, OUTLINE, 2.5);
ctx.fillStyle = '#2f3238';
ctx.fillRect(c - S * 0.035, c - S * 0.34, S * 0.07, S * 0.16);
// lens glow
ctx.beginPath(); ctx.arc(c, c - S * 0.30, S * 0.055, 0, Math.PI * 2);
fillStroke(ctx, ACCENT, OUTLINE, 1.5);
// capacitor studs
ctx.fillStyle = HOT;
for (const ax of [-1, 1]) {
ctx.beginPath(); ctx.arc(c + ax * S * 0.26, c + S * 0.16, S * 0.035, 0, Math.PI * 2); ctx.fill();
}
},
missileLauncher(ctx, S) {
const p = S * 0.14, w = S - p * 2, h = S - p * 2;
// bunker base
roundRect(ctx, p, p, w, h, S * 0.08); fillStroke(ctx, BODY, OUTLINE, 3);
// blast deflector ribs down the flanks
ctx.fillStyle = BODY_DARK;
for (let i = 0; i < 3; i++) {
ctx.fillRect(p + 4, p + h * (0.24 + i * 0.22), w * 0.14, h * 0.12);
ctx.fillRect(p + w - 4 - w * 0.14, p + h * (0.24 + i * 0.22), w * 0.14, h * 0.12);
}
// launch rack: four cells, missile noses showing
const rw = w * 0.46, rh = h * 0.60, rx = p + w / 2 - rw / 2, ry = p + h * 0.20;
roundRect(ctx, rx, ry, rw, rh, S * 0.04); fillStroke(ctx, '#2f3238', OUTLINE, 2.5);
for (let i = 0; i < 4; i++) {
const cx = rx + rw * (0.25 + (i % 2) * 0.5);
const cy = ry + rh * (0.26 + ((i / 2) | 0) * 0.44);
ctx.beginPath(); ctx.arc(cx, cy, Math.min(rw, rh) * 0.14, 0, Math.PI * 2);
fillStroke(ctx, HOT, OUTLINE, 1.5);
}
// hazard stripe along the bottom lip
ctx.strokeStyle = HOT; ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(p + w * 0.24, p + h - 8); ctx.lineTo(p + w * 0.76, p + h - 8);
ctx.stroke();
},
// The 4x4 footprint's extra width goes to a SECOND bay door — twice the production, which
// is the whole pitch of the "advanced" tier — plus a trim line the basic plant doesn't have.
advancedVehiclePlant(ctx, S) {
const p = S * 0.06, w = S - p * 2, h = S - p * 2;
roundRect(ctx, p, p, w, h, S * 0.04); fillStroke(ctx, BODY, OUTLINE, 3);
// twin gantry rails across the roof
ctx.fillStyle = BODY_DARK;
ctx.fillRect(p + 4, p + h * 0.10, w - 8, h * 0.05);
ctx.fillRect(p + 4, p + h * 0.18, w - 8, h * 0.05);
ctx.fillStyle = '#3b3f47';
for (let i = 0; i < 7; i++) ctx.fillRect(p + 8 + i * ((w - 16) / 7), p + h * 0.10, 4, h * 0.13);
// two roll-up bay doors side by side, each slatted like the basic plant's single door
const dw = w * 0.30, dy = p + h * 0.36, dh = h * 0.46;
const doorX = [p + w * 0.16, p + w * 0.54];
for (const dx of doorX) {
ctx.fillStyle = '#2f3238';
ctx.fillRect(dx, dy, dw, dh);
ctx.strokeStyle = '#4a4e56'; ctx.lineWidth = 2;
for (let i = 1; i < 6; i++) {
const y = dy + (dh * i) / 6;
ctx.beginPath(); ctx.moveTo(dx + 2, y); ctx.lineTo(dx + dw - 2, y); ctx.stroke();
}
}
// accent trim along the roofline marks the upgraded facility
ctx.strokeStyle = ACCENT; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(p + 6, p + 6); ctx.lineTo(p + w - 6, p + 6); ctx.stroke();
// floor chevron out of each mouth, same language as the basic plant
ctx.strokeStyle = HOT; ctx.lineWidth = 4;
for (const dx of doorX) {
const mx = dx + dw / 2;
ctx.beginPath();
ctx.moveTo(mx - 12, p + h - 18); ctx.lineTo(mx, p + h - 8); ctx.lineTo(mx + 12, p + h - 18);
ctx.stroke();
}
},
// An apron rather than a shed: the runway IS the building, so it reads as air production
// from the minimap without needing a door like the ground factories have.
airfield(ctx, S) {
const p = S * 0.06, w = S - p * 2, h = S - p * 2;
roundRect(ctx, p, p, w, h, S * 0.04); fillStroke(ctx, BODY_DARK, OUTLINE, 3);
// runway strip down the middle, pointing at the spawn edge (bottom)
const rw = w * 0.34, rx = p + w / 2 - rw / 2;
ctx.fillStyle = '#2f3238';
ctx.fillRect(rx, p + h * 0.10, rw, h * 0.90);
// centreline dashes
ctx.strokeStyle = '#d8dde4'; ctx.lineWidth = 2.5;
ctx.setLineDash([h * 0.07, h * 0.05]);
ctx.beginPath();
ctx.moveTo(p + w / 2, p + h * 0.14); ctx.lineTo(p + w / 2, p + h * 0.96);
ctx.stroke();
ctx.setLineDash([]);
// hangars flanking the strip
ctx.fillStyle = BODY;
for (const hx of [p + w * 0.06, p + w * 0.72]) {
roundRect(ctx, hx, p + h * 0.22, w * 0.22, h * 0.34, S * 0.03);
fillStroke(ctx, BODY, OUTLINE, 2);
}
// control tower, one corner
ctx.beginPath(); ctx.arc(p + w * 0.17, p + h * 0.78, S * 0.07, 0, Math.PI * 2);
fillStroke(ctx, BODY, OUTLINE, 2);
ctx.fillStyle = GLASS;
ctx.beginPath(); ctx.arc(p + w * 0.17, p + h * 0.78, S * 0.04, 0, Math.PI * 2); ctx.fill();
// approach lights along the threshold
ctx.fillStyle = HOT;
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.arc(p + w * (0.36 + i * 0.14), p + h * 0.06, S * 0.022, 0, Math.PI * 2);
ctx.fill();
}
},
};
// ---------------------------------------------------------------------------
// Painters, keyed by sheet kind
// ---------------------------------------------------------------------------
const PROC_PAINTERS = {
/** One frame per unit hull, plus one per turret. Driven entirely off the rules. */
unit(scene, key, spec, rules) {
const defs = rules.units.filter((u) => u.sheetSlot === 'unitSheet');
let count = 0;
for (const d of defs) count = Math.max(count, d.frame + 1, (d.turretFrame ?? -1) + 1);
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
for (const d of defs) {
const hull = UNIT_SHAPES[d.procShape];
if (hull) sh.at(d.frame, (ctx, S) => hull(ctx, S));
if (d.turretFrame != null) {
const turret = UNIT_SHAPES[`${d.procShape}Turret`];
if (turret) sh.at(d.turretFrame, (ctx, S) => turret(ctx, S));
}
}
return sh.finish();
},
/** Two frames per building: finished, then a translucent wireframe for the build site. */
structure(scene, key, spec, rules) {
const defs = rules.buildings.filter((b) => b.sheetSlot === 'structureSheet');
let count = 0;
for (const d of defs) count = Math.max(count, d.frame + 1, (d.buildFrame ?? -1) + 1);
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
for (const d of defs) {
const draw = STRUCT_SHAPES[d.procShape];
if (!draw) continue;
sh.at(d.frame, (ctx, S) => draw(ctx, S));
if (d.buildFrame != null) {
sh.at(d.buildFrame, (ctx, S) => {
ctx.globalAlpha = 0.35;
draw(ctx, S);
ctx.globalAlpha = 1;
});
}
}
return sh.finish();
},
/** One sheet per theme, coloured entirely from that theme's palette. */
terrain(scene, key, spec, rules, art, sheetName) {
const theme = Object.values(art.themes ?? {}).find((t) => t.sheet === sheetName);
const p = theme?.palette ?? {};
const F = art.terrainFrames ?? {};
// The JSON carries a `_comment` key alongside the frame numbers, so filter to actual
// numbers — one string in this list turns the max into NaN and the sheet into a
// zero-height canvas.
const frameNums = Object.values(F).filter((v) => Number.isFinite(v));
const count = Math.max(9, ...frameNums.map((v) => v + 1));
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
const speckle = (ctx, S, seed, color, n, r0, r1) => {
const rnd = frameRng(seed);
ctx.fillStyle = color;
for (let i = 0; i < n; i++) {
const x = rnd() * S, y = rnd() * S, r = r0 + rnd() * (r1 - r0);
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
}
};
const flat = (frame, color, seed, speckColor, n = 10) => sh.at(frame, (ctx, S) => {
ctx.fillStyle = color; ctx.fillRect(0, 0, S, S);
speckle(ctx, S, seed, speckColor, n, 1, 2.6);
});
flat(F.ground ?? 0, p.ground ?? '#5f7f3d', 11, p.speck ?? '#000', 10);
flat(F.groundAlt1 ?? 1, p.groundAlt1 ?? p.ground ?? '#6b8c45', 22, p.speck ?? '#000', 12);
flat(F.groundAlt2 ?? 2, p.groundAlt2 ?? p.ground ?? '#55743a', 33, p.speck ?? '#000', 8);
sh.at(F.rough ?? 3, (ctx, S) => {
ctx.fillStyle = p.rough ?? '#7d7048'; ctx.fillRect(0, 0, S, S);
speckle(ctx, S, 44, 'rgba(0,0,0,0.30)', 22, 1.5, 3.6);
speckle(ctx, S, 45, 'rgba(255,255,255,0.14)', 14, 1, 2.4);
});
sh.at(F.cliff ?? 4, (ctx, S) => {
ctx.fillStyle = p.cliff ?? '#4c4838'; ctx.fillRect(0, 0, S, S);
// lit top edge sells the height difference without a real height layer
ctx.fillStyle = p.cliffTop ?? '#6b6552';
ctx.fillRect(0, 0, S, S * 0.30);
ctx.strokeStyle = 'rgba(0,0,0,0.45)'; ctx.lineWidth = 2;
const rnd = frameRng(55);
for (let i = 0; i < 5; i++) {
const x = rnd() * S;
ctx.beginPath(); ctx.moveTo(x, S * 0.30); ctx.lineTo(x + (rnd() - 0.5) * 12, S); ctx.stroke();
}
});
const wave = (frame, color, seed) => sh.at(frame, (ctx, S) => {
ctx.fillStyle = color; ctx.fillRect(0, 0, S, S);
const rnd = frameRng(seed);
ctx.strokeStyle = 'rgba(255,255,255,0.16)'; ctx.lineWidth = 2;
for (let i = 0; i < 3; i++) {
const y = rnd() * S;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.quadraticCurveTo(S / 2, y + (rnd() - 0.5) * 10, S, y);
ctx.stroke();
}
});
wave(F.water ?? 5, p.water ?? '#2f5f9e', 66);
wave(F.waterDeep ?? 6, p.waterDeep ?? '#1d3f70', 77);
sh.at(F.metal ?? 7, (ctx, S) => {
ctx.fillStyle = p.metal ?? '#9aa3ab'; ctx.fillRect(0, 0, S, S);
ctx.fillStyle = 'rgba(0,0,0,0.35)';
for (let ry = 0; ry < 3; ry++) {
for (let rx = 0; rx < 3; rx++) {
ctx.beginPath();
ctx.arc(S * (0.22 + rx * 0.28), S * (0.22 + ry * 0.28), 2.6, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.strokeStyle = 'rgba(255,255,255,0.20)'; ctx.lineWidth = 2;
ctx.strokeRect(3, 3, S - 6, S - 6);
});
flat(F.road ?? 8, p.road ?? '#8b8578', 88, 'rgba(0,0,0,0.25)', 6);
return sh.finish();
},
/** Build-menu glyphs: a dark plate plus a shrunken copy of the unit/structure shape. */
icon(scene, key, spec, rules) {
const entries = [];
for (const d of [...rules.units, ...rules.buildings]) {
if (d.icon != null) entries.push({ frame: d.icon, shape: d.procShape, building: !!d.isBuilding });
}
const cmd = rules.commandIcons ?? {};
let count = 0;
for (const e of entries) count = Math.max(count, e.frame + 1);
for (const f of Object.values(cmd)) count = Math.max(count, f + 1);
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
const plate = (ctx, S) => {
roundRect(ctx, 2, 2, S - 4, S - 4, 6);
ctx.fillStyle = '#1b2029'; ctx.fill();
ctx.strokeStyle = '#3d4753'; ctx.lineWidth = 2; ctx.stroke();
};
for (const e of entries) {
sh.at(e.frame, (ctx, S) => {
plate(ctx, S);
const shape = e.building ? STRUCT_SHAPES[e.shape] : UNIT_SHAPES[e.shape];
if (!shape) return;
ctx.save();
if (e.building) {
ctx.translate(S * 0.12, S * 0.12);
ctx.scale(0.76, 0.76);
shape(ctx, S);
} else {
// Units are drawn facing right around the cell centre; turn them 45 degrees so a
// build icon reads as a vehicle rather than as a sideways smear.
ctx.translate(S / 2, S / 2); ctx.rotate(-Math.PI / 4); ctx.scale(0.95, 0.95);
ctx.translate(-S / 2, -S / 2);
shape(ctx, S);
}
ctx.restore();
});
}
const glyph = (frame, draw) => { if (frame != null) sh.at(frame, (ctx, S) => { plate(ctx, S); draw(ctx, S); }); };
const stroke = (ctx, color = '#cfe3ff', lw = 3) => { ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.stroke(); };
glyph(cmd.move, (ctx, S) => {
ctx.beginPath(); ctx.moveTo(S * 0.28, S * 0.72); ctx.lineTo(S * 0.72, S * 0.28); stroke(ctx);
ctx.beginPath(); ctx.moveTo(S * 0.72, S * 0.28); ctx.lineTo(S * 0.52, S * 0.30);
ctx.moveTo(S * 0.72, S * 0.28); ctx.lineTo(S * 0.70, S * 0.48); stroke(ctx);
});
glyph(cmd.attack, (ctx, S) => {
ctx.beginPath(); ctx.arc(S / 2, S / 2, S * 0.24, 0, Math.PI * 2); stroke(ctx, '#ff9a8a');
ctx.beginPath();
ctx.moveTo(S / 2, S * 0.16); ctx.lineTo(S / 2, S * 0.34);
ctx.moveTo(S / 2, S * 0.66); ctx.lineTo(S / 2, S * 0.84);
ctx.moveTo(S * 0.16, S / 2); ctx.lineTo(S * 0.34, S / 2);
ctx.moveTo(S * 0.66, S / 2); ctx.lineTo(S * 0.84, S / 2);
stroke(ctx, '#ff9a8a', 2.5);
});
glyph(cmd.attackMove, (ctx, S) => {
ctx.beginPath(); ctx.arc(S / 2, S / 2, S * 0.20, 0, Math.PI * 2); stroke(ctx, '#ffc98a', 2.5);
ctx.beginPath(); ctx.moveTo(S * 0.20, S * 0.80); ctx.lineTo(S * 0.80, S * 0.20); stroke(ctx, '#ffc98a', 2.5);
});
glyph(cmd.stop, (ctx, S) => {
roundRect(ctx, S * 0.30, S * 0.30, S * 0.40, S * 0.40, 3);
ctx.fillStyle = '#ff9a8a'; ctx.fill();
});
glyph(cmd.hold, (ctx, S) => {
ctx.beginPath();
ctx.moveTo(S / 2, S * 0.20); ctx.lineTo(S * 0.76, S * 0.34);
ctx.lineTo(S * 0.76, S * 0.58); ctx.lineTo(S / 2, S * 0.80);
ctx.lineTo(S * 0.24, S * 0.58); ctx.lineTo(S * 0.24, S * 0.34);
ctx.closePath(); stroke(ctx, '#9ee8b0');
});
glyph(cmd.patrol, (ctx, S) => {
ctx.beginPath(); ctx.arc(S / 2, S / 2, S * 0.22, 0.6, Math.PI * 1.7); stroke(ctx);
ctx.beginPath(); ctx.moveTo(S * 0.70, S * 0.62); ctx.lineTo(S * 0.78, S * 0.72);
ctx.lineTo(S * 0.62, S * 0.76); stroke(ctx, '#cfe3ff', 2.5);
});
glyph(cmd.guard, (ctx, S) => {
ctx.beginPath();
ctx.moveTo(S / 2, S * 0.18); ctx.lineTo(S * 0.78, S * 0.32);
ctx.lineTo(S * 0.70, S * 0.72); ctx.lineTo(S / 2, S * 0.84);
ctx.lineTo(S * 0.30, S * 0.72); ctx.lineTo(S * 0.22, S * 0.32);
ctx.closePath(); ctx.fillStyle = 'rgba(158,232,176,0.25)'; ctx.fill(); stroke(ctx, '#9ee8b0', 2.5);
});
glyph(cmd.assist, (ctx, S) => {
ctx.strokeStyle = '#8ad4ff'; ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(S / 2, S * 0.24); ctx.lineTo(S / 2, S * 0.76);
ctx.moveTo(S * 0.24, S / 2); ctx.lineTo(S * 0.76, S / 2);
ctx.stroke();
});
// A wrench, deliberately unlike assist's plus sign: assist pours build power into something
// unfinished, repair puts hit points back into something already standing.
glyph(cmd.repair, (ctx, S) => {
ctx.strokeStyle = '#8affd0'; ctx.lineWidth = 5; ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(S * 0.32, S * 0.68); ctx.lineTo(S * 0.66, S * 0.34);
ctx.stroke();
ctx.lineCap = 'butt';
ctx.beginPath(); ctx.arc(S * 0.70, S * 0.30, S * 0.13, 0.6, 5.2);
ctx.lineWidth = 4; ctx.stroke();
ctx.fillStyle = '#8affd0';
ctx.beginPath(); ctx.arc(S * 0.30, S * 0.70, S * 0.06, 0, Math.PI * 2); ctx.fill();
});
// A pennant on a post — where finished units are told to go.
glyph(cmd.rally, (ctx, S) => {
ctx.strokeStyle = '#cfe3ff'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(S * 0.34, S * 0.18); ctx.lineTo(S * 0.34, S * 0.82); ctx.stroke();
ctx.fillStyle = '#ffd27a';
ctx.beginPath();
ctx.moveTo(S * 0.34, S * 0.20); ctx.lineTo(S * 0.76, S * 0.34); ctx.lineTo(S * 0.34, S * 0.48);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#26262e'; ctx.lineWidth = 2; ctx.stroke();
});
return sh.finish();
},
};
/**
* Resolve every declared sheet to a usable texture key, painting stand-ins as needed.
* @returns {{keys:Object<string,string>, procedural:string[]}} sheetName -> texture key
*/
export function ensureSheets(scene, rules, art) {
const keys = Object.create(null);
const procedural = [];
for (const [name, spec] of Object.entries(art.sheets ?? {})) {
if (spec.path && scene.textures.exists(spec.key)) { keys[name] = spec.key; continue; }
const procKey = `${spec.key}-proc`;
if (!scene.textures.exists(procKey)) {
const painter = PROC_PAINTERS[spec.kind];
if (!painter) {
console.warn(`[TAArt] sheet "${name}" has unknown kind "${spec.kind}" — skipping`);
continue;
}
painter(scene, procKey, spec, rules, art, name);
}
keys[name] = procKey;
procedural.push(name);
}
return { keys, procedural };
}
/** Frame dimensions of a resolved sheet, for sprite scaling. */
export function sheetFrameSize(art, sheetName) {
const s = art.sheets?.[sheetName];
return s ? { w: s.frameWidth, h: s.frameHeight } : { w: 64, h: 64 };
}
export { PROC_PAINTERS, UNIT_SHAPES, STRUCT_SHAPES, mkCanvasSheet, roundRect };