**Update game icons assets**

Refresh game icons in both source (PSD) and exported (PNG) formats to reflect updated design.
This commit is contained in:
Brian Fertig 2026-07-24 16:01:13 -06:00
parent 94cc51b997
commit 93b4877c2d
13 changed files with 4667 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

View File

@ -0,0 +1,99 @@
{
"_readme": [
"Drop-in painted art for Total Annihilation (spec: src/games/totalannihilation/sprites.md).",
"Paint a sheet, drop the PNG under assets/images/totalannihilation/, set its `path` below and",
"reload — no code changes needed. Any sheet left path:null is rendered procedurally.",
"",
"`kind` selects which procedural painter stands in for an unpainted sheet, and it is the reason",
"adding a sheet here is a JSON-only change: painters are keyed by kind, never by sheet name.",
" unit — frameWidth == frameHeight, art drawn FACING RIGHT (+x) and CENTRED in the cell,",
" in neutral grey so the runtime army tint multiplies correctly. The engine rotates",
" the sprite, so keep the silhouette inside a circle of diameter frameWidth*0.7 or",
" the corners will clip as it turns.",
" structure — frameWidth == frameHeight, buildings are NOT rotated and NOT drawn facing right.",
" Two frames per building: the finished one and a translucent wireframe used while",
" it is still being nanolathed.",
" terrain — one sheet per theme. Frame order is fixed (see `terrainFrames` below) so any new",
" theme sheet drops straight in.",
" icon — build-menu glyphs, full colour, never tinted.",
"",
"`cols` only sizes the procedural stand-in canvas; a painted sheet is measured from the PNG.",
"Adding a THIRD ARMY = add two sheets here (kind unit + kind structure) and one `armies` entry",
"in data/totalannihilation-rules.json. Adding a THEME = one sheet here, one `themes` entry, and",
"the theme id in rules.skirmish.themes."
],
"terrainFrames": {
"_comment": "Fixed frame order every kind:terrain sheet must follow.",
"ground": 0, "groundAlt1": 1, "groundAlt2": 2, "rough": 3,
"cliff": 4, "water": 5, "waterDeep": 6, "metal": 7, "road": 8
},
"sheets": {
"arm-units": {
"key": "ta-arm-units", "path": null, "kind": "unit",
"frameWidth": 64, "frameHeight": 64, "cols": 8
},
"arm-structures": {
"key": "ta-arm-structures", "path": null, "kind": "structure",
"frameWidth": 128, "frameHeight": 128, "cols": 6
},
"core-units": {
"key": "ta-core-units", "path": null, "kind": "unit",
"frameWidth": 64, "frameHeight": 64, "cols": 8
},
"core-structures": {
"key": "ta-core-structures", "path": null, "kind": "structure",
"frameWidth": 128, "frameHeight": 128, "cols": 6
},
"terrain-grasslands": {
"key": "ta-terrain-grasslands", "path": null, "kind": "terrain",
"frameWidth": 64, "frameHeight": 64, "cols": 9
},
"terrain-snowfields": {
"key": "ta-terrain-snowfields", "path": null, "kind": "terrain",
"frameWidth": 64, "frameHeight": 64, "cols": 9
},
"terrain-tropics": {
"key": "ta-terrain-tropics", "path": null, "kind": "terrain",
"frameWidth": 64, "frameHeight": 64, "cols": 9
},
"icons": {
"key": "ta-icons", "path": null, "kind": "icon",
"frameWidth": 44, "frameHeight": 44, "cols": 10
}
},
"themes": {
"grasslands": {
"name": "Grasslands",
"sheet": "terrain-grasslands",
"palette": {
"ground": "#5f7f3d", "groundAlt1": "#6b8c45", "groundAlt2": "#55743a",
"rough": "#7d7048", "cliff": "#4c4838", "cliffTop": "#6b6552",
"water": "#2f5f9e", "waterDeep": "#1d3f70",
"metal": "#9aa3ab", "road": "#8b8578", "speck": "#3f5a28"
}
},
"snowfields": {
"name": "Snowfields",
"sheet": "terrain-snowfields",
"palette": {
"ground": "#d6dee6", "groundAlt1": "#e4ebf1", "groundAlt2": "#c4cfda",
"rough": "#a9b4bf", "cliff": "#6a737e", "cliffTop": "#8e99a4",
"water": "#3d6d95", "waterDeep": "#27506f",
"metal": "#9aa3ab", "road": "#9fa6ad", "speck": "#aebccb"
}
},
"tropics": {
"name": "Tropics",
"sheet": "terrain-tropics",
"palette": {
"ground": "#2f6b3c", "groundAlt1": "#3a7f46", "groundAlt2": "#265c33",
"rough": "#7a6b3a", "cliff": "#4a3f2e", "cliffTop": "#6d5f45",
"water": "#1f8fa8", "waterDeep": "#12667a",
"metal": "#9aa3ab", "road": "#9c8f6e", "speck": "#1d4a26"
}
}
}
}

View File

@ -0,0 +1,276 @@
{
"_readme": [
"Engine data for Total Annihilation. Everything the simulation knows lives here — adding a unit,",
"building, weapon, terrain type, army or map theme is a JSON-only change.",
"After editing, run `node tools/verifyTotalAnnihilation.js` — it cross-checks every id reference,",
"frame index and closed key set in this file and will tell you exactly what broke.",
"",
"Sheet resolution: a unit/building's `sheetSlot` names a field on its ARMY (unitSheet or",
"structureSheet), so one unit definition renders correctly for every army. That is what makes a",
"third army a two-line change: add an `armies` entry pointing at two new sheets in",
"data/totalannihilation-artwork.json.",
"",
"Units are drawn once and ROTATED at runtime — there are no per-facing frames. `turretFrame` is",
"an optional second sprite that aims independently of the hull."
],
"version": 1,
"constants": {
"tickHz": 20,
"tileSize": 64,
"unitCapPerArmy": 100,
"projectileCap": 400,
"startEnergy": 1000,
"startMass": 1000,
"baseEnergyCap": 500,
"baseMassCap": 500,
"pathBudgetPerTick": 8,
"retargetPeriodTicks": 10,
"separationStiffness": 0.55,
"shoveAfterSec": 0.6,
"stuckGiveUpSec": 3.0,
"arriveSlackPx": 12,
"friendlyFire": false,
"buildPowerNominal": 100,
"eliminateWhenUnrecoverable": true
},
"sizeClasses": {
"small": { "radius": 16, "footprint": 1, "mass": 1 },
"medium": { "radius": 26, "footprint": 1, "mass": 4 },
"large": { "radius": 44, "footprint": 2, "mass": 16 }
},
"armorClasses": ["infantry", "light", "medium", "heavy", "structure"],
"moveClasses": {
"foot": {
"cost": { "ground": 1.0, "rough": 1.3, "road": 0.85, "metal": 1.0, "cliff": null, "water": null }
},
"tread": {
"cost": { "ground": 1.0, "rough": 1.8, "road": 0.7, "metal": 1.0, "cliff": null, "water": null }
},
"wheel": {
"cost": { "ground": 1.0, "rough": 2.4, "road": 0.6, "metal": 1.0, "cliff": null, "water": null }
}
},
"armies": [
{ "id": "arm", "name": "ARM", "color": "#4a90e2",
"unitSheet": "arm-units", "structureSheet": "arm-structures" },
{ "id": "core", "name": "CORE", "color": "#e05a2f",
"unitSheet": "core-units", "structureSheet": "core-structures" }
],
"commanders": [
{ "id": "vance", "armyId": "arm", "opponentId": "ethel", "name": "Commander Ethel",
"tagline": "Holds the line, and makes you pay for every metre of it.",
"aiProfile": { "aggression": 0.45, "expansion": 0.65 } },
{ "id": "klaxon", "armyId": "core", "opponentId": "klaxon", "name": "Unit KLAXON",
"tagline": "Efficiency is a weapon. You are an inefficiency.",
"aiProfile": { "aggression": 0.7, "expansion": 0.45 } },
{ "id": "cybro", "armyId": "core", "opponentId": "cybro", "name": "Cy-Bro",
"tagline": "Builds faster than you can knock it down.",
"aiProfile": { "aggression": 0.35, "expansion": 0.85 } },
{ "id": "nadia", "armyId": "arm", "opponentId": "nadia", "name": "Commander Nadia",
"tagline": "Hits the flank while you watch the front.",
"aiProfile": { "aggression": 0.8, "expansion": 0.4 } }
],
"terrain": [
{ "id": "ground", "ch": ".", "frame": 0, "buildable": true, "blocksMove": false, "elevation": 0 },
{ "id": "rough", "ch": "r", "frame": 3, "buildable": true, "blocksMove": false, "elevation": 0 },
{ "id": "road", "ch": "=", "frame": 8, "buildable": true, "blocksMove": false, "elevation": 0 },
{ "id": "metal", "ch": "M", "frame": 7, "buildable": true, "blocksMove": false, "elevation": 0,
"massMultiplier": 2.0 },
{ "id": "cliff", "ch": "^", "frame": 4, "buildable": false, "blocksMove": true, "blocksFire": true, "elevation": 1 },
{ "id": "water", "ch": "~", "frame": 5, "buildable": false, "blocksMove": true, "elevation": -1 }
],
"weapons": [
{ "id": "rifle", "name": "Assault Rifle", "kind": "hitscan",
"damage": 6, "reload": 0.5, "burst": 3, "burstDelay": 0.06,
"range": 200, "spread": 0.05, "targets": ["ground"],
"armorMul": { "infantry": 1.4, "light": 0.8, "medium": 0.35, "heavy": 0.15, "structure": 0.2 },
"fx": { "style": "tracer", "color": "#ffe6a0", "width": 1.5, "lifeMs": 70 },
"sound": "sfx-battle-gunfire-modern" },
{ "id": "sniperrifle", "name": "Marksman Rifle", "kind": "hitscan",
"damage": 55, "reload": 3.0, "burst": 1,
"range": 460, "spread": 0.0, "targets": ["ground"],
"armorMul": { "infantry": 2.2, "light": 0.9, "medium": 0.3, "heavy": 0.1, "structure": 0.1 },
"fx": { "style": "beam", "color": "#d8f0ff", "width": 2, "lifeMs": 160 },
"sound": "sfx-laser-zap" },
{ "id": "tankgun", "name": "120mm Cannon", "kind": "ballistic",
"damage": 90, "reload": 2.4, "burst": 1,
"range": 340, "speed": 700, "spread": 0.015, "leadTarget": true,
"aoe": 48, "aoeFalloff": 0.4, "targets": ["ground"],
"armorMul": { "infantry": 0.5, "light": 1.0, "medium": 1.0, "heavy": 0.8, "structure": 1.2 },
"fx": { "style": "shell", "color": "#ffd27a", "width": 3, "muzzle": 18 },
"sound": "sfx-battle-tank" },
{ "id": "rocketpod", "name": "Rocket Pod", "kind": "guided",
"damage": 130, "reload": 4.0, "burst": 2, "burstDelay": 0.25,
"range": 480, "minRange": 140, "speed": 340, "turnRate": 2.2,
"aoe": 80, "aoeFalloff": 0.35, "targets": ["ground"],
"armorMul": { "infantry": 0.6, "light": 1.1, "medium": 1.2, "heavy": 1.3, "structure": 1.4 },
"fx": { "style": "rocket", "color": "#ff8844", "width": 2, "trail": true },
"sound": "sfx-battle-missle" },
{ "id": "dgun", "name": "D-Gun", "kind": "beam",
"damage": 2000, "reload": 1.5, "burst": 1,
"range": 220, "energyPerShot": 500, "targets": ["ground"],
"armorMul": { "infantry": 1, "light": 1, "medium": 1, "heavy": 1, "structure": 1 },
"fx": { "style": "dgun", "color": "#8ad4ff", "width": 9, "lifeMs": 260 },
"sound": "sfx-scifi-launch" }
],
"units": [
{ "id": "commander", "name": "Commander", "role": "builder",
"size": "medium", "radius": 20,
"hp": 3000, "speed": 62, "turnRate": 3.5, "moveClass": "foot",
"armorClass": "heavy", "sight": 420,
"cost": { "energy": 0, "mass": 0 }, "buildTime": 0,
"buildPower": 100, "buildRange": 200,
"builds": ["energygen", "massgen", "barracks", "vehicleplant"],
"produce": { "energy": 25, "mass": 3.0 },
"storage": { "energy": 1000, "mass": 1000 },
"weapons": ["dgun"],
"deathExplosion": { "radius": 280, "damage": 1200 },
"sheetSlot": "unitSheet", "frame": 0, "turretFrame": 1,
"procShape": "commander", "icon": 0, "spritePx": 56 },
{ "id": "infantry", "name": "Infantry", "role": "combat",
"size": "small", "radius": 16,
"hp": 90, "speed": 55, "turnRate": 6.0, "moveClass": "foot",
"armorClass": "infantry", "sight": 260,
"cost": { "energy": 120, "mass": 24 }, "buildTime": 6,
"builtBy": ["barracks"],
"weapons": ["rifle"],
"sheetSlot": "unitSheet", "frame": 2,
"procShape": "infantry", "icon": 1, "spritePx": 30,
"moveSound": "sfx-march" },
{ "id": "sniper", "name": "Sniper", "role": "combat",
"size": "small", "radius": 16,
"hp": 70, "speed": 46, "turnRate": 5.0, "moveClass": "foot",
"armorClass": "infantry", "sight": 500,
"cost": { "energy": 260, "mass": 55 }, "buildTime": 12,
"builtBy": ["barracks"],
"weapons": ["sniperrifle"],
"sheetSlot": "unitSheet", "frame": 3,
"procShape": "sniper", "icon": 2, "spritePx": 30,
"moveSound": "sfx-march" },
{ "id": "jeep", "name": "Jeep", "role": "scout",
"size": "small", "radius": 20,
"hp": 180, "speed": 175, "turnRate": 3.4, "moveClass": "wheel",
"armorClass": "light", "sight": 520,
"cost": { "energy": 320, "mass": 70 }, "buildTime": 8,
"builtBy": ["vehicleplant"],
"weapons": ["rifle"],
"sheetSlot": "unitSheet", "frame": 4, "turretFrame": 5,
"procShape": "jeep", "icon": 3, "spritePx": 40,
"moveSound": "sfx-engine-medium" },
{ "id": "tank", "name": "Tank", "role": "combat",
"size": "medium", "radius": 26,
"hp": 520, "speed": 90, "turnRate": 2.2, "moveClass": "tread",
"armorClass": "medium", "sight": 300,
"cost": { "energy": 700, "mass": 180 }, "buildTime": 20,
"builtBy": ["vehicleplant"],
"weapons": ["tankgun"],
"sheetSlot": "unitSheet", "frame": 6, "turretFrame": 7,
"procShape": "tank", "icon": 4, "spritePx": 54,
"moveSound": "sfx-engine-heavy" },
{ "id": "rockettank", "name": "Rocket Tank", "role": "artillery",
"size": "medium", "radius": 28,
"hp": 400, "speed": 75, "turnRate": 1.8, "moveClass": "tread",
"armorClass": "medium", "sight": 320,
"cost": { "energy": 1100, "mass": 300 }, "buildTime": 28,
"builtBy": ["vehicleplant"],
"weapons": ["rocketpod"],
"sheetSlot": "unitSheet", "frame": 8, "turretFrame": 9,
"procShape": "rockettank", "icon": 5, "spritePx": 56,
"moveSound": "sfx-engine-heavy" }
],
"buildings": [
{ "id": "energygen", "name": "Energy Generator",
"footprint": { "w": 2, "h": 2 },
"hp": 800, "armorClass": "structure", "sight": 160,
"cost": { "energy": 200, "mass": 60 }, "buildTime": 12,
"produce": { "energy": 20 },
"storage": { "energy": 500 },
"deathExplosion": { "radius": 110, "damage": 160 },
"sheetSlot": "structureSheet", "frame": 0, "buildFrame": 1,
"procShape": "energyGen", "icon": 10 },
{ "id": "massgen", "name": "Mass Generator",
"footprint": { "w": 2, "h": 2 },
"hp": 700, "armorClass": "structure", "sight": 160,
"cost": { "energy": 600, "mass": 50 }, "buildTime": 16,
"produce": { "mass": 2.0 },
"upkeep": { "energy": 15 },
"storage": { "mass": 250 },
"terrainMultiplier": "massMultiplier",
"sheetSlot": "structureSheet", "frame": 2, "buildFrame": 3,
"procShape": "massGen", "icon": 11 },
{ "id": "barracks", "name": "Barracks",
"footprint": { "w": 3, "h": 3 },
"hp": 1400, "armorClass": "structure", "sight": 220,
"cost": { "energy": 600, "mass": 180 }, "buildTime": 26,
"buildPower": 100, "builds": ["infantry", "sniper"],
"spawnOffset": { "x": 0, "y": 2.2 },
"sheetSlot": "structureSheet", "frame": 4, "buildFrame": 5,
"procShape": "barracks", "icon": 12 },
{ "id": "vehicleplant", "name": "Vehicle Plant",
"footprint": { "w": 3, "h": 3 },
"hp": 2200, "armorClass": "structure", "sight": 220,
"cost": { "energy": 1400, "mass": 520 }, "buildTime": 40,
"buildPower": 100, "builds": ["jeep", "tank", "rockettank"],
"spawnOffset": { "x": 0, "y": 2.2 },
"sheetSlot": "structureSheet", "frame": 6, "buildFrame": 7,
"procShape": "vehiclePlant", "icon": 13 }
],
"commandIcons": {
"move": 20, "attack": 21, "attackMove": 22, "stop": 23,
"hold": 24, "patrol": 25, "guard": 26, "assist": 27
},
"aiSkills": [
{ "skill": 1, "decisionPeriodMs": 1500, "apmCap": 1, "squadSize": 3, "expansion": 0.6, "microLevel": 0, "reactionMs": 4000 },
{ "skill": 2, "decisionPeriodMs": 1100, "apmCap": 2, "squadSize": 4, "expansion": 1.0, "microLevel": 1, "reactionMs": 3000 },
{ "skill": 3, "decisionPeriodMs": 800, "apmCap": 4, "squadSize": 6, "expansion": 1.6, "microLevel": 2, "reactionMs": 2000 },
{ "skill": 4, "decisionPeriodMs": 500, "apmCap": 8, "squadSize": 8, "expansion": 2.2, "microLevel": 3, "reactionMs": 1000 },
{ "skill": 5, "decisionPeriodMs": 300, "apmCap": 15, "squadSize": 10, "expansion": 3.0, "microLevel": 4, "reactionMs": 300 }
],
"skirmish": {
"sizes": { "small": 64, "medium": 96, "large": 128 },
"symmetries": ["mirror-x", "mirror-y", "rotational"],
"themes": ["grasslands", "snowfields", "tropics"],
"defaults": {
"size": "medium", "symmetry": "mirror-x", "theme": "grasslands",
"aiSkill": 3, "playerArmy": "arm"
},
"startUnits": ["commander"],
"gen": {
"waterFraction": 0.10,
"roughFraction": 0.18,
"cliffFraction": 0.10,
"metalSpotsPerStart": 4,
"metalSpotRadiusTiles": 14,
"minStartSeparationTiles": 40,
"smoothPasses": 3,
"startClearRadiusTiles": 8,
"noiseScale": 0.09
}
}
}

