**Update game icons assets**
Refresh game icons in both source (PSD) and exported (PNG) formats to reflect updated design.
This commit is contained in:
parent
94cc51b997
commit
93b4877c2d
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 331 KiB After Width: | Height: | Size: 335 KiB |
Binary file not shown.
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -0,0 +1,561 @@
|
|||
// Total Annihilation — procedural art fallback.
|
||||
//
|
||||
// Every spritesheet declared in data/totalannihilation-artwork.json is optional. If its
|
||||
// `path` is null (or the PNG 404s), we paint a canvas stand-in with the identical frame
|
||||
// layout, so the view never knows the difference and the game is fully playable with zero
|
||||
// art files present.
|
||||
//
|
||||
// The one structural change from the Advance Wars original this is modelled on
|
||||
// (AdvanceWarsMapView.js ensureSheets/PROC_PAINTERS): painters are keyed by the sheet's
|
||||
// `kind`, NOT by its name. That is what makes adding a third army or a fourth terrain theme
|
||||
// a pure JSON edit — a new sheet with kind:"unit" automatically gets the unit painter.
|
||||
|
||||
const BODY = '#dcdcdc'; // neutral grey — the runtime army tint multiplies over this
|
||||
const BODY_DARK = '#a8adb5';
|
||||
const OUTLINE = '#26262e';
|
||||
const ACCENT = '#8f939b';
|
||||
const GLASS = '#3a4450';
|
||||
const HOT = '#ffd27a';
|
||||
|
||||
/** Deterministic per-frame PRNG so speckles are stable across reloads. */
|
||||
function frameRng(seed) {
|
||||
let a = (seed * 2654435761) >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), a | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a canvas texture laid out as a spritesheet and register its frames.
|
||||
* `at(frame, draw)` translates the context to that frame's origin; `finish()` uploads.
|
||||
*/
|
||||
function mkCanvasSheet(scene, key, frameW, frameH, cols, count) {
|
||||
const rows = Math.max(1, Math.ceil(count / cols));
|
||||
const tex = scene.textures.createCanvas(key, cols * frameW, rows * frameH);
|
||||
const ctx = tex.getContext();
|
||||
return {
|
||||
ctx,
|
||||
at(frame, draw) {
|
||||
const fx = (frame % cols) * frameW;
|
||||
const fy = ((frame / cols) | 0) * frameH;
|
||||
ctx.save();
|
||||
ctx.translate(fx, fy);
|
||||
draw(ctx, frameW, frameH);
|
||||
ctx.restore();
|
||||
},
|
||||
finish() {
|
||||
tex.refresh();
|
||||
for (let f = 0; f < count; f++) {
|
||||
const fx = (f % cols) * frameW;
|
||||
const fy = ((f / cols) | 0) * frameH;
|
||||
tex.add(f, 0, fx, fy, frameW, frameH);
|
||||
}
|
||||
return tex;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function fillStroke(ctx, fill, stroke = OUTLINE, lw = 2) {
|
||||
ctx.fillStyle = fill; ctx.fill();
|
||||
if (stroke) { ctx.lineWidth = lw; ctx.strokeStyle = stroke; ctx.stroke(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit shapes — all drawn FACING RIGHT (+x), centred, inside a rotation-safe circle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UNIT_SHAPES = {
|
||||
commander(ctx, S) {
|
||||
const c = S / 2;
|
||||
// legs behind
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
ctx.fillStyle = BODY_DARK; ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2;
|
||||
for (const sy of [-9, 9]) {
|
||||
roundRect(ctx, -14, sy - 4, 14, 8, 3); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
// torso
|
||||
roundRect(ctx, -11, -11, 24, 22, 6); fillStroke(ctx, BODY);
|
||||
// shoulder pods
|
||||
for (const sy of [-12, 12]) {
|
||||
ctx.beginPath(); ctx.arc(-2, sy, 5.5, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
|
||||
}
|
||||
// visor
|
||||
ctx.fillStyle = GLASS;
|
||||
roundRect(ctx, 6, -5, 6, 10, 2); ctx.fill();
|
||||
// nanolathe arm
|
||||
ctx.strokeStyle = ACCENT; ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(10, -3); ctx.lineTo(21, -3); ctx.stroke();
|
||||
ctx.fillStyle = HOT;
|
||||
ctx.beginPath(); ctx.arc(22, -3, 2.5, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
commanderTurret(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
ctx.beginPath(); ctx.arc(0, 0, 7, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
|
||||
roundRect(ctx, 5, -4.5, 17, 9, 3); fillStroke(ctx, BODY);
|
||||
ctx.strokeStyle = '#8ad4ff'; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(21, 0, 5, -0.9, 0.9); ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
infantry(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
roundRect(ctx, -8, -5, 16, 10, 5); fillStroke(ctx, BODY);
|
||||
ctx.beginPath(); ctx.arc(1, 0, 4.5, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(4, -2); ctx.lineTo(14, -2); ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
sniper(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
roundRect(ctx, -9, -4, 17, 8, 4); fillStroke(ctx, BODY);
|
||||
ctx.beginPath(); ctx.arc(0, 0, 4, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(3, -2); ctx.lineTo(20, -2); ctx.stroke();
|
||||
ctx.fillStyle = GLASS;
|
||||
ctx.beginPath(); ctx.arc(6, -4.5, 2.2, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
jeep(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
// wheels first, so the body overlaps them
|
||||
ctx.fillStyle = '#2f3238';
|
||||
for (const [wx, wy] of [[-8, -9], [8, -9], [-8, 9], [8, 9]]) {
|
||||
roundRect(ctx, wx - 4, wy - 3, 8, 6, 2.5); ctx.fill();
|
||||
}
|
||||
roundRect(ctx, -13, -8, 26, 16, 4); fillStroke(ctx, BODY);
|
||||
// windscreen wedge
|
||||
ctx.fillStyle = GLASS;
|
||||
ctx.beginPath(); ctx.moveTo(4, -6); ctx.lineTo(12, -3); ctx.lineTo(12, 3); ctx.lineTo(4, 6);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
jeepTurret(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
ctx.beginPath(); ctx.arc(0, 0, 4.5, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2.5;
|
||||
ctx.beginPath(); ctx.moveTo(3, 0); ctx.lineTo(14, 0); ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
tank(ctx, S) { tankHull(ctx, S, 34, 24); },
|
||||
rockettank(ctx, S) { tankHull(ctx, S, 36, 24); },
|
||||
|
||||
tankTurret(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
// trapezoid turret
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-8, -8); ctx.lineTo(7, -6); ctx.lineTo(7, 6); ctx.lineTo(-8, 8);
|
||||
ctx.closePath(); fillStroke(ctx, BODY);
|
||||
ctx.beginPath(); ctx.arc(-1, 0, 4, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 4;
|
||||
ctx.beginPath(); ctx.moveTo(5, 0); ctx.lineTo(24, 0); ctx.stroke();
|
||||
ctx.strokeStyle = BODY_DARK; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(5, 0); ctx.lineTo(24, 0); ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
rockettankTurret(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
roundRect(ctx, -8, -9, 18, 18, 3); fillStroke(ctx, BODY);
|
||||
ctx.fillStyle = '#2f3238';
|
||||
for (const [tx, ty] of [[3, -4.5], [3, 4.5], [-2, -4.5], [-2, 4.5]]) {
|
||||
ctx.beginPath(); ctx.arc(tx, ty, 2.6, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
|
||||
function tankHull(ctx, S, len, wid) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
// tread rails
|
||||
ctx.fillStyle = '#33363c'; ctx.strokeStyle = OUTLINE; ctx.lineWidth = 1.5;
|
||||
for (const sy of [-wid / 2, wid / 2 - 6]) {
|
||||
roundRect(ctx, -len / 2, sy, len, 6, 2); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = '#4a4e56';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const bx = -len / 2 + 2 + i * ((len - 4) / 6);
|
||||
ctx.fillRect(bx, -wid / 2 + 1, 3, 4);
|
||||
ctx.fillRect(bx, wid / 2 - 5, 3, 4);
|
||||
}
|
||||
// hull
|
||||
roundRect(ctx, -len / 2 + 3, -wid / 2 + 5, len - 6, wid - 10, 4); fillStroke(ctx, BODY);
|
||||
// glacis plate
|
||||
ctx.fillStyle = BODY_DARK;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(len / 2 - 3, -wid / 2 + 6); ctx.lineTo(len / 2 - 3, wid / 2 - 6);
|
||||
ctx.lineTo(len / 2 - 9, wid / 2 - 6); ctx.lineTo(len / 2 - 9, -wid / 2 + 6);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structure shapes — NOT rotated, drawn to fill the cell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STRUCT_SHAPES = {
|
||||
energyGen(ctx, S) {
|
||||
const p = S * 0.12, d = S - p * 2;
|
||||
roundRect(ctx, p, p, d, d, S * 0.10); fillStroke(ctx, BODY, OUTLINE, 3);
|
||||
// vent slots
|
||||
ctx.fillStyle = '#3b3f47';
|
||||
for (let i = 0; i < 4; i++) ctx.fillRect(p + d * 0.14, p + d * (0.16 + i * 0.18), d * 0.24, d * 0.10);
|
||||
// lightning glyph
|
||||
ctx.fillStyle = HOT;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p + d * 0.66, p + d * 0.14);
|
||||
ctx.lineTo(p + d * 0.48, p + d * 0.52);
|
||||
ctx.lineTo(p + d * 0.62, p + d * 0.52);
|
||||
ctx.lineTo(p + d * 0.46, p + d * 0.90);
|
||||
ctx.lineTo(p + d * 0.82, p + d * 0.44);
|
||||
ctx.lineTo(p + d * 0.66, p + d * 0.44);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2; ctx.stroke();
|
||||
},
|
||||
|
||||
massGen(ctx, S) {
|
||||
const c = S / 2, r = S * 0.40;
|
||||
// hex pad
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = (i / 6) * Math.PI * 2 + Math.PI / 6;
|
||||
const x = c + Math.cos(a) * r, y = c + Math.sin(a) * r;
|
||||
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
||||
}
|
||||
ctx.closePath(); fillStroke(ctx, BODY, OUTLINE, 3);
|
||||
// concentric rings
|
||||
ctx.strokeStyle = ACCENT; ctx.lineWidth = 2.5;
|
||||
for (const rr of [r * 0.62, r * 0.44, r * 0.26]) {
|
||||
ctx.beginPath(); ctx.arc(c, c, rr, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
// drill spindle
|
||||
ctx.beginPath(); ctx.arc(c, c, r * 0.16, 0, Math.PI * 2); fillStroke(ctx, '#3b3f47', OUTLINE, 2);
|
||||
// corner anchors
|
||||
ctx.fillStyle = BODY_DARK;
|
||||
for (const [ax, ay] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
|
||||
roundRect(ctx, c + ax * r * 0.74 - 5, c + ay * r * 0.74 - 5, 10, 10, 2); ctx.fill();
|
||||
}
|
||||
},
|
||||
|
||||
barracks(ctx, S) {
|
||||
const p = S * 0.10, w = S - p * 2, h = S - p * 2;
|
||||
roundRect(ctx, p, p, w, h, S * 0.06); fillStroke(ctx, BODY, OUTLINE, 3);
|
||||
// corrugated roof
|
||||
ctx.strokeStyle = BODY_DARK; ctx.lineWidth = 2;
|
||||
for (let i = 1; i < 7; i++) {
|
||||
const y = p + (h * i) / 8;
|
||||
ctx.beginPath(); ctx.moveTo(p + 4, y); ctx.lineTo(p + w - 4, y); ctx.stroke();
|
||||
}
|
||||
// arched door at the bay end
|
||||
ctx.fillStyle = '#2f3238';
|
||||
ctx.beginPath();
|
||||
const dw = w * 0.34, dx = p + w / 2 - dw / 2, dy = p + h * 0.62;
|
||||
ctx.moveTo(dx, p + h - 4);
|
||||
ctx.lineTo(dx, dy + dw / 2);
|
||||
ctx.arc(dx + dw / 2, dy + dw / 2, dw / 2, Math.PI, 0);
|
||||
ctx.lineTo(dx + dw, p + h - 4);
|
||||
ctx.closePath(); ctx.fill();
|
||||
// marching chevron over the door
|
||||
ctx.strokeStyle = HOT; ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p + w / 2 - 10, dy - 12); ctx.lineTo(p + w / 2, dy - 4); ctx.lineTo(p + w / 2 + 10, dy - 12);
|
||||
ctx.stroke();
|
||||
},
|
||||
|
||||
vehiclePlant(ctx, S) {
|
||||
const p = S * 0.07, w = S - p * 2, h = S - p * 2;
|
||||
roundRect(ctx, p, p, w, h, S * 0.05); fillStroke(ctx, BODY, OUTLINE, 3);
|
||||
// gantry rail across the roof
|
||||
ctx.fillStyle = BODY_DARK;
|
||||
ctx.fillRect(p + 4, p + h * 0.12, w - 8, h * 0.09);
|
||||
ctx.fillStyle = '#3b3f47';
|
||||
for (let i = 0; i < 5; i++) ctx.fillRect(p + 8 + i * ((w - 16) / 5), p + h * 0.12, 5, h * 0.09);
|
||||
// roll-up bay door with slats
|
||||
const dw = w * 0.62, dx = p + w / 2 - dw / 2, dy = p + h * 0.38, dh = h * 0.44;
|
||||
ctx.fillStyle = '#2f3238';
|
||||
ctx.fillRect(dx, dy, dw, dh);
|
||||
ctx.strokeStyle = '#4a4e56'; ctx.lineWidth = 2;
|
||||
for (let i = 1; i < 6; i++) {
|
||||
const y = dy + (dh * i) / 6;
|
||||
ctx.beginPath(); ctx.moveTo(dx + 2, y); ctx.lineTo(dx + dw - 2, y); ctx.stroke();
|
||||
}
|
||||
// floor chevron pointing out of the mouth — also shows where units will spawn
|
||||
ctx.strokeStyle = HOT; ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p + w / 2 - 14, p + h - 20); ctx.lineTo(p + w / 2, p + h - 8); ctx.lineTo(p + w / 2 + 14, p + h - 20);
|
||||
ctx.stroke();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Painters, keyed by sheet kind
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROC_PAINTERS = {
|
||||
/** One frame per unit hull, plus one per turret. Driven entirely off the rules. */
|
||||
unit(scene, key, spec, rules) {
|
||||
const defs = rules.units.filter((u) => u.sheetSlot === 'unitSheet');
|
||||
let count = 0;
|
||||
for (const d of defs) count = Math.max(count, d.frame + 1, (d.turretFrame ?? -1) + 1);
|
||||
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
|
||||
for (const d of defs) {
|
||||
const hull = UNIT_SHAPES[d.procShape];
|
||||
if (hull) sh.at(d.frame, (ctx, S) => hull(ctx, S));
|
||||
if (d.turretFrame != null) {
|
||||
const turret = UNIT_SHAPES[`${d.procShape}Turret`];
|
||||
if (turret) sh.at(d.turretFrame, (ctx, S) => turret(ctx, S));
|
||||
}
|
||||
}
|
||||
return sh.finish();
|
||||
},
|
||||
|
||||
/** Two frames per building: finished, then a translucent wireframe for the build site. */
|
||||
structure(scene, key, spec, rules) {
|
||||
const defs = rules.buildings.filter((b) => b.sheetSlot === 'structureSheet');
|
||||
let count = 0;
|
||||
for (const d of defs) count = Math.max(count, d.frame + 1, (d.buildFrame ?? -1) + 1);
|
||||
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
|
||||
for (const d of defs) {
|
||||
const draw = STRUCT_SHAPES[d.procShape];
|
||||
if (!draw) continue;
|
||||
sh.at(d.frame, (ctx, S) => draw(ctx, S));
|
||||
if (d.buildFrame != null) {
|
||||
sh.at(d.buildFrame, (ctx, S) => {
|
||||
ctx.globalAlpha = 0.35;
|
||||
draw(ctx, S);
|
||||
ctx.globalAlpha = 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
return sh.finish();
|
||||
},
|
||||
|
||||
/** One sheet per theme, coloured entirely from that theme's palette. */
|
||||
terrain(scene, key, spec, rules, art, sheetName) {
|
||||
const theme = Object.values(art.themes ?? {}).find((t) => t.sheet === sheetName);
|
||||
const p = theme?.palette ?? {};
|
||||
const F = art.terrainFrames ?? {};
|
||||
const count = Math.max(9, ...Object.values(F).map((v) => v + 1));
|
||||
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
|
||||
|
||||
const speckle = (ctx, S, seed, color, n, r0, r1) => {
|
||||
const rnd = frameRng(seed);
|
||||
ctx.fillStyle = color;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = rnd() * S, y = rnd() * S, r = r0 + rnd() * (r1 - r0);
|
||||
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
};
|
||||
const flat = (frame, color, seed, speckColor, n = 10) => sh.at(frame, (ctx, S) => {
|
||||
ctx.fillStyle = color; ctx.fillRect(0, 0, S, S);
|
||||
speckle(ctx, S, seed, speckColor, n, 1, 2.6);
|
||||
});
|
||||
|
||||
flat(F.ground ?? 0, p.ground ?? '#5f7f3d', 11, p.speck ?? '#000', 10);
|
||||
flat(F.groundAlt1 ?? 1, p.groundAlt1 ?? p.ground ?? '#6b8c45', 22, p.speck ?? '#000', 12);
|
||||
flat(F.groundAlt2 ?? 2, p.groundAlt2 ?? p.ground ?? '#55743a', 33, p.speck ?? '#000', 8);
|
||||
|
||||
sh.at(F.rough ?? 3, (ctx, S) => {
|
||||
ctx.fillStyle = p.rough ?? '#7d7048'; ctx.fillRect(0, 0, S, S);
|
||||
speckle(ctx, S, 44, 'rgba(0,0,0,0.30)', 22, 1.5, 3.6);
|
||||
speckle(ctx, S, 45, 'rgba(255,255,255,0.14)', 14, 1, 2.4);
|
||||
});
|
||||
|
||||
sh.at(F.cliff ?? 4, (ctx, S) => {
|
||||
ctx.fillStyle = p.cliff ?? '#4c4838'; ctx.fillRect(0, 0, S, S);
|
||||
// lit top edge sells the height difference without a real height layer
|
||||
ctx.fillStyle = p.cliffTop ?? '#6b6552';
|
||||
ctx.fillRect(0, 0, S, S * 0.30);
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.45)'; ctx.lineWidth = 2;
|
||||
const rnd = frameRng(55);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const x = rnd() * S;
|
||||
ctx.beginPath(); ctx.moveTo(x, S * 0.30); ctx.lineTo(x + (rnd() - 0.5) * 12, S); ctx.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
const wave = (frame, color, seed) => sh.at(frame, (ctx, S) => {
|
||||
ctx.fillStyle = color; ctx.fillRect(0, 0, S, S);
|
||||
const rnd = frameRng(seed);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.16)'; ctx.lineWidth = 2;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const y = rnd() * S;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.quadraticCurveTo(S / 2, y + (rnd() - 0.5) * 10, S, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
wave(F.water ?? 5, p.water ?? '#2f5f9e', 66);
|
||||
wave(F.waterDeep ?? 6, p.waterDeep ?? '#1d3f70', 77);
|
||||
|
||||
sh.at(F.metal ?? 7, (ctx, S) => {
|
||||
ctx.fillStyle = p.metal ?? '#9aa3ab'; ctx.fillRect(0, 0, S, S);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.35)';
|
||||
for (let ry = 0; ry < 3; ry++) {
|
||||
for (let rx = 0; rx < 3; rx++) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(S * (0.22 + rx * 0.28), S * (0.22 + ry * 0.28), 2.6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.20)'; ctx.lineWidth = 2;
|
||||
ctx.strokeRect(3, 3, S - 6, S - 6);
|
||||
});
|
||||
|
||||
flat(F.road ?? 8, p.road ?? '#8b8578', 88, 'rgba(0,0,0,0.25)', 6);
|
||||
return sh.finish();
|
||||
},
|
||||
|
||||
/** Build-menu glyphs: a dark plate plus a shrunken copy of the unit/structure shape. */
|
||||
icon(scene, key, spec, rules) {
|
||||
const entries = [];
|
||||
for (const d of [...rules.units, ...rules.buildings]) {
|
||||
if (d.icon != null) entries.push({ frame: d.icon, shape: d.procShape, building: !!d.isBuilding });
|
||||
}
|
||||
const cmd = rules.commandIcons ?? {};
|
||||
let count = 0;
|
||||
for (const e of entries) count = Math.max(count, e.frame + 1);
|
||||
for (const f of Object.values(cmd)) count = Math.max(count, f + 1);
|
||||
|
||||
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
|
||||
const plate = (ctx, S) => {
|
||||
roundRect(ctx, 2, 2, S - 4, S - 4, 6);
|
||||
ctx.fillStyle = '#1b2029'; ctx.fill();
|
||||
ctx.strokeStyle = '#3d4753'; ctx.lineWidth = 2; ctx.stroke();
|
||||
};
|
||||
|
||||
for (const e of entries) {
|
||||
sh.at(e.frame, (ctx, S) => {
|
||||
plate(ctx, S);
|
||||
const shape = e.building ? STRUCT_SHAPES[e.shape] : UNIT_SHAPES[e.shape];
|
||||
if (!shape) return;
|
||||
ctx.save();
|
||||
if (e.building) {
|
||||
ctx.translate(S * 0.12, S * 0.12);
|
||||
ctx.scale(0.76, 0.76);
|
||||
shape(ctx, S);
|
||||
} else {
|
||||
// Units are drawn facing right around the cell centre; turn them 45 degrees so a
|
||||
// build icon reads as a vehicle rather than as a sideways smear.
|
||||
ctx.translate(S / 2, S / 2); ctx.rotate(-Math.PI / 4); ctx.scale(0.95, 0.95);
|
||||
ctx.translate(-S / 2, -S / 2);
|
||||
shape(ctx, S);
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
|
||||
const glyph = (frame, draw) => { if (frame != null) sh.at(frame, (ctx, S) => { plate(ctx, S); draw(ctx, S); }); };
|
||||
const stroke = (ctx, color = '#cfe3ff', lw = 3) => { ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.stroke(); };
|
||||
|
||||
glyph(cmd.move, (ctx, S) => {
|
||||
ctx.beginPath(); ctx.moveTo(S * 0.28, S * 0.72); ctx.lineTo(S * 0.72, S * 0.28); stroke(ctx);
|
||||
ctx.beginPath(); ctx.moveTo(S * 0.72, S * 0.28); ctx.lineTo(S * 0.52, S * 0.30);
|
||||
ctx.moveTo(S * 0.72, S * 0.28); ctx.lineTo(S * 0.70, S * 0.48); stroke(ctx);
|
||||
});
|
||||
glyph(cmd.attack, (ctx, S) => {
|
||||
ctx.beginPath(); ctx.arc(S / 2, S / 2, S * 0.24, 0, Math.PI * 2); stroke(ctx, '#ff9a8a');
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(S / 2, S * 0.16); ctx.lineTo(S / 2, S * 0.34);
|
||||
ctx.moveTo(S / 2, S * 0.66); ctx.lineTo(S / 2, S * 0.84);
|
||||
ctx.moveTo(S * 0.16, S / 2); ctx.lineTo(S * 0.34, S / 2);
|
||||
ctx.moveTo(S * 0.66, S / 2); ctx.lineTo(S * 0.84, S / 2);
|
||||
stroke(ctx, '#ff9a8a', 2.5);
|
||||
});
|
||||
glyph(cmd.attackMove, (ctx, S) => {
|
||||
ctx.beginPath(); ctx.arc(S / 2, S / 2, S * 0.20, 0, Math.PI * 2); stroke(ctx, '#ffc98a', 2.5);
|
||||
ctx.beginPath(); ctx.moveTo(S * 0.20, S * 0.80); ctx.lineTo(S * 0.80, S * 0.20); stroke(ctx, '#ffc98a', 2.5);
|
||||
});
|
||||
glyph(cmd.stop, (ctx, S) => {
|
||||
roundRect(ctx, S * 0.30, S * 0.30, S * 0.40, S * 0.40, 3);
|
||||
ctx.fillStyle = '#ff9a8a'; ctx.fill();
|
||||
});
|
||||
glyph(cmd.hold, (ctx, S) => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(S / 2, S * 0.20); ctx.lineTo(S * 0.76, S * 0.34);
|
||||
ctx.lineTo(S * 0.76, S * 0.58); ctx.lineTo(S / 2, S * 0.80);
|
||||
ctx.lineTo(S * 0.24, S * 0.58); ctx.lineTo(S * 0.24, S * 0.34);
|
||||
ctx.closePath(); stroke(ctx, '#9ee8b0');
|
||||
});
|
||||
glyph(cmd.patrol, (ctx, S) => {
|
||||
ctx.beginPath(); ctx.arc(S / 2, S / 2, S * 0.22, 0.6, Math.PI * 1.7); stroke(ctx);
|
||||
ctx.beginPath(); ctx.moveTo(S * 0.70, S * 0.62); ctx.lineTo(S * 0.78, S * 0.72);
|
||||
ctx.lineTo(S * 0.62, S * 0.76); stroke(ctx, '#cfe3ff', 2.5);
|
||||
});
|
||||
glyph(cmd.guard, (ctx, S) => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(S / 2, S * 0.18); ctx.lineTo(S * 0.78, S * 0.32);
|
||||
ctx.lineTo(S * 0.70, S * 0.72); ctx.lineTo(S / 2, S * 0.84);
|
||||
ctx.lineTo(S * 0.30, S * 0.72); ctx.lineTo(S * 0.22, S * 0.32);
|
||||
ctx.closePath(); ctx.fillStyle = 'rgba(158,232,176,0.25)'; ctx.fill(); stroke(ctx, '#9ee8b0', 2.5);
|
||||
});
|
||||
glyph(cmd.assist, (ctx, S) => {
|
||||
ctx.strokeStyle = '#8ad4ff'; ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(S / 2, S * 0.24); ctx.lineTo(S / 2, S * 0.76);
|
||||
ctx.moveTo(S * 0.24, S / 2); ctx.lineTo(S * 0.76, S / 2);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
return sh.finish();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve every declared sheet to a usable texture key, painting stand-ins as needed.
|
||||
* @returns {{keys:Object<string,string>, procedural:string[]}} sheetName -> texture key
|
||||
*/
|
||||
export function ensureSheets(scene, rules, art) {
|
||||
const keys = Object.create(null);
|
||||
const procedural = [];
|
||||
for (const [name, spec] of Object.entries(art.sheets ?? {})) {
|
||||
if (spec.path && scene.textures.exists(spec.key)) { keys[name] = spec.key; continue; }
|
||||
const procKey = `${spec.key}-proc`;
|
||||
if (!scene.textures.exists(procKey)) {
|
||||
const painter = PROC_PAINTERS[spec.kind];
|
||||
if (!painter) {
|
||||
console.warn(`[TAArt] sheet "${name}" has unknown kind "${spec.kind}" — skipping`);
|
||||
continue;
|
||||
}
|
||||
painter(scene, procKey, spec, rules, art, name);
|
||||
}
|
||||
keys[name] = procKey;
|
||||
procedural.push(name);
|
||||
}
|
||||
return { keys, procedural };
|
||||
}
|
||||
|
||||
/** Frame dimensions of a resolved sheet, for sprite scaling. */
|
||||
export function sheetFrameSize(art, sheetName) {
|
||||
const s = art.sheets?.[sheetName];
|
||||
return s ? { w: s.frameWidth, h: s.frameHeight } : { w: 64, h: 64 };
|
||||
}
|
||||
|
||||
export { PROC_PAINTERS, UNIT_SHAPES, STRUCT_SHAPES, mkCanvasSheet, roundRect };
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
// Total Annihilation — procedural combat effects.
|
||||
//
|
||||
// Every weapon effect in this game is drawn, not sprited: gunfire tracers, tank shells,
|
||||
// rocket trails, beams, explosions, nanolathe streams and craters are all Graphics strokes.
|
||||
// Two layers are cleared and re-stroked each frame from a TTL list — the Star Control
|
||||
// `fxList` idiom (StarControlGame.js renderWorld) using the repo's glow-stroke pair: one
|
||||
// wide low-alpha pass, then a thin opaque one on top.
|
||||
|
||||
const MAX_FX = 420; // hard cap; oldest transients are dropped rather than dropping frames
|
||||
|
||||
export default class TAFx {
|
||||
constructor(scene, worldRoot, depths) {
|
||||
this.scene = scene;
|
||||
// Under the actors: trails, nanolathe streams, scorch. Over them: tracers and blasts.
|
||||
this.gUnder = scene.add.graphics().setDepth(depths.fxUnder);
|
||||
this.gOver = scene.add.graphics().setDepth(depths.fxOver);
|
||||
worldRoot.add(this.gUnder);
|
||||
worldRoot.add(this.gOver);
|
||||
this.list = [];
|
||||
this.shakeUntil = 0;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.gUnder.destroy();
|
||||
this.gOver.destroy();
|
||||
this.list.length = 0;
|
||||
}
|
||||
|
||||
clear() { this.list.length = 0; }
|
||||
|
||||
push(fx) {
|
||||
if (this.list.length >= MAX_FX) this.list.shift();
|
||||
fx.age = 0;
|
||||
this.list.push(fx);
|
||||
}
|
||||
|
||||
/** Translate a simulation event into a transient effect. */
|
||||
onEvent(ev, rules) {
|
||||
switch (ev.t) {
|
||||
case 'shot':
|
||||
this.push({
|
||||
kind: ev.style === 'beam' || ev.style === 'dgun' ? 'beam' : 'tracer',
|
||||
x1: ev.x1, y1: ev.y1, x2: ev.x2, y2: ev.y2,
|
||||
color: colorInt(ev.color), width: ev.width ?? 2, ttl: ev.lifeMs ?? 80,
|
||||
});
|
||||
break;
|
||||
case 'weaponFired':
|
||||
this.push({
|
||||
kind: 'muzzle', x: ev.x, y: ev.y, heading: ev.heading,
|
||||
color: 0xffe6a0, ttl: 70,
|
||||
});
|
||||
break;
|
||||
case 'impact':
|
||||
this.push({
|
||||
kind: 'blast', x: ev.x, y: ev.y, r: Math.max(10, ev.radius),
|
||||
color: 0xffb45a, ttl: ev.radius > 30 ? 380 : 200,
|
||||
});
|
||||
break;
|
||||
case 'bigExplosion':
|
||||
this.push({ kind: 'shockwave', x: ev.x, y: ev.y, r: ev.radius, color: 0xfff0c0, ttl: 700 });
|
||||
this.push({ kind: 'blast', x: ev.x, y: ev.y, r: ev.radius * 0.55, color: 0xff8a3c, ttl: 520 });
|
||||
this.shakeUntil = this.scene.time.now + 320;
|
||||
this.scene.cameras.main.shake(300, 0.010);
|
||||
break;
|
||||
case 'unitDestroyed':
|
||||
this.push({
|
||||
kind: 'blast', x: ev.x, y: ev.y,
|
||||
r: Math.max(16, ev.radius * (ev.isBuilding ? 2.0 : 1.4)),
|
||||
color: ev.isBuilding ? 0xffa040 : 0xff9060,
|
||||
ttl: ev.isBuilding ? 460 : 300,
|
||||
});
|
||||
this.push({ kind: 'scorch', x: ev.x, y: ev.y, r: ev.radius * 1.2, ttl: 4200 });
|
||||
break;
|
||||
case 'nanolathe':
|
||||
// The stream itself is drawn live from builder->site in draw(); this is the sparkle
|
||||
// at the receiving end so a site under construction always reads as active.
|
||||
this.push({ kind: 'spark', x: ev.x, y: ev.y, color: 0x8ad4ff, ttl: 220 });
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} delta ms since last frame
|
||||
* @param {Array} nanoLinks live [{x1,y1,x2,y2,color}] builder->target streams
|
||||
* @param {Array} projectiles interpolated [{x,y,vx,vy,color,width,style,trail}]
|
||||
*/
|
||||
draw(delta, nanoLinks, projectiles, timeMs) {
|
||||
const gu = this.gUnder, go = this.gOver;
|
||||
gu.clear(); go.clear();
|
||||
|
||||
// ---- persistent-ish world marks (under actors) ----
|
||||
for (const fx of this.list) {
|
||||
if (fx.kind !== 'scorch') continue;
|
||||
const t = fx.age / fx.ttl;
|
||||
gu.fillStyle(0x1a1410, 0.5 * (1 - t));
|
||||
gu.fillCircle(fx.x, fx.y, fx.r);
|
||||
}
|
||||
|
||||
// ---- nanolathe streams (under actors) ----
|
||||
for (const link of nanoLinks) {
|
||||
const phase = (timeMs * 0.006) % 1;
|
||||
gu.lineStyle(5, link.color, 0.14);
|
||||
gu.lineBetween(link.x1, link.y1, link.x2, link.y2);
|
||||
gu.lineStyle(2, link.color, 0.9);
|
||||
// dashed, marching toward the target so it reads as material flowing
|
||||
const dx = link.x2 - link.x1, dy = link.y2 - link.y1;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / len, uy = dy / len;
|
||||
const dash = 14, gap = 10;
|
||||
for (let d = phase * (dash + gap); d < len; d += dash + gap) {
|
||||
const a = Math.min(len, d), b = Math.min(len, d + dash);
|
||||
gu.lineBetween(link.x1 + ux * a, link.y1 + uy * a, link.x1 + ux * b, link.y1 + uy * b);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- rocket trails (under actors) ----
|
||||
for (const p of projectiles) {
|
||||
if (!p.trail || p.trail.length < 4) continue;
|
||||
const n = p.trail.length / 2;
|
||||
for (let i = 1; i < n; i++) {
|
||||
const a = i / n;
|
||||
go.lineStyle(p.width * 1.6 * a, p.color, 0.30 * a);
|
||||
gu.lineStyle(p.width * 1.6 * a, p.color, 0.30 * a);
|
||||
gu.lineBetween(p.trail[(i - 1) * 2], p.trail[(i - 1) * 2 + 1], p.trail[i * 2], p.trail[i * 2 + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- live projectiles (over actors) ----
|
||||
for (const p of projectiles) {
|
||||
const ang = Math.atan2(p.vy, p.vx);
|
||||
if (p.style === 'rocket') {
|
||||
const bx = p.x - Math.cos(ang) * 9, by = p.y - Math.sin(ang) * 9;
|
||||
go.lineStyle(p.width * 2.4, p.color, 0.22);
|
||||
go.lineBetween(bx, by, p.x, p.y);
|
||||
go.fillStyle(0xfff0c0, 1);
|
||||
go.fillCircle(p.x, p.y, p.width * 0.9);
|
||||
go.fillStyle(p.color, 0.85);
|
||||
go.fillCircle(bx, by, p.width * 1.3);
|
||||
} else {
|
||||
const bx = p.x - Math.cos(ang) * 12, by = p.y - Math.sin(ang) * 12;
|
||||
go.lineStyle(p.width * 1.9, p.color, 0.20);
|
||||
go.lineBetween(bx, by, p.x, p.y);
|
||||
go.lineStyle(p.width * 0.8, p.color, 1);
|
||||
go.lineBetween(bx, by, p.x, p.y);
|
||||
go.fillStyle(0xfff6d8, 1);
|
||||
go.fillCircle(p.x, p.y, p.width * 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- transients (over actors) ----
|
||||
const keep = [];
|
||||
for (const fx of this.list) {
|
||||
fx.age += delta;
|
||||
if (fx.age >= fx.ttl) continue;
|
||||
keep.push(fx);
|
||||
const t = fx.age / fx.ttl;
|
||||
|
||||
switch (fx.kind) {
|
||||
case 'tracer': {
|
||||
const a = 1 - t;
|
||||
go.lineStyle(fx.width * 3.2, fx.color, 0.16 * a);
|
||||
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
|
||||
go.lineStyle(fx.width, fx.color, a);
|
||||
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
|
||||
break;
|
||||
}
|
||||
case 'beam': {
|
||||
const a = 1 - t * 0.75;
|
||||
go.lineStyle(fx.width * 2.6 * (1 - t) + 1, fx.color, 0.18 * a);
|
||||
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
|
||||
go.lineStyle(fx.width * (1 - t) + 1, fx.color, a);
|
||||
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
|
||||
break;
|
||||
}
|
||||
case 'muzzle': {
|
||||
const a = 1 - t;
|
||||
const len = 13 * (1 - t) + 4;
|
||||
go.fillStyle(fx.color, 0.85 * a);
|
||||
for (const spread of [-0.35, 0, 0.35]) {
|
||||
const ang = fx.heading + spread;
|
||||
go.beginPath();
|
||||
go.moveTo(fx.x, fx.y);
|
||||
go.lineTo(fx.x + Math.cos(ang - 0.13) * len, fx.y + Math.sin(ang - 0.13) * len);
|
||||
go.lineTo(fx.x + Math.cos(ang + 0.13) * len, fx.y + Math.sin(ang + 0.13) * len);
|
||||
go.closePath();
|
||||
go.fillPath();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'blast': {
|
||||
const a = 1 - t;
|
||||
const r = fx.r * (0.35 + t * 0.9);
|
||||
go.fillStyle(fx.color, 0.55 * a);
|
||||
go.fillCircle(fx.x, fx.y, r);
|
||||
go.fillStyle(0xfff6d8, 0.7 * a * a);
|
||||
go.fillCircle(fx.x, fx.y, r * 0.45);
|
||||
go.lineStyle(3 * a + 1, 0xffffff, 0.7 * a);
|
||||
go.strokeCircle(fx.x, fx.y, r);
|
||||
break;
|
||||
}
|
||||
case 'shockwave': {
|
||||
const a = 1 - t;
|
||||
go.lineStyle(9 * a + 1, fx.color, a);
|
||||
go.strokeCircle(fx.x, fx.y, fx.r * t);
|
||||
go.lineStyle(4 * a + 1, 0xff8a3c, a * 0.7);
|
||||
go.strokeCircle(fx.x, fx.y, fx.r * t * 0.72);
|
||||
break;
|
||||
}
|
||||
case 'spark': {
|
||||
const a = 1 - t;
|
||||
go.fillStyle(fx.color, a);
|
||||
go.fillCircle(fx.x, fx.y, 3 * a + 1);
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
this.list = keep;
|
||||
}
|
||||
}
|
||||
|
||||
function colorInt(v) {
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v === 'string' && v[0] === '#') return parseInt(v.slice(1), 16);
|
||||
return 0xffffff;
|
||||
}
|
||||
|
||||
export { colorInt };
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue