1741 lines
68 KiB
JavaScript
1741 lines
68 KiB
JavaScript
// Total Annihilation — the simulation.
|
|
//
|
|
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
|
|
//
|
|
// Contract with the view: the scene NEVER mutates state. It calls issueOrder() for player
|
|
// commands and step()/tick() to advance time; tick() returns an event list the view replays
|
|
// as animation. The AI calls the exact same issueOrder(), which is what lets the verify
|
|
// script bot the human's side and assert that a campaign mission is actually winnable.
|
|
//
|
|
// Determinism: all randomness comes from rngNext(state) (mulberry32 seeded into state), so a
|
|
// seed plus an order log replays exactly. Never call Math.random() in this file, TANav.js,
|
|
// TAAI.js or TAMapGen.js.
|
|
|
|
import {
|
|
createNav, stampFootprint, clearanceFor, findPath, smoothPath, formationSlots,
|
|
nearestUsableTile, tileIndex, worldToTileX, worldToTileY, tileCenterX, tileCenterY,
|
|
SpatialHash, segmentClear,
|
|
} from './TANav.js';
|
|
|
|
export const SAVE_VERSION = 1;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RNG — mulberry32, state carried in the match so saves resume deterministically
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function rngNext(state) {
|
|
let t = (state.rngState = (state.rngState + 0x6d2b79f5) >>> 0);
|
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
}
|
|
|
|
export function rngRange(state, lo, hi) { return lo + rngNext(state) * (hi - lo); }
|
|
export function rngInt(state, n) { return Math.floor(rngNext(state) * n); }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Match creation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @param {object} rules compiled rules
|
|
* @param {object} opts { seed, map:{w,h,terrain,starts,theme}, victory, armies:[{armyId,commanderId,isHuman,aiSkill}] }
|
|
*/
|
|
export function createMatch(rules, opts) {
|
|
const { map } = opts;
|
|
const c = rules.constants;
|
|
const state = {
|
|
v: SAVE_VERSION,
|
|
rulesVersion: rules.version,
|
|
seed: opts.seed >>> 0,
|
|
rngState: (opts.seed >>> 0) || 1,
|
|
tick: 0,
|
|
accumulatorMs: 0,
|
|
alpha: 0,
|
|
w: map.w, h: map.h,
|
|
tileSize: c.tileSize,
|
|
worldW: map.w * c.tileSize,
|
|
worldH: map.h * c.tileSize,
|
|
theme: map.theme ?? rules.skirmish.defaults.theme,
|
|
terrain: map.terrain,
|
|
starts: (map.starts ?? []).map((s) => ({ army: s.army, x: s.x, y: s.y })),
|
|
armies: [],
|
|
entities: [],
|
|
nextId: 1,
|
|
projectiles: [],
|
|
nextProjectileId: 1,
|
|
pathQueue: [],
|
|
events: [],
|
|
over: null,
|
|
elapsedSec: 0,
|
|
// 'commander' — an army dies with its Commander. 'annihilation' — it fights on until it
|
|
// can no longer produce. Skirmish setup picks; everything else takes the rules default.
|
|
victory: opts.victory ?? rules.constants.victoryDefault ?? 'commander',
|
|
};
|
|
|
|
state.nav = createNav(rules, map);
|
|
state.hash = new SpatialHash(128, state.worldW, state.worldH);
|
|
|
|
const visW = Math.ceil(map.w / 2), visH = Math.ceil(map.h / 2);
|
|
state.visW = visW; state.visH = visH;
|
|
|
|
for (let i = 0; i < opts.armies.length; i++) {
|
|
const spec = opts.armies[i];
|
|
state.armies.push({
|
|
idx: i,
|
|
armyId: spec.armyId,
|
|
commanderId: spec.commanderId ?? null,
|
|
isHuman: !!spec.isHuman,
|
|
aiSkill: spec.aiSkill ?? 3,
|
|
aiProfile: spec.aiProfile ?? null,
|
|
energy: spec.startEnergy ?? c.startEnergy,
|
|
mass: spec.startMass ?? c.startMass,
|
|
energyCap: c.baseEnergyCap,
|
|
massCap: c.baseMassCap,
|
|
eIncome: 0, mIncome: 0, eUpkeep: 0, eDrain: 0, mDrain: 0,
|
|
stallE: 1, stallM: 1,
|
|
alive: true, hadCommander: false,
|
|
explored: new Uint8Array(visW * visH),
|
|
visible: new Uint8Array(visW * visH),
|
|
builtEver: 0, lostEver: 0, killsEver: 0,
|
|
});
|
|
}
|
|
|
|
// Starting units.
|
|
for (const s of map.starts ?? []) {
|
|
if (s.army >= state.armies.length) continue;
|
|
for (const unitId of rules.skirmish.startUnits) {
|
|
spawnUnit(state, rules, s.army, unitId,
|
|
tileCenterX(state.nav, s.x), tileCenterY(state.nav, s.y), Math.PI / 2);
|
|
}
|
|
}
|
|
// Anything the map pre-places (campaign missions use this).
|
|
for (const u of map.units ?? []) {
|
|
spawnUnit(state, rules, u.army, u.type, u.x * c.tileSize, u.y * c.tileSize, u.heading ?? 0);
|
|
}
|
|
for (const b of map.buildings ?? []) {
|
|
const e = placeBuilding(state, rules, b.army, b.type, b.tx, b.ty);
|
|
if (e) { e.progress = 1; e.site = false; e.hp = rules.defById[b.type].hp; onBuildingComplete(state, rules, e); }
|
|
}
|
|
|
|
// Only an army that FIELDED a Commander can lose by losing it. A scenario that hands an
|
|
// army nothing but a garrison would otherwise be eliminated on its first tick.
|
|
for (const a of state.armies) a.hadCommander = armyHasCommander(state, rules, a.idx);
|
|
|
|
recomputeEconomyCaps(state, rules);
|
|
computeVision(state, rules);
|
|
return state;
|
|
}
|
|
|
|
function armyHasCommander(state, rules, armyIdx) {
|
|
return state.entities.some((e) => !e.dead && !e.site && e.army === armyIdx
|
|
&& rules.defById[e.defId]?.isCommander);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entities
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function baseEntity(state, army, def) {
|
|
return {
|
|
id: state.nextId++,
|
|
army,
|
|
defId: def.id,
|
|
isBuilding: !!def.isBuilding,
|
|
x: 0, y: 0, px: 0, py: 0,
|
|
heading: 0, pheading: 0,
|
|
turretRot: 0, pturretRot: 0,
|
|
vx: 0, vy: 0,
|
|
hp: def.hp, maxHp: def.hp,
|
|
dead: false,
|
|
site: false, progress: 1,
|
|
orders: [], path: null, pathIdx: 0,
|
|
destX: 0, destY: 0, slotX: 0, slotY: 0,
|
|
wantPath: false, noPath: false,
|
|
stuckTicks: 0, blockedTicks: 0, stuckCheckTick: null, stuckCheckX: 0, stuckCheckY: 0,
|
|
// Far enough in the past that a fresh unit is eligible to regenerate immediately.
|
|
// A finite sentinel rather than -Infinity, which JSON.stringify turns into null.
|
|
lastDamagedTick: -1e9,
|
|
targetId: 0, reload: (def.weaponDefs ?? []).map(() => 0),
|
|
burstLeft: (def.weaponDefs ?? []).map(() => 0),
|
|
buildTargetId: 0,
|
|
queue: [], jobProgress: 0, rallyX: 0, rallyY: 0, hasRally: false,
|
|
_power: 0, _repairPower: 0,
|
|
produceE: 0, produceM: 0, upkeepE: 0, storeE: 0, storeM: 0,
|
|
};
|
|
}
|
|
|
|
export function spawnUnit(state, rules, army, defId, x, y, heading = 0) {
|
|
const def = rules.unitById[defId];
|
|
if (!def) return null;
|
|
const e = baseEntity(state, army, def);
|
|
e.x = e.px = x;
|
|
e.y = e.py = y;
|
|
e.heading = e.pheading = heading;
|
|
e.turretRot = e.pturretRot = heading;
|
|
e.radius = def.radius;
|
|
e.destX = x; e.destY = y;
|
|
e.rallyX = x; e.rallyY = y;
|
|
if (def.produce) { e.produceE = def.produce.energy ?? 0; e.produceM = def.produce.mass ?? 0; }
|
|
if (def.storage) { e.storeE = def.storage.energy ?? 0; e.storeM = def.storage.mass ?? 0; }
|
|
state.entities.push(e);
|
|
state.armies[army].builtEver++;
|
|
state.events.push({ t: 'spawn', id: e.id, army, defId, x, y });
|
|
return e;
|
|
}
|
|
|
|
/** Create a build SITE. It blocks the nav grid immediately so pathing routes around it. */
|
|
export function placeBuilding(state, rules, army, defId, tx, ty) {
|
|
const def = rules.buildingById[defId];
|
|
if (!def) return null;
|
|
const ts = state.tileSize;
|
|
const e = baseEntity(state, army, def);
|
|
e.tx = tx; e.ty = ty;
|
|
e.fw = def.footprint.w; e.fh = def.footprint.h;
|
|
e.x = e.px = (tx + e.fw / 2) * ts;
|
|
e.y = e.py = (ty + e.fh / 2) * ts;
|
|
e.radius = def.radius;
|
|
e.site = true;
|
|
e.progress = 0;
|
|
e.hp = Math.max(1, def.hp * 0.05);
|
|
state.entities.push(e);
|
|
stampFootprint(state.nav, rules, tx, ty, e.fw, e.fh, true);
|
|
state.events.push({ t: 'siteCreated', id: e.id, army, defId, x: e.x, y: e.y });
|
|
return e;
|
|
}
|
|
|
|
function onBuildingComplete(state, rules, e) {
|
|
const def = rules.buildingById[e.defId];
|
|
e.site = false;
|
|
e.progress = 1;
|
|
e.hp = def.hp;
|
|
if (def.produce) {
|
|
e.produceE = def.produce.energy ?? 0;
|
|
let m = def.produce.mass ?? 0;
|
|
// Terrain bonus (a Mass Generator on a metal patch): resolved once, at completion.
|
|
if (m > 0 && def.terrainMultiplier) {
|
|
let best = 1;
|
|
for (let y = e.ty; y < e.ty + e.fh; y++) {
|
|
for (let x = e.tx; x < e.tx + e.fw; x++) {
|
|
if (x < 0 || y < 0 || x >= state.w || y >= state.h) continue;
|
|
const t = rules.terrain[state.terrain[y * state.w + x]];
|
|
const mul = t?.[def.terrainMultiplier];
|
|
if (mul > best) best = mul;
|
|
}
|
|
}
|
|
m *= best;
|
|
e.terrainBonus = best;
|
|
}
|
|
e.produceM = m;
|
|
}
|
|
if (def.upkeep) e.upkeepE = def.upkeep.energy ?? 0;
|
|
if (def.storage) { e.storeE = def.storage.energy ?? 0; e.storeM = def.storage.mass ?? 0; }
|
|
recomputeEconomyCaps(state, rules);
|
|
state.events.push({ t: 'buildingComplete', id: e.id, army: e.army, defId: e.defId, x: e.x, y: e.y });
|
|
}
|
|
|
|
export function entityById(state, id) {
|
|
if (!id) return null;
|
|
for (let i = 0; i < state.entities.length; i++) {
|
|
const e = state.entities[i];
|
|
if (e.id === id) return e.dead ? null : e;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function defOf(rules, e) { return rules.defById[e.defId]; }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Order API — the single mutation entry point shared by the human and the AI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ORDER_TYPES = new Set([
|
|
'move', 'attackMove', 'attack', 'stop', 'hold', 'patrol', 'guard', 'build', 'assist', 'repair',
|
|
]);
|
|
|
|
/**
|
|
* @param {object} cmd { army, unitIds:[], order:{type,...}, queue:boolean }
|
|
* @returns {{ok:boolean, error?:string}}
|
|
*/
|
|
export function issueOrder(state, rules, cmd) {
|
|
if (state.over) return { ok: false, error: 'match is over' };
|
|
const { army, order } = cmd;
|
|
if (!order) return { ok: false, error: 'no order' };
|
|
|
|
// Factory / rally commands act on a building, not on a unit's order queue.
|
|
if (order.type === 'factoryEnqueue' || order.type === 'factoryCancel' || order.type === 'setRally') {
|
|
return factoryCommand(state, rules, army, order);
|
|
}
|
|
if (!ORDER_TYPES.has(order.type)) return { ok: false, error: `unknown order "${order.type}"` };
|
|
|
|
const units = [];
|
|
for (const id of cmd.unitIds ?? []) {
|
|
const e = entityById(state, id);
|
|
if (!e || e.army !== army || e.isBuilding || e.site) continue;
|
|
units.push(e);
|
|
}
|
|
if (!units.length) return { ok: false, error: 'no controllable units' };
|
|
|
|
if (order.type === 'build') return buildCommand(state, rules, army, units, order, cmd.queue);
|
|
|
|
if (order.type === 'attack') {
|
|
const target = entityById(state, order.targetId);
|
|
if (!target) return { ok: false, error: 'target does not exist' };
|
|
if (target.army === army && !rules.constants.friendlyFire) {
|
|
return { ok: false, error: 'cannot attack an ally' };
|
|
}
|
|
}
|
|
if (order.type === 'assist') {
|
|
const target = entityById(state, order.targetId);
|
|
if (!target || target.army !== army) return { ok: false, error: 'can only assist your own' };
|
|
if (!canBuild(rules, units[0]) ) return { ok: false, error: 'unit cannot assist' };
|
|
}
|
|
if (order.type === 'repair') {
|
|
const target = entityById(state, order.targetId);
|
|
if (!target || target.army !== army) return { ok: false, error: 'can only repair your own' };
|
|
if (target.site) return { ok: false, error: 'use assist on a building under construction' };
|
|
if (!units.some((u) => canBuild(rules, u))) return { ok: false, error: 'no builder selected' };
|
|
if (target.hp >= target.maxHp) return { ok: false, error: 'already at full health' };
|
|
}
|
|
if (order.type === 'guard') {
|
|
const target = entityById(state, order.targetId);
|
|
if (!target || target.army !== army) return { ok: false, error: 'can only guard your own' };
|
|
}
|
|
|
|
const moveLike = order.type === 'move' || order.type === 'attackMove' || order.type === 'patrol';
|
|
if (moveLike) {
|
|
if (!Number.isFinite(order.x) || !Number.isFinite(order.y)) return { ok: false, error: 'bad destination' };
|
|
// One shared destination, fanned into formation slots — a 40-unit move order costs one A*.
|
|
const slots = formationSlots(order.x, order.y, units);
|
|
units.forEach((u, i) => {
|
|
const o = { ...order, sx: slots[i * 2], sy: slots[i * 2 + 1] };
|
|
if (order.type === 'patrol') { o.fromX = u.x; o.fromY = u.y; o.leg = 0; }
|
|
pushOrder(u, o, cmd.queue);
|
|
});
|
|
return { ok: true };
|
|
}
|
|
|
|
for (const u of units) pushOrder(u, { ...order }, cmd.queue);
|
|
return { ok: true };
|
|
}
|
|
|
|
function pushOrder(e, order, queue) {
|
|
if (!queue) {
|
|
e.orders.length = 0;
|
|
e.path = null;
|
|
e.wantPath = false;
|
|
e.noPath = false;
|
|
// Drop the nanolathe link here rather than waiting for stepOrders to notice. Economy runs
|
|
// before orders within a tick, so leaving it set would bill the player for one more tick
|
|
// of a build or repair they just cancelled.
|
|
e.buildTargetId = 0;
|
|
}
|
|
e.orders.push(order);
|
|
}
|
|
|
|
function canBuild(rules, e) {
|
|
const def = defOf(rules, e);
|
|
return !!(def.builds ?? []).length && def.buildPower > 0;
|
|
}
|
|
|
|
function buildCommand(state, rules, army, units, order, queue) {
|
|
const builder = units.find((u) => canBuild(rules, u));
|
|
if (!builder) return { ok: false, error: 'no builder selected' };
|
|
const def = rules.buildingById[order.defId];
|
|
if (!def) return { ok: false, error: `unknown building "${order.defId}"` };
|
|
const bdef = defOf(rules, builder);
|
|
if (!(bdef.builds ?? []).includes(order.defId)) {
|
|
return { ok: false, error: `${bdef.name} cannot build ${def.name}` };
|
|
}
|
|
const legal = canPlaceAt(state, rules, order.tx, order.ty, def);
|
|
if (!legal.ok) return legal;
|
|
|
|
const site = placeBuilding(state, rules, army, order.defId, order.tx, order.ty);
|
|
if (!site) return { ok: false, error: 'placement failed' };
|
|
pushOrder(builder, { type: 'build', targetId: site.id }, queue);
|
|
return { ok: true, siteId: site.id };
|
|
}
|
|
|
|
/**
|
|
* Remove one order from a unit's queue by index — the missing counterpart to `stop`, which
|
|
* only ever clears the whole queue. Cancelling the active order (index 0) gets the same state
|
|
* reset stepOrders' own completion paths already do, so nothing is left chasing an order that
|
|
* is no longer there.
|
|
*
|
|
* A queued BUILD order carries a live site entity from the moment it was placed (see
|
|
* placeBuilding) — stamped into the nav grid regardless of how deep in the queue it sits.
|
|
* Cancelling it before any progress has been made tears that site back out (footprint
|
|
* unstamped, entity removed, any other builder pointed at it freed up); once work has begun,
|
|
* only the queue slot goes away — the site, and anyone else building it, are untouched.
|
|
*/
|
|
export function cancelOrder(state, rules, army, unitId, index) {
|
|
const e = entityById(state, unitId);
|
|
if (!e || e.army !== army || !Array.isArray(e.orders) || index < 0 || index >= e.orders.length) {
|
|
return { ok: false, error: 'no such queued order' };
|
|
}
|
|
const order = e.orders[index];
|
|
|
|
if (order.type === 'build') {
|
|
const site = entityById(state, order.targetId);
|
|
if (site && site.isBuilding && (site.progress ?? 0) <= 0) {
|
|
stampFootprint(state.nav, rules, site.tx, site.ty, site.fw, site.fh, false);
|
|
site.dead = true;
|
|
for (const o of state.entities) {
|
|
if (o.buildTargetId === site.id) o.buildTargetId = 0;
|
|
if (o.targetId === site.id) o.targetId = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
e.orders.splice(index, 1);
|
|
if (index === 0) {
|
|
e.path = null; e.movingTo = null; e.targetId = 0; e.buildTargetId = 0;
|
|
e.stuckTicks = 0; e.noPath = false;
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
/** Can this footprint go here? Checks bounds, terrain buildability and occupancy. */
|
|
export function canPlaceAt(state, rules, tx, ty, def) {
|
|
const fw = def.footprint.w, fh = def.footprint.h;
|
|
if (tx < 0 || ty < 0 || tx + fw > state.w || ty + fh > state.h) {
|
|
return { ok: false, error: 'off the map' };
|
|
}
|
|
for (let y = ty; y < ty + fh; y++) {
|
|
for (let x = tx; x < tx + fw; x++) {
|
|
const i = y * state.w + x;
|
|
if (state.nav.blocked[i]) return { ok: false, error: 'occupied' };
|
|
const t = rules.terrain[state.terrain[i]];
|
|
if (!t.buildable) return { ok: false, error: `cannot build on ${t.id}` };
|
|
}
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
function factoryCommand(state, rules, army, order) {
|
|
const f = entityById(state, order.factoryId);
|
|
if (!f || f.army !== army || !f.isBuilding || f.site) return { ok: false, error: 'no such factory' };
|
|
const def = defOf(rules, f);
|
|
if (order.type === 'setRally') {
|
|
f.rallyX = order.x; f.rallyY = order.y; f.hasRally = true;
|
|
return { ok: true };
|
|
}
|
|
if (order.type === 'factoryCancel') {
|
|
const idx = order.index ?? 0;
|
|
if (idx < 0 || idx >= f.queue.length) return { ok: false, error: 'bad queue index' };
|
|
const item = f.queue[idx];
|
|
const take = Math.min(item.count, order.count ?? 1);
|
|
item.count -= take;
|
|
if (idx === 0 && f.jobProgress > 0 && item.count === 0) {
|
|
// Refund what the cancelled head job actually consumed.
|
|
const udef = rules.unitById[item.defId];
|
|
const a = state.armies[army];
|
|
a.energy = Math.min(a.energyCap, a.energy + udef.cost.energy * f.jobProgress);
|
|
a.mass = Math.min(a.massCap, a.mass + udef.cost.mass * f.jobProgress);
|
|
f.jobProgress = 0;
|
|
}
|
|
if (item.count <= 0) f.queue.splice(idx, 1);
|
|
state.events.push({ t: 'queueChanged', id: f.id });
|
|
return { ok: true };
|
|
}
|
|
// factoryEnqueue
|
|
if (!(def.builds ?? []).includes(order.defId)) {
|
|
return { ok: false, error: `${def.name} cannot build ${order.defId}` };
|
|
}
|
|
const count = Math.max(1, order.count ?? 1);
|
|
const head = f.queue[f.queue.length - 1];
|
|
if (head && head.defId === order.defId) head.count += count;
|
|
else f.queue.push({ defId: order.defId, count });
|
|
state.events.push({ t: 'queueChanged', id: f.id });
|
|
return { ok: true };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Economy
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function recomputeEconomyCaps(state, rules) {
|
|
const c = rules.constants;
|
|
for (const a of state.armies) { a.energyCap = c.baseEnergyCap; a.massCap = c.baseMassCap; }
|
|
for (const e of state.entities) {
|
|
if (e.dead || e.site) continue;
|
|
const a = state.armies[e.army];
|
|
if (!a) continue;
|
|
a.energyCap += e.storeE;
|
|
a.massCap += e.storeM;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The flow economy. Income and drain are RATES; construction consumes continuously and
|
|
* slows proportionally when income can't keep up — that stall factor is the whole reason
|
|
* Energy and Mass Generators are separate buildings.
|
|
*/
|
|
function stepEconomy(state, rules) {
|
|
const dt = rules.dt;
|
|
const nominal = rules.constants.buildPowerNominal;
|
|
|
|
for (const a of state.armies) {
|
|
a.eIncome = 0; a.mIncome = 0; a.eUpkeep = 0; a.eDrain = 0; a.mDrain = 0;
|
|
a._mFree = 0; a._mGated = 0; a.upkeepFactor = 1;
|
|
}
|
|
for (const e of state.entities) { e._power = 0; e._repairPower = 0; }
|
|
|
|
// 1. Income and upkeep from completed entities. Mass production is split into what runs
|
|
// for free and what is gated behind an energy upkeep (the Mass Generator), because
|
|
// those two behave completely differently in a brownout — see step 4.
|
|
for (const e of state.entities) {
|
|
if (e.dead || e.site) continue;
|
|
const a = state.armies[e.army];
|
|
if (!a) continue;
|
|
a.eIncome += e.produceE;
|
|
a.eUpkeep += e.upkeepE;
|
|
if (e.upkeepE > 0) a._mGated += e.produceM;
|
|
else a._mFree += e.produceM;
|
|
}
|
|
|
|
// 2. Gather build power onto each job.
|
|
for (const e of state.entities) {
|
|
if (e.dead || e.site) continue;
|
|
const def = defOf(rules, e);
|
|
if (!def.buildPower) continue;
|
|
if (e.isBuilding) {
|
|
if (e.queue.length) e._power += def.buildPower; // factory works its own head item
|
|
} else if (e.buildTargetId) {
|
|
const target = entityById(state, e.buildTargetId);
|
|
if (target && target.army === e.army) {
|
|
const d = Math.hypot(target.x - e.x, target.y - e.y);
|
|
if (d > def.buildRange + target.radius) continue;
|
|
// Repair power is kept in its OWN accumulator. A damaged factory has build power of
|
|
// its own for the unit it is producing; folding an incoming repair into the same
|
|
// number would silently speed up that unit's production.
|
|
if (target.site) target._power += def.buildPower;
|
|
else if (target.hp < target.maxHp) target._repairPower += def.buildPower;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Demand.
|
|
const jobs = [];
|
|
for (const e of state.entities) {
|
|
const a = state.armies[e.army];
|
|
if (e.dead || !a) continue;
|
|
|
|
if (e._power > 0) {
|
|
let jobDef = null;
|
|
if (e.site) jobDef = defOf(rules, e);
|
|
else if (e.isBuilding && e.queue.length) jobDef = rules.unitById[e.queue[0].defId];
|
|
if (jobDef && jobDef.buildTime > 0) {
|
|
const rate = (e._power / nominal) / jobDef.buildTime; // progress fraction per second
|
|
a.eDrain += (jobDef.cost.energy ?? 0) * rate;
|
|
a.mDrain += (jobDef.cost.mass ?? 0) * rate;
|
|
jobs.push({ e, rate, army: e.army, kind: e.site ? 'site' : 'factory' });
|
|
}
|
|
}
|
|
|
|
// Repair is priced as a fraction of a fresh build: restoring X% of a unit's HP costs X%
|
|
// of its build cost and takes X% of its build time. It therefore runs through exactly the
|
|
// same rate, drain and stall machinery as construction — including being throttled in a
|
|
// brownout rather than proceeding for free.
|
|
if (e._repairPower > 0 && !e.site && e.hp < e.maxHp) {
|
|
const def = defOf(rules, e);
|
|
if (def.buildTime > 0) {
|
|
const full = (e._repairPower / nominal) / def.buildTime;
|
|
// Never bill for more than the damage actually outstanding.
|
|
const remaining = (e.maxHp - e.hp) / e.maxHp;
|
|
const rate = Math.min(full, remaining / dt);
|
|
a.eDrain += (def.cost.energy ?? 0) * rate;
|
|
a.mDrain += (def.cost.mass ?? 0) * rate;
|
|
jobs.push({ e, rate, army: e.army, kind: 'repair' });
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Stall factors, then apply.
|
|
//
|
|
// Claim order on energy is CONSTRUCTION FIRST, upkeep second. That ordering matters:
|
|
// if upkeep were paid first, an army that over-built Mass Generators (upkeep > income)
|
|
// would pin its energy at zero, drop build efficiency to zero, and be permanently
|
|
// unable to finish the very Energy Generators that would rescue it. Throttling the
|
|
// metal makers instead is both self-correcting and what a TA player does by hand.
|
|
for (const a of state.armies) {
|
|
const eAvail = Math.max(0, a.energy + a.eIncome * dt);
|
|
const eNeed = a.eDrain * dt;
|
|
a.stallE = eNeed > eAvail ? (eNeed > 0 ? eAvail / eNeed : 1) : 1;
|
|
|
|
const eLeft = Math.max(0, eAvail - eNeed * a.stallE);
|
|
const upkeepNeed = a.eUpkeep * dt;
|
|
a.upkeepFactor = upkeepNeed > 0 ? Math.min(1, eLeft / upkeepNeed) : 1;
|
|
// Gated producers only deliver in proportion to the upkeep they were actually paid.
|
|
a.mIncome = a._mFree + a._mGated * a.upkeepFactor;
|
|
|
|
const mAvail = Math.max(0, a.mass + a.mIncome * dt);
|
|
const mNeed = a.mDrain * dt;
|
|
a.stallM = mNeed > mAvail ? (mNeed > 0 ? mAvail / mNeed : 1) : 1;
|
|
a.buildEff = Math.min(a.stallE, a.stallM);
|
|
if (a.buildEff < 0.999 && !a._stalling) {
|
|
a._stalling = true;
|
|
state.events.push({ t: 'stallStart', army: a.idx, energy: a.stallE < 0.999, mass: a.stallM < 0.999 });
|
|
} else if (a.buildEff >= 0.999 && a._stalling) {
|
|
a._stalling = false;
|
|
state.events.push({ t: 'stallEnd', army: a.idx });
|
|
}
|
|
}
|
|
|
|
for (const j of jobs) {
|
|
const a = state.armies[j.army];
|
|
j.adv = j.rate * a.buildEff * dt;
|
|
}
|
|
|
|
for (const a of state.armies) {
|
|
const f = a.buildEff;
|
|
const eNext = a.energy + (a.eIncome - a.eUpkeep * a.upkeepFactor - a.eDrain * f) * dt;
|
|
const mNext = a.mass + (a.mIncome - a.mDrain * f) * dt;
|
|
if (eNext > a.energyCap || mNext > a.massCap) {
|
|
state.events.push({ t: 'wasting', army: a.idx, energy: eNext > a.energyCap, mass: mNext > a.massCap });
|
|
}
|
|
a.energy = Math.max(0, Math.min(a.energyCap, eNext));
|
|
a.mass = Math.max(0, Math.min(a.massCap, mNext));
|
|
}
|
|
|
|
// 5. Advance construction and repair.
|
|
for (const j of jobs) {
|
|
const e = j.e;
|
|
const adv = j.adv ?? 0;
|
|
if (adv <= 0) continue;
|
|
if (j.kind === 'repair') {
|
|
e.hp = Math.min(e.maxHp, e.hp + e.maxHp * adv);
|
|
state.events.push({ t: 'nanolathe', id: e.id, x: e.x, y: e.y, army: e.army });
|
|
if (e.hp >= e.maxHp) {
|
|
// Release the builders so they fall through to whatever they were told to do next.
|
|
for (const b of state.entities) {
|
|
if (b.buildTargetId !== e.id) continue;
|
|
b.buildTargetId = 0;
|
|
if (b.orders[0]?.type === 'repair' && b.orders[0].targetId === e.id) b.orders.shift();
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if (e.site) {
|
|
const def = defOf(rules, e);
|
|
e.progress = Math.min(1, e.progress + adv);
|
|
e.hp = Math.max(1, def.hp * Math.max(0.05, e.progress));
|
|
state.events.push({ t: 'nanolathe', id: e.id, x: e.x, y: e.y, army: e.army });
|
|
if (e.progress >= 1) {
|
|
onBuildingComplete(state, rules, e);
|
|
// Release any builder that was working on it.
|
|
for (const b of state.entities) {
|
|
if (b.buildTargetId === e.id) {
|
|
b.buildTargetId = 0;
|
|
if (b.orders[0]?.type === 'build' && b.orders[0].targetId === e.id) b.orders.shift();
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
e.jobProgress = Math.min(1, e.jobProgress + adv);
|
|
state.events.push({ t: 'nanolathe', id: e.id, x: e.x, y: e.y, army: e.army });
|
|
if (e.jobProgress >= 1) finishFactoryJob(state, rules, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
function finishFactoryJob(state, rules, f) {
|
|
const item = f.queue[0];
|
|
if (!item) { f.jobProgress = 0; return; }
|
|
const def = defOf(rules, f);
|
|
const off = def.spawnOffset ?? { x: 0, y: 1 };
|
|
const ts = state.tileSize;
|
|
let sx = f.x + off.x * ts;
|
|
let sy = f.y + off.y * ts;
|
|
// Nudge out of the footprint if the bay mouth is somehow blocked.
|
|
const nav = state.nav;
|
|
const udef = rules.unitById[item.defId];
|
|
const need = clearanceFor(udef.radius, ts);
|
|
const mc = udef.moveClass;
|
|
let tx = worldToTileX(nav, sx), ty = worldToTileY(nav, sy);
|
|
const idx = nearestUsableTile(nav, mc, need, tx, ty, 6);
|
|
if (idx >= 0) { sx = tileCenterX(nav, idx % nav.w); sy = tileCenterY(nav, (idx / nav.w) | 0); }
|
|
|
|
const u = spawnUnit(state, rules, f.army, item.defId, sx, sy, Math.atan2(off.y, off.x));
|
|
f.jobProgress = 0;
|
|
item.count--;
|
|
if (item.count <= 0) f.queue.shift();
|
|
state.events.push({ t: 'queueChanged', id: f.id });
|
|
if (u && f.hasRally) {
|
|
issueOrder(state, rules, {
|
|
army: f.army, unitIds: [u.id], order: { type: 'move', x: f.rallyX, y: f.rallyY }, queue: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Orders and movement
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function requestPath(state, e, x, y) {
|
|
e.destX = x; e.destY = y;
|
|
e.wantPath = true;
|
|
e.noPath = false;
|
|
if (!state.pathQueue.includes(e.id)) state.pathQueue.push(e.id);
|
|
}
|
|
|
|
function servicePathQueue(state, rules) {
|
|
let budget = rules.constants.pathBudgetPerTick;
|
|
while (budget-- > 0 && state.pathQueue.length) {
|
|
const id = state.pathQueue.shift();
|
|
const e = entityById(state, id);
|
|
if (!e || !e.wantPath) continue;
|
|
const def = defOf(rules, e);
|
|
const nav = state.nav;
|
|
const need = clearanceFor(e.radius, state.tileSize);
|
|
const mc = def.moveClass;
|
|
|
|
const sIdx = nearestUsableTile(nav, mc, need, worldToTileX(nav, e.x), worldToTileY(nav, e.y), 6);
|
|
const gIdx = nearestUsableTile(nav, mc, need, worldToTileX(nav, e.destX), worldToTileY(nav, e.destY), 24);
|
|
e.wantPath = false;
|
|
if (sIdx < 0 || gIdx < 0) { e.noPath = true; e.path = null; continue; }
|
|
|
|
const tiles = findPath(nav, mc, need, sIdx, gIdx);
|
|
if (!tiles) { e.noPath = true; e.path = null; state.events.push({ t: 'noPath', id: e.id }); continue; }
|
|
e.path = smoothPath(nav, mc, need, tiles, e.destX, e.destY);
|
|
e.pathIdx = 0;
|
|
}
|
|
}
|
|
|
|
function orderDestination(e, order) {
|
|
if (order.type === 'patrol') {
|
|
return order.leg === 0 ? { x: order.sx ?? order.x, y: order.sy ?? order.y }
|
|
: { x: order.fromX, y: order.fromY };
|
|
}
|
|
return { x: order.sx ?? order.x, y: order.sy ?? order.y };
|
|
}
|
|
|
|
function stepOrders(state, rules) {
|
|
const ts = state.tileSize;
|
|
for (const e of state.entities) {
|
|
if (e.dead || e.site) continue;
|
|
if (e.isBuilding) continue;
|
|
const def = defOf(rules, e);
|
|
const order = e.orders[0];
|
|
|
|
if (!order) {
|
|
e.movingTo = null;
|
|
e.buildTargetId = 0;
|
|
continue;
|
|
}
|
|
|
|
// A builder only holds a nanolathe target while its CURRENT order is a construction one.
|
|
// Without this, giving a repairing Commander a move order left buildTargetId pointing at
|
|
// the patient, and the economy kept pouring resources into the repair — for as long as
|
|
// the builder happened to stay in range — even though the player had cancelled it.
|
|
if (order.type !== 'build' && order.type !== 'assist' && order.type !== 'repair') {
|
|
e.buildTargetId = 0;
|
|
}
|
|
|
|
switch (order.type) {
|
|
case 'stop':
|
|
e.orders.length = 0;
|
|
e.path = null;
|
|
e.targetId = 0;
|
|
e.buildTargetId = 0;
|
|
break;
|
|
|
|
case 'hold':
|
|
e.path = null;
|
|
e.movingTo = null;
|
|
break;
|
|
|
|
case 'move':
|
|
case 'attackMove':
|
|
case 'patrol': {
|
|
// An attack-move that has closed on a hostile BUILDING stops as soon as it is
|
|
// comfortably inside weapon range of that building's surface, and holds there while
|
|
// it shoots. Otherwise the unit keeps driving at its move destination — which for a
|
|
// base assault is a point inside a building — and grinds against the wall of
|
|
// something it could have killed from a screen away.
|
|
//
|
|
// Deliberately buildings only. Applying it to mobile targets as well made every
|
|
// squad halt at maximum range the instant it saw anything, which took the sting out
|
|
// of attacking altogether and flattened the AI skill ladder from 90% to 53%.
|
|
if (order.type === 'attackMove' && def.maxRange > 0) {
|
|
const foe = entityById(state, e.targetId);
|
|
if (foe && foe.isBuilding && !foe.dead && foe.army !== e.army && state.armies[foe.army]
|
|
&& surfaceDist(rules, e, foe) <= def.maxRange * (rules.constants.engageHoldFraction ?? 0.85)) {
|
|
e.path = null;
|
|
e.movingTo = null;
|
|
e.stuckTicks = 0;
|
|
e.noPath = false;
|
|
break;
|
|
}
|
|
}
|
|
const dest = orderDestination(e, order);
|
|
if (!e.path && !e.wantPath && !e.noPath) requestPath(state, e, dest.x, dest.y);
|
|
e.movingTo = dest;
|
|
const d = Math.hypot(dest.x - e.x, dest.y - e.y);
|
|
const slack = Math.max(rules.constants.arriveSlackPx, e.radius * 1.2);
|
|
if (d <= slack || (e.noPath && e.stuckTicks > rules.constants.stuckGiveUpSec * rules.constants.tickHz)) {
|
|
if (order.type === 'patrol') {
|
|
order.leg = order.leg === 0 ? 1 : 0;
|
|
e.path = null; e.noPath = false; e.stuckTicks = 0;
|
|
requestPath(state, e, ...(order.leg === 0
|
|
? [order.sx ?? order.x, order.sy ?? order.y] : [order.fromX, order.fromY]));
|
|
} else {
|
|
e.orders.shift();
|
|
e.path = null; e.movingTo = null; e.stuckTicks = 0; e.noPath = false;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'attack': {
|
|
const target = entityById(state, order.targetId);
|
|
if (!target) { e.orders.shift(); e.targetId = 0; e.path = null; break; }
|
|
e.targetId = target.id;
|
|
const d = surfaceDist(rules, e, target);
|
|
const wantRange = def.maxRange * (rules.constants.engageHoldFraction ?? 0.85);
|
|
if (d > wantRange) {
|
|
if (!e.path && !e.wantPath) requestPath(state, e, target.x, target.y);
|
|
e.movingTo = { x: target.x, y: target.y };
|
|
// Re-path when the target has drifted well away from where we aimed.
|
|
if (e.path && Math.hypot(target.x - e.destX, target.y - e.destY) > ts * 3) {
|
|
e.path = null; requestPath(state, e, target.x, target.y);
|
|
}
|
|
} else {
|
|
e.path = null;
|
|
e.movingTo = null;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'guard': {
|
|
const target = entityById(state, order.targetId);
|
|
if (!target) { e.orders.shift(); break; }
|
|
const d = Math.hypot(target.x - e.x, target.y - e.y);
|
|
if (d > ts * 3) {
|
|
if (!e.path && !e.wantPath) requestPath(state, e, target.x, target.y);
|
|
e.movingTo = { x: target.x, y: target.y };
|
|
if (e.path && Math.hypot(target.x - e.destX, target.y - e.destY) > ts * 2) {
|
|
e.path = null; requestPath(state, e, target.x, target.y);
|
|
}
|
|
} else {
|
|
e.path = null; e.movingTo = null;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'build':
|
|
case 'assist':
|
|
case 'repair': {
|
|
const target = entityById(state, order.targetId);
|
|
if (!target) { e.orders.shift(); e.buildTargetId = 0; e.path = null; break; }
|
|
if (order.type === 'assist' && !target.site && !target.queue?.length) {
|
|
e.orders.shift(); e.buildTargetId = 0; break;
|
|
}
|
|
// Nothing left to mend — either it was topped up by someone else or it was never
|
|
// damaged by the time we arrived.
|
|
if (order.type === 'repair' && (target.hp >= target.maxHp || target.site)) {
|
|
e.orders.shift(); e.buildTargetId = 0; break;
|
|
}
|
|
const reach = (def.buildRange ?? 0) + target.radius;
|
|
const d = Math.hypot(target.x - e.x, target.y - e.y);
|
|
if (d > reach * 0.9) {
|
|
if (!e.path && !e.wantPath && !e.noPath) requestPath(state, e, target.x, target.y);
|
|
e.movingTo = { x: target.x, y: target.y };
|
|
e.buildTargetId = 0;
|
|
} else {
|
|
e.path = null;
|
|
e.movingTo = null;
|
|
e.buildTargetId = target.id;
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
e.orders.shift();
|
|
}
|
|
}
|
|
}
|
|
|
|
function stepMovement(state, rules) {
|
|
const dt = rules.dt;
|
|
const nav = state.nav;
|
|
|
|
for (const e of state.entities) {
|
|
e.px = e.x; e.py = e.y; e.pheading = e.heading; e.pturretRot = e.turretRot;
|
|
e._movingThisTick = false;
|
|
if (e.dead || e.isBuilding || e.site) { e.vx = 0; e.vy = 0; continue; }
|
|
|
|
const def = defOf(rules, e);
|
|
let tx = null, ty = null;
|
|
|
|
if (e.path && e.path.length >= 2) {
|
|
// Consume waypoints we've reached.
|
|
while (e.pathIdx * 2 + 1 < e.path.length) {
|
|
const wx = e.path[e.pathIdx * 2], wy = e.path[e.pathIdx * 2 + 1];
|
|
const last = (e.pathIdx + 1) * 2 >= e.path.length;
|
|
const tol = last ? Math.max(6, e.radius * 0.6) : Math.max(10, e.radius);
|
|
if (Math.hypot(wx - e.x, wy - e.y) <= tol) e.pathIdx++;
|
|
else break;
|
|
}
|
|
if (e.pathIdx * 2 + 1 < e.path.length) {
|
|
tx = e.path[e.pathIdx * 2]; ty = e.path[e.pathIdx * 2 + 1];
|
|
} else {
|
|
e.path = null;
|
|
}
|
|
} else if (e.movingTo && e.noPath) {
|
|
tx = e.movingTo.x; ty = e.movingTo.y;
|
|
}
|
|
|
|
if (tx == null) { e.vx = 0; e.vy = 0; e.stuckTicks = 0; continue; }
|
|
|
|
const dx = tx - e.x, dy = ty - e.y;
|
|
const dist = Math.hypot(dx, dy);
|
|
if (dist < 0.001) { e.vx = 0; e.vy = 0; continue; }
|
|
|
|
const want = Math.atan2(dy, dx);
|
|
e.heading = turnToward(e.heading, want, def.turnRate * dt);
|
|
|
|
// Terrain slows movement: speed scales by the inverse of the tile's move cost.
|
|
const ti = worldToTileY(nav, e.y) * state.w + worldToTileX(nav, e.x);
|
|
const tcost = rules.moveClasses[def.moveClass].costByTerrainIndex[state.terrain[ti]] ?? 1;
|
|
const terrainMul = tcost ? 1 / tcost : 1;
|
|
|
|
// Slow down while still swinging round, and ease into the final waypoint.
|
|
const misalign = Math.abs(angleDelta(e.heading, want));
|
|
const turnMul = misalign > 1.2 ? 0.25 : (misalign > 0.5 ? 0.65 : 1);
|
|
const arriveMul = dist < e.radius * 2 ? Math.max(0.25, dist / (e.radius * 2)) : 1;
|
|
|
|
const speed = def.speed * terrainMul * turnMul * arriveMul;
|
|
const step = Math.min(speed * dt, dist);
|
|
e.vx = Math.cos(e.heading) * step / dt;
|
|
e.vy = Math.sin(e.heading) * step / dt;
|
|
e.x += Math.cos(e.heading) * step;
|
|
e.y += Math.sin(e.heading) * step;
|
|
e._movingThisTick = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stuck detection, run after stepSeparation so it sees where units actually ended up rather
|
|
* than where stepMovement merely aimed them. A unit whose intended step this tick was fine on
|
|
* paper can still be shoved straight back by the building-clearance clamp in stepSeparation.
|
|
*
|
|
* This measures NET displacement over a whole `stuckRepathSec` window rather than per-tick
|
|
* displacement. A unit wedged against the corner of a wall doesn't sit still — it gets nudged
|
|
* one way by the clearance clamp, drives back toward its blocked waypoint, gets nudged again —
|
|
* and every one of those individual ticks moves it plenty. Only the position at the start and
|
|
* end of the window tells you it never actually got anywhere.
|
|
*
|
|
* A path that stops making real progress gets thrown away and re-requested against the
|
|
* CURRENT nav grid, which is what makes a unit route around an obstacle it didn't know about
|
|
* when the path was first computed (e.g. a building that went up mid-transit) instead of
|
|
* grinding against the same blocked waypoint forever.
|
|
*/
|
|
function finalizeStuck(state, rules) {
|
|
const windowTicks = Math.max(1, Math.round((rules.constants.stuckRepathSec ?? 1.0) * rules.constants.tickHz));
|
|
for (const e of state.entities) {
|
|
if (!e._movingThisTick) { e.stuckCheckTick = null; continue; }
|
|
if (e.stuckCheckTick == null) {
|
|
e.stuckCheckTick = state.tick; e.stuckCheckX = e.x; e.stuckCheckY = e.y;
|
|
continue;
|
|
}
|
|
if (state.tick - e.stuckCheckTick < windowTicks) continue;
|
|
|
|
const def = defOf(rules, e);
|
|
const moved = Math.hypot(e.x - e.stuckCheckX, e.y - e.stuckCheckY);
|
|
const expected = def.speed * (windowTicks / rules.constants.tickHz);
|
|
e.stuckCheckTick = state.tick; e.stuckCheckX = e.x; e.stuckCheckY = e.y;
|
|
|
|
if (moved >= expected * 0.15) { e.stuckTicks = 0; continue; }
|
|
e.stuckTicks += windowTicks;
|
|
if (e.path) { e.path = null; requestPath(state, e, e.destX, e.destY); }
|
|
}
|
|
}
|
|
|
|
function turnToward(cur, want, maxStep) {
|
|
const d = angleDelta(cur, want);
|
|
if (Math.abs(d) <= maxStep) return want;
|
|
return normalizeAngle(cur + Math.sign(d) * maxStep);
|
|
}
|
|
|
|
export function angleDelta(from, to) {
|
|
let d = (to - from) % (Math.PI * 2);
|
|
if (d > Math.PI) d -= Math.PI * 2;
|
|
if (d < -Math.PI) d += Math.PI * 2;
|
|
return d;
|
|
}
|
|
|
|
function normalizeAngle(a) {
|
|
a %= Math.PI * 2;
|
|
if (a > Math.PI) a -= Math.PI * 2;
|
|
if (a < -Math.PI) a += Math.PI * 2;
|
|
return a;
|
|
}
|
|
|
|
/**
|
|
* Positional separation. This is what makes the size classes real: three r=16 units settle
|
|
* inside one 64px tile, an r=26 tank takes the tile alone, and heavier units shove lighter
|
|
* ones aside rather than the other way round.
|
|
*/
|
|
function stepSeparation(state, rules) {
|
|
const stiff = rules.constants.separationStiffness;
|
|
const shoveTicks = rules.constants.shoveAfterSec * rules.constants.tickHz;
|
|
const hash = state.hash;
|
|
hash.clear();
|
|
const movers = [];
|
|
for (const e of state.entities) {
|
|
if (e.dead || e.isBuilding || e.site) continue;
|
|
hash.insert(e, e.x, e.y);
|
|
movers.push(e);
|
|
}
|
|
|
|
const near = [];
|
|
for (const a of movers) {
|
|
near.length = 0;
|
|
hash.query(a.x, a.y, a.radius * 2 + 32, near);
|
|
for (const b of near) {
|
|
if (b.id <= a.id) continue; // handle each pair once, deterministically
|
|
const dx = b.x - a.x, dy = b.y - a.y;
|
|
const minD = a.radius + b.radius;
|
|
let d2 = dx * dx + dy * dy;
|
|
if (d2 >= minD * minD) continue;
|
|
let d = Math.sqrt(d2);
|
|
let nx, ny;
|
|
if (d < 0.0001) {
|
|
// Exactly coincident: push apart along a deterministic axis derived from ids.
|
|
const ang = ((a.id * 2654435761 + b.id) % 628) / 100;
|
|
nx = Math.cos(ang); ny = Math.sin(ang); d = 0.0001;
|
|
} else {
|
|
nx = dx / d; ny = dy / d;
|
|
}
|
|
const overlap = (minD - d) * stiff;
|
|
const da = defOf(rules, a), db = defOf(rules, b);
|
|
const ma = da.massClass ?? 1, mb = db.massClass ?? 1;
|
|
const total = ma + mb;
|
|
const aShare = mb / total, bShare = ma / total;
|
|
a.x -= nx * overlap * aShare; a.y -= ny * overlap * aShare;
|
|
b.x += nx * overlap * bShare; b.y += ny * overlap * bShare;
|
|
|
|
// Traffic-jam breaker: a unit trying to move that's stuck behind a parked friendly
|
|
// tells the blocker to step aside. Without this, groups deadlock in corridors.
|
|
if (a.army === b.army) {
|
|
const aMoving = !!(a.path || a.movingTo), bMoving = !!(b.path || b.movingTo);
|
|
if (aMoving && !bMoving) { b.blockedTicks++; if (b.blockedTicks > shoveTicks) shove(state, rules, b, nx, ny); }
|
|
else if (bMoving && !aMoving) { a.blockedTicks++; if (a.blockedTicks > shoveTicks) shove(state, rules, a, -nx, -ny); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Push everything out of blocked tiles and back inside the map.
|
|
const nav = state.nav;
|
|
const ts = state.tileSize;
|
|
for (const e of movers) {
|
|
const def = defOf(rules, e);
|
|
const need = clearanceFor(e.radius, ts);
|
|
const cl = nav.clearance[def.moveClass];
|
|
const tx = worldToTileX(nav, e.x), ty = worldToTileY(nav, e.y);
|
|
if (cl[ty * nav.w + tx] < need) {
|
|
const idx = nearestUsableTile(nav, def.moveClass, need, tx, ty, 8);
|
|
if (idx >= 0) {
|
|
const cx = tileCenterX(nav, idx % nav.w), cy = tileCenterY(nav, (idx / nav.w) | 0);
|
|
const d = Math.hypot(cx - e.x, cy - e.y) || 1;
|
|
e.x += ((cx - e.x) / d) * Math.min(ts * 0.5, d);
|
|
e.y += ((cy - e.y) / d) * Math.min(ts * 0.5, d);
|
|
}
|
|
}
|
|
e.x = Math.max(e.radius, Math.min(state.worldW - e.radius, e.x));
|
|
e.y = Math.max(e.radius, Math.min(state.worldH - e.radius, e.y));
|
|
}
|
|
}
|
|
|
|
function shove(state, rules, e, nx, ny) {
|
|
e.blockedTicks = 0;
|
|
const ts = state.tileSize;
|
|
// Step perpendicular to the pusher, whichever side is open.
|
|
const target = { x: e.x - ny * ts * 1.5, y: e.y + nx * ts * 1.5 };
|
|
issueOrder(state, rules, {
|
|
army: e.army, unitIds: [e.id], order: { type: 'move', x: target.x, y: target.y }, queue: false,
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Combat
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function stepCombat(state, rules) {
|
|
const c = rules.constants;
|
|
const dt = rules.dt;
|
|
const hash = state.hash;
|
|
const near = [];
|
|
|
|
for (const e of state.entities) {
|
|
if (e.dead || e.site) continue;
|
|
const def = defOf(rules, e);
|
|
if (!def.weaponDefs?.length) continue;
|
|
|
|
for (let wi = 0; wi < e.reload.length; wi++) if (e.reload[wi] > 0) e.reload[wi]--;
|
|
|
|
// Staggered acquisition — the single biggest CPU lever in the sim. Re-scan only every
|
|
// retargetPeriodTicks, phase-shifted per unit so the cost spreads across ticks.
|
|
const holdOrder = e.orders[0]?.type === 'attack';
|
|
let target = entityById(state, e.targetId);
|
|
if (target && !inWeaponRange(rules, e, def, target)) target = null;
|
|
const due = ((state.tick + e.id) % c.retargetPeriodTicks) === 0;
|
|
if (!holdOrder && (!target || due)) {
|
|
target = acquireTarget(state, rules, e, def, hash, near);
|
|
e.targetId = target ? target.id : 0;
|
|
} else if (holdOrder) {
|
|
target = entityById(state, e.orders[0].targetId) ?? target;
|
|
e.targetId = target ? target.id : 0;
|
|
}
|
|
if (!target) continue;
|
|
|
|
const aimPt = aimPointOn(rules, e, target);
|
|
const dx = aimPt.x - e.x, dy = aimPt.y - e.y;
|
|
const aim = Math.atan2(dy, dx);
|
|
const surface = surfaceDist(rules, e, target);
|
|
const hasTurret = def.turretFrame != null;
|
|
// A structure has no hull to swing round: its mount traverses freely and is always on
|
|
// target. Without this a defensive tower could only ever shoot due east — a building's
|
|
// heading is fixed at 0 and nothing ever turns it.
|
|
if (e.isBuilding) e.turretRot = aim;
|
|
else if (hasTurret) e.turretRot = turnToward(e.turretRot, aim, def.turnRate * 2.5 * dt);
|
|
else e.turretRot = e.heading;
|
|
|
|
const facing = (e.isBuilding || hasTurret) ? e.turretRot : e.heading;
|
|
// A unit that's driving somewhere still shoots; it just has to be roughly on target.
|
|
const aligned = e.isBuilding || Math.abs(angleDelta(facing, aim)) < (hasTurret ? 0.12 : 0.35);
|
|
|
|
for (let wi = 0; wi < def.weaponDefs.length; wi++) {
|
|
const w = def.weaponDefs[wi];
|
|
if (e.reload[wi] > 0) continue;
|
|
// Manual weapons (the D-Gun) only fire when this exact target was ordered attacked.
|
|
if (w.manual && !(holdOrder && e.orders[0].targetId === target.id)) continue;
|
|
const dist = surface;
|
|
if (dist > w.range || dist < (w.minRange ?? 0)) continue;
|
|
if (!aligned) continue;
|
|
if (w.kind === 'beam' && w.energyPerShot) {
|
|
const a = state.armies[e.army];
|
|
if (a.energy < w.energyPerShot) continue;
|
|
a.energy -= w.energyPerShot;
|
|
}
|
|
fireWeapon(state, rules, e, w, target, facing, aimPt);
|
|
e.burstLeft[wi] = (w.burst ?? 1) - 1;
|
|
e.reload[wi] = e.burstLeft[wi] > 0 ? w.burstDelayTicks : w.reloadTicks;
|
|
if (e.burstLeft[wi] <= 0) e.burstLeft[wi] = 0;
|
|
else e._burstWeapon = wi;
|
|
}
|
|
|
|
// Continue an in-flight burst.
|
|
for (let wi = 0; wi < def.weaponDefs.length; wi++) {
|
|
const w = def.weaponDefs[wi];
|
|
if (w.manual) continue;
|
|
if (e.burstLeft[wi] > 0 && e.reload[wi] === 0) {
|
|
fireWeapon(state, rules, e, w, target, facing, aimPt);
|
|
e.burstLeft[wi]--;
|
|
e.reload[wi] = e.burstLeft[wi] > 0 ? w.burstDelayTicks : w.reloadTicks;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gap from `from`'s centre to the target's SURFACE — the rectangle for a building, a circle
|
|
* for anything else. Zero when `from` is inside the footprint.
|
|
*
|
|
* A building is a box, not a disc: treating a 3x3 plant as a circle of radius 96 understates
|
|
* it by 40px on the diagonal, so units had to close well past their real firing distance
|
|
* before the range test agreed they had arrived.
|
|
*/
|
|
export function surfaceDist(rules, from, target) {
|
|
if (!target.isBuilding) {
|
|
return Math.hypot(target.x - from.x, target.y - from.y) - target.radius;
|
|
}
|
|
const def = defOf(rules, target);
|
|
const dx = Math.max(Math.abs(from.x - target.x) - (def.halfW ?? target.radius), 0);
|
|
const dy = Math.max(Math.abs(from.y - target.y) - (def.halfH ?? target.radius), 0);
|
|
return Math.hypot(dx, dy);
|
|
}
|
|
|
|
/** The point on the target actually shot at: the nearest bit of a building's face. */
|
|
export function aimPointOn(rules, from, target) {
|
|
if (!target.isBuilding) return { x: target.x, y: target.y };
|
|
const def = defOf(rules, target);
|
|
const hw = def.halfW ?? target.radius, hh = def.halfH ?? target.radius;
|
|
return {
|
|
x: Math.min(Math.max(from.x, target.x - hw), target.x + hw),
|
|
y: Math.min(Math.max(from.y, target.y - hh), target.y + hh),
|
|
};
|
|
}
|
|
|
|
function inWeaponRange(rules, e, def, target) {
|
|
if (target.dead) return false;
|
|
return surfaceDist(rules, e, target) <= def.maxRange;
|
|
}
|
|
|
|
function acquireTarget(state, rules, e, def, hash, near) {
|
|
near.length = 0;
|
|
hash.query(e.x, e.y, def.maxRange + 64, near);
|
|
// Buildings aren't in the movers hash, so scan them directly — there are few of them.
|
|
let best = null, bestScore = -Infinity;
|
|
const consider = (t) => {
|
|
if (t.dead || t.army === e.army) return;
|
|
if (!state.armies[t.army]) return;
|
|
const d = surfaceDist(rules, e, t);
|
|
if (d > def.maxRange) return;
|
|
let score = -Infinity;
|
|
for (const w of def.weaponDefs) {
|
|
if (d > w.range || d < (w.minRange ?? 0)) continue;
|
|
const mul = w.armorMul?.[rules.defById[t.defId].armorClass] ?? 1;
|
|
const s = (w.damage * mul) / (1 + d / w.range);
|
|
if (s > score) score = s;
|
|
}
|
|
if (score === -Infinity) return;
|
|
// Prefer things that can shoot back, and break ties by id so it's deterministic.
|
|
const tdef = rules.defById[t.defId];
|
|
if (tdef.weaponDefs?.length) score *= 1.35;
|
|
if (t.site) score *= 0.6;
|
|
if (score > bestScore || (score === bestScore && best && t.id < best.id)) {
|
|
best = t; bestScore = score;
|
|
}
|
|
};
|
|
for (const t of near) consider(t);
|
|
for (const t of state.entities) if (t.isBuilding && !t.dead) consider(t);
|
|
return best;
|
|
}
|
|
|
|
function fireWeapon(state, rules, e, w, target, facing, aimPt) {
|
|
const aim = aimPt ?? aimPointOn(rules, e, target);
|
|
const muzzle = (w.fx?.muzzle ?? e.radius);
|
|
const ox = e.x + Math.cos(facing) * muzzle;
|
|
const oy = e.y + Math.sin(facing) * muzzle;
|
|
|
|
state.events.push({
|
|
t: 'weaponFired', id: e.id, army: e.army, weapon: w.id,
|
|
x: ox, y: oy, heading: facing, style: w.fx.style, sound: w.sound,
|
|
});
|
|
|
|
if (w.kind === 'hitscan' || w.kind === 'beam') {
|
|
let hit = true;
|
|
if (w.spread > 0) {
|
|
// Spread becomes a miss chance that grows with range — cheap, and it means massed
|
|
// infantry still trade meaningfully at the edge of their envelope.
|
|
const d = Math.hypot(aim.x - e.x, aim.y - e.y);
|
|
const missChance = Math.min(0.6, w.spread * (d / w.range) * 3);
|
|
hit = rngNext(state) >= missChance;
|
|
}
|
|
const ex = hit ? aim.x : aim.x + rngRange(state, -24, 24);
|
|
const ey = hit ? aim.y : aim.y + rngRange(state, -24, 24);
|
|
state.events.push({
|
|
t: 'shot', style: w.fx.style, x1: ox, y1: oy, x2: ex, y2: ey,
|
|
color: w.fx.color, width: w.fx.width, lifeMs: w.fx.lifeMs ?? 80, army: e.army,
|
|
});
|
|
if (hit) {
|
|
applyDamage(state, rules, target, w.damage * (w.armorMul?.[rules.defById[target.defId].armorClass] ?? 1), e);
|
|
if (w.aoe) applyAoe(state, rules, aim.x, aim.y, w, e);
|
|
state.events.push({ t: 'impact', x: ex, y: ey, radius: w.aoe ?? 8, style: w.fx.style, sound: w.impactSound });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Travelling ordnance: leading matters, and a strafing target can genuinely be missed.
|
|
let aimX = aim.x, aimY = aim.y;
|
|
if (w.leadTarget) {
|
|
const d = Math.hypot(target.x - ox, target.y - oy);
|
|
const flight = d / w.speed;
|
|
aimX += (target.vx ?? 0) * flight;
|
|
aimY += (target.vy ?? 0) * flight;
|
|
}
|
|
let ang = Math.atan2(aimY - oy, aimX - ox);
|
|
if (w.spread > 0) ang += rngRange(state, -w.spread, w.spread);
|
|
|
|
if (state.projectiles.length >= rules.constants.projectileCap) return;
|
|
state.projectiles.push({
|
|
id: state.nextProjectileId++,
|
|
weapon: w.id, army: e.army, ownerId: e.id,
|
|
x: ox, y: oy, px: ox, py: oy,
|
|
vx: Math.cos(ang) * w.speed, vy: Math.sin(ang) * w.speed,
|
|
targetId: w.kind === 'guided' ? target.id : 0,
|
|
aimX, aimY,
|
|
ttl: Math.ceil(((w.range * 1.4) / w.speed) * rules.constants.tickHz),
|
|
trail: [],
|
|
});
|
|
}
|
|
|
|
function stepProjectiles(state, rules) {
|
|
const dt = rules.dt;
|
|
const keep = [];
|
|
for (const p of state.projectiles) {
|
|
const w = rules.weaponById[p.weapon];
|
|
p.px = p.x; p.py = p.y;
|
|
|
|
if (w.kind === 'guided') {
|
|
const target = entityById(state, p.targetId);
|
|
if (target) {
|
|
const want = Math.atan2(target.y - p.y, target.x - p.x);
|
|
const cur = Math.atan2(p.vy, p.vx);
|
|
const next = turnToward(cur, want, w.turnRate * dt);
|
|
p.vx = Math.cos(next) * w.speed;
|
|
p.vy = Math.sin(next) * w.speed;
|
|
const gp = aimPointOn(rules, p, target);
|
|
p.aimX = gp.x; p.aimY = gp.y;
|
|
}
|
|
}
|
|
|
|
p.x += p.vx * dt;
|
|
p.y += p.vy * dt;
|
|
if (w.fx?.trail) {
|
|
p.trail.push(p.x, p.y);
|
|
if (p.trail.length > 16) p.trail.splice(0, 2);
|
|
}
|
|
|
|
let detonate = false;
|
|
if (--p.ttl <= 0) detonate = true;
|
|
if (p.x < 0 || p.y < 0 || p.x > state.worldW || p.y > state.worldH) detonate = true;
|
|
|
|
// Collision is SWEPT along this tick's segment, not sampled at the new position. A shell
|
|
// travels 35px in one 20 Hz tick — well past a 12px hit window and past most unit radii —
|
|
// so a point test made tank fire pass clean through whatever it was aimed at.
|
|
if (!detonate) {
|
|
const hitR = w.kind === 'guided' ? 14 : 12;
|
|
if (segDistSq(p.px, p.py, p.x, p.y, p.aimX, p.aimY) <= hitR * hitR) {
|
|
detonate = true;
|
|
// Detonate where the shell actually met the aim point, not where the step ended.
|
|
const s = segClosestT(p.px, p.py, p.x, p.y, p.aimX, p.aimY);
|
|
p.x = p.px + (p.x - p.px) * s; p.y = p.py + (p.y - p.py) * s;
|
|
}
|
|
}
|
|
// Direct contact with any hostile it passes through.
|
|
if (!detonate) {
|
|
let bestT = Infinity, hit = null;
|
|
for (const e of state.entities) {
|
|
if (e.dead || e.site || e.army === p.army) continue;
|
|
if (segDistSq(p.px, p.py, p.x, p.y, e.x, e.y) > e.radius * e.radius) continue;
|
|
const s = segClosestT(p.px, p.py, p.x, p.y, e.x, e.y);
|
|
if (s < bestT) { bestT = s; hit = e; }
|
|
}
|
|
if (hit) {
|
|
detonate = true;
|
|
p.x = p.px + (p.x - p.px) * bestT; p.y = p.py + (p.y - p.py) * bestT;
|
|
}
|
|
}
|
|
|
|
if (detonate) {
|
|
state.events.push({ t: 'impact', x: p.x, y: p.y, radius: w.aoe ?? 12, style: w.fx.style, sound: w.impactSound });
|
|
if (w.aoe) applyAoe(state, rules, p.x, p.y, w, { army: p.army, id: p.ownerId });
|
|
else {
|
|
for (const e of state.entities) {
|
|
if (e.dead || e.army === p.army) continue;
|
|
if (Math.hypot(e.x - p.x, e.y - p.y) <= e.radius) {
|
|
applyDamage(state, rules, e, w.damage * (w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1), { army: p.army, id: p.ownerId });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
keep.push(p);
|
|
}
|
|
}
|
|
state.projectiles = keep;
|
|
}
|
|
|
|
/** Parametric position along segment AB of the point on it closest to P, clamped to [0,1]. */
|
|
function segClosestT(ax, ay, bx, by, px, py) {
|
|
const dx = bx - ax, dy = by - ay;
|
|
const len2 = dx * dx + dy * dy;
|
|
if (len2 <= 1e-9) return 0;
|
|
const t = ((px - ax) * dx + (py - ay) * dy) / len2;
|
|
return t < 0 ? 0 : t > 1 ? 1 : t;
|
|
}
|
|
|
|
/** Squared distance from P to segment AB. */
|
|
function segDistSq(ax, ay, bx, by, px, py) {
|
|
const t = segClosestT(ax, ay, bx, by, px, py);
|
|
const cx = ax + (bx - ax) * t - px, cy = ay + (by - ay) * t - py;
|
|
return cx * cx + cy * cy;
|
|
}
|
|
|
|
function applyAoe(state, rules, x, y, w, source) {
|
|
const r = w.aoe;
|
|
const falloff = w.aoeFalloff ?? 0.4;
|
|
for (const e of state.entities) {
|
|
if (e.dead) continue;
|
|
if (e.army === source.army && !rules.constants.friendlyFire) continue;
|
|
// Falloff is measured to the SURFACE, so a shell that lands squarely on a building's
|
|
// face counts as a direct hit wherever along that face it strikes. Measuring from the
|
|
// centre circle instead scored an off-axis wall hit as 32px away and cut its damage by
|
|
// 80%, which made big buildings absurdly tough to shoot from any angle but dead-on.
|
|
const d = surfaceDist(rules, { x, y }, e);
|
|
if (d > r) continue;
|
|
const t = Math.max(0, Math.min(1, d / r));
|
|
// Quadratic falloff, not linear. Linear left near-full damage on every neighbour of the
|
|
// unit actually hit, so one shell wiped a whole packed formation and splash weapons beat
|
|
// everything else on cost regardless of what they were pointed at.
|
|
const scale = falloff + (1 - falloff) * (1 - t) * (1 - t);
|
|
const mul = w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1;
|
|
applyDamage(state, rules, e, w.damage * mul * scale, source);
|
|
}
|
|
}
|
|
|
|
export function applyDamage(state, rules, target, amount, source) {
|
|
if (target.dead || amount <= 0) return;
|
|
target.hp -= amount;
|
|
target.lastDamagedTick = state.tick;
|
|
state.events.push({ t: 'damaged', id: target.id, amount, x: target.x, y: target.y });
|
|
if (target.hp <= 0) killEntity(state, rules, target, source);
|
|
}
|
|
|
|
/**
|
|
* Self-repair. Only defs that declare `selfHeal` regenerate, and the clock restarts on every
|
|
* hit — so a Commander recovers between engagements but never out-heals incoming fire, and
|
|
* cannot be used to tank a fight it is losing.
|
|
*/
|
|
function stepRegen(state, rules) {
|
|
for (const e of state.entities) {
|
|
if (e.dead || e.site) continue;
|
|
const heal = defOf(rules, e).selfHeal;
|
|
if (!heal) continue;
|
|
if (e.hp >= e.maxHp) continue;
|
|
if (state.tick - e.lastDamagedTick < heal.pauseTicks) continue;
|
|
e.hp = Math.min(e.maxHp, e.hp + heal.hpPerTick);
|
|
}
|
|
}
|
|
|
|
function killEntity(state, rules, e, source) {
|
|
if (e.dead) return;
|
|
e.dead = true;
|
|
e.hp = 0;
|
|
const def = defOf(rules, e);
|
|
state.armies[e.army].lostEver++;
|
|
if (source && state.armies[source.army]) state.armies[source.army].killsEver++;
|
|
state.events.push({
|
|
t: 'unitDestroyed', id: e.id, army: e.army, defId: e.defId,
|
|
x: e.x, y: e.y, radius: e.radius, isBuilding: e.isBuilding,
|
|
});
|
|
if (e.isBuilding) {
|
|
stampFootprint(state.nav, rules, e.tx, e.ty, e.fw, e.fh, false);
|
|
recomputeEconomyCaps(state, rules);
|
|
}
|
|
// Anyone building or assisting this is now idle.
|
|
for (const o of state.entities) {
|
|
if (o.buildTargetId === e.id) o.buildTargetId = 0;
|
|
if (o.targetId === e.id) o.targetId = 0;
|
|
}
|
|
if (def.deathExplosion) {
|
|
const ex = def.deathExplosion;
|
|
state.events.push({ t: 'bigExplosion', x: e.x, y: e.y, radius: ex.radius });
|
|
for (const o of state.entities) {
|
|
if (o.dead || o.id === e.id) continue;
|
|
const d = Math.hypot(o.x - e.x, o.y - e.y) - o.radius;
|
|
if (d > ex.radius) continue;
|
|
const scale = 1 - Math.max(0, Math.min(1, d / ex.radius)) * 0.7;
|
|
applyDamage(state, rules, o, ex.damage * scale, { army: -1, id: 0 });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Vision
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Coarse 2-tile vision grid. Recomputed every 4th tick — it does not need to be exact. */
|
|
export function computeVision(state, rules) {
|
|
const cell = state.tileSize * 2;
|
|
for (const a of state.armies) a.visible.fill(0);
|
|
for (const e of state.entities) {
|
|
if (e.dead) continue;
|
|
const a = state.armies[e.army];
|
|
if (!a) continue;
|
|
const def = defOf(rules, e);
|
|
const sight = e.site ? Math.max(64, (def.sight ?? 0) * 0.4) : (def.sight ?? 0);
|
|
if (sight <= 0) continue;
|
|
const r = Math.ceil(sight / cell);
|
|
const cx = Math.floor(e.x / cell), cy = Math.floor(e.y / cell);
|
|
for (let y = cy - r; y <= cy + r; y++) {
|
|
if (y < 0 || y >= state.visH) continue;
|
|
for (let x = cx - r; x <= cx + r; x++) {
|
|
if (x < 0 || x >= state.visW) continue;
|
|
const wx = x * cell + cell / 2, wy = y * cell + cell / 2;
|
|
if (Math.hypot(wx - e.x, wy - e.y) > sight) continue;
|
|
const i = y * state.visW + x;
|
|
a.visible[i] = 1;
|
|
a.explored[i] = 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Is `e` currently visible to `armyIdx`? The AI is held to this, same as the player. */
|
|
export function isVisibleTo(state, armyIdx, e) {
|
|
if (e.army === armyIdx) return true;
|
|
const a = state.armies[armyIdx];
|
|
if (!a) return false;
|
|
const cell = state.tileSize * 2;
|
|
const x = Math.floor(e.x / cell), y = Math.floor(e.y / cell);
|
|
if (x < 0 || y < 0 || x >= state.visW || y >= state.visH) return false;
|
|
return a.visible[y * state.visW + x] === 1;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Victory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function checkResult(state, rules) {
|
|
if (state.over) return;
|
|
// Default: the Commander IS the army. A single lucky raid can therefore decide a match,
|
|
// which is the classic TA bargain — the Commander is a walking win condition, so escorting
|
|
// it and hunting theirs is the game. 'annihilation' trades that for the longer match, where
|
|
// elimination means "can never produce again" and economy and army quality decide it.
|
|
const commanderEnds = state.victory !== 'annihilation';
|
|
const unrecoverableEnds = rules.constants.eliminateWhenUnrecoverable !== false;
|
|
for (const a of state.armies) {
|
|
if (!a.alive) continue;
|
|
let hasAny = false, hasBuilder = false, hasFactory = false, hasCommander = false;
|
|
for (const e of state.entities) {
|
|
if (e.dead || e.army !== a.idx || e.site) continue;
|
|
hasAny = true;
|
|
const def = rules.defById[e.defId];
|
|
if (def.isCommander) hasCommander = true;
|
|
if (!(def.builds ?? []).length) continue;
|
|
if (e.isBuilding) hasFactory = true; else hasBuilder = true;
|
|
}
|
|
const lostCommander = commanderEnds && a.hadCommander && !hasCommander;
|
|
// An army that still owns a factory can rebuild, so it stays in; one with neither a mobile
|
|
// builder nor a factory is only running down the clock.
|
|
const unrecoverable = unrecoverableEnds && !hasBuilder && !hasFactory;
|
|
if (!hasAny || lostCommander || unrecoverable) {
|
|
a.alive = false;
|
|
state.events.push({
|
|
t: 'armyEliminated',
|
|
army: a.idx,
|
|
reason: !hasAny ? 'wiped' : (lostCommander ? 'commanderLost' : 'unrecoverable'),
|
|
});
|
|
}
|
|
}
|
|
const alive = state.armies.filter((a) => a.alive);
|
|
if (alive.length <= 1) {
|
|
state.over = { winner: alive.length ? alive[0].idx : -1, tick: state.tick };
|
|
state.events.push({ t: 'gameOver', winner: state.over.winner });
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The tick
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Advance the simulation one fixed step. Returns this tick's events. */
|
|
export function tick(state, rules) {
|
|
state.events = [];
|
|
if (state.over) return state.events;
|
|
|
|
state.tick++;
|
|
state.elapsedSec = state.tick * rules.dt;
|
|
|
|
stepEconomy(state, rules);
|
|
stepOrders(state, rules);
|
|
servicePathQueue(state, rules);
|
|
stepMovement(state, rules);
|
|
stepSeparation(state, rules);
|
|
finalizeStuck(state, rules);
|
|
stepCombat(state, rules);
|
|
stepProjectiles(state, rules);
|
|
stepRegen(state, rules);
|
|
|
|
if (state.tick % 4 === 0) computeVision(state, rules);
|
|
|
|
// Reap the dead once per tick so nothing iterates a corpse mid-phase.
|
|
if (state.entities.some((e) => e.dead)) {
|
|
state.entities = state.entities.filter((e) => !e.dead);
|
|
}
|
|
checkResult(state, rules);
|
|
return state.events;
|
|
}
|
|
|
|
/**
|
|
* Drive the sim from a wall-clock delta, draining whole fixed steps. The accumulator lives
|
|
* in state so the verify script runs the identical loop headlessly, and `state.alpha` gives
|
|
* the view its interpolation factor between the last two ticks.
|
|
*/
|
|
/**
|
|
* @param {function} [onTick] runs just BEFORE each fixed step — where the AI belongs, so its
|
|
* think cadence is measured in sim ticks rather than riding on the render framerate.
|
|
*/
|
|
export function step(state, rules, deltaMs, maxSteps = 4, onTick = null) {
|
|
const out = [];
|
|
state.accumulatorMs += deltaMs;
|
|
let n = 0;
|
|
while (state.accumulatorMs >= rules.stepMs && n < maxSteps) {
|
|
state.accumulatorMs -= rules.stepMs;
|
|
if (onTick) onTick(state);
|
|
const ev = tick(state, rules);
|
|
for (let i = 0; i < ev.length; i++) out.push(ev[i]);
|
|
n++;
|
|
}
|
|
// Spiral-of-death guard: if we hit the cap, drop the backlog rather than fall further behind.
|
|
if (n === maxSteps && state.accumulatorMs >= rules.stepMs) state.accumulatorMs = 0;
|
|
state.alpha = Math.min(1, state.accumulatorMs / rules.stepMs);
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Serialization
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** RLE the terrain — a 128x128 map compresses roughly 30:1, which matters because
|
|
* localStorage is a ~5MB budget shared across every game in this repo. */
|
|
function rleEncode(arr) {
|
|
const out = [];
|
|
let run = 1;
|
|
for (let i = 1; i <= arr.length; i++) {
|
|
if (i < arr.length && arr[i] === arr[i - 1] && run < 65535) { run++; continue; }
|
|
out.push(arr[i - 1], run);
|
|
run = 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function rleDecode(list, len) {
|
|
const arr = new Uint8Array(len);
|
|
let p = 0;
|
|
for (let i = 0; i < list.length; i += 2) {
|
|
const v = list[i], n = list[i + 1];
|
|
for (let k = 0; k < n && p < len; k++) arr[p++] = v;
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
export function serialize(state) {
|
|
return JSON.stringify({
|
|
v: SAVE_VERSION,
|
|
rulesVersion: state.rulesVersion,
|
|
seed: state.seed, rngState: state.rngState,
|
|
tick: state.tick, w: state.w, h: state.h, theme: state.theme,
|
|
terrain: rleEncode(state.terrain),
|
|
victory: state.victory,
|
|
over: state.over,
|
|
nextId: state.nextId,
|
|
armies: state.armies.map((a) => ({
|
|
armyId: a.armyId, commanderId: a.commanderId, isHuman: a.isHuman,
|
|
aiSkill: a.aiSkill, aiProfile: a.aiProfile,
|
|
energy: a.energy, mass: a.mass, alive: a.alive, hadCommander: a.hadCommander,
|
|
builtEver: a.builtEver, lostEver: a.lostEver, killsEver: a.killsEver,
|
|
explored: rleEncode(a.explored),
|
|
})),
|
|
entities: state.entities.map((e) => ({
|
|
id: e.id, army: e.army, defId: e.defId, isBuilding: e.isBuilding,
|
|
x: Math.round(e.x * 8) / 8, y: Math.round(e.y * 8) / 8,
|
|
heading: Math.round(e.heading * 1000) / 1000,
|
|
turretRot: Math.round(e.turretRot * 1000) / 1000,
|
|
hp: Math.round(e.hp * 100) / 100,
|
|
site: e.site, progress: Math.round(e.progress * 1000) / 1000,
|
|
tx: e.tx, ty: e.ty, fw: e.fw, fh: e.fh,
|
|
orders: e.orders, queue: e.queue, jobProgress: Math.round(e.jobProgress * 1000) / 1000,
|
|
rallyX: e.rallyX, rallyY: e.rallyY, hasRally: e.hasRally,
|
|
targetId: e.targetId, buildTargetId: e.buildTargetId,
|
|
lastDamagedTick: e.lastDamagedTick,
|
|
reload: e.reload, burstLeft: e.burstLeft,
|
|
terrainBonus: e.terrainBonus,
|
|
})),
|
|
// Projectiles are sub-second transients; dropping them costs nothing and saves bytes.
|
|
});
|
|
}
|
|
|
|
export function deserialize(rules, raw) {
|
|
let data;
|
|
try { data = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return null; }
|
|
if (!data || data.v !== SAVE_VERSION) return null;
|
|
if (data.rulesVersion !== rules.version) return null;
|
|
|
|
const terrain = rleDecode(data.terrain, data.w * data.h);
|
|
const state = createMatch(rules, {
|
|
seed: data.seed,
|
|
// A save written before victory conditions were configurable was played under the
|
|
// annihilation rule, so that is what it resumes under — loading it as a Commander-kill
|
|
// match could end it on the spot over a Commander that died an hour ago.
|
|
victory: data.victory ?? 'annihilation',
|
|
map: { w: data.w, h: data.h, terrain, theme: data.theme, starts: [] },
|
|
armies: data.armies.map((a) => ({
|
|
armyId: a.armyId, commanderId: a.commanderId, isHuman: a.isHuman,
|
|
aiSkill: a.aiSkill, aiProfile: a.aiProfile,
|
|
})),
|
|
});
|
|
|
|
state.rngState = data.rngState;
|
|
state.tick = data.tick;
|
|
state.elapsedSec = data.tick * rules.dt;
|
|
state.over = data.over;
|
|
state.nextId = data.nextId;
|
|
state.entities = [];
|
|
state.nav.blocked.fill(0);
|
|
|
|
data.armies.forEach((a, i) => {
|
|
Object.assign(state.armies[i], {
|
|
energy: a.energy, mass: a.mass, alive: a.alive, hadCommander: !!a.hadCommander,
|
|
builtEver: a.builtEver, lostEver: a.lostEver, killsEver: a.killsEver,
|
|
explored: rleDecode(a.explored, state.visW * state.visH),
|
|
});
|
|
});
|
|
|
|
for (const s of data.entities) {
|
|
const def = rules.defById[s.defId];
|
|
if (!def) continue;
|
|
const e = baseEntity(state, s.army, def);
|
|
Object.assign(e, s);
|
|
e.px = e.x; e.py = e.y; e.pheading = e.heading; e.pturretRot = e.turretRot;
|
|
e.radius = def.isBuilding ? def.radius : (def.radius ?? 16);
|
|
e.path = null; e.pathIdx = 0; e.wantPath = false;
|
|
state.entities.push(e);
|
|
if (e.isBuilding) {
|
|
stampFootprint(state.nav, rules, e.tx, e.ty, e.fw, e.fh, true);
|
|
if (!e.site) {
|
|
const bdef = rules.buildingById[e.defId];
|
|
if (bdef.produce) {
|
|
e.produceE = bdef.produce.energy ?? 0;
|
|
e.produceM = (bdef.produce.mass ?? 0) * (e.terrainBonus ?? 1);
|
|
}
|
|
if (bdef.upkeep) e.upkeepE = bdef.upkeep.energy ?? 0;
|
|
if (bdef.storage) { e.storeE = bdef.storage.energy ?? 0; e.storeM = bdef.storage.mass ?? 0; }
|
|
}
|
|
} else {
|
|
const udef = rules.unitById[e.defId];
|
|
if (udef.produce) { e.produceE = udef.produce.energy ?? 0; e.produceM = udef.produce.mass ?? 0; }
|
|
if (udef.storage) { e.storeE = udef.storage.energy ?? 0; e.storeM = udef.storage.mass ?? 0; }
|
|
}
|
|
}
|
|
state.nextId = data.nextId;
|
|
recomputeEconomyCaps(state, rules);
|
|
computeVision(state, rules);
|
|
return state;
|
|
}
|
|
|
|
/** FNV-1a over the serialized state. Used for determinism and round-trip assertions. */
|
|
export function hashState(state) {
|
|
const s = serialize(state);
|
|
let h = 0x811c9dc5;
|
|
for (let i = 0; i < s.length; i++) {
|
|
h ^= s.charCodeAt(i);
|
|
h = Math.imul(h, 0x01000193) >>> 0;
|
|
}
|
|
return h >>> 0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Queries used by the view, the HUD and the AI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function armyEntities(state, armyIdx, includeSites = true) {
|
|
return state.entities.filter((e) => !e.dead && e.army === armyIdx && (includeSites || !e.site));
|
|
}
|
|
|
|
export function buildOptionsFor(rules, e) {
|
|
const def = rules.defById[e.defId];
|
|
return (def.builds ?? []).map((id) => rules.defById[id]);
|
|
}
|
|
|
|
/** Can this army afford `def` right now, out of stored resources? */
|
|
export function canAfford(state, armyIdx, def) {
|
|
const a = state.armies[armyIdx];
|
|
return a.energy >= (def.cost?.energy ?? 0) * 0.25 && a.mass >= (def.cost?.mass ?? 0) * 0.25;
|
|
}
|
|
|
|
export { segmentClear, tileIndex, worldToTileX, worldToTileY, tileCenterX, tileCenterY };
|