View File

@ -0,0 +1,533 @@
// Total Annihilation — the CPU commander.
//
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
//
// Two rules this file must never break:
// 1. It issues orders ONLY through TALogic.issueOrder(). It never mutates state directly.
// That's what lets the verify script point this same AI at the human's side and assert
// a campaign mission is actually winnable.
// 2. It sees only what fog lets it see (isVisibleTo), and it never gets free resources.
// Difficulty is decision quality, reaction time and an orders-per-second cap — not
// income cheating. A cheating AI's win rate would tell us nothing about the balance.
import { issueOrder, isVisibleTo, canPlaceAt, rngNext, rngInt } from './TALogic.js';
import { worldToTileX, worldToTileY } from './TANav.js';
/** Desired force mix per skill. Below skill 3 the AI just buys whatever it can afford. */
const COMPOSITION = {
infantry: 0.34, sniper: 0.10, jeep: 0.06, tank: 0.34, rockettank: 0.16,
};
function memFor(state, armyIdx) {
if (!state.aiMem) state.aiMem = [];
let m = state.aiMem[armyIdx];
if (!m) {
m = state.aiMem[armyIdx] = {
nextThinkTick: 0,
ordersThisSecond: 0, secondMark: 0,
squad: [], squadPeak: 0, phase: 'build',
scoutId: 0,
knownEnemies: [], // [{x,y,defId,isBuilding,tick}]
lastAttackTick: -99999,
buildSpiral: 0,
baseX: 0, baseY: 0, baseSet: false,
};
}
return m;
}
/**
* Run one AI slice. Call every sim tick it throttles itself to the skill's think period.
* @param {object} profile { skill, aggression, expansion } mission JSON can override
*/
export function runAI(rules, state, armyIdx, profile = {}) {
if (state.over) return;
const army = state.armies[armyIdx];
if (!army || !army.alive) return;
const skill = Math.max(1, Math.min(5, profile.skill ?? army.aiSkill ?? 3));
const tuning = rules.aiBySkill[skill] ?? rules.aiSkills[rules.aiSkills.length - 1];
const aggression = profile.aggression ?? army.aiProfile?.aggression ?? 0.5;
// Economic ambition is primarily a SKILL trait — tuning.expansion runs 0.6..3.0 across
// skills 1-5, normalised here to 0.2..1.0. A commander's own aiProfile only nudges it,
// otherwise every skill level expands identically and the ladder collapses.
const skillExpansion = (tuning.expansion ?? 1.6) / 3;
const expansion = profile.expansion
?? (army.aiProfile?.expansion != null
? (skillExpansion * 0.6 + army.aiProfile.expansion * 0.4)
: skillExpansion);
const mem = memFor(state, armyIdx);
const periodTicks = Math.max(1, Math.round((tuning.decisionPeriodMs / 1000) * rules.constants.tickHz));
if (state.tick < mem.nextThinkTick) return;
mem.nextThinkTick = state.tick + periodTicks;
// Orders-per-second cap: the honest handicap. A skill-1 AI physically cannot micro.
if (state.tick - mem.secondMark >= rules.constants.tickHz) {
mem.secondMark = state.tick;
mem.ordersThisSecond = 0;
}
const ctx = {
rules, state, armyIdx, army, skill, tuning, aggression, expansion, mem,
budget: Math.max(1, tuning.apmCap),
};
observe(ctx);
const mine = gatherOwn(ctx);
if (!mem.baseSet) {
const seed = mine.commanders[0] ?? mine.buildings[0] ?? mine.units[0];
if (seed) { mem.baseX = seed.x; mem.baseY = seed.y; mem.baseSet = true; }
}
manageEconomyAndBase(ctx, mine);
manageProduction(ctx, mine);
manageScout(ctx, mine);
manageMilitary(ctx, mine);
}
function order(ctx, cmd) {
if (ctx.mem.ordersThisSecond >= ctx.budget) return { ok: false, error: 'apm cap' };
ctx.mem.ordersThisSecond++;
return issueOrder(ctx.state, ctx.rules, cmd);
}
// ---------------------------------------------------------------------------
// Perception — fog-limited, with memory of where things were last seen
// ---------------------------------------------------------------------------
function observe(ctx) {
const { state, armyIdx, mem } = ctx;
const seen = [];
for (const e of state.entities) {
if (e.dead || e.army === armyIdx) continue;
if (!state.armies[e.army]) continue;
if (!isVisibleTo(state, armyIdx, e)) continue;
seen.push({ id: e.id, x: e.x, y: e.y, defId: e.defId, isBuilding: e.isBuilding, tick: state.tick });
}
// Merge into memory: refresh what we can see, keep stale entries as "last known".
const byId = new Map(mem.knownEnemies.map((k) => [k.id, k]));
for (const s of seen) byId.set(s.id, s);
// Forget anything we haven't seen in two minutes, and anything we can see is gone.
const cutoff = state.tick - ctx.rules.constants.tickHz * 120;
const live = new Set(state.entities.filter((e) => !e.dead).map((e) => e.id));
mem.knownEnemies = [...byId.values()]
.filter((k) => k.tick >= cutoff && live.has(k.id))
.sort((a, b) => a.id - b.id);
}
function gatherOwn(ctx) {
const { state, armyIdx, rules } = ctx;
const out = { commanders: [], builders: [], factories: [], buildings: [], units: [], sites: [], byDef: {} };
for (const e of state.entities) {
if (e.dead || e.army !== armyIdx) continue;
const def = rules.defById[e.defId];
out.byDef[e.defId] = (out.byDef[e.defId] ?? 0) + 1;
if (e.site) { out.sites.push(e); continue; }
if (e.isBuilding) {
out.buildings.push(e);
if (def.builds?.length) out.factories.push(e);
} else {
out.units.push(e);
if (def.builds?.length) { out.builders.push(e); out.commanders.push(e); }
}
}
return out;
}
// ---------------------------------------------------------------------------
// Economy and base building
// ---------------------------------------------------------------------------
function manageEconomyAndBase(ctx, mine) {
const { state, army, rules, mem, expansion } = ctx;
const builder = mine.builders[0];
if (!builder) return;
// Build orders QUEUE on the builder rather than replacing its current job, so the
// Commander rolls straight from one structure into the next with no idle gap. Issuing
// these unqueued caps every skill at one building in flight, which flattens the whole
// skill ladder — a skill-5 economy then looks identical to a skill-1 one.
const maxQueued = Math.max(1, Math.round(1 + expansion * 3));
const queued = builder.orders.reduce((n, o) => n + (o.type === 'build' ? 1 : 0), 0);
if (queued >= maxQueued) return;
const want = chooseBuilding(ctx, mine);
if (!want) return;
const spot = findPlacement(ctx, mine, want, builder);
if (!spot) return;
order(ctx, {
army: ctx.armyIdx, unitIds: [builder.id],
order: { type: 'build', defId: want.id, tx: spot.tx, ty: spot.ty },
queue: queued > 0 || builder.orders.length > 0,
});
}
function chooseBuilding(ctx, mine) {
const { rules, army, mem, expansion } = ctx;
const n = (id) => (mine.byDef[id] ?? 0);
const eGen = rules.buildingById.energygen;
const mGen = rules.buildingById.massgen;
const barracks = rules.buildingById.barracks;
const plant = rules.buildingById.vehicleplant;
// Opening: two power, one mass, then a barracks so there's something to fight with.
if (n('energygen') < 2) return eGen;
if (n('massgen') < 1) return mGen;
if (n('barracks') < 1) return barracks;
if (n('energygen') < 3) return eGen;
if (n('massgen') < 2) return mGen;
if (n('vehicleplant') < 1) return plant;
// Steady state: chase the energy:mass income RATIO that the unit roster actually costs.
// A Tank is 700 energy to 180 mass, so roughly 4:1 — an AI that treats the two resources
// symmetrically ends up capping out on energy while its factories starve for mass.
const netE = Math.max(0, army.eIncome - army.eUpkeep);
const ratio = netE / Math.max(0.5, army.mIncome);
const TARGET_RATIO = 4.0;
const cap = Math.round(3 + expansion * 7);
const roomE = n('energygen') < cap;
// A Mass Generator is an energy CONSUMER. Building one without the headroom to power it
// just throttles the ones already standing, so require spare energy before adding another.
const upkeepPer = mGen.upkeep?.energy ?? 0;
const roomM = n('massgen') < cap && netE > upkeepPer * 1.6;
if (army.stallM < 0.98 && roomM) return mGen;
if (army.stallE < 0.98 && roomE) return eGen;
if (ratio > TARGET_RATIO && roomM) return mGen;
if (ratio < TARGET_RATIO && roomE) return eGen;
if (roomM) return mGen;
if (roomE) return eGen;
// Economy is built out — convert the surplus into production capacity.
const factories = n('barracks') + n('vehicleplant');
if (factories < Math.round(2 + expansion * 3)) {
return n('vehicleplant') <= n('barracks') ? plant : barracks;
}
return null;
}
/**
* Spiral outward from the base for a legal footprint. Mass Generators additionally hunt for
* a metal patch, which is what makes map control matter without a reclaim economy.
*/
function findPlacement(ctx, mine, def, builder) {
const { state, rules } = ctx;
const mem = ctx.mem;
const ts = state.tileSize;
const bx = worldToTileX(state.nav, mem.baseSet ? mem.baseX : builder.x);
const by = worldToTileY(state.nav, mem.baseSet ? mem.baseY : builder.y);
if (def.terrainMultiplier) {
const spot = findMetalSpot(ctx, def, bx, by);
if (spot) return spot;
}
const maxR = 22;
for (let r = 2; r < maxR; r++) {
// Rotate the starting angle per attempt so the base doesn't grow as a solid slab —
// AoE chains through tightly packed buildings.
const steps = Math.max(8, r * 4);
const off = (ctx.mem.buildSpiral++ % steps);
for (let s = 0; s < steps; s++) {
const a = ((s + off) / steps) * Math.PI * 2;
const tx = bx + Math.round(Math.cos(a) * r);
const ty = by + Math.round(Math.sin(a) * r);
if (!spacedEnough(ctx, mine, tx, ty, def)) continue;
if (canPlaceAt(state, rules, tx, ty, def).ok) return { tx, ty };
}
}
return null;
}
function findMetalSpot(ctx, def, bx, by) {
const { state, rules } = ctx;
let best = null, bestD = Infinity;
for (let ty = 0; ty < state.h; ty++) {
for (let tx = 0; tx < state.w; tx++) {
const t = rules.terrain[state.terrain[ty * state.w + tx]];
if (!t.massMultiplier) continue;
const d = Math.hypot(tx - bx, ty - by);
if (d > 30 || d >= bestD) continue;
if (!canPlaceAt(state, rules, tx, ty, def).ok) continue;
best = { tx, ty }; bestD = d;
}
}
return best;
}
function spacedEnough(ctx, mine, tx, ty, def) {
const gap = 1;
for (const b of [...mine.buildings, ...mine.sites]) {
if (tx + def.footprint.w + gap <= b.tx) continue;
if (b.tx + b.fw + gap <= tx) continue;
if (ty + def.footprint.h + gap <= b.ty) continue;
if (b.ty + b.fh + gap <= ty) continue;
return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Production
// ---------------------------------------------------------------------------
function manageProduction(ctx, mine) {
const { rules, state, skill, army } = ctx;
for (const f of mine.factories) {
const def = rules.defById[f.defId];
const builds = def.builds ?? [];
if (!builds.length) continue;
const queued = f.queue.reduce((s, q) => s + q.count, 0);
if (queued >= 3) continue;
// Don't feed a factory while the economy is already choking — an over-queued plant
// starves the base builder and the AI never gets off the ground.
if (army.buildEff < 0.55 && queued >= 1) continue;
const pick = skill <= 2 ? randomPick(ctx, builds) : templatePick(ctx, mine, builds);
if (!pick) continue;
order(ctx, {
army: ctx.armyIdx,
order: { type: 'factoryEnqueue', factoryId: f.id, defId: pick, count: 1 },
});
}
}
function randomPick(ctx, builds) {
return builds[rngInt(ctx.state, builds.length)];
}
/** Pick whichever buildable unit is furthest below its share of the desired mix. */
function templatePick(ctx, mine, builds) {
const { rules, skill } = ctx;
const weights = { ...COMPOSITION };
if (skill >= 4) applyCounters(ctx, weights);
const total = builds.reduce((s, id) => s + (mine.byDef[id] ?? 0), 0) + 1;
let best = null, bestGap = -Infinity;
for (const id of builds) {
const wgt = weights[id] ?? 0.1;
const have = (mine.byDef[id] ?? 0) / total;
const gap = wgt - have;
if (gap > bestGap) { bestGap = gap; best = id; }
}
return best;
}
/** Skill 4+: bias the mix against what we've actually seen the enemy field. */
function applyCounters(ctx, weights) {
const { mem, rules } = ctx;
let infantryish = 0, armour = 0, structures = 0;
for (const k of mem.knownEnemies) {
const def = rules.defById[k.defId];
if (!def) continue;
if (k.isBuilding) { structures++; continue; }
if (def.armorClass === 'infantry' || def.armorClass === 'light') infantryish++;
else armour++;
}
const seen = infantryish + armour;
if (!seen) return;
const armourShare = armour / seen;
// Tanks shred infantry; rockets and snipers answer armour and buildings.
weights.tank *= 1 + (1 - armourShare) * 0.8;
weights.rockettank *= 1 + armourShare * 1.0 + (structures > 2 ? 0.3 : 0);
weights.sniper *= 1 + (1 - armourShare) * 0.5;
}
// ---------------------------------------------------------------------------
// Scouting
// ---------------------------------------------------------------------------
function manageScout(ctx, mine) {
const { state, mem, skill } = ctx;
if (skill < 3) return;
const scout = state.entities.find((e) => e.id === mem.scoutId && !e.dead);
if (scout) {
if (scout.orders.length) return;
const t = enemyBaseGuess(ctx);
if (t) order(ctx, { army: ctx.armyIdx, unitIds: [scout.id], order: { type: 'move', x: t.x, y: t.y } });
return;
}
const jeep = mine.units.find((u) => u.defId === 'jeep' && !u.orders.length);
if (!jeep) return;
mem.scoutId = jeep.id;
const t = enemyBaseGuess(ctx);
if (t) order(ctx, { army: ctx.armyIdx, unitIds: [jeep.id], order: { type: 'attackMove', x: t.x, y: t.y } });
}
function enemyBaseGuess(ctx) {
const { state, armyIdx, mem } = ctx;
// Prefer something we've actually seen; fall back to the enemy's start position.
const building = mem.knownEnemies.find((k) => k.isBuilding);
if (building) return { x: building.x, y: building.y };
if (mem.knownEnemies.length) return { x: mem.knownEnemies[0].x, y: mem.knownEnemies[0].y };
// Nothing in sight. If we've already been to the enemy start, sweep the map instead of
// parking on a stale position — otherwise a won game never actually ends, because the
// loser's last builder is hiding in a corner we stopped looking at.
const start = state.starts.find((s) => s.army !== armyIdx);
if (start) {
const sx = (start.x + 0.5) * state.tileSize, sy = (start.y + 0.5) * state.tileSize;
if (!mem.startCleared) {
const near = state.entities.some((e) => !e.dead && e.army === armyIdx
&& Math.hypot(e.x - sx, e.y - sy) < state.tileSize * 8);
if (near) mem.startCleared = true;
else return { x: sx, y: sy };
}
}
return sweepTarget(ctx);
}
/**
* Rotate through map sectors, preferring ones this army has never explored. This is what
* turns a won game into a finished game.
*/
function sweepTarget(ctx) {
const { state, army, mem } = ctx;
const SECTORS = 4;
const cellW = state.worldW / SECTORS, cellH = state.worldH / SECTORS;
const cell = state.tileSize * 2;
const candidates = [];
for (let sy = 0; sy < SECTORS; sy++) {
for (let sx = 0; sx < SECTORS; sx++) {
const cx = (sx + 0.5) * cellW, cy = (sy + 0.5) * cellH;
const gx = Math.min(state.visW - 1, Math.floor(cx / cell));
const gy = Math.min(state.visH - 1, Math.floor(cy / cell));
const explored = army.explored[gy * state.visW + gx] === 1;
candidates.push({ x: cx, y: cy, explored, i: sy * SECTORS + sx });
}
}
const unexplored = candidates.filter((c) => !c.explored);
const pool = unexplored.length ? unexplored : candidates;
const pick = pool[(mem.sweepIdx = ((mem.sweepIdx ?? 0) + 1) % pool.length)];
return { x: pick.x, y: pick.y };
}
// ---------------------------------------------------------------------------
// Military
// ---------------------------------------------------------------------------
function manageMilitary(ctx, mine) {
const { state, rules, mem, tuning, aggression, skill, armyIdx } = ctx;
// Anything combat-capable and idle joins the pool.
const idle = mine.units.filter((u) => {
if (u.id === mem.scoutId) return false;
const def = rules.defById[u.defId];
if (!def.weaponDefs?.length || def.builds?.length) return false;
return u.orders.length === 0;
});
mem.squad = mem.squad.filter((id) => state.entities.some((e) => e.id === id && !e.dead));
for (const u of idle) if (!mem.squad.includes(u.id)) mem.squad.push(u.id);
// Home defence always wins over pushing out.
const threat = nearestThreatToBase(ctx);
if (threat) {
const defenders = mem.squad.slice(0, Math.max(3, Math.ceil(mem.squad.length * 0.7)));
if (defenders.length) {
order(ctx, {
army: armyIdx, unitIds: defenders,
order: { type: 'attackMove', x: threat.x, y: threat.y },
});
mem.phase = 'defend';
}
return;
}
const target = enemyBaseGuess(ctx);
if (!target) return;
const wantSize = Math.max(2, Math.round(tuning.squadSize * (0.6 + aggression * 0.8)));
if (mem.phase !== 'attack') {
if (mem.squad.length < wantSize) {
// Gather near the base while we build up.
const gathering = idle.filter((u) => Math.hypot(u.x - mem.baseX, u.y - mem.baseY) > state.tileSize * 8);
if (gathering.length) {
order(ctx, {
army: armyIdx, unitIds: gathering.map((u) => u.id),
order: { type: 'move', x: mem.baseX, y: mem.baseY },
});
}
return;
}
mem.phase = 'attack';
mem.squadPeak = mem.squad.length;
mem.lastAttackTick = state.tick;
}
if (mem.phase === 'attack') {
// Break off if the push has been gutted — skill 2+ knows when it's lost a fight.
if (skill >= 2 && mem.squad.length < mem.squadPeak * 0.4) {
mem.phase = 'build';
order(ctx, {
army: armyIdx, unitIds: mem.squad.slice(),
order: { type: 'move', x: mem.baseX, y: mem.baseY },
});
mem.squad = [];
return;
}
const pushing = mem.squad.filter((id) => {
const e = state.entities.find((x) => x.id === id && !x.dead);
return e && e.orders.length === 0;
});
if (pushing.length) {
order(ctx, {
army: armyIdx, unitIds: pushing,
order: { type: 'attackMove', x: target.x, y: target.y },
});
}
if (skill >= 3) focusFire(ctx, mine);
}
}
function nearestThreatToBase(ctx) {
const { state, mem, armyIdx } = ctx;
if (!mem.baseSet) return null;
const radius = state.tileSize * 16;
let best = null, bestD = Infinity;
for (const e of state.entities) {
if (e.dead || e.army === armyIdx || !state.armies[e.army]) continue;
if (!isVisibleTo(state, armyIdx, e)) continue;
if (e.isBuilding) continue;
const d = Math.hypot(e.x - mem.baseX, e.y - mem.baseY);
if (d < radius && d < bestD) { best = e; bestD = d; }
}
return best;
}
/** Skill 3+: concentrate the squad on one target instead of spreading damage. */
function focusFire(ctx, mine) {
const { state, rules, mem, armyIdx } = ctx;
const members = mem.squad
.map((id) => state.entities.find((e) => e.id === id && !e.dead))
.filter(Boolean);
if (members.length < 3) return;
const cx = members.reduce((s, u) => s + u.x, 0) / members.length;
const cy = members.reduce((s, u) => s + u.y, 0) / members.length;
let best = null, bestScore = -Infinity;
for (const e of state.entities) {
if (e.dead || e.army === armyIdx || !state.armies[e.army]) continue;
if (!isVisibleTo(state, armyIdx, e)) continue;
const d = Math.hypot(e.x - cx, e.y - cy);
if (d > state.tileSize * 9) continue;
const def = rules.defById[e.defId];
// Prefer the closest thing to dying that can still shoot back.
const score = (def.weaponDefs?.length ? 2 : 1) * (1 - e.hp / e.maxHp + 0.3) * (1 - d / (state.tileSize * 9));
if (score > bestScore) { best = e; bestScore = score; }
}
if (!best) return;
const shooters = members.filter((u) => Math.hypot(u.x - best.x, u.y - best.y) < rules.defById[u.defId].maxRange * 1.5);
if (shooters.length < 2) return;
order(ctx, {
army: armyIdx, unitIds: shooters.map((u) => u.id),
order: { type: 'attack', targetId: best.id },
});
}
export { memFor };

View File

@ -0,0 +1,561 @@
// 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, frameW, frameH, cols, 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();
},
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();
},
};
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();
},
};
// ---------------------------------------------------------------------------
// 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 ?? {};
const count = Math.max(9, ...Object.values(F).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();
});
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 };

