Spaces:
Paused
Paused
Claude
Claude Opus 5
Tier 1, step 4e: clear autoTarget on a plain move order (A13)
3589155 unverified Download engine/combat.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 28.8 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/engine/combat.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities/engine/combat.js
-
curl -L -o combat.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/engine/combat.js
28.8 kB
| /* ============================================================ | |
| Combat-role units (Skiff, Bastion): auto-acquire the nearest enemy | |
| within aggro range, close to weapon range, and fire on cooldown. An | |
| explicit 'attack' order pins the target; 'attack-move' auto-engages | |
| anything encountered en route to its destination. A unit type's | |
| bonusVs table (entities.js) adds extra damage against a specific | |
| target type β Bastion's slow, short-range bulk is built to punch | |
| through Skiff hulls specifically. | |
| A plain 'move' order is the deliberate exception: it's how a player | |
| pulls a unit OUT of a fight or redirects it past a threat, so it | |
| skips auto-acquire entirely rather than getting silently overridden | |
| the moment an enemy wanders into aggro range. | |
| ============================================================ */ | |
| ; | |
| import { stepToward, keepEscortStation, keepFormationStation, keepFollowingLeader, orderedSpeed } from "./movement.js"; | |
| import { UNITS, BUILDINGS, upgradeMult, rankMults } from "./entities.js"; | |
| import { getEntity, removeEntity } from "./state.js"; | |
| import { queryNeighbors } from "./grid.js"; | |
| import { sampleTerrain, sideMod, TERRAIN } from "./map.js"; | |
| import { UPHILL_FRAC } from "./fog.js"; | |
| import { hashStr } from "./rng.js"; | |
| import { detonateIfAttacked } from "./bomb.js"; | |
| import { depositWreckage } from "./wreckage.js"; | |
| export function updateCombat(state, unit, dt) { | |
| const def = UNITS[unit.type]; | |
| unit.attackTimer = Math.max(0, unit.attackTimer - dt); | |
| if (unit.order && unit.order.type === "move") { | |
| // Clear the auto-acquired target on the way out. This block returns BEFORE the acquisition | |
| // code below, and that code is the only place in the engine that ever writes autoTarget β so | |
| // the field was unreachable-for-clearing for the whole duration of a move. separation.js's | |
| // isCombatMode reads it, which made a unit pulled out of a fight with Move (the documented way | |
| // to disengage) keep the tight combat packing forever instead of the padded idle spacing | |
| // SEPARATION_PAD_MULT exists to give it β contradicting isCombatMode's own comment, which | |
| // claims a stale autoTarget only reads as combat-mode "for one tick too long". | |
| unit.autoTarget = null; | |
| const arrived = stepToward(state, unit, unit.order.x, unit.order.y, orderedSpeed(def.speed, unit.order), dt); | |
| if (arrived) unit.order = null; | |
| return; | |
| } | |
| let targetId = unit.order && unit.order.type === "attack" ? unit.order.targetId : null; | |
| if (targetId && !isAlive(state, targetId)) { unit.order = null; targetId = null; } | |
| // AI focus-fire (engine/ai.js sets focusId when the Tactical AI is directing a | |
| // squad onto one target): concentrate on it while it's a live enemy inside this | |
| // unit's aggro. If it's dead or out of reach we fall straight through to the | |
| // normal dispersed auto-acquire, so a stale/too-far focus never freezes a unit. | |
| if (!targetId && unit.focusId && stillEngageable(state, unit, def, unit.focusId)) { | |
| targetId = unit.focusId; | |
| } | |
| if (!targetId) { | |
| // Stick to last tick's auto-target while it's still a live enemy in aggro | |
| // range; only when it dies or slips away do we acquire a fresh (dispersed) | |
| // one. Committing to a target instead of re-picking the nearest every tick | |
| // is what makes spread-targeting hold β otherwise the whole line would | |
| // re-converge on the single closest enemy each frame. | |
| if (unit.autoTarget && stillEngageable(state, unit, def, unit.autoTarget)) { | |
| targetId = unit.autoTarget; | |
| } else { | |
| targetId = acquireTarget(state, unit, def); | |
| unit.autoTarget = targetId; | |
| } | |
| } | |
| if (targetId) { | |
| const target = getEntity(state, targetId); | |
| if (target && target.hp > 0) { | |
| const dist = Math.hypot(target.x - unit.x, target.y - unit.y); | |
| if (dist > def.range) { | |
| // Hold stance: stand fast rather than chasing an out-of-range target β | |
| // fire only once something comes to us (a player-set defensive stance; | |
| // the AI never sets it, so this can't touch the resolve guarantee). | |
| // Overcharged Core's chaseSpeedMult (Assault Tier 2, entities.js UPGRADES) closes | |
| // distance ~10% faster specifically HERE β an already-acquired target being run down β | |
| // never on the plain 'move' order above (that branch reads def.speed directly, untouched). | |
| // "Combat drive": the tactical verb that makes Assault's Tier-2 more than a second flat | |
| // damage multiplier. | |
| const chaseSpeed = def.speed * upgradeMult(state.players[unit.owner]?.upgrades, "chaseSpeedMult"); | |
| if (!unit.hold) stepToward(state, unit, target.x, target.y, chaseSpeed, dt); | |
| } else if (unit.attackTimer <= 0) { | |
| const died = performAttack(state, unit, def, target); | |
| // Overdrive Actuators (Assault Tier 3, entities.js UPGRADES) shortens every cooldown | |
| // reset after a landed shot via attackCooldownMult β see updateWorkerCombat below for the | |
| // other reset site (a worker's explicit attack order). Deliberately NOT applied in | |
| // updateBuildingCombat's own reset, further down: this is the ARMY's tempo upgrade | |
| // (mobile units only), not a base-defense buff β Bulwark, not Assault, is the doctrine | |
| // that already reaches structures too (reinforcedPlating's own comment, entities.js). | |
| unit.attackTimer = def.cooldown * upgradeMult(state.players[unit.owner]?.upgrades, "attackCooldownMult"); | |
| if (died && unit.order && unit.order.targetId === target.id) unit.order = null; | |
| } else if (state.ai?.micro && unit.owner === "ai" && def.range >= KITE_MIN_RANGE) { | |
| maybeKite(state, unit, def, dt); // in range but reloading β a Tactical ranged unit stutter-steps back | |
| } | |
| return; | |
| } | |
| } | |
| if (unit.order && unit.order.type === "attack-move") { | |
| const arrived = stepToward(state, unit, unit.order.x, unit.order.y, orderedSpeed(def.speed, unit.order), dt); | |
| if (arrived) unit.order = null; | |
| return; | |
| } | |
| // Escort: with no threat acquired above (that block returns when engaging), keep station on | |
| // the protective ring around the guarded ship β a persistent follow, never cleared on arrival, | |
| // so the escort trails the target wherever it's ordered until given a new order. | |
| if (unit.order && unit.order.type === "escort") { | |
| keepEscortStation(state, unit, def.speed, dt); | |
| } else if (unit.order && unit.order.type === "hold-formation") { | |
| // Same idea, but the "target" is this unit's OWN fixed anchor rather than a moving ship β | |
| // a formation leader holding its ground, still fully combat-reactive above (this only runs | |
| // once no threat was acquired/engaged this tick). | |
| keepFormationStation(state, unit, def.speed, dt); | |
| } else if (unit.order && unit.order.type === "follow-leader") { | |
| // A formation follower: keeps its slot relative to the leader's live position, same | |
| // combat-reactive treatment as escort/hold-formation above. | |
| keepFollowingLeader(state, unit, def.speed, dt); | |
| } | |
| } | |
| // Only genuinely ranged units kite (Lancer 55 / Breacher 150 / Dreadnought 68); | |
| // short-range brawlers (Skiff 40, Bastion 44) stand and trade. | |
| const KITE_MIN_RANGE = 50; | |
| // Stutter-step kiting: while a ranged unit is reloading and an enemy UNIT has | |
| // closed inside 0.75x its weapon range, it steps directly away to keep the | |
| // distance advantage, then stops to fire the instant its weapon is ready (that's | |
| // the branch above this one). Only ever reacts to enemy UNITS β never buildings β | |
| // so a ranged unit shelling an undefended base never backs off, keeping the | |
| // resolves-to-a-winner guarantee intact. The retreat point is clamped to the map | |
| // so a cornered kiter can't walk off the edge. | |
| function maybeKite(state, unit, def, dt) { | |
| const danger = def.range * 0.75; | |
| const threat = nearestEnemyUnitWithin(state, unit, danger); | |
| if (!threat) return; | |
| const dx = unit.x - threat.x, dy = unit.y - threat.y; | |
| const len = Math.hypot(dx, dy) || 1; | |
| let tx = unit.x + (dx / len) * 40, ty = unit.y + (dy / len) * 40; | |
| if (state.map) { | |
| tx = Math.max(0, Math.min(state.map.width, tx)); | |
| ty = Math.max(0, Math.min(state.map.height, ty)); | |
| } | |
| stepToward(state, unit, tx, ty, def.speed, dt); | |
| } | |
| function nearestEnemyUnitWithin(state, unit, radius) { | |
| const cands = state.unitGrid ? queryNeighbors(state.unitGrid, unit.x, unit.y, radius) : state.units.values(); | |
| let best = null, bestD = Infinity; | |
| for (const e of cands) { | |
| if (e.owner === unit.owner || e.hp <= 0 || UNITS[e.type].role !== "combat") continue; | |
| const d = Math.hypot(e.x - unit.x, e.y - unit.y); | |
| if (d <= radius && d < bestD) { bestD = d; best = e; } | |
| } | |
| return best; | |
| } | |
| // Returns true if the target died. Shared by mobile units and turrets so | |
| // upgrade multipliers and kill/event bookkeeping stay in exactly one place. | |
| // The attackHit event carries both endpoints and the attacker's type so | |
| // render.js can draw a tracer from shooter to target (a turret reads as | |
| // its own "turret" type), `heavy` so a siege hit thumps deeper, `bonus` | |
| // so a counter-triangle hit (Skiff catching a Lancer, etc.) telegraphs too β | |
| // scoped to the per-type hard counter (bonusVs) only, distinct from `heavy`'s | |
| // class-wide siege bonus (bonusVsBuildings), since there's no specific | |
| // matchup to telegraph there β and `splashRadius` (def.splash's radius, when | |
| // the attacker carries one) so renderEffects.js can draw an impact ring. | |
| // Zero sim effect β the flags are read only by the UI layer (effects.js/ | |
| // renderEffects.js, hudSelection.js derives its "strong vs / falls to" text | |
| // straight from the same bonusVs tables independently). | |
| function performAttack(state, attacker, def, target) { | |
| // An ARMED Helium Bomb doesn't take damage from a hit β it detonates instead, | |
| // dealing distance-falloff blast damage to everything (attacker included) | |
| // within its blast radius. Checked before the normal damage/death path, | |
| // which never runs for it. | |
| if (detonateIfAttacked(state, target)) return true; | |
| const dmg = attackDamage(state, attacker, def, target); | |
| target.hp -= dmg; | |
| // Bulwark's out-of-combat regen (engine/repair.js updateBulwarkRegen) reads this to tell | |
| // whether a unit is still "in a fight" β stamped wherever damage actually lands, unit or | |
| // building alike, so a target under continuous fire can never coast into regen between two | |
| // hits that land less than the doctrine's own delay apart. See applySplash below for the same | |
| // stamp on splash damage. | |
| target.lastHitAt = state.time; | |
| state.events.push({ | |
| type: "attackHit", x: target.x, y: target.y, | |
| fromX: attacker.x, fromY: attacker.y, unitType: attacker.type, owner: attacker.owner, | |
| heavy: !!(def.bonusVsBuildings && target.kind === "building"), | |
| bonus: !!(def.bonusVs && def.bonusVs[target.type]), | |
| splashRadius: def.splash ? def.splash.radius : undefined, | |
| }); | |
| // Splash (def.splash = {radius, frac}, Colossus first): after the primary hit above, rattle | |
| // any enemy UNITS caught near the impact point too β see applySplash's own header comment for | |
| // why this can never touch a building or the attacker's own side. | |
| if (def.splash) applySplash(state, attacker, target, dmg, def.splash); | |
| if (target.hp <= 0) { | |
| // Veterancy (docs/improvement-proposals.md "Veterancy ranks"): a UNIT-kind attacker's | |
| // confirmed kill counts toward its own rankMults (entities.js), applied in attackDamage | |
| // above/below like any other multiplier. Buildings/turrets (kind "building") never accrue | |
| // this β static defense holds ground by hp and ammo, not by leveling up. | |
| if (attacker.kind === "unit") attacker.kills = (attacker.kills || 0) + 1; | |
| // Nothing is really destroyed: ~80% of whatever was spent building this thing | |
| // reappears as a minable wreck site a short delay later (engine/wreckage.js) β | |
| // replaces the old instant, combat-unit-only salvage refund, and now covers | |
| // workers and buildings too. | |
| depositWreckage(state, target); | |
| removeEntity(state, target.id); | |
| // unitType/kind (docs/improvement-proposals.md "Tiered destruction"): purely additive on top | |
| // of the pre-existing x/y/owner β boot.js/effects.js/renderEffects.js/sound.js scale the | |
| // death's visuals and audio by what actually died (a Worker pops, a Dreadnought booms). | |
| state.events.push({ type: "entityKilled", x: target.x, y: target.y, owner: target.owner, unitType: target.type, kind: target.kind }); | |
| return true; | |
| } | |
| return false; | |
| } | |
| // def.splash's implementation (Colossus first): enemy units of attacker.owner within | |
| // `splash.radius` of the primary target's own position (the impact point) take | |
| // dmg*splash.frac*(1 - d/splash.radius) falloff damage β the mechanical punishment for a packed, | |
| // same-owner formation (separation.js's own packing behavior). The primary target itself is | |
| // excluded: it already took the full, unmitigated `dmg` hit above, so splash is purely what | |
| // happens to everyone ELSE caught nearby, not a second helping for the thing actually hit. | |
| // | |
| // Mirrors acquireTarget's own grid/full-scan idiom (queryNeighbors when state.unitGrid exists, | |
| // else a plain scan over state.units.values() for the many direct-call tests that never build a | |
| // grid) β and by walking only state.unitGrid/state.units, this can never reach a building no | |
| // matter how close one stands to the impact (buildUnitGrid only ever indexes units; see | |
| // engine/grid.js), so "buildings never take splash damage" holds by construction, not by a | |
| // proximity check that could drift out of sync with colliders.js's placement rules. | |
| // | |
| // HP subtraction happens for every caught unit in one pass BEFORE any of them is checked for | |
| // death (a second pass over the same `hit` list) β so the outcome can never depend on the order | |
| // queryNeighbors happens to return candidates in. Each splash death still funnels through the | |
| // same depositWreckage/removeEntity/entityKilled path as an ordinary kill. | |
| function applySplash(state, attacker, target, dmg, splash) { | |
| const cands = state.unitGrid | |
| ? queryNeighbors(state.unitGrid, target.x, target.y, splash.radius) | |
| : state.units.values(); | |
| const hit = []; | |
| for (const e of cands) { | |
| if (e.id === target.id || e.owner === attacker.owner || e.hp <= 0) continue; // never the primary target, never friendly, never an already-dead grid straggler | |
| const d = Math.hypot(e.x - target.x, e.y - target.y); | |
| if (d > splash.radius) continue; // queryNeighbors is a padded superset β the exact-distance check is still ours to make | |
| e.hp -= dmg * splash.frac * (1 - d / splash.radius); | |
| e.lastHitAt = state.time; // splash counts as a landed hit too β see performAttack's own stamp above | |
| hit.push(e); | |
| } | |
| for (const e of hit) { | |
| if (e.hp > 0) continue; | |
| depositWreckage(state, e); | |
| removeEntity(state, e.id); | |
| // Same unitType/kind addition as performAttack's own entityKilled push above β a splash | |
| // kill gets the same tiered destruction feedback as a direct one. | |
| state.events.push({ type: "entityKilled", x: e.x, y: e.y, owner: e.owner, unitType: e.type, kind: e.kind }); | |
| } | |
| } | |
| // Workers can fight, but only on an explicit 'attack' order β they never | |
| // auto-acquire (that's the whole point of keeping them on the economy), so | |
| // this handles just the ordered case: close to the target, strike on cooldown | |
| // with the worker's weak stats (entities.js), and drop the order when it dies | |
| // so a queued waypoint (or idle) takes over. Reuses the same performAttack as | |
| // real combat units, so a worker's hit shows a tracer and plays its sound too. | |
| export function updateWorkerCombat(state, unit, def, dt) { | |
| unit.attackTimer = Math.max(0, unit.attackTimer - dt); | |
| const targetId = unit.order.targetId; | |
| if (!isAlive(state, targetId)) { unit.order = null; return; } | |
| const target = getEntity(state, targetId); | |
| const dist = Math.hypot(target.x - unit.x, target.y - unit.y); | |
| if (dist > def.range) { | |
| // No chaseSpeedMult here β Overcharged Core's "combat drive" is scoped to updateCombat's own | |
| // chase branch above (an army unit running down an acquired target), not a worker closing on | |
| // its explicit attack order; the proposal's own "Where" section names updateCombat only. | |
| stepToward(state, unit, target.x, target.y, def.speed, dt); | |
| } else if (unit.attackTimer <= 0) { | |
| const died = performAttack(state, unit, def, target); | |
| // Overdrive Actuators (Assault Tier 3) applies here too β see updateCombat's own reset site | |
| // above for the shared attackCooldownMult comment. | |
| unit.attackTimer = def.cooldown * upgradeMult(state.players[unit.owner]?.upgrades, "attackCooldownMult"); | |
| if (died) unit.order = null; | |
| } | |
| } | |
| // Bonus-vs-type is a flat add (a specific hard counter), while researched | |
| // Refinery upgrades (entities.js's UPGRADES) are multipliers applied on | |
| // top β one on the attacker's side (damage dealt), one on the | |
| // defender's (damage taken) β so both sides' research matters on every | |
| // single hit, not just for units built after researching. | |
| function attackDamage(state, unit, def, target) { | |
| const bonus = def.bonusVs && def.bonusVs[target.type] || 0; | |
| // Siege class (Breacher) hits every building for a flat class-wide bonus | |
| // via bonusVsBuildings β kept separate from bonusVs (which is a hard | |
| // counter to one unit type) so new buildings need no per-type entries. | |
| const structureBonus = target.kind === "building" ? (def.bonusVsBuildings || 0) : 0; | |
| let dmg = (def.attack || 0) + bonus + structureBonus; // belt-and-braces: a weaponless def can't NaN-poison hp | |
| // Every researched Assault upgrade (attacker side) stacks its damage-dealt | |
| // multiplier; every Bulwark upgrade (defender side) its damage-taken multiplier. | |
| // Data-driven via upgradeMult, so the doctrine tiers just work. Both sides | |
| // optional-chained symmetrically: an attacker whose owner isn't in state.players | |
| // (a corrupt/tampered entity β a valid owner always is) reads no-upgrades (mult 1) | |
| // instead of throwing on `.upgrades`, matching the defender-side guard below. | |
| dmg *= upgradeMult(state.players[unit.owner]?.upgrades, "damageDealtMult"); | |
| dmg *= upgradeMult(state.players[target.owner]?.upgrades, "damageTakenMult"); | |
| // Veterancy (entities.js rankMults): the same shape as the two upgrade multipliers just | |
| // above β the attacker's own kill tally raises damage dealt, the target's raises damage | |
| // taken (i.e. reduces it, since takenMult < 1 per rank). 1/1 no-op for a fresh unit or any | |
| // building/turret (kills never set on those), so this is purely additive to existing combat. | |
| dmg *= rankMults(unit).dealtMult; | |
| dmg *= rankMults(target).takenMult; | |
| // The attacker's faction (and any future world) damage edge, through the same | |
| // sideMod seam as every other side modifier. 1 for a neutral/economy/mobility | |
| // faction; the Syndicate's firepower trait multiplies here (see factions.js). | |
| dmg *= sideMod(state, unit.owner, "damageDealtMult"); | |
| // A positional edge, not a counter: an attacker firing from high ground hits | |
| // for a flat bonus (terrain combatMult). Situational and symmetric, so the RPS | |
| // triangle is untouched. OPEN / map-less states read 1. | |
| if (state.map?.terrain) dmg *= sampleTerrain(state.map.terrain, unit.x, unit.y).combatMult; | |
| // A friendly Aegis nearby projects a cryo-armour bubble that reduces the damage its | |
| // allies take β the anvil's real value, applied wherever it stands (no targeting change | |
| // needed). Reads the tick's precomputed anvil list (sim.js), so it's O(anvils) β usually | |
| // zero. An Aegis never shields itself, so it can still be focused down. | |
| dmg *= anvilAura(state, target); | |
| return dmg; | |
| } | |
| // The damage multiplier from any friendly aura projector (Aegis) covering `target`. 1 when | |
| // none is in range (or the list isn't built β a bare test state). Deterministic: positions | |
| // are the tick-start snapshot in state.anvils. | |
| function anvilAura(state, target) { | |
| const anvils = state.anvils; | |
| if (!anvils) return 1; | |
| for (const a of anvils) { | |
| if (a.owner !== target.owner || a.id === target.id) continue; // shields allies, never itself | |
| if (Math.hypot(a.x - target.x, a.y - target.y) <= a.range) return a.mult; | |
| } | |
| return 1; | |
| } | |
| // How many of the nearest in-weapon-range enemies attackers fan out across, | |
| // instead of the whole line dogpiling the single closest. This is the softener | |
| // for the focus-fire snowball: spreading damage lets the losing side keep its | |
| // shooters alive longer, so engagements trade instead of ending in a near-wipe | |
| // from a slight edge (Lanchester's square law relaxed toward a linear one). | |
| const SPREAD_TARGETS = 3; | |
| function isAlive(state, id) { | |
| const e = getEntity(state, id); | |
| return !!e && e.hp > 0; | |
| } | |
| // Still worth holding onto: a live enemy inside this unit's aggro range. | |
| function stillEngageable(state, unit, def, id) { | |
| const e = getEntity(state, id); | |
| if (!e || e.hp <= 0) return false; | |
| const terrainMult = state.map?.terrain ? sampleTerrain(state.map.terrain, unit.x, unit.y).sightMult : 1; | |
| const aggro = def.aggroRange * sideMod(state, unit.owner, "sightMult") * terrainMult; | |
| return Math.hypot(e.x - unit.x, e.y - unit.y) <= aggro; | |
| } | |
| // Uphill concealment's acquisition half (mirrors fog.js's reveal(): same UPHILL_FRAC, same "the | |
| // SOURCE'S own tile decides, not the candidate's" rule): an acquirer whose own tile is NOT high | |
| // ground can only find (or hold) an enemy standing on high ground within UPHILL_FRAC of the | |
| // normal range β beyond that, the ridge is as blind to weapons as it is to fog. Computed once per | |
| // acquirer (via the `acquirerHigh` flag callers pass in β one sampleTerrain call, not one per | |
| // candidate) and applied only to a CANDIDATE that itself sits on high ground; open/rough ground | |
| // targets, and any target when the acquirer is itself on high ground, use the plain `range`. | |
| function highGroundCap(terrain, acquirerHigh, e, range) { | |
| if (acquirerHigh || !terrain) return range; | |
| return sampleTerrain(terrain, e.x, e.y) === TERRAIN[2] ? range * UPHILL_FRAC : range; | |
| } | |
| function nearestEnemy(state, entities, unit, maxRange) { | |
| const terrain = state.map?.terrain; | |
| const acquirerHigh = !!(terrain && sampleTerrain(terrain, unit.x, unit.y) === TERRAIN[2]); | |
| let best = null, bestD = Infinity; | |
| for (const e of entities) { | |
| if (e.owner === unit.owner || e.hp <= 0) continue; // hp>0 also skips stale dead refs a grid bucket may still hold | |
| const d = Math.hypot(e.x - unit.x, e.y - unit.y); | |
| const cap = highGroundCap(terrain, acquirerHigh, e, maxRange); | |
| if (d <= cap && d < bestD) { bestD = d; best = e; } | |
| } | |
| return best ? { id: best.id, d: bestD } : null; | |
| } | |
| // Like nearestEnemy, but once several enemies are within weapon range it fans | |
| // this unit onto one of the SPREAD_TARGETS nearest (chosen by a per-unit hash), | |
| // rather than everyone locking the single closest. Still returns the plain | |
| // nearest while closing in (nothing in weapon range yet), so units approach | |
| // together and only spread once they can actually fire. Combined with the | |
| // target stickiness in updateCombat, each attacker commits to its own target. | |
| function spreadEnemy(state, entities, unit, def, aggro) { | |
| // The local engagement band β a bit past weapon range so it catches the front | |
| // line even in a tight melee, where almost no one has 2+ foes strictly within | |
| // their (often short) weapon range at once. Spreading across this band is what | |
| // actually reduces the dogpile. | |
| const band = def.range + 60; | |
| const terrain = state.map?.terrain; | |
| const acquirerHigh = !!(terrain && sampleTerrain(terrain, unit.x, unit.y) === TERRAIN[2]); | |
| let nearest = null, nearestD = Infinity; | |
| const local = []; | |
| for (const e of entities) { | |
| if (e.owner === unit.owner || e.hp <= 0) continue; | |
| const d = Math.hypot(e.x - unit.x, e.y - unit.y); | |
| if (d > highGroundCap(terrain, acquirerHigh, e, aggro)) continue; | |
| if (d < nearestD) { nearestD = d; nearest = e; } | |
| if (d <= highGroundCap(terrain, acquirerHigh, e, band)) local.push({ e, d }); | |
| } | |
| if (!nearest) return null; | |
| if (local.length <= 1) return { id: nearest.id, d: nearestD }; // approaching, or a lone foe β just take nearest | |
| local.sort((a, b) => a.d - b.d || (a.e.id < b.e.id ? -1 : 1)); // stable, deterministic order | |
| const pick = local[hashStr(unit.id) % Math.min(local.length, SPREAD_TARGETS)]; | |
| return { id: pick.e.id, d: pick.d }; | |
| } | |
| // Default policy is nearest-of-anything, with exact ties going to units | |
| // (they're scanned first, so an equal-distance building never displaces a | |
| // unit already chosen). prefersBuildings flips that for siege: shell | |
| // structures inside aggro even when a unit stands closer, falling back to | |
| // the nearest unit only when no building is in range. | |
| function acquireTarget(state, unit, def) { | |
| // The same sight modifier that scales fog reveal also scales how far a unit | |
| // (or turret) reaches out to acquire β so a storm-shortened world bites both | |
| // sides' aggro symmetrically. Optional-chained for map-less test states. | |
| // Terrain folds in the same way fog.js's updateFog scales reveal by the source | |
| // tile's sightMult (srcMult there): a unit standing on high ground acquires (and, | |
| // via stillEngageable below, holds) targets out to the same extended radius it can | |
| // see, not just the flat aggroRange β a held mesa is dangerous to approach, not just | |
| // hard to sneak up on. Turrets share this path (updateBuildingCombat calls this too), | |
| // so a Sentinel Turret on high ground gets the same reach. | |
| const terrainMult = state.map?.terrain ? sampleTerrain(state.map.terrain, unit.x, unit.y).sightMult : 1; | |
| const aggro = def.aggroRange * sideMod(state, unit.owner, "sightMult") * terrainMult; | |
| // Units through the broad-phase grid (there can be hundreds); buildings stay a | |
| // straight scan since there are only ever a handful. Full-scan fallback when | |
| // no grid is present (direct combat tests). Both nearestEnemy/spreadEnemy apply | |
| // uphill concealment's acquisition half internally (highGroundCap above) β the | |
| // mesa's extra reach cuts both ways: it sees farther, but a lowland unit still | |
| // can't shoot back at it past UPHILL_FRAC of that same reach. | |
| const unitCandidates = state.unitGrid | |
| ? queryNeighbors(state.unitGrid, unit.x, unit.y, aggro) | |
| : state.units.values(); | |
| const u = spreadEnemy(state, unitCandidates, unit, def, aggro); | |
| const b = nearestEnemy(state, state.buildings.values(), unit, aggro); | |
| if (def.prefersBuildings && b) return b.id; | |
| if (!u) return b ? b.id : null; | |
| if (!b) return u.id; | |
| return u.d <= b.d ? u.id : b.id; | |
| } | |
| // Static defense: a completed building with an attack stat (Sentinel | |
| // Turret) acquires and fires like a unit that can never move β it only | |
| // engages inside its own range (aggroRange === range by definition) and | |
| // never chases. Buildings have no order pipeline, so there is no | |
| // player-directed focus fire; nearest-enemy is the whole policy. | |
| export function updateBuildingCombat(state, building, dt) { | |
| const def = BUILDINGS[building.type]; | |
| if (!def.attack) return; | |
| if (building.constructing) { building.targetId = null; return; } | |
| building.attackTimer = Math.max(0, building.attackTimer - dt); | |
| building.targetId = acquireTarget(state, building, def); | |
| if (!building.targetId || building.attackTimer > 0) return; | |
| const target = getEntity(state, building.targetId); | |
| // Same live-target guard the mobile updateCombat path applies before firing: the | |
| // acquired target may have been removed or dropped to hp<=0 between acquire and | |
| // now (a stale/tampered targetId, or a future reorder of the tick), so re-check it | |
| // exists and is alive β otherwise performAttack would deref undefined / NaN-poison | |
| // hp. Identity for valid data: a freshly acquired target is always live. | |
| if (!target || target.hp <= 0) return; | |
| // Ammo-fed static defense (the Torpedo Battery, entities.js def.ammo): real logistics paid | |
| // in a manufactured commodity a worker hauls in (engine/haul.js), not a free-fire structure β | |
| // a dry magazine holds fire rather than shooting for nothing. Still acquires/tracks a target | |
| // above (so it visibly trains on the threat), it just won't spend a cooldown on an empty gun; | |
| // the very next tick re-checks, so it fires the instant a hauler tops the larder back up. | |
| // A no-op for every ammo-less def (turret/bastille/β¦), so their firing is untouched. | |
| if (def.ammo && (building.input?.[def.ammo.com] || 0) < def.ammo.perShot) return; | |
| performAttack(state, building, def, target); | |
| building.attackTimer = def.cooldown; | |
| if (def.ammo) building.input[def.ammo.com] -= def.ammo.perShot; | |
| } | |