diff --git a/assets/images/civilization/civilization-cities-steampunk.png b/assets/images/civilization/civilization-cities-steampunk.png new file mode 100644 index 0000000..c3dc646 Binary files /dev/null and b/assets/images/civilization/civilization-cities-steampunk.png differ diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index c12cab3..05694aa 100644 Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd index 3818930..4217d1c 100644 Binary files a/assets/images/game-icons.psd and b/assets/images/game-icons.psd differ diff --git a/data/totalannihilation-artwork.json b/data/totalannihilation-artwork.json new file mode 100644 index 0000000..1e633a0 --- /dev/null +++ b/data/totalannihilation-artwork.json @@ -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" + } + } + } +} diff --git a/data/totalannihilation-rules.json b/data/totalannihilation-rules.json new file mode 100644 index 0000000..c7a6e3d --- /dev/null +++ b/data/totalannihilation-rules.json @@ -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 + } + } +} diff --git a/src/games/totalannihilation/TAAI.js b/src/games/totalannihilation/TAAI.js new file mode 100644 index 0000000..31d8287 --- /dev/null +++ b/src/games/totalannihilation/TAAI.js @@ -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 }; diff --git a/src/games/totalannihilation/TAArt.js b/src/games/totalannihilation/TAArt.js new file mode 100644 index 0000000..689af98 --- /dev/null +++ b/src/games/totalannihilation/TAArt.js @@ -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, 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 }; diff --git a/src/games/totalannihilation/TAFx.js b/src/games/totalannihilation/TAFx.js new file mode 100644 index 0000000..ea21c2f --- /dev/null +++ b/src/games/totalannihilation/TAFx.js @@ -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 }; diff --git a/src/games/totalannihilation/TALogic.js b/src/games/totalannihilation/TALogic.js new file mode 100644 index 0000000..2d7a102 --- /dev/null +++ b/src/games/totalannihilation/TALogic.js @@ -0,0 +1,1451 @@ +// Total Annihilation — the simulation. +// +// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js. +// +// Contract with the view: the scene NEVER mutates state. It calls issueOrder() for player +// commands and step()/tick() to advance time; tick() returns an event list the view replays +// as animation. The AI calls the exact same issueOrder(), which is what lets the verify +// script bot the human's side and assert that a campaign mission is actually winnable. +// +// Determinism: all randomness comes from rngNext(state) (mulberry32 seeded into state), so a +// seed plus an order log replays exactly. Never call Math.random() in this file, TANav.js, +// TAAI.js or TAMapGen.js. + +import { + createNav, stampFootprint, clearanceFor, findPath, smoothPath, formationSlots, + nearestUsableTile, tileIndex, worldToTileX, worldToTileY, tileCenterX, tileCenterY, + SpatialHash, segmentClear, +} from './TANav.js'; + +export const SAVE_VERSION = 1; + +// --------------------------------------------------------------------------- +// RNG — mulberry32, state carried in the match so saves resume deterministically +// --------------------------------------------------------------------------- + +export function rngNext(state) { + let t = (state.rngState = (state.rngState + 0x6d2b79f5) >>> 0); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} + +export function rngRange(state, lo, hi) { return lo + rngNext(state) * (hi - lo); } +export function rngInt(state, n) { return Math.floor(rngNext(state) * n); } + +// --------------------------------------------------------------------------- +// Match creation +// --------------------------------------------------------------------------- + +/** + * @param {object} rules compiled rules + * @param {object} opts { seed, map:{w,h,terrain,starts,theme}, armies:[{armyId,commanderId,isHuman,aiSkill}] } + */ +export function createMatch(rules, opts) { + const { map } = opts; + const c = rules.constants; + const state = { + v: SAVE_VERSION, + rulesVersion: rules.version, + seed: opts.seed >>> 0, + rngState: (opts.seed >>> 0) || 1, + tick: 0, + accumulatorMs: 0, + alpha: 0, + w: map.w, h: map.h, + tileSize: c.tileSize, + worldW: map.w * c.tileSize, + worldH: map.h * c.tileSize, + theme: map.theme ?? rules.skirmish.defaults.theme, + terrain: map.terrain, + starts: (map.starts ?? []).map((s) => ({ army: s.army, x: s.x, y: s.y })), + armies: [], + entities: [], + nextId: 1, + projectiles: [], + nextProjectileId: 1, + pathQueue: [], + events: [], + over: null, + elapsedSec: 0, + }; + + state.nav = createNav(rules, map); + state.hash = new SpatialHash(128, state.worldW, state.worldH); + + const visW = Math.ceil(map.w / 2), visH = Math.ceil(map.h / 2); + state.visW = visW; state.visH = visH; + + for (let i = 0; i < opts.armies.length; i++) { + const spec = opts.armies[i]; + state.armies.push({ + idx: i, + armyId: spec.armyId, + commanderId: spec.commanderId ?? null, + isHuman: !!spec.isHuman, + aiSkill: spec.aiSkill ?? 3, + aiProfile: spec.aiProfile ?? null, + energy: spec.startEnergy ?? c.startEnergy, + mass: spec.startMass ?? c.startMass, + energyCap: c.baseEnergyCap, + massCap: c.baseMassCap, + eIncome: 0, mIncome: 0, eUpkeep: 0, eDrain: 0, mDrain: 0, + stallE: 1, stallM: 1, + alive: true, + explored: new Uint8Array(visW * visH), + visible: new Uint8Array(visW * visH), + builtEver: 0, lostEver: 0, killsEver: 0, + }); + } + + // Starting units. + for (const s of map.starts ?? []) { + if (s.army >= state.armies.length) continue; + for (const unitId of rules.skirmish.startUnits) { + spawnUnit(state, rules, s.army, unitId, + tileCenterX(state.nav, s.x), tileCenterY(state.nav, s.y), Math.PI / 2); + } + } + // Anything the map pre-places (campaign missions use this). + for (const u of map.units ?? []) { + spawnUnit(state, rules, u.army, u.type, u.x * c.tileSize, u.y * c.tileSize, u.heading ?? 0); + } + for (const b of map.buildings ?? []) { + const e = placeBuilding(state, rules, b.army, b.type, b.tx, b.ty); + if (e) { e.progress = 1; e.site = false; e.hp = rules.defById[b.type].hp; onBuildingComplete(state, rules, e); } + } + + recomputeEconomyCaps(state, rules); + computeVision(state, rules); + return state; +} + +// --------------------------------------------------------------------------- +// Entities +// --------------------------------------------------------------------------- + +function baseEntity(state, army, def) { + return { + id: state.nextId++, + army, + defId: def.id, + isBuilding: !!def.isBuilding, + x: 0, y: 0, px: 0, py: 0, + heading: 0, pheading: 0, + turretRot: 0, pturretRot: 0, + vx: 0, vy: 0, + hp: def.hp, maxHp: def.hp, + dead: false, + site: false, progress: 1, + orders: [], path: null, pathIdx: 0, + destX: 0, destY: 0, slotX: 0, slotY: 0, + wantPath: false, noPath: false, + stuckTicks: 0, blockedTicks: 0, + targetId: 0, reload: (def.weaponDefs ?? []).map(() => 0), + burstLeft: (def.weaponDefs ?? []).map(() => 0), + buildTargetId: 0, + queue: [], jobProgress: 0, rallyX: 0, rallyY: 0, hasRally: false, + _power: 0, + produceE: 0, produceM: 0, upkeepE: 0, storeE: 0, storeM: 0, + }; +} + +export function spawnUnit(state, rules, army, defId, x, y, heading = 0) { + const def = rules.unitById[defId]; + if (!def) return null; + const e = baseEntity(state, army, def); + e.x = e.px = x; + e.y = e.py = y; + e.heading = e.pheading = heading; + e.turretRot = e.pturretRot = heading; + e.radius = def.radius; + e.destX = x; e.destY = y; + e.rallyX = x; e.rallyY = y; + if (def.produce) { e.produceE = def.produce.energy ?? 0; e.produceM = def.produce.mass ?? 0; } + if (def.storage) { e.storeE = def.storage.energy ?? 0; e.storeM = def.storage.mass ?? 0; } + state.entities.push(e); + state.armies[army].builtEver++; + state.events.push({ t: 'spawn', id: e.id, army, defId, x, y }); + return e; +} + +/** Create a build SITE. It blocks the nav grid immediately so pathing routes around it. */ +export function placeBuilding(state, rules, army, defId, tx, ty) { + const def = rules.buildingById[defId]; + if (!def) return null; + const ts = state.tileSize; + const e = baseEntity(state, army, def); + e.tx = tx; e.ty = ty; + e.fw = def.footprint.w; e.fh = def.footprint.h; + e.x = e.px = (tx + e.fw / 2) * ts; + e.y = e.py = (ty + e.fh / 2) * ts; + e.radius = def.radius; + e.site = true; + e.progress = 0; + e.hp = Math.max(1, def.hp * 0.05); + state.entities.push(e); + stampFootprint(state.nav, rules, tx, ty, e.fw, e.fh, true); + state.events.push({ t: 'siteCreated', id: e.id, army, defId, x: e.x, y: e.y }); + return e; +} + +function onBuildingComplete(state, rules, e) { + const def = rules.buildingById[e.defId]; + e.site = false; + e.progress = 1; + e.hp = def.hp; + if (def.produce) { + e.produceE = def.produce.energy ?? 0; + let m = def.produce.mass ?? 0; + // Terrain bonus (a Mass Generator on a metal patch): resolved once, at completion. + if (m > 0 && def.terrainMultiplier) { + let best = 1; + for (let y = e.ty; y < e.ty + e.fh; y++) { + for (let x = e.tx; x < e.tx + e.fw; x++) { + if (x < 0 || y < 0 || x >= state.w || y >= state.h) continue; + const t = rules.terrain[state.terrain[y * state.w + x]]; + const mul = t?.[def.terrainMultiplier]; + if (mul > best) best = mul; + } + } + m *= best; + e.terrainBonus = best; + } + e.produceM = m; + } + if (def.upkeep) e.upkeepE = def.upkeep.energy ?? 0; + if (def.storage) { e.storeE = def.storage.energy ?? 0; e.storeM = def.storage.mass ?? 0; } + recomputeEconomyCaps(state, rules); + state.events.push({ t: 'buildingComplete', id: e.id, army: e.army, defId: e.defId, x: e.x, y: e.y }); +} + +export function entityById(state, id) { + if (!id) return null; + for (let i = 0; i < state.entities.length; i++) { + const e = state.entities[i]; + if (e.id === id) return e.dead ? null : e; + } + return null; +} + +function defOf(rules, e) { return rules.defById[e.defId]; } + +// --------------------------------------------------------------------------- +// Order API — the single mutation entry point shared by the human and the AI +// --------------------------------------------------------------------------- + +const ORDER_TYPES = new Set([ + 'move', 'attackMove', 'attack', 'stop', 'hold', 'patrol', 'guard', 'build', 'assist', +]); + +/** + * @param {object} cmd { army, unitIds:[], order:{type,...}, queue:boolean } + * @returns {{ok:boolean, error?:string}} + */ +export function issueOrder(state, rules, cmd) { + if (state.over) return { ok: false, error: 'match is over' }; + const { army, order } = cmd; + if (!order) return { ok: false, error: 'no order' }; + + // Factory / rally commands act on a building, not on a unit's order queue. + if (order.type === 'factoryEnqueue' || order.type === 'factoryCancel' || order.type === 'setRally') { + return factoryCommand(state, rules, army, order); + } + if (!ORDER_TYPES.has(order.type)) return { ok: false, error: `unknown order "${order.type}"` }; + + const units = []; + for (const id of cmd.unitIds ?? []) { + const e = entityById(state, id); + if (!e || e.army !== army || e.isBuilding || e.site) continue; + units.push(e); + } + if (!units.length) return { ok: false, error: 'no controllable units' }; + + if (order.type === 'build') return buildCommand(state, rules, army, units, order, cmd.queue); + + if (order.type === 'attack') { + const target = entityById(state, order.targetId); + if (!target) return { ok: false, error: 'target does not exist' }; + if (target.army === army && !rules.constants.friendlyFire) { + return { ok: false, error: 'cannot attack an ally' }; + } + } + if (order.type === 'assist') { + const target = entityById(state, order.targetId); + if (!target || target.army !== army) return { ok: false, error: 'can only assist your own' }; + if (!canBuild(rules, units[0]) ) return { ok: false, error: 'unit cannot assist' }; + } + if (order.type === 'guard') { + const target = entityById(state, order.targetId); + if (!target || target.army !== army) return { ok: false, error: 'can only guard your own' }; + } + + const moveLike = order.type === 'move' || order.type === 'attackMove' || order.type === 'patrol'; + if (moveLike) { + if (!Number.isFinite(order.x) || !Number.isFinite(order.y)) return { ok: false, error: 'bad destination' }; + // One shared destination, fanned into formation slots — a 40-unit move order costs one A*. + const slots = formationSlots(order.x, order.y, units); + units.forEach((u, i) => { + const o = { ...order, sx: slots[i * 2], sy: slots[i * 2 + 1] }; + if (order.type === 'patrol') { o.fromX = u.x; o.fromY = u.y; o.leg = 0; } + pushOrder(u, o, cmd.queue); + }); + return { ok: true }; + } + + for (const u of units) pushOrder(u, { ...order }, cmd.queue); + return { ok: true }; +} + +function pushOrder(e, order, queue) { + if (!queue) { + e.orders.length = 0; + e.path = null; + e.wantPath = false; + e.noPath = false; + } + e.orders.push(order); +} + +function canBuild(rules, e) { + const def = defOf(rules, e); + return !!(def.builds ?? []).length && def.buildPower > 0; +} + +function buildCommand(state, rules, army, units, order, queue) { + const builder = units.find((u) => canBuild(rules, u)); + if (!builder) return { ok: false, error: 'no builder selected' }; + const def = rules.buildingById[order.defId]; + if (!def) return { ok: false, error: `unknown building "${order.defId}"` }; + const bdef = defOf(rules, builder); + if (!(bdef.builds ?? []).includes(order.defId)) { + return { ok: false, error: `${bdef.name} cannot build ${def.name}` }; + } + const legal = canPlaceAt(state, rules, order.tx, order.ty, def); + if (!legal.ok) return legal; + + const site = placeBuilding(state, rules, army, order.defId, order.tx, order.ty); + if (!site) return { ok: false, error: 'placement failed' }; + pushOrder(builder, { type: 'build', targetId: site.id }, queue); + return { ok: true, siteId: site.id }; +} + +/** Can this footprint go here? Checks bounds, terrain buildability and occupancy. */ +export function canPlaceAt(state, rules, tx, ty, def) { + const fw = def.footprint.w, fh = def.footprint.h; + if (tx < 0 || ty < 0 || tx + fw > state.w || ty + fh > state.h) { + return { ok: false, error: 'off the map' }; + } + for (let y = ty; y < ty + fh; y++) { + for (let x = tx; x < tx + fw; x++) { + const i = y * state.w + x; + if (state.nav.blocked[i]) return { ok: false, error: 'occupied' }; + const t = rules.terrain[state.terrain[i]]; + if (!t.buildable) return { ok: false, error: `cannot build on ${t.id}` }; + } + } + return { ok: true }; +} + +function factoryCommand(state, rules, army, order) { + const f = entityById(state, order.factoryId); + if (!f || f.army !== army || !f.isBuilding || f.site) return { ok: false, error: 'no such factory' }; + const def = defOf(rules, f); + if (order.type === 'setRally') { + f.rallyX = order.x; f.rallyY = order.y; f.hasRally = true; + return { ok: true }; + } + if (order.type === 'factoryCancel') { + const idx = order.index ?? 0; + if (idx < 0 || idx >= f.queue.length) return { ok: false, error: 'bad queue index' }; + const item = f.queue[idx]; + const take = Math.min(item.count, order.count ?? 1); + item.count -= take; + if (idx === 0 && f.jobProgress > 0 && item.count === 0) { + // Refund what the cancelled head job actually consumed. + const udef = rules.unitById[item.defId]; + const a = state.armies[army]; + a.energy = Math.min(a.energyCap, a.energy + udef.cost.energy * f.jobProgress); + a.mass = Math.min(a.massCap, a.mass + udef.cost.mass * f.jobProgress); + f.jobProgress = 0; + } + if (item.count <= 0) f.queue.splice(idx, 1); + state.events.push({ t: 'queueChanged', id: f.id }); + return { ok: true }; + } + // factoryEnqueue + if (!(def.builds ?? []).includes(order.defId)) { + return { ok: false, error: `${def.name} cannot build ${order.defId}` }; + } + const count = Math.max(1, order.count ?? 1); + const head = f.queue[f.queue.length - 1]; + if (head && head.defId === order.defId) head.count += count; + else f.queue.push({ defId: order.defId, count }); + state.events.push({ t: 'queueChanged', id: f.id }); + return { ok: true }; +} + +// --------------------------------------------------------------------------- +// Economy +// --------------------------------------------------------------------------- + +function recomputeEconomyCaps(state, rules) { + const c = rules.constants; + for (const a of state.armies) { a.energyCap = c.baseEnergyCap; a.massCap = c.baseMassCap; } + for (const e of state.entities) { + if (e.dead || e.site) continue; + const a = state.armies[e.army]; + if (!a) continue; + a.energyCap += e.storeE; + a.massCap += e.storeM; + } +} + +/** + * The flow economy. Income and drain are RATES; construction consumes continuously and + * slows proportionally when income can't keep up — that stall factor is the whole reason + * Energy and Mass Generators are separate buildings. + */ +function stepEconomy(state, rules) { + const dt = rules.dt; + const nominal = rules.constants.buildPowerNominal; + + for (const a of state.armies) { + a.eIncome = 0; a.mIncome = 0; a.eUpkeep = 0; a.eDrain = 0; a.mDrain = 0; + a._mFree = 0; a._mGated = 0; a.upkeepFactor = 1; + } + for (const e of state.entities) e._power = 0; + + // 1. Income and upkeep from completed entities. Mass production is split into what runs + // for free and what is gated behind an energy upkeep (the Mass Generator), because + // those two behave completely differently in a brownout — see step 4. + for (const e of state.entities) { + if (e.dead || e.site) continue; + const a = state.armies[e.army]; + if (!a) continue; + a.eIncome += e.produceE; + a.eUpkeep += e.upkeepE; + if (e.upkeepE > 0) a._mGated += e.produceM; + else a._mFree += e.produceM; + } + + // 2. Gather build power onto each job. + for (const e of state.entities) { + if (e.dead || e.site) continue; + const def = defOf(rules, e); + if (!def.buildPower) continue; + if (e.isBuilding) { + if (e.queue.length) e._power += def.buildPower; // factory works its own head item + } else if (e.buildTargetId) { + const target = entityById(state, e.buildTargetId); + if (target && target.army === e.army) { + const d = Math.hypot(target.x - e.x, target.y - e.y); + if (d <= def.buildRange + target.radius) target._power += def.buildPower; + } + } + } + + // 3. Demand. + const jobs = []; + for (const e of state.entities) { + if (e.dead || e._power <= 0) continue; + let jobDef = null; + if (e.site) jobDef = defOf(rules, e); + else if (e.isBuilding && e.queue.length) jobDef = rules.unitById[e.queue[0].defId]; + if (!jobDef || !(jobDef.buildTime > 0)) continue; + const rate = (e._power / nominal) / jobDef.buildTime; // progress fraction per second + const a = state.armies[e.army]; + if (!a) continue; + const dE = (jobDef.cost.energy ?? 0) * rate; + const dM = (jobDef.cost.mass ?? 0) * rate; + a.eDrain += dE; + a.mDrain += dM; + jobs.push({ e, rate, army: e.army }); + } + + // 4. Stall factors, then apply. + // + // Claim order on energy is CONSTRUCTION FIRST, upkeep second. That ordering matters: + // if upkeep were paid first, an army that over-built Mass Generators (upkeep > income) + // would pin its energy at zero, drop build efficiency to zero, and be permanently + // unable to finish the very Energy Generators that would rescue it. Throttling the + // metal makers instead is both self-correcting and what a TA player does by hand. + for (const a of state.armies) { + const eAvail = Math.max(0, a.energy + a.eIncome * dt); + const eNeed = a.eDrain * dt; + a.stallE = eNeed > eAvail ? (eNeed > 0 ? eAvail / eNeed : 1) : 1; + + const eLeft = Math.max(0, eAvail - eNeed * a.stallE); + const upkeepNeed = a.eUpkeep * dt; + a.upkeepFactor = upkeepNeed > 0 ? Math.min(1, eLeft / upkeepNeed) : 1; + // Gated producers only deliver in proportion to the upkeep they were actually paid. + a.mIncome = a._mFree + a._mGated * a.upkeepFactor; + + const mAvail = Math.max(0, a.mass + a.mIncome * dt); + const mNeed = a.mDrain * dt; + a.stallM = mNeed > mAvail ? (mNeed > 0 ? mAvail / mNeed : 1) : 1; + a.buildEff = Math.min(a.stallE, a.stallM); + if (a.buildEff < 0.999 && !a._stalling) { + a._stalling = true; + state.events.push({ t: 'stallStart', army: a.idx, energy: a.stallE < 0.999, mass: a.stallM < 0.999 }); + } else if (a.buildEff >= 0.999 && a._stalling) { + a._stalling = false; + state.events.push({ t: 'stallEnd', army: a.idx }); + } + } + + for (const j of jobs) { + const a = state.armies[j.army]; + j.e.__adv = j.rate * a.buildEff * dt; + } + + for (const a of state.armies) { + const f = a.buildEff; + const eNext = a.energy + (a.eIncome - a.eUpkeep * a.upkeepFactor - a.eDrain * f) * dt; + const mNext = a.mass + (a.mIncome - a.mDrain * f) * dt; + if (eNext > a.energyCap || mNext > a.massCap) { + state.events.push({ t: 'wasting', army: a.idx, energy: eNext > a.energyCap, mass: mNext > a.massCap }); + } + a.energy = Math.max(0, Math.min(a.energyCap, eNext)); + a.mass = Math.max(0, Math.min(a.massCap, mNext)); + } + + // 5. Advance construction. + for (const j of jobs) { + const e = j.e; + const adv = e.__adv ?? 0; + e.__adv = 0; + if (adv <= 0) continue; + if (e.site) { + const def = defOf(rules, e); + e.progress = Math.min(1, e.progress + adv); + e.hp = Math.max(1, def.hp * Math.max(0.05, e.progress)); + state.events.push({ t: 'nanolathe', id: e.id, x: e.x, y: e.y, army: e.army }); + if (e.progress >= 1) { + onBuildingComplete(state, rules, e); + // Release any builder that was working on it. + for (const b of state.entities) { + if (b.buildTargetId === e.id) { + b.buildTargetId = 0; + if (b.orders[0]?.type === 'build' && b.orders[0].targetId === e.id) b.orders.shift(); + } + } + } + } else { + e.jobProgress = Math.min(1, e.jobProgress + adv); + state.events.push({ t: 'nanolathe', id: e.id, x: e.x, y: e.y, army: e.army }); + if (e.jobProgress >= 1) finishFactoryJob(state, rules, e); + } + } +} + +function finishFactoryJob(state, rules, f) { + const item = f.queue[0]; + if (!item) { f.jobProgress = 0; return; } + const def = defOf(rules, f); + const off = def.spawnOffset ?? { x: 0, y: 1 }; + const ts = state.tileSize; + let sx = f.x + off.x * ts; + let sy = f.y + off.y * ts; + // Nudge out of the footprint if the bay mouth is somehow blocked. + const nav = state.nav; + const udef = rules.unitById[item.defId]; + const need = clearanceFor(udef.radius, ts); + const mc = udef.moveClass; + let tx = worldToTileX(nav, sx), ty = worldToTileY(nav, sy); + const idx = nearestUsableTile(nav, mc, need, tx, ty, 6); + if (idx >= 0) { sx = tileCenterX(nav, idx % nav.w); sy = tileCenterY(nav, (idx / nav.w) | 0); } + + const u = spawnUnit(state, rules, f.army, item.defId, sx, sy, Math.atan2(off.y, off.x)); + f.jobProgress = 0; + item.count--; + if (item.count <= 0) f.queue.shift(); + state.events.push({ t: 'queueChanged', id: f.id }); + if (u && f.hasRally) { + issueOrder(state, rules, { + army: f.army, unitIds: [u.id], order: { type: 'move', x: f.rallyX, y: f.rallyY }, queue: false, + }); + } +} + +// --------------------------------------------------------------------------- +// Orders and movement +// --------------------------------------------------------------------------- + +function requestPath(state, e, x, y) { + e.destX = x; e.destY = y; + e.wantPath = true; + e.noPath = false; + if (!state.pathQueue.includes(e.id)) state.pathQueue.push(e.id); +} + +function servicePathQueue(state, rules) { + let budget = rules.constants.pathBudgetPerTick; + while (budget-- > 0 && state.pathQueue.length) { + const id = state.pathQueue.shift(); + const e = entityById(state, id); + if (!e || !e.wantPath) continue; + const def = defOf(rules, e); + const nav = state.nav; + const need = clearanceFor(e.radius, state.tileSize); + const mc = def.moveClass; + + const sIdx = nearestUsableTile(nav, mc, need, worldToTileX(nav, e.x), worldToTileY(nav, e.y), 6); + const gIdx = nearestUsableTile(nav, mc, need, worldToTileX(nav, e.destX), worldToTileY(nav, e.destY), 24); + e.wantPath = false; + if (sIdx < 0 || gIdx < 0) { e.noPath = true; e.path = null; continue; } + + const tiles = findPath(nav, mc, need, sIdx, gIdx); + if (!tiles) { e.noPath = true; e.path = null; state.events.push({ t: 'noPath', id: e.id }); continue; } + e.path = smoothPath(nav, mc, need, tiles, e.destX, e.destY); + e.pathIdx = 0; + } +} + +function orderDestination(e, order) { + if (order.type === 'patrol') { + return order.leg === 0 ? { x: order.sx ?? order.x, y: order.sy ?? order.y } + : { x: order.fromX, y: order.fromY }; + } + return { x: order.sx ?? order.x, y: order.sy ?? order.y }; +} + +function stepOrders(state, rules) { + const ts = state.tileSize; + for (const e of state.entities) { + if (e.dead || e.site) continue; + if (e.isBuilding) continue; + const def = defOf(rules, e); + const order = e.orders[0]; + + if (!order) { + e.movingTo = null; + e.buildTargetId = 0; + continue; + } + + switch (order.type) { + case 'stop': + e.orders.length = 0; + e.path = null; + e.targetId = 0; + e.buildTargetId = 0; + break; + + case 'hold': + e.path = null; + e.movingTo = null; + break; + + case 'move': + case 'attackMove': + case 'patrol': { + const dest = orderDestination(e, order); + if (!e.path && !e.wantPath && !e.noPath) requestPath(state, e, dest.x, dest.y); + e.movingTo = dest; + const d = Math.hypot(dest.x - e.x, dest.y - e.y); + const slack = Math.max(rules.constants.arriveSlackPx, e.radius * 1.2); + if (d <= slack || (e.noPath && e.stuckTicks > rules.constants.stuckGiveUpSec * rules.constants.tickHz)) { + if (order.type === 'patrol') { + order.leg = order.leg === 0 ? 1 : 0; + e.path = null; e.noPath = false; e.stuckTicks = 0; + requestPath(state, e, ...(order.leg === 0 + ? [order.sx ?? order.x, order.sy ?? order.y] : [order.fromX, order.fromY])); + } else { + e.orders.shift(); + e.path = null; e.movingTo = null; e.stuckTicks = 0; e.noPath = false; + } + } + break; + } + + case 'attack': { + const target = entityById(state, order.targetId); + if (!target) { e.orders.shift(); e.targetId = 0; e.path = null; break; } + e.targetId = target.id; + const d = Math.hypot(target.x - e.x, target.y - e.y) - target.radius; + const wantRange = def.maxRange * 0.85; + if (d > wantRange) { + if (!e.path && !e.wantPath) requestPath(state, e, target.x, target.y); + e.movingTo = { x: target.x, y: target.y }; + // Re-path when the target has drifted well away from where we aimed. + if (e.path && Math.hypot(target.x - e.destX, target.y - e.destY) > ts * 3) { + e.path = null; requestPath(state, e, target.x, target.y); + } + } else { + e.path = null; + e.movingTo = null; + } + break; + } + + case 'guard': { + const target = entityById(state, order.targetId); + if (!target) { e.orders.shift(); break; } + const d = Math.hypot(target.x - e.x, target.y - e.y); + if (d > ts * 3) { + if (!e.path && !e.wantPath) requestPath(state, e, target.x, target.y); + e.movingTo = { x: target.x, y: target.y }; + if (e.path && Math.hypot(target.x - e.destX, target.y - e.destY) > ts * 2) { + e.path = null; requestPath(state, e, target.x, target.y); + } + } else { + e.path = null; e.movingTo = null; + } + break; + } + + case 'build': + case 'assist': { + const target = entityById(state, order.targetId); + if (!target) { e.orders.shift(); e.buildTargetId = 0; e.path = null; break; } + if (order.type === 'assist' && !target.site && !target.queue?.length) { + e.orders.shift(); e.buildTargetId = 0; break; + } + const reach = (def.buildRange ?? 0) + target.radius; + const d = Math.hypot(target.x - e.x, target.y - e.y); + if (d > reach * 0.9) { + if (!e.path && !e.wantPath && !e.noPath) requestPath(state, e, target.x, target.y); + e.movingTo = { x: target.x, y: target.y }; + e.buildTargetId = 0; + } else { + e.path = null; + e.movingTo = null; + e.buildTargetId = target.id; + } + break; + } + default: + e.orders.shift(); + } + } +} + +function stepMovement(state, rules) { + const dt = rules.dt; + const nav = state.nav; + + for (const e of state.entities) { + e.px = e.x; e.py = e.y; e.pheading = e.heading; e.pturretRot = e.turretRot; + if (e.dead || e.isBuilding || e.site) { e.vx = 0; e.vy = 0; continue; } + + const def = defOf(rules, e); + let tx = null, ty = null; + + if (e.path && e.path.length >= 2) { + // Consume waypoints we've reached. + while (e.pathIdx * 2 + 1 < e.path.length) { + const wx = e.path[e.pathIdx * 2], wy = e.path[e.pathIdx * 2 + 1]; + const last = (e.pathIdx + 1) * 2 >= e.path.length; + const tol = last ? Math.max(6, e.radius * 0.6) : Math.max(10, e.radius); + if (Math.hypot(wx - e.x, wy - e.y) <= tol) e.pathIdx++; + else break; + } + if (e.pathIdx * 2 + 1 < e.path.length) { + tx = e.path[e.pathIdx * 2]; ty = e.path[e.pathIdx * 2 + 1]; + } else { + e.path = null; + } + } else if (e.movingTo && e.noPath) { + tx = e.movingTo.x; ty = e.movingTo.y; + } + + if (tx == null) { e.vx = 0; e.vy = 0; e.stuckTicks = 0; continue; } + + const dx = tx - e.x, dy = ty - e.y; + const dist = Math.hypot(dx, dy); + if (dist < 0.001) { e.vx = 0; e.vy = 0; continue; } + + const want = Math.atan2(dy, dx); + e.heading = turnToward(e.heading, want, def.turnRate * dt); + + // Terrain slows movement: speed scales by the inverse of the tile's move cost. + const ti = worldToTileY(nav, e.y) * state.w + worldToTileX(nav, e.x); + const tcost = rules.moveClasses[def.moveClass].costByTerrainIndex[state.terrain[ti]] ?? 1; + const terrainMul = tcost ? 1 / tcost : 1; + + // Slow down while still swinging round, and ease into the final waypoint. + const misalign = Math.abs(angleDelta(e.heading, want)); + const turnMul = misalign > 1.2 ? 0.25 : (misalign > 0.5 ? 0.65 : 1); + const arriveMul = dist < e.radius * 2 ? Math.max(0.25, dist / (e.radius * 2)) : 1; + + const speed = def.speed * terrainMul * turnMul * arriveMul; + const step = Math.min(speed * dt, dist); + e.vx = Math.cos(e.heading) * step / dt; + e.vy = Math.sin(e.heading) * step / dt; + e.x += Math.cos(e.heading) * step; + e.y += Math.sin(e.heading) * step; + + if (step < speed * dt * 0.2) e.stuckTicks++; else e.stuckTicks = 0; + } +} + +function turnToward(cur, want, maxStep) { + const d = angleDelta(cur, want); + if (Math.abs(d) <= maxStep) return want; + return normalizeAngle(cur + Math.sign(d) * maxStep); +} + +export function angleDelta(from, to) { + let d = (to - from) % (Math.PI * 2); + if (d > Math.PI) d -= Math.PI * 2; + if (d < -Math.PI) d += Math.PI * 2; + return d; +} + +function normalizeAngle(a) { + a %= Math.PI * 2; + if (a > Math.PI) a -= Math.PI * 2; + if (a < -Math.PI) a += Math.PI * 2; + return a; +} + +/** + * Positional separation. This is what makes the size classes real: three r=16 units settle + * inside one 64px tile, an r=26 tank takes the tile alone, and heavier units shove lighter + * ones aside rather than the other way round. + */ +function stepSeparation(state, rules) { + const stiff = rules.constants.separationStiffness; + const shoveTicks = rules.constants.shoveAfterSec * rules.constants.tickHz; + const hash = state.hash; + hash.clear(); + const movers = []; + for (const e of state.entities) { + if (e.dead || e.isBuilding || e.site) continue; + hash.insert(e, e.x, e.y); + movers.push(e); + } + + const near = []; + for (const a of movers) { + near.length = 0; + hash.query(a.x, a.y, a.radius * 2 + 32, near); + for (const b of near) { + if (b.id <= a.id) continue; // handle each pair once, deterministically + const dx = b.x - a.x, dy = b.y - a.y; + const minD = a.radius + b.radius; + let d2 = dx * dx + dy * dy; + if (d2 >= minD * minD) continue; + let d = Math.sqrt(d2); + let nx, ny; + if (d < 0.0001) { + // Exactly coincident: push apart along a deterministic axis derived from ids. + const ang = ((a.id * 2654435761 + b.id) % 628) / 100; + nx = Math.cos(ang); ny = Math.sin(ang); d = 0.0001; + } else { + nx = dx / d; ny = dy / d; + } + const overlap = (minD - d) * stiff; + const da = defOf(rules, a), db = defOf(rules, b); + const ma = da.massClass ?? 1, mb = db.massClass ?? 1; + const total = ma + mb; + const aShare = mb / total, bShare = ma / total; + a.x -= nx * overlap * aShare; a.y -= ny * overlap * aShare; + b.x += nx * overlap * bShare; b.y += ny * overlap * bShare; + + // Traffic-jam breaker: a unit trying to move that's stuck behind a parked friendly + // tells the blocker to step aside. Without this, groups deadlock in corridors. + if (a.army === b.army) { + const aMoving = !!(a.path || a.movingTo), bMoving = !!(b.path || b.movingTo); + if (aMoving && !bMoving) { b.blockedTicks++; if (b.blockedTicks > shoveTicks) shove(state, rules, b, nx, ny); } + else if (bMoving && !aMoving) { a.blockedTicks++; if (a.blockedTicks > shoveTicks) shove(state, rules, a, -nx, -ny); } + } + } + } + + // Push everything out of blocked tiles and back inside the map. + const nav = state.nav; + const ts = state.tileSize; + for (const e of movers) { + const def = defOf(rules, e); + const need = clearanceFor(e.radius, ts); + const cl = nav.clearance[def.moveClass]; + const tx = worldToTileX(nav, e.x), ty = worldToTileY(nav, e.y); + if (cl[ty * nav.w + tx] < need) { + const idx = nearestUsableTile(nav, def.moveClass, need, tx, ty, 8); + if (idx >= 0) { + const cx = tileCenterX(nav, idx % nav.w), cy = tileCenterY(nav, (idx / nav.w) | 0); + const d = Math.hypot(cx - e.x, cy - e.y) || 1; + e.x += ((cx - e.x) / d) * Math.min(ts * 0.5, d); + e.y += ((cy - e.y) / d) * Math.min(ts * 0.5, d); + } + } + e.x = Math.max(e.radius, Math.min(state.worldW - e.radius, e.x)); + e.y = Math.max(e.radius, Math.min(state.worldH - e.radius, e.y)); + } +} + +function shove(state, rules, e, nx, ny) { + e.blockedTicks = 0; + const ts = state.tileSize; + // Step perpendicular to the pusher, whichever side is open. + const target = { x: e.x - ny * ts * 1.5, y: e.y + nx * ts * 1.5 }; + issueOrder(state, rules, { + army: e.army, unitIds: [e.id], order: { type: 'move', x: target.x, y: target.y }, queue: false, + }); +} + +// --------------------------------------------------------------------------- +// Combat +// --------------------------------------------------------------------------- + +function stepCombat(state, rules) { + const c = rules.constants; + const dt = rules.dt; + const hash = state.hash; + const near = []; + + for (const e of state.entities) { + if (e.dead || e.site) continue; + const def = defOf(rules, e); + if (!def.weaponDefs?.length) continue; + + for (let wi = 0; wi < e.reload.length; wi++) if (e.reload[wi] > 0) e.reload[wi]--; + + // Staggered acquisition — the single biggest CPU lever in the sim. Re-scan only every + // retargetPeriodTicks, phase-shifted per unit so the cost spreads across ticks. + const holdOrder = e.orders[0]?.type === 'attack'; + let target = entityById(state, e.targetId); + if (target && !inWeaponRange(e, def, target)) target = null; + const due = ((state.tick + e.id) % c.retargetPeriodTicks) === 0; + if (!holdOrder && (!target || due)) { + target = acquireTarget(state, rules, e, def, hash, near); + e.targetId = target ? target.id : 0; + } else if (holdOrder) { + target = entityById(state, e.orders[0].targetId) ?? target; + e.targetId = target ? target.id : 0; + } + if (!target) continue; + + const dx = target.x - e.x, dy = target.y - e.y; + const aim = Math.atan2(dy, dx); + const hasTurret = def.turretFrame != null; + if (hasTurret) e.turretRot = turnToward(e.turretRot, aim, def.turnRate * 2.5 * dt); + else e.turretRot = e.heading; + + const facing = hasTurret ? e.turretRot : e.heading; + // A unit that's driving somewhere still shoots; it just has to be roughly on target. + const aligned = Math.abs(angleDelta(facing, aim)) < (hasTurret ? 0.12 : 0.35); + + for (let wi = 0; wi < def.weaponDefs.length; wi++) { + const w = def.weaponDefs[wi]; + if (e.reload[wi] > 0) continue; + const dist = Math.hypot(dx, dy) - target.radius; + if (dist > w.range || dist < (w.minRange ?? 0)) continue; + if (!aligned) continue; + if (w.kind === 'beam' && w.energyPerShot) { + const a = state.armies[e.army]; + if (a.energy < w.energyPerShot) continue; + a.energy -= w.energyPerShot; + } + fireWeapon(state, rules, e, w, target, facing); + e.burstLeft[wi] = (w.burst ?? 1) - 1; + e.reload[wi] = e.burstLeft[wi] > 0 ? w.burstDelayTicks : w.reloadTicks; + if (e.burstLeft[wi] <= 0) e.burstLeft[wi] = 0; + else e._burstWeapon = wi; + } + + // Continue an in-flight burst. + for (let wi = 0; wi < def.weaponDefs.length; wi++) { + const w = def.weaponDefs[wi]; + if (e.burstLeft[wi] > 0 && e.reload[wi] === 0) { + fireWeapon(state, rules, e, w, target, facing); + e.burstLeft[wi]--; + e.reload[wi] = e.burstLeft[wi] > 0 ? w.burstDelayTicks : w.reloadTicks; + } + } + } +} + +function inWeaponRange(e, def, target) { + if (target.dead) return false; + const d = Math.hypot(target.x - e.x, target.y - e.y) - target.radius; + return d <= def.maxRange; +} + +function acquireTarget(state, rules, e, def, hash, near) { + near.length = 0; + hash.query(e.x, e.y, def.maxRange + 64, near); + // Buildings aren't in the movers hash, so scan them directly — there are few of them. + let best = null, bestScore = -Infinity; + const consider = (t) => { + if (t.dead || t.army === e.army) return; + if (!state.armies[t.army]) return; + const dx = t.x - e.x, dy = t.y - e.y; + const d = Math.hypot(dx, dy) - t.radius; + if (d > def.maxRange) return; + let score = -Infinity; + for (const w of def.weaponDefs) { + if (d > w.range || d < (w.minRange ?? 0)) continue; + const mul = w.armorMul?.[rules.defById[t.defId].armorClass] ?? 1; + const s = (w.damage * mul) / (1 + d / w.range); + if (s > score) score = s; + } + if (score === -Infinity) return; + // Prefer things that can shoot back, and break ties by id so it's deterministic. + const tdef = rules.defById[t.defId]; + if (tdef.weaponDefs?.length) score *= 1.35; + if (t.site) score *= 0.6; + if (score > bestScore || (score === bestScore && best && t.id < best.id)) { + best = t; bestScore = score; + } + }; + for (const t of near) consider(t); + for (const t of state.entities) if (t.isBuilding && !t.dead) consider(t); + return best; +} + +function fireWeapon(state, rules, e, w, target, facing) { + const muzzle = (w.fx?.muzzle ?? e.radius); + const ox = e.x + Math.cos(facing) * muzzle; + const oy = e.y + Math.sin(facing) * muzzle; + + state.events.push({ + t: 'weaponFired', id: e.id, army: e.army, weapon: w.id, + x: ox, y: oy, heading: facing, style: w.fx.style, sound: w.sound, + }); + + if (w.kind === 'hitscan' || w.kind === 'beam') { + let hit = true; + if (w.spread > 0) { + // Spread becomes a miss chance that grows with range — cheap, and it means massed + // infantry still trade meaningfully at the edge of their envelope. + const d = Math.hypot(target.x - e.x, target.y - e.y); + const missChance = Math.min(0.6, w.spread * (d / w.range) * 3); + hit = rngNext(state) >= missChance; + } + const ex = hit ? target.x : target.x + rngRange(state, -24, 24); + const ey = hit ? target.y : target.y + rngRange(state, -24, 24); + state.events.push({ + t: 'shot', style: w.fx.style, x1: ox, y1: oy, x2: ex, y2: ey, + color: w.fx.color, width: w.fx.width, lifeMs: w.fx.lifeMs ?? 80, army: e.army, + }); + if (hit) { + applyDamage(state, rules, target, w.damage * (w.armorMul?.[rules.defById[target.defId].armorClass] ?? 1), e); + if (w.aoe) applyAoe(state, rules, target.x, target.y, w, e); + state.events.push({ t: 'impact', x: ex, y: ey, radius: w.aoe ?? 8, style: w.fx.style }); + } + return; + } + + // Travelling ordnance: leading matters, and a strafing target can genuinely be missed. + let aimX = target.x, aimY = target.y; + if (w.leadTarget) { + const d = Math.hypot(target.x - ox, target.y - oy); + const flight = d / w.speed; + aimX += (target.vx ?? 0) * flight; + aimY += (target.vy ?? 0) * flight; + } + let ang = Math.atan2(aimY - oy, aimX - ox); + if (w.spread > 0) ang += rngRange(state, -w.spread, w.spread); + + if (state.projectiles.length >= rules.constants.projectileCap) return; + state.projectiles.push({ + id: state.nextProjectileId++, + weapon: w.id, army: e.army, ownerId: e.id, + x: ox, y: oy, px: ox, py: oy, + vx: Math.cos(ang) * w.speed, vy: Math.sin(ang) * w.speed, + targetId: w.kind === 'guided' ? target.id : 0, + aimX, aimY, + ttl: Math.ceil(((w.range * 1.4) / w.speed) * rules.constants.tickHz), + trail: [], + }); +} + +function stepProjectiles(state, rules) { + const dt = rules.dt; + const keep = []; + for (const p of state.projectiles) { + const w = rules.weaponById[p.weapon]; + p.px = p.x; p.py = p.y; + + if (w.kind === 'guided') { + const target = entityById(state, p.targetId); + if (target) { + const want = Math.atan2(target.y - p.y, target.x - p.x); + const cur = Math.atan2(p.vy, p.vx); + const next = turnToward(cur, want, w.turnRate * dt); + p.vx = Math.cos(next) * w.speed; + p.vy = Math.sin(next) * w.speed; + p.aimX = target.x; p.aimY = target.y; + } + } + + p.x += p.vx * dt; + p.y += p.vy * dt; + if (w.fx?.trail) { + p.trail.push(p.x, p.y); + if (p.trail.length > 16) p.trail.splice(0, 2); + } + + let detonate = false; + if (--p.ttl <= 0) detonate = true; + if (p.x < 0 || p.y < 0 || p.x > state.worldW || p.y > state.worldH) detonate = true; + + // Proximity to the aim point (ballistic) or the live target (guided). + if (!detonate) { + const hitR = w.kind === 'guided' ? 14 : 12; + if (Math.hypot(p.aimX - p.x, p.aimY - p.y) <= hitR) detonate = true; + } + // Direct contact with any hostile it passes through. + if (!detonate) { + for (const e of state.entities) { + if (e.dead || e.site || e.army === p.army) continue; + if (Math.hypot(e.x - p.x, e.y - p.y) <= e.radius) { detonate = true; break; } + } + } + + if (detonate) { + state.events.push({ t: 'impact', x: p.x, y: p.y, radius: w.aoe ?? 12, style: w.fx.style }); + if (w.aoe) applyAoe(state, rules, p.x, p.y, w, { army: p.army, id: p.ownerId }); + else { + for (const e of state.entities) { + if (e.dead || e.army === p.army) continue; + if (Math.hypot(e.x - p.x, e.y - p.y) <= e.radius) { + applyDamage(state, rules, e, w.damage * (w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1), { army: p.army, id: p.ownerId }); + break; + } + } + } + } else { + keep.push(p); + } + } + state.projectiles = keep; +} + +function applyAoe(state, rules, x, y, w, source) { + const r = w.aoe; + const falloff = w.aoeFalloff ?? 0.4; + for (const e of state.entities) { + if (e.dead) continue; + if (e.army === source.army && !rules.constants.friendlyFire) continue; + const d = Math.hypot(e.x - x, e.y - y) - e.radius; + if (d > r) continue; + const t = Math.max(0, Math.min(1, d / r)); + const scale = 1 - (1 - falloff) * t; + const mul = w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1; + applyDamage(state, rules, e, w.damage * mul * scale, source); + } +} + +export function applyDamage(state, rules, target, amount, source) { + if (target.dead || amount <= 0) return; + target.hp -= amount; + state.events.push({ t: 'damaged', id: target.id, amount, x: target.x, y: target.y }); + if (target.hp <= 0) killEntity(state, rules, target, source); +} + +function killEntity(state, rules, e, source) { + if (e.dead) return; + e.dead = true; + e.hp = 0; + const def = defOf(rules, e); + state.armies[e.army].lostEver++; + if (source && state.armies[source.army]) state.armies[source.army].killsEver++; + state.events.push({ + t: 'unitDestroyed', id: e.id, army: e.army, defId: e.defId, + x: e.x, y: e.y, radius: e.radius, isBuilding: e.isBuilding, + }); + if (e.isBuilding) { + stampFootprint(state.nav, rules, e.tx, e.ty, e.fw, e.fh, false); + recomputeEconomyCaps(state, rules); + } + // Anyone building or assisting this is now idle. + for (const o of state.entities) { + if (o.buildTargetId === e.id) o.buildTargetId = 0; + if (o.targetId === e.id) o.targetId = 0; + } + if (def.deathExplosion) { + const ex = def.deathExplosion; + state.events.push({ t: 'bigExplosion', x: e.x, y: e.y, radius: ex.radius }); + for (const o of state.entities) { + if (o.dead || o.id === e.id) continue; + const d = Math.hypot(o.x - e.x, o.y - e.y) - o.radius; + if (d > ex.radius) continue; + const scale = 1 - Math.max(0, Math.min(1, d / ex.radius)) * 0.7; + applyDamage(state, rules, o, ex.damage * scale, { army: -1, id: 0 }); + } + } +} + +// --------------------------------------------------------------------------- +// Vision +// --------------------------------------------------------------------------- + +/** Coarse 2-tile vision grid. Recomputed every 4th tick — it does not need to be exact. */ +export function computeVision(state, rules) { + const cell = state.tileSize * 2; + for (const a of state.armies) a.visible.fill(0); + for (const e of state.entities) { + if (e.dead) continue; + const a = state.armies[e.army]; + if (!a) continue; + const def = defOf(rules, e); + const sight = e.site ? Math.max(64, (def.sight ?? 0) * 0.4) : (def.sight ?? 0); + if (sight <= 0) continue; + const r = Math.ceil(sight / cell); + const cx = Math.floor(e.x / cell), cy = Math.floor(e.y / cell); + for (let y = cy - r; y <= cy + r; y++) { + if (y < 0 || y >= state.visH) continue; + for (let x = cx - r; x <= cx + r; x++) { + if (x < 0 || x >= state.visW) continue; + const wx = x * cell + cell / 2, wy = y * cell + cell / 2; + if (Math.hypot(wx - e.x, wy - e.y) > sight) continue; + const i = y * state.visW + x; + a.visible[i] = 1; + a.explored[i] = 1; + } + } + } +} + +/** Is `e` currently visible to `armyIdx`? The AI is held to this, same as the player. */ +export function isVisibleTo(state, armyIdx, e) { + if (e.army === armyIdx) return true; + const a = state.armies[armyIdx]; + if (!a) return false; + const cell = state.tileSize * 2; + const x = Math.floor(e.x / cell), y = Math.floor(e.y / cell); + if (x < 0 || y < 0 || x >= state.visW || y >= state.visH) return false; + return a.visible[y * state.visW + x] === 1; +} + +// --------------------------------------------------------------------------- +// Victory +// --------------------------------------------------------------------------- + +function checkResult(state, rules) { + if (state.over) return; + const commanderEnds = rules.constants.commanderDeathEnds !== false; + for (const a of state.armies) { + if (!a.alive) continue; + let hasAny = false, hasBuilder = false; + for (const e of state.entities) { + if (e.dead || e.army !== a.idx || e.site) continue; + hasAny = true; + const def = rules.defById[e.defId]; + if (!e.isBuilding && (def.builds ?? []).length) hasBuilder = true; + } + // TA's "Commander Ends" rule. Without it a match can sit at the time limit while the + // loser hides one Mass Generator in a corner with no way to ever build again. + if (!hasAny || (commanderEnds && !hasBuilder)) { + a.alive = false; + state.events.push({ t: 'armyEliminated', army: a.idx, reason: hasAny ? 'commander' : 'wiped' }); + } + } + const alive = state.armies.filter((a) => a.alive); + if (alive.length <= 1) { + state.over = { winner: alive.length ? alive[0].idx : -1, tick: state.tick }; + state.events.push({ t: 'gameOver', winner: state.over.winner }); + } +} + +// --------------------------------------------------------------------------- +// The tick +// --------------------------------------------------------------------------- + +/** Advance the simulation one fixed step. Returns this tick's events. */ +export function tick(state, rules) { + state.events = []; + if (state.over) return state.events; + + state.tick++; + state.elapsedSec = state.tick * rules.dt; + + stepEconomy(state, rules); + stepOrders(state, rules); + servicePathQueue(state, rules); + stepMovement(state, rules); + stepSeparation(state, rules); + stepCombat(state, rules); + stepProjectiles(state, rules); + + if (state.tick % 4 === 0) computeVision(state, rules); + + // Reap the dead once per tick so nothing iterates a corpse mid-phase. + if (state.entities.some((e) => e.dead)) { + state.entities = state.entities.filter((e) => !e.dead); + } + checkResult(state, rules); + return state.events; +} + +/** + * Drive the sim from a wall-clock delta, draining whole fixed steps. The accumulator lives + * in state so the verify script runs the identical loop headlessly, and `state.alpha` gives + * the view its interpolation factor between the last two ticks. + */ +export function step(state, rules, deltaMs, maxSteps = 4) { + const out = []; + state.accumulatorMs += deltaMs; + let n = 0; + while (state.accumulatorMs >= rules.stepMs && n < maxSteps) { + state.accumulatorMs -= rules.stepMs; + const ev = tick(state, rules); + for (let i = 0; i < ev.length; i++) out.push(ev[i]); + n++; + } + // Spiral-of-death guard: if we hit the cap, drop the backlog rather than fall further behind. + if (n === maxSteps && state.accumulatorMs >= rules.stepMs) state.accumulatorMs = 0; + state.alpha = Math.min(1, state.accumulatorMs / rules.stepMs); + return out; +} + +// --------------------------------------------------------------------------- +// Serialization +// --------------------------------------------------------------------------- + +/** RLE the terrain — a 128x128 map compresses roughly 30:1, which matters because + * localStorage is a ~5MB budget shared across every game in this repo. */ +function rleEncode(arr) { + const out = []; + let run = 1; + for (let i = 1; i <= arr.length; i++) { + if (i < arr.length && arr[i] === arr[i - 1] && run < 65535) { run++; continue; } + out.push(arr[i - 1], run); + run = 1; + } + return out; +} + +function rleDecode(list, len) { + const arr = new Uint8Array(len); + let p = 0; + for (let i = 0; i < list.length; i += 2) { + const v = list[i], n = list[i + 1]; + for (let k = 0; k < n && p < len; k++) arr[p++] = v; + } + return arr; +} + +export function serialize(state) { + return JSON.stringify({ + v: SAVE_VERSION, + rulesVersion: state.rulesVersion, + seed: state.seed, rngState: state.rngState, + tick: state.tick, w: state.w, h: state.h, theme: state.theme, + terrain: rleEncode(state.terrain), + over: state.over, + nextId: state.nextId, + armies: state.armies.map((a) => ({ + armyId: a.armyId, commanderId: a.commanderId, isHuman: a.isHuman, + aiSkill: a.aiSkill, aiProfile: a.aiProfile, + energy: a.energy, mass: a.mass, alive: a.alive, + builtEver: a.builtEver, lostEver: a.lostEver, killsEver: a.killsEver, + explored: rleEncode(a.explored), + })), + entities: state.entities.map((e) => ({ + id: e.id, army: e.army, defId: e.defId, isBuilding: e.isBuilding, + x: Math.round(e.x * 8) / 8, y: Math.round(e.y * 8) / 8, + heading: Math.round(e.heading * 1000) / 1000, + turretRot: Math.round(e.turretRot * 1000) / 1000, + hp: Math.round(e.hp * 100) / 100, + site: e.site, progress: Math.round(e.progress * 1000) / 1000, + tx: e.tx, ty: e.ty, fw: e.fw, fh: e.fh, + orders: e.orders, queue: e.queue, jobProgress: Math.round(e.jobProgress * 1000) / 1000, + rallyX: e.rallyX, rallyY: e.rallyY, hasRally: e.hasRally, + targetId: e.targetId, buildTargetId: e.buildTargetId, + reload: e.reload, burstLeft: e.burstLeft, + terrainBonus: e.terrainBonus, + })), + // Projectiles are sub-second transients; dropping them costs nothing and saves bytes. + }); +} + +export function deserialize(rules, raw) { + let data; + try { data = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return null; } + if (!data || data.v !== SAVE_VERSION) return null; + if (data.rulesVersion !== rules.version) return null; + + const terrain = rleDecode(data.terrain, data.w * data.h); + const state = createMatch(rules, { + seed: data.seed, + map: { w: data.w, h: data.h, terrain, theme: data.theme, starts: [] }, + armies: data.armies.map((a) => ({ + armyId: a.armyId, commanderId: a.commanderId, isHuman: a.isHuman, + aiSkill: a.aiSkill, aiProfile: a.aiProfile, + })), + }); + + state.rngState = data.rngState; + state.tick = data.tick; + state.elapsedSec = data.tick * rules.dt; + state.over = data.over; + state.nextId = data.nextId; + state.entities = []; + state.nav.blocked.fill(0); + + data.armies.forEach((a, i) => { + Object.assign(state.armies[i], { + energy: a.energy, mass: a.mass, alive: a.alive, + builtEver: a.builtEver, lostEver: a.lostEver, killsEver: a.killsEver, + explored: rleDecode(a.explored, state.visW * state.visH), + }); + }); + + for (const s of data.entities) { + const def = rules.defById[s.defId]; + if (!def) continue; + const e = baseEntity(state, s.army, def); + Object.assign(e, s); + e.px = e.x; e.py = e.y; e.pheading = e.heading; e.pturretRot = e.turretRot; + e.radius = def.isBuilding ? def.radius : (def.radius ?? 16); + e.path = null; e.pathIdx = 0; e.wantPath = false; + state.entities.push(e); + if (e.isBuilding) { + stampFootprint(state.nav, rules, e.tx, e.ty, e.fw, e.fh, true); + if (!e.site) { + const bdef = rules.buildingById[e.defId]; + if (bdef.produce) { + e.produceE = bdef.produce.energy ?? 0; + e.produceM = (bdef.produce.mass ?? 0) * (e.terrainBonus ?? 1); + } + if (bdef.upkeep) e.upkeepE = bdef.upkeep.energy ?? 0; + if (bdef.storage) { e.storeE = bdef.storage.energy ?? 0; e.storeM = bdef.storage.mass ?? 0; } + } + } else { + const udef = rules.unitById[e.defId]; + if (udef.produce) { e.produceE = udef.produce.energy ?? 0; e.produceM = udef.produce.mass ?? 0; } + if (udef.storage) { e.storeE = udef.storage.energy ?? 0; e.storeM = udef.storage.mass ?? 0; } + } + } + state.nextId = data.nextId; + recomputeEconomyCaps(state, rules); + computeVision(state, rules); + return state; +} + +/** FNV-1a over the serialized state. Used for determinism and round-trip assertions. */ +export function hashState(state) { + const s = serialize(state); + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h >>> 0; +} + +// --------------------------------------------------------------------------- +// Queries used by the view, the HUD and the AI +// --------------------------------------------------------------------------- + +export function armyEntities(state, armyIdx, includeSites = true) { + return state.entities.filter((e) => !e.dead && e.army === armyIdx && (includeSites || !e.site)); +} + +export function buildOptionsFor(rules, e) { + const def = rules.defById[e.defId]; + return (def.builds ?? []).map((id) => rules.defById[id]); +} + +/** Can this army afford `def` right now, out of stored resources? */ +export function canAfford(state, armyIdx, def) { + const a = state.armies[armyIdx]; + return a.energy >= (def.cost?.energy ?? 0) * 0.25 && a.mass >= (def.cost?.mass ?? 0) * 0.25; +} + +export { segmentClear, tileIndex, worldToTileX, worldToTileY, tileCenterX, tileCenterY }; diff --git a/src/games/totalannihilation/TAMapGen.js b/src/games/totalannihilation/TAMapGen.js new file mode 100644 index 0000000..25d1766 --- /dev/null +++ b/src/games/totalannihilation/TAMapGen.js @@ -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 }; diff --git a/src/games/totalannihilation/TANav.js b/src/games/totalannihilation/TANav.js new file mode 100644 index 0000000..bda0056 --- /dev/null +++ b/src/games/totalannihilation/TANav.js @@ -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; + } +} diff --git a/src/games/totalannihilation/TARules.js b/src/games/totalannihilation/TARules.js new file mode 100644 index 0000000..21d0aa6 --- /dev/null +++ b/src/games/totalannihilation/TARules.js @@ -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; +} diff --git a/src/games/totalannihilation/TAWorldView.js b/src/games/totalannihilation/TAWorldView.js new file mode 100644 index 0000000..9200b06 --- /dev/null +++ b/src/games/totalannihilation/TAWorldView.js @@ -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; +}