View File

@ -0,0 +1,229 @@
// Total Annihilation — procedural combat effects.
//
// Every weapon effect in this game is drawn, not sprited: gunfire tracers, tank shells,
// rocket trails, beams, explosions, nanolathe streams and craters are all Graphics strokes.
// Two layers are cleared and re-stroked each frame from a TTL list — the Star Control
// `fxList` idiom (StarControlGame.js renderWorld) using the repo's glow-stroke pair: one
// wide low-alpha pass, then a thin opaque one on top.
const MAX_FX = 420; // hard cap; oldest transients are dropped rather than dropping frames
export default class TAFx {
constructor(scene, worldRoot, depths) {
this.scene = scene;
// Under the actors: trails, nanolathe streams, scorch. Over them: tracers and blasts.
this.gUnder = scene.add.graphics().setDepth(depths.fxUnder);
this.gOver = scene.add.graphics().setDepth(depths.fxOver);
worldRoot.add(this.gUnder);
worldRoot.add(this.gOver);
this.list = [];
this.shakeUntil = 0;
}
destroy() {
this.gUnder.destroy();
this.gOver.destroy();
this.list.length = 0;
}
clear() { this.list.length = 0; }
push(fx) {
if (this.list.length >= MAX_FX) this.list.shift();
fx.age = 0;
this.list.push(fx);
}
/** Translate a simulation event into a transient effect. */
onEvent(ev, rules) {
switch (ev.t) {
case 'shot':
this.push({
kind: ev.style === 'beam' || ev.style === 'dgun' ? 'beam' : 'tracer',
x1: ev.x1, y1: ev.y1, x2: ev.x2, y2: ev.y2,
color: colorInt(ev.color), width: ev.width ?? 2, ttl: ev.lifeMs ?? 80,
});
break;
case 'weaponFired':
this.push({
kind: 'muzzle', x: ev.x, y: ev.y, heading: ev.heading,
color: 0xffe6a0, ttl: 70,
});
break;
case 'impact':
this.push({
kind: 'blast', x: ev.x, y: ev.y, r: Math.max(10, ev.radius),
color: 0xffb45a, ttl: ev.radius > 30 ? 380 : 200,
});
break;
case 'bigExplosion':
this.push({ kind: 'shockwave', x: ev.x, y: ev.y, r: ev.radius, color: 0xfff0c0, ttl: 700 });
this.push({ kind: 'blast', x: ev.x, y: ev.y, r: ev.radius * 0.55, color: 0xff8a3c, ttl: 520 });
this.shakeUntil = this.scene.time.now + 320;
this.scene.cameras.main.shake(300, 0.010);
break;
case 'unitDestroyed':
this.push({
kind: 'blast', x: ev.x, y: ev.y,
r: Math.max(16, ev.radius * (ev.isBuilding ? 2.0 : 1.4)),
color: ev.isBuilding ? 0xffa040 : 0xff9060,
ttl: ev.isBuilding ? 460 : 300,
});
this.push({ kind: 'scorch', x: ev.x, y: ev.y, r: ev.radius * 1.2, ttl: 4200 });
break;
case 'nanolathe':
// The stream itself is drawn live from builder->site in draw(); this is the sparkle
// at the receiving end so a site under construction always reads as active.
this.push({ kind: 'spark', x: ev.x, y: ev.y, color: 0x8ad4ff, ttl: 220 });
break;
default: break;
}
}
/**
* @param {number} delta ms since last frame
* @param {Array} nanoLinks live [{x1,y1,x2,y2,color}] builder->target streams
* @param {Array} projectiles interpolated [{x,y,vx,vy,color,width,style,trail}]
*/
draw(delta, nanoLinks, projectiles, timeMs) {
const gu = this.gUnder, go = this.gOver;
gu.clear(); go.clear();
// ---- persistent-ish world marks (under actors) ----
for (const fx of this.list) {
if (fx.kind !== 'scorch') continue;
const t = fx.age / fx.ttl;
gu.fillStyle(0x1a1410, 0.5 * (1 - t));
gu.fillCircle(fx.x, fx.y, fx.r);
}
// ---- nanolathe streams (under actors) ----
for (const link of nanoLinks) {
const phase = (timeMs * 0.006) % 1;
gu.lineStyle(5, link.color, 0.14);
gu.lineBetween(link.x1, link.y1, link.x2, link.y2);
gu.lineStyle(2, link.color, 0.9);
// dashed, marching toward the target so it reads as material flowing
const dx = link.x2 - link.x1, dy = link.y2 - link.y1;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len, uy = dy / len;
const dash = 14, gap = 10;
for (let d = phase * (dash + gap); d < len; d += dash + gap) {
const a = Math.min(len, d), b = Math.min(len, d + dash);
gu.lineBetween(link.x1 + ux * a, link.y1 + uy * a, link.x1 + ux * b, link.y1 + uy * b);
}
}
// ---- rocket trails (under actors) ----
for (const p of projectiles) {
if (!p.trail || p.trail.length < 4) continue;
const n = p.trail.length / 2;
for (let i = 1; i < n; i++) {
const a = i / n;
go.lineStyle(p.width * 1.6 * a, p.color, 0.30 * a);
gu.lineStyle(p.width * 1.6 * a, p.color, 0.30 * a);
gu.lineBetween(p.trail[(i - 1) * 2], p.trail[(i - 1) * 2 + 1], p.trail[i * 2], p.trail[i * 2 + 1]);
}
}
// ---- live projectiles (over actors) ----
for (const p of projectiles) {
const ang = Math.atan2(p.vy, p.vx);
if (p.style === 'rocket') {
const bx = p.x - Math.cos(ang) * 9, by = p.y - Math.sin(ang) * 9;
go.lineStyle(p.width * 2.4, p.color, 0.22);
go.lineBetween(bx, by, p.x, p.y);
go.fillStyle(0xfff0c0, 1);
go.fillCircle(p.x, p.y, p.width * 0.9);
go.fillStyle(p.color, 0.85);
go.fillCircle(bx, by, p.width * 1.3);
} else {
const bx = p.x - Math.cos(ang) * 12, by = p.y - Math.sin(ang) * 12;
go.lineStyle(p.width * 1.9, p.color, 0.20);
go.lineBetween(bx, by, p.x, p.y);
go.lineStyle(p.width * 0.8, p.color, 1);
go.lineBetween(bx, by, p.x, p.y);
go.fillStyle(0xfff6d8, 1);
go.fillCircle(p.x, p.y, p.width * 0.75);
}
}
// ---- transients (over actors) ----
const keep = [];
for (const fx of this.list) {
fx.age += delta;
if (fx.age >= fx.ttl) continue;
keep.push(fx);
const t = fx.age / fx.ttl;
switch (fx.kind) {
case 'tracer': {
const a = 1 - t;
go.lineStyle(fx.width * 3.2, fx.color, 0.16 * a);
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
go.lineStyle(fx.width, fx.color, a);
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
break;
}
case 'beam': {
const a = 1 - t * 0.75;
go.lineStyle(fx.width * 2.6 * (1 - t) + 1, fx.color, 0.18 * a);
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
go.lineStyle(fx.width * (1 - t) + 1, fx.color, a);
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
break;
}
case 'muzzle': {
const a = 1 - t;
const len = 13 * (1 - t) + 4;
go.fillStyle(fx.color, 0.85 * a);
for (const spread of [-0.35, 0, 0.35]) {
const ang = fx.heading + spread;
go.beginPath();
go.moveTo(fx.x, fx.y);
go.lineTo(fx.x + Math.cos(ang - 0.13) * len, fx.y + Math.sin(ang - 0.13) * len);
go.lineTo(fx.x + Math.cos(ang + 0.13) * len, fx.y + Math.sin(ang + 0.13) * len);
go.closePath();
go.fillPath();
}
break;
}
case 'blast': {
const a = 1 - t;
const r = fx.r * (0.35 + t * 0.9);
go.fillStyle(fx.color, 0.55 * a);
go.fillCircle(fx.x, fx.y, r);
go.fillStyle(0xfff6d8, 0.7 * a * a);
go.fillCircle(fx.x, fx.y, r * 0.45);
go.lineStyle(3 * a + 1, 0xffffff, 0.7 * a);
go.strokeCircle(fx.x, fx.y, r);
break;
}
case 'shockwave': {
const a = 1 - t;
go.lineStyle(9 * a + 1, fx.color, a);
go.strokeCircle(fx.x, fx.y, fx.r * t);
go.lineStyle(4 * a + 1, 0xff8a3c, a * 0.7);
go.strokeCircle(fx.x, fx.y, fx.r * t * 0.72);
break;
}
case 'spark': {
const a = 1 - t;
go.fillStyle(fx.color, a);
go.fillCircle(fx.x, fx.y, 3 * a + 1);
break;
}
default: break;
}
}
this.list = keep;
}
}
function colorInt(v) {
if (typeof v === 'number') return v;
if (typeof v === 'string' && v[0] === '#') return parseInt(v.slice(1), 16);
return 0xffffff;
}
export { colorInt };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,295 @@
// Total Annihilation — seeded procedural skirmish map generator.
//
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
//
// Produces exactly the same `map` shape a hand-authored campaign mission declares
// ({ w, h, theme, terrain, starts }), so campaign and skirmish share one loader.
//
// Determinism: same seed + same options => byte-identical map. Everything draws from the
// local mulberry32; never call Math.random() here.
import { createNav, findPath, clearanceFor, nearestUsableTile, tileIndex } from './TANav.js';
function mulberry32(seed) {
let a = seed >>> 0;
return function next() {
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;
};
}
/** Smooth 2D value noise: a coarse random lattice, bilinearly interpolated, two octaves. */
function valueNoise(rnd, w, h, scale) {
const lw = Math.max(2, Math.ceil(w * scale) + 1);
const lh = Math.max(2, Math.ceil(h * scale) + 1);
const lattice = new Float32Array(lw * lh);
for (let i = 0; i < lattice.length; i++) lattice[i] = rnd();
const smooth = (t) => t * t * (3 - 2 * t);
const sample = (fx, fy) => {
const x0 = Math.min(lw - 1, Math.floor(fx)), y0 = Math.min(lh - 1, Math.floor(fy));
const x1 = Math.min(lw - 1, x0 + 1), y1 = Math.min(lh - 1, y0 + 1);
const tx = smooth(fx - x0), ty = smooth(fy - y0);
const a = lattice[y0 * lw + x0], b = lattice[y0 * lw + x1];
const c = lattice[y1 * lw + x0], d = lattice[y1 * lw + x1];
return (a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
};
const out = new Float32Array(w * h);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const base = sample(x * scale, y * scale);
const detail = sample(x * scale * 2.7, y * scale * 2.7);
out[y * w + x] = base * 0.72 + detail * 0.28;
}
}
return out;
}
/** Map (x,y) onto the canonical half/quadrant so the result is exactly symmetric. */
function canonical(sym, x, y, w, h) {
switch (sym) {
case 'mirror-x': return x < w / 2 ? [x, y] : [w - 1 - x, y];
case 'mirror-y': return y < h / 2 ? [x, y] : [x, h - 1 - y];
case 'rotational': {
// 180-degree rotation about the centre.
const beforeCentre = (y * w + x) < ((h * w) / 2);
return beforeCentre ? [x, y] : [w - 1 - x, h - 1 - y];
}
default: return [x, y];
}
}
function startPositions(sym, w, h, margin) {
switch (sym) {
case 'mirror-x': return [[margin, h >> 1], [w - 1 - margin, h >> 1]];
case 'mirror-y': return [[w >> 1, margin], [w >> 1, h - 1 - margin]];
case 'rotational': return [[margin, margin], [w - 1 - margin, h - 1 - margin]];
default: return [[margin, h >> 1], [w - 1 - margin, h >> 1]];
}
}
/**
* @param {object} rules compiled rules
* @param {object} opts { seed, size:'small'|'medium'|'large', symmetry, theme }
* @returns {{w,h,theme,terrain:Uint8Array,starts:Array,metalSpots:Array}}
*/
export function generateMap(rules, opts = {}) {
const sk = rules.skirmish;
const size = opts.size ?? sk.defaults.size;
const symmetry = opts.symmetry ?? sk.defaults.symmetry;
const theme = opts.theme ?? sk.defaults.theme;
const seed = (opts.seed ?? 1) >>> 0;
const g = sk.gen;
const dim = sk.sizes[size];
if (!dim) throw new Error(`[TAMapGen] unknown size "${size}"`);
if (!sk.symmetries.includes(symmetry)) throw new Error(`[TAMapGen] unknown symmetry "${symmetry}"`);
if (!sk.themes.includes(theme)) throw new Error(`[TAMapGen] unknown theme "${theme}"`);
const w = dim, h = dim;
const rnd = mulberry32(seed);
const T = {};
for (const t of rules.terrain) T[t.id] = t.index;
const noise = valueNoise(rnd, w, h, g.noiseScale);
const detail = valueNoise(rnd, w, h, g.noiseScale * 2.2);
const terrain = new Uint8Array(w * h);
// Thresholds are PERCENTILES of the actual noise, not raw values. Smoothed value noise
// clusters hard around 0.5, so comparing it against a raw 0.10 yields almost no water —
// quantiles make `waterFraction: 0.10` mean literally a tenth of the map.
const quantile = (arr, q) => {
const s = Float32Array.from(arr).sort();
return s[Math.max(0, Math.min(s.length - 1, Math.floor(q * (s.length - 1))))];
};
const waterT = quantile(noise, g.waterFraction);
const cliffT = quantile(noise, 1 - g.cliffFraction);
const roughLo = quantile(detail, 0.5 - g.roughFraction / 2);
const roughHi = quantile(detail, 0.5 + g.roughFraction / 2);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const [cx, cy] = canonical(symmetry, x, y, w, h);
const n = noise[cy * w + cx];
const d = detail[cy * w + cx];
let id = T.ground;
if (n < waterT) id = T.water;
else if (n > cliffT) id = T.cliff;
else if (d > roughLo && d < roughHi) id = T.rough;
terrain[y * w + x] = id;
}
}
// Majority smoothing removes single-tile speckle that would otherwise make pathing ugly.
for (let pass = 0; pass < g.smoothPasses; pass++) {
const src = terrain.slice();
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const counts = new Map();
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const v = src[(y + dy) * w + (x + dx)];
counts.set(v, (counts.get(v) ?? 0) + 1);
}
}
let bestV = src[y * w + x], bestC = 0;
// Deterministic tie-break by terrain index — never rely on Map iteration order alone.
for (const [v, c] of [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0])) {
if (c > bestC) { bestV = v; bestC = c; }
}
if (bestC >= 6) terrain[y * w + x] = bestV;
}
}
}
// ---- starts ------------------------------------------------------------
const margin = Math.max(g.startClearRadiusTiles + 2, Math.floor(dim * 0.10));
const [p0, p1] = startPositions(symmetry, w, h, margin);
const starts = [
{ army: 0, x: p0[0], y: p0[1] },
{ army: 1, x: p1[0], y: p1[1] },
];
const clearR = g.startClearRadiusTiles;
for (const s of starts) {
for (let y = s.y - clearR; y <= s.y + clearR; y++) {
for (let x = s.x - clearR; x <= s.x + clearR; x++) {
if (x < 0 || y < 0 || x >= w || y >= h) continue;
if (Math.hypot(x - s.x, y - s.y) > clearR) continue;
terrain[y * w + x] = T.ground;
}
}
}
// ---- metal spots -------------------------------------------------------
const metalSpots = [];
const SPOT = 2; // spots are 2x2 tiles
const canPlaceSpot = (x, y) => {
if (x < 1 || y < 1 || x + SPOT > w - 1 || y + SPOT > h - 1) return false;
for (let dy = 0; dy < SPOT; dy++) {
for (let dx = 0; dx < SPOT; dx++) {
const t = terrain[(y + dy) * w + (x + dx)];
if (t !== T.ground && t !== T.rough) return false;
}
}
// Keep spots apart so a single Mass Generator can't cover two.
for (const s of metalSpots) if (Math.hypot(s.x - x, s.y - y) < 4) return false;
return true;
};
const commitSpot = (x, y) => {
for (let dy = 0; dy < SPOT; dy++) {
for (let dx = 0; dx < SPOT; dx++) terrain[(y + dy) * w + (x + dx)] = T.metal;
}
metalSpots.push({ x, y });
};
// Mirror the BLOCK, not its corner: a 2x2 block starting at x mirrors to one starting at
// w - x - SPOT. Mirroring the corner alone shifts the block one tile and breaks symmetry.
const mirrorOf = (x, y) => {
switch (symmetry) {
case 'mirror-x': return [w - x - SPOT, y];
case 'mirror-y': return [x, h - y - SPOT];
default: return [w - x - SPOT, h - y - SPOT];
}
};
const spotR = g.metalSpotRadiusTiles;
let guard = 0;
while (metalSpots.length < g.metalSpotsPerStart * 2 && guard++ < 4000) {
const a = rnd() * Math.PI * 2;
const r = clearR * 0.5 + rnd() * (spotR - clearR * 0.5);
const x = Math.round(starts[0].x + Math.cos(a) * r);
const y = Math.round(starts[0].y + Math.sin(a) * r);
const [mx, my] = mirrorOf(x, y);
// Commit both or neither, or the two players end up with different economies.
if (!canPlaceSpot(x, y)) continue;
if (!canPlaceSpot(mx, my)) continue;
commitSpot(x, y);
commitSpot(mx, my);
}
const map = { w, h, theme, terrain, starts, metalSpots, seed, size, symmetry };
// ---- reachability ------------------------------------------------------
// Guarantee the starts can actually reach each other for the widest unit in the ruleset,
// carving a corridor rather than rejecting the seed — a retry loop makes generation
// non-deterministic in wall-clock terms and can hang on a hostile parameter set.
const maxNeed = rules.units.reduce(
(m, u) => Math.max(m, clearanceFor(u.radius, rules.constants.tileSize)), 1);
const widestMoveClass = pickWidestMoveClass(rules);
ensureConnected(rules, map, widestMoveClass, maxNeed, T);
return map;
}
/** The move class with the most restrictive terrain table — if it can get through, all can. */
function pickWidestMoveClass(rules) {
let worst = null, worstScore = Infinity;
for (const [mc, spec] of Object.entries(rules.moveClasses)) {
const score = spec.costByTerrainIndex.reduce((s, c) => s + (c == null ? 0 : 1 / c), 0);
if (score < worstScore) { worstScore = score; worst = mc; }
}
return worst ?? Object.keys(rules.moveClasses)[0];
}
function ensureConnected(rules, map, mc, need, T) {
const nav = createNav(rules, map);
const [a, b] = map.starts;
const ai = nearestUsableTile(nav, mc, need, a.x, a.y, 12);
const bi = nearestUsableTile(nav, mc, need, b.x, b.y, 12);
if (ai >= 0 && bi >= 0 && findPath(nav, mc, need, ai, bi)) return;
// Carve a straight corridor wide enough for the widest unit.
const halfW = Math.max(1, need + 1);
const steps = Math.ceil(Math.hypot(b.x - a.x, b.y - a.y)) * 2;
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const cx = Math.round(a.x + (b.x - a.x) * t);
const cy = Math.round(a.y + (b.y - a.y) * t);
for (let y = cy - halfW; y <= cy + halfW; y++) {
for (let x = cx - halfW; x <= cx + halfW; x++) {
if (x < 0 || y < 0 || x >= map.w || y >= map.h) continue;
const cur = map.terrain[y * map.w + x];
if (cur === T.metal) continue;
map.terrain[y * map.w + x] = T.ground;
}
}
}
}
/** Decode a hand-authored mission map (rows of terrain chars) into the same shape. */
export function decodeMap(rules, spec) {
const rows = spec.rows ?? spec.tiles ?? [];
const w = spec.w ?? (rows[0]?.length ?? 0);
const h = spec.h ?? rows.length;
const terrain = new Uint8Array(w * h);
const fallback = rules.terrain[0].index;
for (let y = 0; y < h; y++) {
const row = rows[y] ?? '';
for (let x = 0; x < w; x++) {
const ch = row[x];
const t = ch != null ? rules.terrainByCh[ch] : null;
if (ch != null && ch !== ' ' && !t) {
throw new Error(`[TAMapGen] unknown terrain char "${ch}" at ${x},${y}`);
}
terrain[y * w + x] = t ? t.index : fallback;
}
}
return {
w, h,
theme: spec.theme ?? rules.skirmish.defaults.theme,
terrain,
starts: spec.starts ?? [],
units: spec.units ?? [],
buildings: spec.buildings ?? [],
areas: spec.areas ?? [],
};
}
export { tileIndex };

View File

@ -0,0 +1,441 @@
// Total Annihilation — navigation: grid, clearance field, A*, path smoothing, spatial hash.
//
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
//
// The tile grid exists ONLY for pathfinding cost and build legality. Units carry float
// positions and a heading; they never snap to tiles. That is what lets three small units
// share one tile while a tank takes it alone — the packing falls out of collision radii,
// not out of a slot system.
//
// Two things here are load-bearing for performance:
// 1. Units are NOT stamped into the grid. Only terrain and buildings block. If every unit
// dirtied the grid, every path would be invalidated every tick.
// 2. Clearance-based pathfinding: clearance[i] is the side of the largest all-passable
// square with tile i at its top-left, so a large unit simply refuses tiles whose
// clearance is below its need. One DP pass at load, patched over a dirty rect when a
// building goes up or dies.
const SQRT2 = Math.SQRT2;
export const MAX_CLEARANCE = 8; // clamped so clearance fits a Uint8Array and the DP stays cheap
// ---------------------------------------------------------------------------
// Nav grid
// ---------------------------------------------------------------------------
/**
* Build the navigation grid for a map.
* @param {object} rules compiled rules
* @param {{w:number,h:number,terrain:Uint8Array}} map terrain indices into rules.terrain
*/
export function createNav(rules, map) {
const { w, h } = map;
const n = w * h;
const moveClassIds = Object.keys(rules.moveClasses);
const nav = {
w, h, n,
tileSize: rules.constants.tileSize,
terrain: map.terrain,
blocked: new Uint8Array(n), // buildings and build sites
moveClassIds,
passable: Object.create(null), // mc -> Uint8Array
cost: Object.create(null), // mc -> Float32Array (Infinity where impassable)
clearance: Object.create(null), // mc -> Uint8Array
// Scratch reused by every A* call so a path request allocates nothing.
_g: new Float64Array(n),
_came: new Int32Array(n),
_stamp: new Int32Array(n),
_epoch: 0,
_heap: createHeap(Math.max(64, n >> 2)),
};
for (const mc of moveClassIds) {
nav.passable[mc] = new Uint8Array(n);
nav.cost[mc] = new Float32Array(n);
nav.clearance[mc] = new Uint8Array(n);
}
refreshRegion(nav, rules, 0, 0, w - 1, h - 1);
return nav;
}
/** Recompute passability, cost and clearance over a tile rect (inclusive). */
export function refreshRegion(nav, rules, x0, y0, x1, y1) {
const { w, h } = nav;
x0 = Math.max(0, x0); y0 = Math.max(0, y0);
x1 = Math.min(w - 1, x1); y1 = Math.min(h - 1, y1);
if (x1 < x0 || y1 < y0) return;
for (const mc of nav.moveClassIds) {
const costTable = rules.moveClasses[mc].costByTerrainIndex;
const pass = nav.passable[mc];
const cost = nav.cost[mc];
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const i = y * w + x;
const c = nav.blocked[i] ? null : costTable[nav.terrain[i]];
pass[i] = c == null ? 0 : 1;
cost[i] = c == null ? Infinity : c;
}
}
}
// Clearance at (x,y) reads (x+1,y), (x,y+1), (x+1,y+1), so a change at (x,y) can only
// affect tiles up-left of it — expand the rect that way and sweep in reverse.
const cx0 = Math.max(0, x0 - MAX_CLEARANCE);
const cy0 = Math.max(0, y0 - MAX_CLEARANCE);
for (const mc of nav.moveClassIds) {
const pass = nav.passable[mc];
const cl = nav.clearance[mc];
for (let y = y1; y >= cy0; y--) {
for (let x = x1; x >= cx0; x--) {
const i = y * w + x;
if (!pass[i]) { cl[i] = 0; continue; }
if (x === w - 1 || y === h - 1) { cl[i] = 1; continue; }
const a = cl[i + 1], b = cl[i + w], d = cl[i + w + 1];
cl[i] = Math.min(MAX_CLEARANCE, 1 + Math.min(a, b, d));
}
}
}
}
/** Stamp (or clear) a building footprint and patch the affected clearance. */
export function stampFootprint(nav, rules, tx, ty, fw, fh, on) {
const { w, h } = nav;
for (let y = ty; y < ty + fh; y++) {
if (y < 0 || y >= h) continue;
for (let x = tx; x < tx + fw; x++) {
if (x < 0 || x >= w) continue;
nav.blocked[y * w + x] = on ? 1 : 0;
}
}
refreshRegion(nav, rules, tx, ty, tx + fw - 1, ty + fh - 1);
}
/** Clearance a unit of this collision radius needs, in tiles. */
export function clearanceFor(radius, tileSize) {
return Math.max(1, Math.ceil((radius * 2) / tileSize));
}
export const tileIndex = (nav, tx, ty) => ty * nav.w + tx;
export const tileX = (nav, i) => i % nav.w;
export const tileY = (nav, i) => (i / nav.w) | 0;
export const worldToTileX = (nav, x) => Math.max(0, Math.min(nav.w - 1, (x / nav.tileSize) | 0));
export const worldToTileY = (nav, y) => Math.max(0, Math.min(nav.h - 1, (y / nav.tileSize) | 0));
export const tileCenterX = (nav, tx) => tx * nav.tileSize + nav.tileSize / 2;
export const tileCenterY = (nav, ty) => ty * nav.tileSize + nav.tileSize / 2;
/** Is this tile usable by a unit with the given move class and clearance need? */
export function tileOk(nav, mc, need, tx, ty) {
if (tx < 0 || ty < 0 || tx >= nav.w || ty >= nav.h) return false;
return nav.clearance[mc][ty * nav.w + tx] >= need;
}
/**
* Nearest tile to (tx,ty) that a unit of this move class/clearance can stand on.
* Spiral BFS, bounded used when an order lands on a wall or inside a building.
*/
export function nearestUsableTile(nav, mc, need, tx, ty, maxRings = 24) {
if (tileOk(nav, mc, need, tx, ty)) return tileIndex(nav, tx, ty);
for (let r = 1; r <= maxRings; r++) {
for (let dx = -r; dx <= r; dx++) {
for (const dy of (dx === -r || dx === r) ? range(-r, r) : [-r, r]) {
const x = tx + dx, y = ty + dy;
if (tileOk(nav, mc, need, x, y)) return tileIndex(nav, x, y);
}
}
}
return -1;
}
function range(a, b) {
const out = [];
for (let v = a; v <= b; v++) out.push(v);
return out;
}
// ---------------------------------------------------------------------------
// Binary min-heap (parallel arrays, grows in place, never allocates mid-search)
// ---------------------------------------------------------------------------
function createHeap(cap) {
return { f: new Float64Array(cap), k: new Int32Array(cap), size: 0, cap };
}
function heapClear(hp) { hp.size = 0; }
function heapPush(hp, f, k) {
if (hp.size === hp.cap) {
const cap = hp.cap * 2;
const nf = new Float64Array(cap); nf.set(hp.f);
const nk = new Int32Array(cap); nk.set(hp.k);
hp.f = nf; hp.k = nk; hp.cap = cap;
}
let i = hp.size++;
hp.f[i] = f; hp.k[i] = k;
while (i > 0) {
const p = (i - 1) >> 1;
if (hp.f[p] <= hp.f[i]) break;
swapHeap(hp, i, p);
i = p;
}
}
function heapPop(hp) {
const top = hp.k[0];
hp.size--;
if (hp.size > 0) {
hp.f[0] = hp.f[hp.size]; hp.k[0] = hp.k[hp.size];
let i = 0;
for (;;) {
const l = i * 2 + 1, r = l + 1;
let m = i;
if (l < hp.size && hp.f[l] < hp.f[m]) m = l;
if (r < hp.size && hp.f[r] < hp.f[m]) m = r;
if (m === i) break;
swapHeap(hp, i, m);
i = m;
}
}
return top;
}
function swapHeap(hp, a, b) {
const f = hp.f[a]; hp.f[a] = hp.f[b]; hp.f[b] = f;
const k = hp.k[a]; hp.k[a] = hp.k[b]; hp.k[b] = k;
}
// ---------------------------------------------------------------------------
// A*
// ---------------------------------------------------------------------------
const NEIGHBOR_DX = [1, -1, 0, 0, 1, 1, -1, -1];
const NEIGHBOR_DY = [0, 0, 1, -1, 1, -1, 1, -1];
/**
* A* over the tile grid.
* @returns {Int32Array|null} tile indices from start to goal inclusive, or null if unreachable
*/
export function findPath(nav, mc, need, startIdx, goalIdx, expansionCap = 20000) {
if (startIdx === goalIdx) return Int32Array.of(startIdx);
const cl = nav.clearance[mc];
const cost = nav.cost[mc];
if (cl[goalIdx] < need || cl[startIdx] < need) return null;
const { w, h } = nav;
const g = nav._g, came = nav._came, stamp = nav._stamp, hp = nav._heap;
const epoch = ++nav._epoch;
heapClear(hp);
const gx = goalIdx % w, gy = (goalIdx / w) | 0;
const heuristic = (i) => {
const dx = Math.abs((i % w) - gx);
const dy = Math.abs(((i / w) | 0) - gy);
// Octile: exact for 8-way movement on a uniform grid, so it never over-estimates
// once terrain cost is >= 1 — which the rules validator guarantees.
return (dx > dy ? dx - dy + SQRT2 * dy : dy - dx + SQRT2 * dx);
};
g[startIdx] = 0;
came[startIdx] = -1;
stamp[startIdx] = epoch;
heapPush(hp, heuristic(startIdx), startIdx);
let expansions = 0;
const closed = new Set();
while (hp.size > 0) {
const cur = heapPop(hp);
if (cur === goalIdx) return reconstruct(came, startIdx, goalIdx);
if (closed.has(cur)) continue;
closed.add(cur);
if (++expansions > expansionCap) return null;
const cx = cur % w, cy = (cur / w) | 0;
const gcur = g[cur];
for (let d = 0; d < 8; d++) {
const nx = cx + NEIGHBOR_DX[d], ny = cy + NEIGHBOR_DY[d];
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
const ni = ny * w + nx;
if (cl[ni] < need) continue;
const diag = d >= 4;
if (diag) {
// No corner cutting: both orthogonal neighbours must also be usable, or a tank
// squeezes diagonally between two buildings that visually touch.
if (cl[cy * w + nx] < need || cl[ny * w + cx] < need) continue;
}
const step = cost[ni] * (diag ? SQRT2 : 1);
const tentative = gcur + step;
if (stamp[ni] === epoch && tentative >= g[ni]) continue;
stamp[ni] = epoch;
g[ni] = tentative;
came[ni] = cur;
heapPush(hp, tentative + heuristic(ni), ni);
}
}
return null;
}
function reconstruct(came, startIdx, goalIdx) {
let len = 1;
for (let i = goalIdx; i !== startIdx; i = came[i]) len++;
const out = new Int32Array(len);
let p = len - 1;
for (let i = goalIdx; i !== -1; i = came[i]) {
out[p--] = i;
if (i === startIdx) break;
}
return out;
}
// ---------------------------------------------------------------------------
// Line of sight and string pulling
// ---------------------------------------------------------------------------
/**
* Can a unit of this clearance travel the straight segment a->b without clipping a
* blocked tile? Supercover walk (every tile the segment touches, not just Bresenham's).
*/
export function segmentClear(nav, mc, need, x0, y0, x1, y1) {
const ts = nav.tileSize;
const cl = nav.clearance[mc];
let tx = Math.floor(x0 / ts), ty = Math.floor(y0 / ts);
const tx1 = Math.floor(x1 / ts), ty1 = Math.floor(y1 / ts);
const dx = x1 - x0, dy = y1 - y0;
const stepX = dx > 0 ? 1 : -1, stepY = dy > 0 ? 1 : -1;
const tDeltaX = dx === 0 ? Infinity : Math.abs(ts / dx);
const tDeltaY = dy === 0 ? Infinity : Math.abs(ts / dy);
let tMaxX = dx === 0 ? Infinity
: Math.abs(((dx > 0 ? (tx + 1) * ts : tx * ts) - x0) / dx);
let tMaxY = dy === 0 ? Infinity
: Math.abs(((dy > 0 ? (ty + 1) * ts : ty * ts) - y0) / dy);
let guard = 0;
const limit = nav.w + nav.h + 4;
for (;;) {
if (tx < 0 || ty < 0 || tx >= nav.w || ty >= nav.h) return false;
if (cl[ty * nav.w + tx] < need) return false;
if (tx === tx1 && ty === ty1) return true;
if (++guard > limit) return false;
if (tMaxX < tMaxY) { tx += stepX; tMaxX += tDeltaX; }
else { ty += stepY; tMaxY += tDeltaY; }
}
}
/**
* String-pull a tile path into a short list of world waypoints. Greedily skips any
* waypoint the unit can reach in a straight line, so units drive smooth diagonals
* instead of staircasing along tile centres.
* @returns {number[]} flat [x0,y0, x1,y1, ...] world coordinates
*/
export function smoothPath(nav, mc, need, path, endX, endY) {
const out = [];
if (!path || path.length === 0) return out;
const px = (i) => tileCenterX(nav, path[i] % nav.w);
const py = (i) => tileCenterY(nav, (path[i] / nav.w) | 0);
let anchorX = px(0), anchorY = py(0);
let i = 0;
while (i < path.length - 1) {
// Advance as far as we can still see from the anchor.
let j = i + 1;
while (j + 1 < path.length && segmentClear(nav, mc, need, anchorX, anchorY, px(j + 1), py(j + 1))) j++;
out.push(px(j), py(j));
anchorX = px(j); anchorY = py(j);
i = j;
}
// Replace the final tile centre with the true destination when it's directly reachable.
if (endX != null && out.length >= 2) {
const lx = out[out.length - 2], ly = out[out.length - 1];
if (segmentClear(nav, mc, need, lx, ly, endX, endY)) {
out[out.length - 2] = endX;
out[out.length - 1] = endY;
} else {
out.push(endX, endY);
}
} else if (endX != null) {
out.push(endX, endY);
}
return out;
}
// ---------------------------------------------------------------------------
// Formation slots
// ---------------------------------------------------------------------------
/**
* Fan a group out around a destination on a hex-ish lattice, then assign units to slots
* greedily by distance. One shared path plus these offsets is what makes a 40-unit move
* order cost one A* instead of forty.
* @returns {number[]} flat [x,y] per unit, in the same order as `units`
*/
export function formationSlots(cx, cy, units, spacingScale = 1.15) {
const nMax = units.length;
let maxR = 0;
for (const u of units) maxR = Math.max(maxR, u.radius);
const spacing = maxR * 2 * spacingScale;
const slots = [[cx, cy]];
for (let ring = 1; slots.length < nMax; ring++) {
const count = 6 * ring;
for (let s = 0; s < count && slots.length < nMax; s++) {
const a = (s / count) * Math.PI * 2;
slots.push([cx + Math.cos(a) * spacing * ring, cy + Math.sin(a) * spacing * ring]);
}
}
// Greedy nearest assignment: sort both sides by distance from the destination and pair
// up, so the units already closest take the inner slots and nobody crosses the group.
const order = units.map((u, i) => ({
i, d: (u.x - cx) * (u.x - cx) + (u.y - cy) * (u.y - cy),
})).sort((a, b) => a.d - b.d || a.i - b.i);
const out = new Array(nMax * 2);
order.forEach((entry, rank) => {
out[entry.i * 2] = slots[rank][0];
out[entry.i * 2 + 1] = slots[rank][1];
});
return out;
}
// ---------------------------------------------------------------------------
// Spatial hash — rebuilt every tick, drives separation and target acquisition
// ---------------------------------------------------------------------------
export class SpatialHash {
constructor(cellSize, worldW, worldH) {
this.cell = cellSize;
this.cols = Math.max(1, Math.ceil(worldW / cellSize));
this.rows = Math.max(1, Math.ceil(worldH / cellSize));
this.buckets = Array.from({ length: this.cols * this.rows }, () => []);
}
clear() {
for (const b of this.buckets) b.length = 0;
}
_bucket(x, y) {
const cx = Math.max(0, Math.min(this.cols - 1, (x / this.cell) | 0));
const cy = Math.max(0, Math.min(this.rows - 1, (y / this.cell) | 0));
return cy * this.cols + cx;
}
insert(item, x, y) {
this.buckets[this._bucket(x, y)].push(item);
}
/** Append every item within `r` of (x,y) into `out`. Order is deterministic. */
query(x, y, r, out) {
const c0 = Math.max(0, ((x - r) / this.cell) | 0);
const c1 = Math.min(this.cols - 1, ((x + r) / this.cell) | 0);
const r0 = Math.max(0, ((y - r) / this.cell) | 0);
const r1 = Math.min(this.rows - 1, ((y + r) / this.cell) | 0);
for (let cy = r0; cy <= r1; cy++) {
const base = cy * this.cols;
for (let cx = c0; cx <= c1; cx++) {
const b = this.buckets[base + cx];
for (let i = 0; i < b.length; i++) out.push(b[i]);
}
}
return out;
}
}

View File

@ -0,0 +1,307 @@
// Total Annihilation — rules compiler and validator.
//
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
//
// compileRules() indexes data/totalannihilation-rules.json by id and validates every
// cross-reference against a closed key set, throwing on the first problem. The point is
// that a typo in the JSON fails loudly in the verify script rather than silently at
// runtime three minutes into a match.
export const WEAPON_KINDS = new Set(['hitscan', 'ballistic', 'guided', 'beam']);
export const FX_STYLES = new Set(['tracer', 'beam', 'shell', 'rocket', 'dgun']);
export const UNIT_ROLES = new Set(['builder', 'combat', 'scout', 'artillery']);
export const SHEET_SLOTS = new Set(['unitSheet', 'structureSheet']);
export const TARGET_DOMAINS = new Set(['ground', 'air']);
// Procedural painter shapes. A def may only name a shape TAArt knows how to draw;
// this set is duplicated there and the verify script asserts the two agree.
export const PROC_SHAPES = new Set([
'commander', 'infantry', 'sniper', 'jeep', 'tank', 'rockettank',
'energyGen', 'massGen', 'barracks', 'vehiclePlant',
]);
function fail(msg) {
throw new Error(`[totalannihilation-rules] ${msg}`);
}
function requireUnique(list, what) {
const seen = new Set();
for (const e of list) {
if (!e || typeof e.id !== 'string' || !e.id) fail(`${what} entry is missing an id`);
if (seen.has(e.id)) fail(`duplicate ${what} id "${e.id}"`);
seen.add(e.id);
}
}
function requirePositive(obj, keys, what) {
for (const k of keys) {
const v = obj[k];
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) {
fail(`${what} "${obj.id}" needs a positive ${k} (got ${JSON.stringify(v)})`);
}
}
}
function indexById(list) {
const m = Object.create(null);
for (const e of list) m[e.id] = e;
return m;
}
/**
* Compile and validate the raw rules JSON.
* @param {object} json parsed data/totalannihilation-rules.json
* @returns {object} compiled rules with by-id indexes and derived tables
*/
export function compileRules(json) {
if (!json || typeof json !== 'object') fail('rules JSON is empty');
const c = json.constants;
if (!c) fail('missing constants');
requirePositive(c, ['tickHz', 'tileSize', 'unitCapPerArmy', 'buildPowerNominal'], 'constants');
if (c.tileSize % 2 !== 0) fail('constants.tileSize must be even');
// ---- closed sets -------------------------------------------------------
const armorClasses = json.armorClasses ?? [];
if (!armorClasses.length) fail('armorClasses must be a non-empty array');
const armorSet = new Set(armorClasses);
const sizeClasses = json.sizeClasses ?? {};
for (const [id, s] of Object.entries(sizeClasses)) {
if (!(s.radius > 0)) fail(`sizeClass "${id}" needs a positive radius`);
if (!(s.footprint >= 1)) fail(`sizeClass "${id}" needs footprint >= 1`);
if (!(s.mass > 0)) fail(`sizeClass "${id}" needs a positive mass`);
}
const sizeSet = new Set(Object.keys(sizeClasses));
// ---- terrain -----------------------------------------------------------
const terrain = json.terrain ?? [];
requireUnique(terrain, 'terrain');
const terrainById = indexById(terrain);
const terrainByCh = Object.create(null);
terrain.forEach((t, i) => {
if (typeof t.ch !== 'string' || t.ch.length !== 1) fail(`terrain "${t.id}" needs a single-char ch`);
if (terrainByCh[t.ch]) fail(`terrain char "${t.ch}" used by both "${terrainByCh[t.ch].id}" and "${t.id}"`);
if (!Number.isInteger(t.frame) || t.frame < 0) fail(`terrain "${t.id}" needs a non-negative integer frame`);
t.index = i;
terrainByCh[t.ch] = t;
});
if (!terrain.length) fail('at least one terrain type is required');
// ---- move classes ------------------------------------------------------
const moveClasses = json.moveClasses ?? {};
for (const [mc, spec] of Object.entries(moveClasses)) {
if (!spec.cost) fail(`moveClass "${mc}" is missing its cost table`);
for (const t of terrain) {
if (!(t.id in spec.cost)) fail(`moveClass "${mc}" has no cost for terrain "${t.id}"`);
const v = spec.cost[t.id];
if (v !== null && !(typeof v === 'number' && v > 0)) {
fail(`moveClass "${mc}" cost for "${t.id}" must be a positive number or null (impassable)`);
}
}
for (const key of Object.keys(spec.cost)) {
if (!terrainById[key]) fail(`moveClass "${mc}" references unknown terrain "${key}"`);
}
// Flat per-terrain-index cost array — the pathfinder's hot path reads this.
spec.costByTerrainIndex = terrain.map((t) => (t.blocksMove ? null : spec.cost[t.id]));
}
const moveClassSet = new Set(Object.keys(moveClasses));
// ---- armies & commanders ----------------------------------------------
const armies = json.armies ?? [];
requireUnique(armies, 'army');
if (armies.length < 2) fail('at least two armies are required');
for (const a of armies) {
if (!a.unitSheet || !a.structureSheet) fail(`army "${a.id}" needs unitSheet and structureSheet`);
if (!/^#[0-9a-f]{6}$/i.test(a.color ?? '')) fail(`army "${a.id}" needs a #rrggbb color`);
a.colorInt = parseInt(a.color.slice(1), 16);
}
const armyById = indexById(armies);
const armyIndexById = Object.create(null);
armies.forEach((a, i) => { armyIndexById[a.id] = i; });
const commanders = json.commanders ?? [];
requireUnique(commanders, 'commander');
for (const cm of commanders) {
if (!armyById[cm.armyId]) fail(`commander "${cm.id}" references unknown army "${cm.armyId}"`);
if (!cm.opponentId) fail(`commander "${cm.id}" needs an opponentId for its portrait`);
}
const commanderById = indexById(commanders);
// ---- weapons -----------------------------------------------------------
const weapons = json.weapons ?? [];
requireUnique(weapons, 'weapon');
for (const w of weapons) {
if (!WEAPON_KINDS.has(w.kind)) {
fail(`weapon "${w.id}" has unknown kind "${w.kind}" (expected ${[...WEAPON_KINDS].join('|')})`);
}
requirePositive(w, ['damage', 'reload', 'range'], 'weapon');
if (w.kind === 'ballistic' || w.kind === 'guided') {
requirePositive(w, ['speed'], 'weapon');
}
if (w.kind === 'guided' && !(w.turnRate > 0)) fail(`guided weapon "${w.id}" needs a positive turnRate`);
if (w.minRange != null && !(w.minRange >= 0 && w.minRange < w.range)) {
fail(`weapon "${w.id}" minRange must be >= 0 and < range`);
}
for (const key of Object.keys(w.armorMul ?? {})) {
if (!armorSet.has(key)) fail(`weapon "${w.id}" armorMul references unknown armor class "${key}"`);
}
for (const a of armorClasses) {
if (!(a in (w.armorMul ?? {}))) fail(`weapon "${w.id}" armorMul is missing armor class "${a}"`);
}
for (const d of w.targets ?? []) {
if (!TARGET_DOMAINS.has(d)) fail(`weapon "${w.id}" targets unknown domain "${d}"`);
}
if (!w.fx || !FX_STYLES.has(w.fx.style)) {
fail(`weapon "${w.id}" needs fx.style from ${[...FX_STYLES].join('|')}`);
}
if (w.aoe != null && !(w.aoe > 0)) fail(`weapon "${w.id}" aoe must be positive when present`);
// Reload/burst in ticks, precomputed so the sim never divides in its hot loop.
w.reloadTicks = Math.max(1, Math.round(w.reload * c.tickHz));
w.burstDelayTicks = Math.max(1, Math.round((w.burstDelay ?? 0) * c.tickHz));
w.burst = w.burst ?? 1;
w.rangeSq = w.range * w.range;
w.minRangeSq = (w.minRange ?? 0) * (w.minRange ?? 0);
w.targetsAir = (w.targets ?? ['ground']).includes('air');
}
const weaponById = indexById(weapons);
// ---- shared unit/building validation ----------------------------------
const buildable = Object.create(null); // id -> def, for `builds` cross-checks
const units = json.units ?? [];
requireUnique(units, 'unit');
for (const u of units) {
if (!sizeSet.has(u.size)) fail(`unit "${u.id}" has unknown size "${u.size}"`);
if (!armorSet.has(u.armorClass)) fail(`unit "${u.id}" has unknown armorClass "${u.armorClass}"`);
if (!moveClassSet.has(u.moveClass)) fail(`unit "${u.id}" has unknown moveClass "${u.moveClass}"`);
if (u.role && !UNIT_ROLES.has(u.role)) fail(`unit "${u.id}" has unknown role "${u.role}"`);
if (!SHEET_SLOTS.has(u.sheetSlot)) fail(`unit "${u.id}" has unknown sheetSlot "${u.sheetSlot}"`);
if (!PROC_SHAPES.has(u.procShape)) fail(`unit "${u.id}" has unknown procShape "${u.procShape}"`);
requirePositive(u, ['hp', 'speed', 'turnRate', 'sight'], 'unit');
if (!Number.isInteger(u.frame) || u.frame < 0) fail(`unit "${u.id}" needs a non-negative integer frame`);
if (u.turretFrame != null && (!Number.isInteger(u.turretFrame) || u.turretFrame < 0)) {
fail(`unit "${u.id}" turretFrame must be a non-negative integer`);
}
for (const wid of u.weapons ?? []) {
if (!weaponById[wid]) fail(`unit "${u.id}" references unknown weapon "${wid}"`);
}
const sc = sizeClasses[u.size];
u.radius = u.radius ?? sc.radius;
u.massClass = sc.mass;
u.footprintTiles = sc.footprint;
u.sightSq = u.sight * u.sight;
u.isBuilding = false;
u.weaponDefs = (u.weapons ?? []).map((wid) => weaponById[wid]);
u.maxRange = u.weaponDefs.reduce((m, w) => Math.max(m, w.range), 0);
u.spritePx = u.spritePx ?? sc.radius * 2;
buildable[u.id] = u;
}
const unitById = indexById(units);
const buildings = json.buildings ?? [];
requireUnique(buildings, 'building');
for (const b of buildings) {
if (!armorSet.has(b.armorClass)) fail(`building "${b.id}" has unknown armorClass "${b.armorClass}"`);
if (!SHEET_SLOTS.has(b.sheetSlot)) fail(`building "${b.id}" has unknown sheetSlot "${b.sheetSlot}"`);
if (!PROC_SHAPES.has(b.procShape)) fail(`building "${b.id}" has unknown procShape "${b.procShape}"`);
requirePositive(b, ['hp', 'buildTime'], 'building');
if (!b.footprint || !(b.footprint.w >= 1) || !(b.footprint.h >= 1)) {
fail(`building "${b.id}" needs a footprint of at least 1x1 tiles`);
}
if (!Number.isInteger(b.frame) || b.frame < 0) fail(`building "${b.id}" needs a non-negative integer frame`);
if (b.terrainMultiplier && !terrain.some((t) => b.terrainMultiplier in t)) {
fail(`building "${b.id}" terrainMultiplier "${b.terrainMultiplier}" is on no terrain type`);
}
for (const wid of b.weapons ?? []) {
if (!weaponById[wid]) fail(`building "${b.id}" references unknown weapon "${wid}"`);
}
b.isBuilding = true;
b.radius = (Math.max(b.footprint.w, b.footprint.h) * c.tileSize) / 2;
b.sight = b.sight ?? 0;
b.sightSq = b.sight * b.sight;
b.weaponDefs = (b.weapons ?? []).map((wid) => weaponById[wid]);
b.maxRange = b.weaponDefs.reduce((m, w) => Math.max(m, w.range), 0);
buildable[b.id] = b;
}
const buildingById = indexById(buildings);
// `builds` lists must resolve, and a factory may only build units (not buildings).
for (const def of [...units, ...buildings]) {
for (const id of def.builds ?? []) {
if (!buildable[id]) fail(`"${def.id}" builds unknown def "${id}"`);
if (def.isBuilding && !unitById[id]) fail(`factory "${def.id}" may only build units, not "${id}"`);
if (!def.isBuilding && !buildingById[id]) fail(`mobile builder "${def.id}" may only build buildings, not "${id}"`);
}
if ((def.builds ?? []).length && !(def.buildPower > 0)) {
fail(`"${def.id}" has a builds list but no positive buildPower`);
}
}
// Every producible unit must be reachable from some factory, or it's dead data.
for (const u of units) {
if (u.buildTime === 0) continue; // starting units (the Commander) are placed, not built
const madeBy = buildings.filter((b) => (b.builds ?? []).includes(u.id));
if (!madeBy.length) fail(`unit "${u.id}" has a buildTime but no building builds it`);
for (const bid of u.builtBy ?? []) {
if (!buildingById[bid]) fail(`unit "${u.id}" builtBy references unknown building "${bid}"`);
if (!(buildingById[bid].builds ?? []).includes(u.id)) {
fail(`unit "${u.id}" claims builtBy "${bid}" but that building does not list it`);
}
}
}
// ---- AI skills ---------------------------------------------------------
const aiSkills = json.aiSkills ?? [];
const aiBySkill = Object.create(null);
for (const s of aiSkills) {
if (!Number.isInteger(s.skill) || s.skill < 1) fail('aiSkills entries need an integer skill >= 1');
aiBySkill[s.skill] = s;
}
if (!aiSkills.length) fail('at least one aiSkills entry is required');
// ---- skirmish ----------------------------------------------------------
const sk = json.skirmish;
if (!sk) fail('missing skirmish config');
for (const [name, tiles] of Object.entries(sk.sizes ?? {})) {
if (!Number.isInteger(tiles) || tiles < 32) fail(`skirmish size "${name}" must be an integer >= 32 tiles`);
}
for (const id of sk.startUnits ?? []) {
if (!unitById[id]) fail(`skirmish.startUnits references unknown unit "${id}"`);
}
if (!sk.sizes?.[sk.defaults?.size]) fail('skirmish.defaults.size is not one of skirmish.sizes');
if (!(sk.symmetries ?? []).includes(sk.defaults?.symmetry)) fail('skirmish.defaults.symmetry is not one of skirmish.symmetries');
if (!(sk.themes ?? []).includes(sk.defaults?.theme)) fail('skirmish.defaults.theme is not one of skirmish.themes');
if (!armyById[sk.defaults?.playerArmy]) fail('skirmish.defaults.playerArmy is not a known army');
return {
version: json.version ?? 1,
constants: c,
armorClasses, armorSet,
sizeClasses,
terrain, terrainById, terrainByCh,
moveClasses,
armies, armyById, armyIndexById,
commanders, commanderById,
weapons, weaponById,
units, unitById,
buildings, buildingById,
defById: buildable,
commandIcons: json.commandIcons ?? {},
aiSkills, aiBySkill,
skirmish: sk,
// Derived conveniences the sim and view both want.
stepMs: 1000 / c.tickHz,
dt: 1 / c.tickHz,
halfTile: c.tileSize / 2,
};
}
/** Damage multiplier of `weapon` against a defender of `armorClass`. */
export function armorMul(weapon, armorClass) {
return weapon.armorMul?.[armorClass] ?? 1;
}
/** Look up a def (unit or building) by id. */
export function defOf(rules, id) {
return rules.defById[id] ?? null;
}

View File

@ -0,0 +1,475 @@
// Total Annihilation — world renderer.
//
// Camera model: a REAL Phaser world camera plus a second screen-space UI camera, the
// MiniMotorwaysGame.js:213-227 pattern. A real camera buys frustum culling, setBounds,
// zoomTo and — decisively for an RTS — pointer.worldX/worldY for click-to-order.
//
// Terrain bakes into chunked RenderTextures (CivilizationMapView's forEachChunk model). A
// large map is 128 tiles = 8192px, well past a safe single GPU texture, so it tiles into
// 1024px chunks created LAZILY as the camera reaches them — 36 chunks eagerly stamped would
// cost several seconds and ~150MB at mission start.
//
// Units are plain Images, never Containers: at 300+ units the Container overhead is real.
// Selection rings, health bars and build ghosts all share ONE Graphics each.
import { ensureSheets, sheetFrameSize } from './TAArt.js';
import { colorInt } from './TAFx.js';
const CHUNK_PX = 1024;
const ZOOMS = [0.5, 0.7, 1.0, 1.4];
const CHUNK_IDLE_MS = 30000; // reclaim chunks the camera hasn't looked at in a while
export const DEPTHS = {
terrain: 0, decal: 5, fxUnder: 8, ghost: 12,
selection: 15, actor: 20, bars: 55, projectile: 60, fxOver: 65, fog: 80,
};
export default class TAWorldView {
constructor(scene, rules, art, state, playerArmy) {
this.scene = scene;
this.rules = rules;
this.art = art;
this.state = state;
this.playerArmy = playerArmy;
this.ts = state.tileSize;
this.worldRoot = scene.add.container(0, 0);
this.uiRoot = scene.add.container(0, 0);
const { keys, procedural } = ensureSheets(scene, rules, art);
this.sheetKeys = keys;
this.proceduralSheets = procedural;
// Per-army resolved sheet keys, so a unit def's `sheetSlot` renders correctly for any army.
this.armySheets = rules.armies.map((a) => ({
unitSheet: keys[a.unitSheet],
structureSheet: keys[a.structureSheet],
unitFrame: sheetFrameSize(art, a.unitSheet),
structureFrame: sheetFrameSize(art, a.structureSheet),
color: a.colorInt,
}));
const themeName = state.theme;
const theme = art.themes?.[themeName];
this.terrainKey = keys[theme?.sheet] ?? keys[Object.keys(art.sheets)[0]];
this.themePalette = theme?.palette ?? {};
this._setupCameras();
this._setupTerrain();
this._setupFog();
this.gSelection = scene.add.graphics().setDepth(DEPTHS.selection);
this.gBars = scene.add.graphics().setDepth(DEPTHS.bars);
this.gGhost = scene.add.graphics().setDepth(DEPTHS.ghost);
this.worldRoot.add(this.gSelection);
this.worldRoot.add(this.gBars);
this.worldRoot.add(this.gGhost);
this.sprites = new Map(); // entity id -> { img, turret }
this.selection = new Set();
this.placement = null; // { def, tx, ty, legal }
this.showAllBars = false;
this._stamp = scene.make.image({ x: 0, y: 0, key: this.terrainKey, add: false }).setOrigin(0, 0);
}
// -------------------------------------------------------------------------
// Cameras
// -------------------------------------------------------------------------
_setupCameras() {
const { scene, state } = this;
const cam = scene.cameras.main;
const margin = this.ts * 3;
cam.setBounds(-margin, -margin, state.worldW + margin * 2, state.worldH + margin * 2);
cam.setBackgroundColor(0x0b0e12);
this.zoomIdx = 2;
cam.setZoom(ZOOMS[this.zoomIdx]);
this.uiCam = scene.cameras.add(0, 0, scene.scale.width, scene.scale.height);
this.uiCam.ignore(this.worldRoot);
cam.ignore(this.uiRoot);
}
/** Keep screen-space extras (music controls, fullscreen button) off the world camera. */
ignoreOnWorldCam(objs) {
if (!objs?.length) return;
this.scene.cameras.main.ignore(objs);
}
panBy(dx, dy) {
const cam = this.scene.cameras.main;
cam.setScroll(cam.scrollX + dx / cam.zoom, cam.scrollY + dy / cam.zoom);
}
centerOn(x, y) { this.scene.cameras.main.centerOn(x, y); }
zoomBy(dir, focusX, focusY) {
const cam = this.scene.cameras.main;
const next = Math.max(0, Math.min(ZOOMS.length - 1, this.zoomIdx + dir));
if (next === this.zoomIdx) return;
// Zoom toward the cursor: keep the world point under the pointer pinned.
const before = cam.getWorldPoint(focusX, focusY);
this.zoomIdx = next;
cam.setZoom(ZOOMS[next]);
const after = cam.getWorldPoint(focusX, focusY);
cam.setScroll(cam.scrollX + (before.x - after.x), cam.scrollY + (before.y - after.y));
}
get zoom() { return this.scene.cameras.main.zoom; }
worldPoint(screenX, screenY) {
return this.scene.cameras.main.getWorldPoint(screenX, screenY);
}
// -------------------------------------------------------------------------
// Terrain (lazy chunked RenderTextures)
// -------------------------------------------------------------------------
_setupTerrain() {
const { state } = this;
this.chunkCols = Math.ceil(state.worldW / CHUNK_PX);
this.chunkRows = Math.ceil(state.worldH / CHUNK_PX);
this.chunks = new Map(); // "cx,cy" -> { rt, ox, oy, w, h, touched }
}
_paintChunk(cx, cy) {
const key = `${cx},${cy}`;
let chunk = this.chunks.get(key);
if (chunk) { chunk.touched = this.scene.time.now; return chunk; }
const { state, rules, ts } = this;
const ox = cx * CHUNK_PX, oy = cy * CHUNK_PX;
const w = Math.min(CHUNK_PX, state.worldW - ox);
const h = Math.min(CHUNK_PX, state.worldH - oy);
const rt = this.scene.add.renderTexture(ox, oy, w, h).setOrigin(0, 0).setDepth(DEPTHS.terrain);
this.worldRoot.add(rt);
const t0 = Math.floor(ox / ts), t1 = Math.ceil((ox + w) / ts);
const r0 = Math.floor(oy / ts), r1 = Math.ceil((oy + h) / ts);
const img = this._stamp;
img.setTexture(this.terrainKey);
img.setDisplaySize(ts, ts);
rt.beginDraw();
for (let ty = r0; ty < r1 && ty < state.h; ty++) {
for (let tx = t0; tx < t1 && tx < state.w; tx++) {
const terr = rules.terrain[state.terrain[ty * state.w + tx]];
// Break up flat ground with the two alternate frames, deterministically per tile.
let frame = terr.frame;
if (terr.id === 'ground') {
const hsh = (tx * 73856093) ^ (ty * 19349663);
const pick = (hsh >>> 3) % 5;
if (pick === 1) frame = this.art.terrainFrames?.groundAlt1 ?? frame;
else if (pick === 2) frame = this.art.terrainFrames?.groundAlt2 ?? frame;
}
img.setFrame(frame);
rt.batchDraw(img, tx * ts - ox, ty * ts - oy);
}
}
rt.endDraw();
chunk = { rt, ox, oy, w, h, touched: this.scene.time.now };
this.chunks.set(key, chunk);
return chunk;
}
/** Create chunks the camera can see; retire ones it hasn't looked at for a while. */
_updateChunks() {
const cam = this.scene.cameras.main;
const view = cam.worldView;
const c0 = Math.max(0, Math.floor((view.x - CHUNK_PX * 0.25) / CHUNK_PX));
const c1 = Math.min(this.chunkCols - 1, Math.floor((view.right + CHUNK_PX * 0.25) / CHUNK_PX));
const r0 = Math.max(0, Math.floor((view.y - CHUNK_PX * 0.25) / CHUNK_PX));
const r1 = Math.min(this.chunkRows - 1, Math.floor((view.bottom + CHUNK_PX * 0.25) / CHUNK_PX));
for (let cy = r0; cy <= r1; cy++) {
for (let cx = c0; cx <= c1; cx++) this._paintChunk(cx, cy);
}
const now = this.scene.time.now;
for (const [key, chunk] of this.chunks) {
if (now - chunk.touched < CHUNK_IDLE_MS) continue;
chunk.rt.destroy();
this.chunks.delete(key);
}
}
/** Repaint the chunks overlapping a world rect — used when terrain changes. */
repaintArea(x0, y0, x1, y1) {
for (const [key, chunk] of this.chunks) {
if (x1 < chunk.ox || x0 > chunk.ox + chunk.w) continue;
if (y1 < chunk.oy || y0 > chunk.oy + chunk.h) continue;
chunk.rt.destroy();
this.chunks.delete(key);
}
}
// -------------------------------------------------------------------------
// Fog of war
// -------------------------------------------------------------------------
_setupFog() {
const { state, scene } = this;
const key = 'ta-fog-canvas';
if (scene.textures.exists(key)) scene.textures.remove(key);
this.fogTex = scene.textures.createCanvas(key, state.visW, state.visH);
this.fogCtx = this.fogTex.getContext();
const cell = this.ts * 2;
this.fogImg = scene.add.image(0, 0, key).setOrigin(0, 0).setDepth(DEPTHS.fog);
this.fogImg.setDisplaySize(state.visW * cell, state.visH * cell);
// Linear filtering upscales the coarse grid into a soft gradient for the cost of a few
// hundred pixels of texture — far cheaper than a per-tile fog RenderTexture.
this.fogTex.setFilter(1); // Phaser.Textures.FilterMode.LINEAR
this.worldRoot.add(this.fogImg);
this.fogDirty = true;
}
setFogEnabled(on) {
this.fogEnabled = on;
this.fogImg.setVisible(on);
}
_redrawFog() {
const { state } = this;
const army = state.armies[this.playerArmy];
if (!army) return;
const ctx = this.fogCtx;
ctx.clearRect(0, 0, state.visW, state.visH);
const img = ctx.createImageData(state.visW, state.visH);
const d = img.data;
for (let i = 0; i < state.visW * state.visH; i++) {
const vis = army.visible[i], exp = army.explored[i];
const a = vis ? 0 : (exp ? 140 : 255);
d[i * 4] = 4; d[i * 4 + 1] = 6; d[i * 4 + 2] = 10; d[i * 4 + 3] = a;
}
ctx.putImageData(img, 0, 0);
this.fogTex.refresh();
}
/** Is this entity currently drawable for the viewing player? */
visibleToPlayer(e) {
if (!this.fogEnabled) return true;
if (e.army === this.playerArmy) return true;
const army = this.state.armies[this.playerArmy];
if (!army) return true;
const cell = this.ts * 2;
const x = Math.floor(e.x / cell), y = Math.floor(e.y / cell);
if (x < 0 || y < 0 || x >= this.state.visW || y >= this.state.visH) return false;
return army.visible[y * this.state.visW + x] === 1;
}
// -------------------------------------------------------------------------
// Sprites
// -------------------------------------------------------------------------
_ensureSprite(e) {
let s = this.sprites.get(e.id);
if (s) return s;
const rules = this.rules;
const def = rules.defById[e.defId];
const sheets = this.armySheets[e.army];
const key = sheets[def.sheetSlot];
const frameSize = def.sheetSlot === 'unitSheet' ? sheets.unitFrame : sheets.structureFrame;
const img = this.scene.add.image(e.x, e.y, key, def.frame);
img.setTint(sheets.color);
if (def.isBuilding) {
img.setDisplaySize(def.footprint.w * this.ts, def.footprint.h * this.ts);
} else {
img.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
}
this.worldRoot.add(img);
let turret = null;
if (!def.isBuilding && def.turretFrame != null) {
turret = this.scene.add.image(e.x, e.y, key, def.turretFrame);
turret.setTint(sheets.color);
turret.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
this.worldRoot.add(turret);
}
s = { img, turret, defId: e.defId, site: e.site };
this.sprites.set(e.id, s);
return s;
}
_releaseSprite(id) {
const s = this.sprites.get(id);
if (!s) return;
s.img.destroy();
s.turret?.destroy();
this.sprites.delete(id);
}
// -------------------------------------------------------------------------
// Frame
// -------------------------------------------------------------------------
/**
* @param {number} alpha interpolation factor between the last two sim ticks
* @returns {{nanoLinks:Array, projectiles:Array}} data the FX layer needs, in view space
*/
render(alpha) {
const { state, rules } = this;
this._updateChunks();
if (this.fogEnabled && this.fogDirty) { this._redrawFog(); this.fogDirty = false; }
const live = new Set();
const gSel = this.gSelection, gBar = this.gBars;
gSel.clear(); gBar.clear();
for (const e of state.entities) {
if (e.dead) continue;
live.add(e.id);
const def = rules.defById[e.defId];
const shown = this.visibleToPlayer(e);
const s = this._ensureSprite(e);
s.img.setVisible(shown);
if (s.turret) s.turret.setVisible(shown);
if (!shown) continue;
const x = e.px + (e.x - e.px) * alpha;
const y = e.py + (e.y - e.py) * alpha;
const heading = lerpAngle(e.pheading, e.heading, alpha);
s.img.setPosition(x, y);
if (!def.isBuilding) s.img.setRotation(heading);
// Y-sorted actor band; buildings sit just under units sharing a row.
s.img.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + (def.isBuilding ? 0 : 0.05));
// Build sites show the wireframe frame and fade in as they complete.
if (e.site !== s.site) {
s.img.setFrame(e.site ? (def.buildFrame ?? def.frame) : def.frame);
s.site = e.site;
}
s.img.setAlpha(e.site ? 0.35 + e.progress * 0.55 : 1);
if (s.turret) {
const tr = lerpAngle(e.pturretRot, e.turretRot, alpha);
s.turret.setPosition(x, y).setRotation(tr);
s.turret.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + 0.08);
}
// Selection ring
if (this.selection.has(e.id)) {
const r = def.isBuilding ? Math.max(def.footprint.w, def.footprint.h) * this.ts * 0.55 : e.radius + 4;
gSel.lineStyle(2, 0x7dff9b, 0.95);
gSel.strokeEllipse(x, y + r * 0.25, r * 2, r * 1.1);
}
// Health / build bars — only when they say something, so we're not stroking 300 of them.
const damaged = e.hp < e.maxHp - 0.5;
if (e.site) {
drawBar(gBar, x, y - e.radius - 10, e.radius * 1.8, e.progress, 0x8ad4ff);
} else if (damaged || this.selection.has(e.id) || this.showAllBars) {
const frac = Math.max(0, e.hp / e.maxHp);
const col = frac > 0.6 ? 0x6fe27a : frac > 0.3 ? 0xe2d16f : 0xe2705f;
drawBar(gBar, x, y - e.radius - 10, e.radius * 1.8, frac, col);
}
}
for (const id of [...this.sprites.keys()]) if (!live.has(id)) this._releaseSprite(id);
this._drawPlacementGhost();
// Nanolathe links: builder -> whatever it is working on.
const nanoLinks = [];
for (const e of state.entities) {
if (e.dead || !e.buildTargetId) continue;
if (!this.visibleToPlayer(e)) continue;
const target = state.entities.find((t) => t.id === e.buildTargetId && !t.dead);
if (!target) continue;
nanoLinks.push({
x1: e.x, y1: e.y, x2: target.x, y2: target.y,
color: this.armySheets[e.army].color,
});
}
// Projectiles, interpolated and pre-coloured for the FX layer.
const projectiles = [];
for (const p of state.projectiles) {
const w = rules.weaponById[p.weapon];
const px = p.px + (p.x - p.px) * alpha;
const py = p.py + (p.y - p.py) * alpha;
if (this.fogEnabled && p.army !== this.playerArmy) {
const cell = this.ts * 2;
const gx = Math.floor(px / cell), gy = Math.floor(py / cell);
const army = state.armies[this.playerArmy];
if (gx < 0 || gy < 0 || gx >= state.visW || gy >= state.visH) continue;
if (!army.visible[gy * state.visW + gx]) continue;
}
projectiles.push({
x: px, y: py, vx: p.vx, vy: p.vy,
color: colorInt(w.fx.color), width: w.fx.width ?? 2,
style: w.fx.style, trail: w.fx.trail ? p.trail : null,
});
}
return { nanoLinks, projectiles };
}
_drawPlacementGhost() {
const g = this.gGhost;
g.clear();
const p = this.placement;
if (!p) return;
const ts = this.ts;
const x = p.tx * ts, y = p.ty * ts;
const w = p.def.footprint.w * ts, h = p.def.footprint.h * ts;
g.fillStyle(p.legal ? 0x4affa0 : 0xff5a5a, 0.22);
g.fillRect(x, y, w, h);
g.lineStyle(2, p.legal ? 0x4affa0 : 0xff5a5a, 0.95);
g.strokeRect(x, y, w, h);
// Show the builder's reach so it's obvious why a distant placement won't start.
if (p.builderX != null) {
g.lineStyle(1, 0x8ad4ff, 0.35);
g.strokeCircle(p.builderX, p.builderY, p.buildRange);
}
}
/** Highlight mass spots while a Mass Generator is being placed. */
highlightMassSpots(on) {
if (this.massSpotG) { this.massSpotG.destroy(); this.massSpotG = null; }
if (!on) return;
const { state, rules, ts } = this;
const g = this.scene.add.graphics().setDepth(DEPTHS.decal);
this.worldRoot.add(g);
g.lineStyle(2, 0xffd27a, 0.85);
for (let ty = 0; ty < state.h; ty++) {
for (let tx = 0; tx < state.w; tx++) {
const t = rules.terrain[state.terrain[ty * state.w + tx]];
if (!t.massMultiplier) continue;
g.strokeRect(tx * ts + 2, ty * ts + 2, ts - 4, ts - 4);
}
}
this.massSpotG = g;
}
destroy() {
for (const id of [...this.sprites.keys()]) this._releaseSprite(id);
for (const [, chunk] of this.chunks) chunk.rt.destroy();
this.chunks.clear();
this.massSpotG?.destroy();
this.fogImg?.destroy();
if (this.scene.textures.exists('ta-fog-canvas')) this.scene.textures.remove('ta-fog-canvas');
this._stamp?.destroy();
this.worldRoot.destroy(true);
this.uiRoot.destroy(true);
if (this.uiCam) this.scene.cameras.remove(this.uiCam);
}
}
function drawBar(g, cx, y, w, frac, color) {
const h = 4;
const x = cx - w / 2;
g.fillStyle(0x101418, 0.85);
g.fillRect(x - 1, y - 1, w + 2, h + 2);
g.fillStyle(color, 1);
g.fillRect(x, y, w * Math.max(0, Math.min(1, frac)), h);
}
function lerpAngle(a, b, t) {
let d = (b - a) % (Math.PI * 2);
if (d > Math.PI) d -= Math.PI * 2;
if (d < -Math.PI) d += Math.PI * 2;
return a + d * t;
}