Files
ark-stronghold-protocol/server/sim/content/devices.js
T
sganggs 60ed4fdab9 feedback1 WC: tactician summons never left out of range with a full hand; source fixes
Review follow-up to the water / tactical point change.

- Re-orienting 伺夜 / 缪尔赛思 in place while hand and temp are full could leave
  a placed 狼群 / 流形 outside the new range (it had nowhere to go and stayed
  put). The re-orientation is now refused with HAND_FULL when a summon it
  would leave out has no stack and no free slot. On the other paths
  (promotion, an owner moved with no room) such a summon leaves the board and
  its stack comes back at the next round start (grantTokensFor), so no
  out-of-range placement reaches the battle.
- Citation corrected: the 深水区 rule comes from the PRTS page 深水区 (地形信息
  tile_deepsea: 部署类型 全部位, 地形机制 "拒绝部署(待补充)"), corroborated by
  PRTS 作战机制 (地形标记 AdvancedBuildableMask lists 深水). There is no PRTS
  page 地形.
- grid.js: the tie-break note now says what changed on 战场#08 (no gate
  route; a few off-route smoothed steps of the boss field go around the water).
- Documented the known limit of the inactive act1 m05: its 特制水上平台 tiles
  are deployable in the prep's deploy map only; the sim does not model the
  canoes and keeps those tiles non-standable.
- gameLogic.summonRange reuses facing.js rangeTiles.
2026-10-03 02:46:07 +08:00

528 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/sim/content/devices.js — stage devices & special terrain (data/stages.json, research 05 §2.3–2.4).
//
// The engine already spawns active 阻隔工事 crates (obstacle devices, 100 HP) and marks active 射击台/mounds as
// obstacles that elevate operators. This module adds, per battle:
// 阻隔工事 an enemy blocked by a crate destroys it (after its attack interval even if it cannot attack: harmless
// walkers no longer stall on a crate); map cards (`deviceOverrides`) remove crates / add or remove
// platforms (obstacle + elevation) / activate hidden devices
// 气流 act2 m01 blowers (`trap_013_blower`, rangeTiles along `dir`): operators whose direction (`u.dir`, UP|
// RIGHT|DOWN|LEFT) equals the blower's dir / is its opposite / is perpendicular get ATK +
// blower_s_character[equal|opposite|vertical].atk (the m01 blowers blow DOWN); enemies moving with /
// against the flow get move speed × (1 + blower_s_enemy[equal|opposite].move_speed)
// 沼泽 m +1 stack on entering and every intervalSec s on the tile (max maxStacks): ASPD aspdPerStack (fractions
// are ×100 ASPD), move speed × (1 + moveMulPerStack × stacks); cleared on leaving (allies and ground enemies)
// 烟雾 g operators on it cannot be targeted by enemy ranged attacks (stealth flag: blocked enemies still hit them)
// 深水 d ground enemies on it: sea_drown[enemy].damage true dmg/s, ASPD attack_speed (×100), move × move_speed
// 活性源石 i units on it (allies and ground enemies): damage true dmg/s, ATK + atk, ASPD + attack_speed, for
// `duration` s of battle time
// “双眼皮” turrets (`trap_1104_aclasert`, band 机械援助 / `deviceOverrides` alias on): ranged arts shooter on its data
// range; ASPD + attack_speed_per_stack × L (≤ max_attack_speed), hits apply fragile 1 + damage_scale_per_stack
// × L (≤ max_damage_scale) for the text's duration; L = the owner's highest bond layers (live)
// 盟约寒风 `startColdWind` / `kjeragColdWind` helpers for the 谢拉格 bond (every interval s all enemies cold)
// Device overrides: a player's battle input `deviceOverrides` ({ alias: bool }) and `alias` keys in its playerEffects
// params (auto_chess_change_map effects: band 机械援助, 机变 map cards) switch the devices on that player's half of
// the field only (unite / boss fields hold both halves); Battle opts.deviceOverrides switch every half.
// `deviceOverridesOf(battle)` is the union (informational).
// Terrain numbers come from `stage.special` (fallback: the device's skill blackboard, then research 05 values).
// Not implemented: the devices of act1 m05–m07 (canoe platforms, sandstorm + mounds, bushes) — weight 0 this season. The
// canoe (特制水上平台) makes its 深水区 deployable in the prep's deploy map only (server/match/board.js); the sim's Grid
// keeps those tiles NONE (grid.js DEPLOY_REFUSED_TILES), so automatic placements never use them [ASSUMED].
import { COLS, ROWS } from '../constants.js';
import { performAttack } from '../ai.js';
import { sortEnemyTargets } from '../targeting.js';
import { DIR_VEC, normDir, oppositeDir } from '../dir.js';
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
/** ASPD blackboard values: a fraction (|v| < 1, e.g. −0.6 / −0.05) is ×100 ASPD, otherwise flat (+20). */
const aspdOf = (v) => (Math.abs(v) < 1 ? v * 100 : v);
export const TERRAIN = Object.freeze({ mire: 1, smog: 2, deepsea: 3, infection: 4 });
const TERRAIN_NAME = ['', 'mire', 'smog', 'deepsea', 'infection'];
const BUFF = { 1: 'terrain:mire', 2: 'terrain:smog', 3: 'terrain:deepsea', 4: 'terrain:infection' };
const AIRFLOW = 'terrain:airflow';
const CRATE_KEY = 'trap_1105_accrate';
const TURRET_KEY = 'trap_1104_aclasert';
/** Research 05 §2.3 values, used only when a stage has no `special` block / device blackboard. */
const RESEARCH = Object.freeze({
mire: { intervalSec: 3, aspdPerStack: -0.05, moveMulPerStack: -0.05, maxStacks: 10 },
deepsea: { damage: 40, attack_speed: -0.6, move_speed: 0.6 },
infection: { damage: 70, atk: 0.2, attack_speed: 20, duration: 300 },
});
const STATE = new WeakMap();
const stateOf = (battle) => STATE.get(battle) ?? null;
// ---------------------------------------------------------------------------------------------------------------
// device overrides
const truthy = (v) => (typeof v === 'number' ? v !== 0 : typeof v === 'string' ? v !== '0' && v !== '' && v !== 'false' : !!v);
function addOverrides(m, obj) {
if (!obj || typeof obj !== 'object') return m;
for (const [k, v] of Object.entries(obj)) if (k.includes('#')) m.set(k, truthy(v));
return m;
}
/** Device overrides of one player (battle input `deviceOverrides` + playerEffects params): Map alias → active. */
export function playerDeviceOverrides(ps) {
const m = new Map();
if (!ps) return m;
addOverrides(m, ps.input?.deviceOverrides);
for (const e of ps.playerEffects || []) addOverrides(m, e?.params);
return m;
}
/** Union of device overrides for this battle: Map alias → active. */
export function deviceOverridesOf(battle) {
const m = new Map();
for (const ps of battle.players || []) for (const [k, v] of playerDeviceOverrides(ps)) m.set(k, v);
addOverrides(m, battle.opts?.deviceOverrides);
return m;
}
const aliasOf = (d) => d?.raw?.alias ?? d?.alias ?? null;
function dataActive(d) {
return d.raw && typeof d.raw.active === 'boolean' ? d.raw.active : !d.hidden && d.active !== false;
}
/**
* Override of a device (true / false, undefined = none). A device belongs to the player whose half of the field
* holds it (the stage has one alias group per half: #0xx own board, #1xx partner half, #2xx/#3xx boss halves); map
* cards (“移除场地上全部阻隔工事”) and band 机械援助 (“所有场地中出现1个”) change the owner's own half only — a
* partner's card never removes this player's crates, a partner's band never adds a turret scaling with this player's
* layers. Battle-level overrides (opts.deviceOverrides) apply to every half.
*/
function overrideOf(battle, st, d) {
const a = aliasOf(d);
if (a == null) return undefined;
const owner = playerForCol(battle, d.col);
const mine = owner != null ? st.byPlayer.get(owner) : null;
if (mine && mine.has(a)) return mine.get(a);
if (st.global.has(a)) return st.global.get(a);
return undefined;
}
function isActive(battle, st, d) {
const o = overrideOf(battle, st, d);
return o === undefined ? dataActive(d) : o;
}
// ---------------------------------------------------------------------------------------------------------------
// cold wind (谢拉格 bond)
/**
* Every `interval` s (first gust after `first` s, default = interval) all enemies on the field become cold for
* `duration` s (a number or `() => number`, evaluated per gust — layers can change mid-battle). A cold enemy that
* gets cold again freezes (engine rule). `playerId` only tags the fx; `ownerOnly` limits the gust to enemies
* attributed to that player. Returns { cancel() } or null for bad numbers.
*/
export function startColdWind(battle, { playerId = null, interval, duration, first, ownerOnly = false } = {}) {
const iv = num(interval, 0);
if (!(iv > 0)) return null;
const durOf = () => num(typeof duration === 'function' ? duration() : duration, 0);
const handle = { cancelled: false, gusts: 0, timer: null, cancel() { this.cancelled = true; this.timer?.cancel(); } };
const gust = () => {
if (handle.cancelled || battle.finished) return;
const dur = durOf();
if (!(dur > 0)) return;
let n = 0;
for (const e of battle.enemies) {
if (!e.alive || e.hidden) continue;
if (ownerOnly && playerId != null && e.ownerId !== playerId) continue;
if (battle.applyStatus(e, 'cold', { duration: dur, source: null })) n++;
}
handle.gusts++;
const R = battle.rect;
battle.fx('coldWind', { x: (R.c0 + R.c1) / 2, y: (R.r0 + R.r1) / 2, playerId, n, duration: dur });
};
const firstDelay = Math.max(0, num(first, iv));
handle.timer = battle.after(firstDelay, () => {
if (handle.cancelled) return;
gust();
handle.timer = battle.every(iv, gust);
});
return handle;
}
/**
* 谢拉格 6-member wind from the bond effect blackboard (effects.json bondeffect_kjerag `env_gbuff_new` bb):
* interval `bond_eff_kjerag[storm].interval`, duration `base_time + time_per_stack × layers` (live 谢拉格 layers).
*/
export function kjeragColdWind(battle, playerId, bb = {}, { bondId = 'kjeragShip' } = {}) {
const interval = num(bb['bond_eff_kjerag[storm].interval'], 0);
const base = num(bb['bond_eff_kjerag[storm].base_time'], 0);
const per = num(bb['bond_eff_kjerag[storm].time_per_stack'], 0);
const layers = () => num(battle.getPlayer(playerId)?.bonds?.[bondId]?.layers, 0);
return startColdWind(battle, { playerId, interval, duration: () => base + per * layers() });
}
// ---------------------------------------------------------------------------------------------------------------
// turrets (“双眼皮”)
/** Highest bond layer count of a player (live copy: in-battle gains included). */
function topLayers(battle, playerId) {
const bonds = battle.getPlayer(playerId)?.bonds;
let L = 0;
if (bonds && typeof bonds === 'object') for (const b of Object.values(bonds)) L = Math.max(L, num(b?.layers, 0));
return L;
}
function playerForCol(battle, c) {
if (!battle.players.length) return null;
if (battle.players.length === 1) return battle.players[0].playerId;
const half = c >= 11 ? 'R' : 'L';
return (battle.players.find((p) => p.half === half) ?? battle.players[0]).playerId;
}
/**
* Tile for a turret: its own position when it is inside the field and free, else the nearest free in-rect tile
* nobody can deploy on — off the enemy paths first (void before a lane / gate: act2 m01's turrets stand on row 8,
* outside the normal field, and would otherwise land on the red gate).
*/
function deviceTile(battle, r0, c0) {
const R = battle.rect;
let best = null, bs = null;
for (let r = R.r0; r <= R.r1; r++) {
for (let c = R.c0; c <= R.c1; c++) {
if (battle.isReservedTile(r, c)) continue;
const exact = r === r0 && c === c0;
if (!exact && battle.grid.tile(r, c).build !== 'NONE') continue;
const walk = !exact && battle.grid.groundPassable(r, c, true) ? 1 : 0;
const s = [exact ? 0 : 1, walk, Math.hypot(r - r0, c - c0), r * COLS + c];
let less = !bs;
if (!less) for (let i = 0; i < s.length; i++) { if (s[i] < bs[i] - 1e-9) { less = true; break; } if (s[i] > bs[i] + 1e-9) break; }
if (less) { best = [r, c]; bs = s; }
}
}
return best;
}
function spawnTurret(battle, st, d) {
const alias = aliasOf(d) ?? `${d.key}@${d.row},${d.col}`;
if (st.turrets.has(alias)) return st.turrets.get(alias);
const tiles = (d.raw?.rangeTiles ?? []).filter((p) => Array.isArray(p) && battle.grid.inRect(p[0], p[1]));
if (!tiles.length) return null; // shoots at nothing on this field
const tile = deviceTile(battle, d.row, d.col);
if (!tile) return null;
const stats = d.raw?.stats ?? d.stats ?? {};
const u = battle.spawnDevice(d.key, tile[0], tile[1], {
hp: num(stats.maxHp, 100), blockCnt: 0, name: d.name ?? d.raw?.name ?? d.key,
atk: num(stats.atk, 0), def: num(stats.def, 0), res: num(stats.res, 0), bat: num(stats.bat, 1), aspd: num(stats.aspd, 100),
});
if (!u) return null;
const skill = d.raw?.skill ?? {};
const bb = skill.bb ?? {};
const durM = String(skill.desc ?? skill.description ?? '').match(/持续(\d+(?:\.\d+)?)秒/);
u.ownerId = playerForCol(battle, d.col);
u.dir = u.ownerId != null ? (battle.getPlayer(u.ownerId)?.dir ?? 'RIGHT') : 'RIGHT';
u.mem.turret = {
alias,
keys: tiles.map(([r, c]) => r * COLS + c),
baseAspd: u.base.aspd,
aspdPer: num(bb.attack_speed_per_stack, 0),
aspdMax: num(bb.max_attack_speed, Infinity),
fragPer: num(bb.damage_scale_per_stack, 0),
fragMax: num(bb.max_damage_scale, Infinity),
fragDur: durM ? +durM[1] : 0,
cd: 0,
};
st.turrets.set(alias, u);
battle.fx('turretOnline', { x: u.x, y: u.y, id: u.id, alias });
return u;
}
const TURRET_PROFILE = Object.freeze({ attack: 'ranged', dmgType: 'arts', projectile: 'bolt', canHitFly: true, maxTargets: 1, atkScale: 1 });
function tickTurret(battle, u, dt) {
const T = u.mem.turret;
if (!u.alive || !T) return;
T.cd -= dt;
if (T.cd > 0 || u.s.flags.stun) return;
const L = u.ownerId != null ? topLayers(battle, u.ownerId) : 0;
const bonus = Math.min(L * T.aspdPer, T.aspdMax);
if (Math.abs(u.base.aspd - (T.baseAspd + bonus)) > 1e-9) { u.base.aspd = T.baseAspd + bonus; u.markDirty(); }
const cands = battle.enemiesInKeys(T.keys, u, TURRET_PROFILE);
if (!cands.length) { T.cd = 0; return; }
sortEnemyTargets(battle, u, cands, null);
const frag = Math.min(1 + L * T.fragPer, T.fragMax) - 1;
const prof = {
...TURRET_PROFILE,
afterHit: (b, src, target) => {
if (frag > 0 && T.fragDur > 0 && target && target.alive) b.applyStatus(target, 'fragile', { duration: T.fragDur, value: frag, source: src });
},
};
performAttack(battle, u, prof, [cands[0]]);
T.cd = u.s.interval;
}
/** Activate “双眼皮” turrets on this field (all known aliases by default; idempotent). Returns the turret units. */
export function activateTurrets(battle, { aliases = null } = {}) {
const st = stateOf(battle);
if (!st) return [];
const out = [];
for (const d of battle.stage?.devices || []) {
if (d.key !== TURRET_KEY && d.role !== 'turret') continue;
if (aliases && !aliases.includes(aliasOf(d))) continue;
const u = spawnTurret(battle, st, d);
if (u) out.push(u);
}
ensureTick(battle, st);
return out;
}
// ---------------------------------------------------------------------------------------------------------------
// terrain
function buildTerrain(battle, st) {
const R = battle.rect;
let any = false;
for (let r = R.r0; r <= R.r1; r++) {
for (let c = R.c0; c <= R.c1; c++) {
const code = TERRAIN[battle.grid.tile(r, c).terrain] ?? 0;
if (code) { st.terrain[r * COLS + c] = code; any = true; }
}
}
st.hasTerrain = any;
const sp = battle.stage?.special || {};
const devSkill = (role) => (battle.stage?.devices || []).find((d) => d.role === role)?.raw?.skill?.bb ?? null;
// mire
const m = sp.mire || {};
const mdev = devSkill('mireController') || {};
st.mire = {
interval: num(m.intervalSec ?? mdev.value, RESEARCH.mire.intervalSec),
aspdPer: aspdOf(num(m.aspdPerStack ?? mdev.attack_speed, RESEARCH.mire.aspdPerStack)),
movePer: num(m.moveMulPerStack ?? mdev.move_speed, RESEARCH.mire.moveMulPerStack),
max: Math.max(1, Math.floor(num(m.maxStacks ?? mdev.max_stack_cnt, RESEARCH.mire.maxStacks))),
};
// deep sea
const ds = sp.deepsea?.bb || devSkill('tideController') || {};
st.deepsea = {
damage: num(ds['sea_drown[enemy].damage'], RESEARCH.deepsea.damage),
aspd: aspdOf(num(ds['sea_drown[enemy].attack_speed'], RESEARCH.deepsea.attack_speed)),
moveMul: num(ds['sea_drown[enemy].move_speed'], RESEARCH.deepsea.move_speed),
};
// active originium
const inf = sp.infection?.bb || {};
st.infection = {
damage: num(inf.damage, RESEARCH.infection.damage),
atk: num(inf.atk, RESEARCH.infection.atk),
aspd: aspdOf(num(inf.attack_speed, RESEARCH.infection.attack_speed)),
until: num(inf.duration, RESEARCH.infection.duration),
};
// blowers
for (const d of battle.stage?.devices || []) {
if (d.role !== 'blower' || !isActive(battle, st, d)) continue;
const raw = String(d.dir ?? d.raw?.dir ?? 'UP').toUpperCase();
const f = DIR_VEC[raw];
if (!f) continue;
const dir = normDir(raw);
const bb = d.raw?.skill?.bb ?? sp.blower?.bb ?? {};
const tiles = d.raw?.rangeTiles ?? [];
for (const p of tiles) {
if (!Array.isArray(p) || !battle.grid.inRect(p[0], p[1])) continue;
// world vector: x = col, y = row
st.flow.set(p[0] * COLS + p[1], { fx: f[1], fy: f[0], dir, bb });
}
}
}
const terrainDamage = (battle, amount) => (ctx) => {
if (amount > 0 && ctx.unit.alive) battle.dealDamage(null, ctx.unit, { amount, type: 'true', canDodge: false, tags: ['terrain'] });
};
function enterTerrain(battle, st, u, code) {
const m = u.mem;
if (m.terrain === code) return;
if (m.terrain) battle.removeBuff(u, BUFF[m.terrain]);
m.terrain = code;
m.terrainSince = battle.time;
m.mireStacks = 0;
if (!code) return;
if (code === TERRAIN.smog) battle.addBuff(u, { key: BUFF[code], flags: { stealth: true } });
else if (code === TERRAIN.deepsea) {
const D = st.deepsea;
battle.addBuff(u, { key: BUFF[code], mods: { aspd: D.aspd, moveMul: D.moveMul }, interval: 1, onTick: terrainDamage(battle, D.damage) });
} else if (code === TERRAIN.infection) {
const I = st.infection;
battle.addBuff(u, { key: BUFF[code], mods: { atkPct: I.atk, aspd: I.aspd }, interval: 1, onTick: terrainDamage(battle, I.damage) });
}
// mire stacks are applied by tickMire
}
function tickMire(battle, st, u) {
const M = st.mire;
const n = Math.min(M.max, 1 + Math.floor((battle.time - u.mem.terrainSince + 1e-9) / M.interval));
if (n === u.mem.mireStacks) return;
u.mem.mireStacks = n;
battle.addBuff(u, { key: BUFF[TERRAIN.mire], refresh: 'replace', mods: { aspd: M.aspdPer * n, moveMul: Math.max(0, 1 + M.movePer * n) } });
}
/**
* Terrain code under a unit (0 = none). Air units (Unit.isFlying: flyers, hovering 近地悬浮 and levitated enemies) and
* hidden units ignore ground terrain; smog only helps allies.
*/
function terrainFor(battle, st, u) {
if (u.hidden || u.isFlying) return 0;
let k;
if (u.side === 'ally') k = u.tileR * COLS + u.tileC;
else {
const r = Math.round(u.y), c = Math.round(u.x);
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return 0;
k = r * COLS + c;
}
let code = st.terrain[k];
if (code === TERRAIN.smog && u.side !== 'ally') code = 0;
if (code === TERRAIN.deepsea && u.side === 'ally') code = 0;
if (code === TERRAIN.infection && battle.time >= st.infection.until) code = 0;
return code;
}
function airflowRelation(dot) { return dot > 0.7 ? 'equal' : dot < -0.7 ? 'opposite' : 'vertical'; }
/** Operator ↔ blower: the same direction ⇒ 'equal', the reverse ⇒ 'opposite', perpendicular ⇒ 'vertical'. */
export function airflowRelationOf(unitDir, flowDir) {
const u = normDir(unitDir), f = normDir(flowDir);
return u === f ? 'equal' : u === oppositeDir(f) ? 'opposite' : 'vertical';
}
function tickAirflowAlly(battle, st, u) {
const f = st.flow.get(u.tileR * COLS + u.tileC);
let atk = 0;
if (f && u.motion !== 'FLY') atk = num(f.bb[`blower_s_character[${airflowRelationOf(u.dir, f.dir)}].atk`], 0);
if (atk === (u.mem.airAtk ?? 0)) return;
u.mem.airAtk = atk;
if (atk) battle.addBuff(u, { key: AIRFLOW, refresh: 'replace', mods: { atkPct: atk } });
else battle.removeBuff(u, AIRFLOW);
}
function tickAirflowEnemy(battle, st, e) {
const px = e.mem.airPx, py = e.mem.airPy;
e.mem.airPx = e.x; e.mem.airPy = e.y;
let mul = 1;
if (!e.hidden && px != null) {
const r = Math.round(e.y), c = Math.round(e.x);
const f = r >= 0 && r < ROWS && c >= 0 && c < COLS ? st.flow.get(r * COLS + c) : null;
const vx = e.x - px, vy = e.y - py;
const v = Math.hypot(vx, vy);
if (f && v > 1e-6) {
const rel = airflowRelation((vx * f.fx + vy * f.fy) / v);
if (rel !== 'vertical') mul = 1 + num(f.bb[`blower_s_enemy[${rel}].move_speed`], 0);
} else if (f && v <= 1e-6) mul = e.mem.airMul ?? 1; // paused this tick (attack pause / blocked): keep the last push
}
if (!(mul >= 0)) mul = 0;
if (mul === (e.mem.airMul ?? 1)) return;
e.mem.airMul = mul;
if (mul !== 1) battle.addBuff(e, { key: AIRFLOW, refresh: 'replace', mods: { moveMul: mul } });
else battle.removeBuff(e, AIRFLOW);
}
/** Apply terrain / airflow state to one unit now (deploy, spawn, every tick). */
function refreshUnit(battle, st, u) {
if (!u.alive || !u.deployed || u.kind === 'device') return;
if (st.hasTerrain) {
enterTerrain(battle, st, u, terrainFor(battle, st, u));
if (u.mem.terrain === TERRAIN.mire) tickMire(battle, st, u);
}
if (st.flow.size) { if (u.side === 'ally') tickAirflowAlly(battle, st, u); else tickAirflowEnemy(battle, st, u); }
}
function ensureTick(battle, st) {
if (st.ticking) return;
if (!st.hasTerrain && !st.flow.size && !st.turrets.size) return;
st.ticking = true;
battle.on('tick', ({ dt }) => {
if (st.hasTerrain || st.flow.size) {
for (const u of battle.allyUnits) refreshUnit(battle, st, u);
for (const e of battle.enemies) refreshUnit(battle, st, e);
}
for (const u of st.turrets.values()) tickTurret(battle, u, dt);
});
// a unit that (re)deploys / spawns starts fresh (death drops its buffs) and gets its tile's terrain at once: smog
// must already hide an operator from the first enemy shot of the battle
battle.on('deploy', (ctx) => {
const u = ctx.unit;
if (!u || !u.mem || u.kind === 'device') return;
u.mem.terrain = 0; u.mem.airAtk = 0; u.mem.airMul = 1; u.mem.airPx = null;
refreshUnit(battle, st, u);
}, { priority: 90 });
}
/** Terrain name under (r, c) for other content ('mire' | 'smog' | 'deepsea' | 'infection' | null). */
export function terrainAt(battle, r, c) {
const st = stateOf(battle);
const code = st && r >= 0 && r < ROWS && c >= 0 && c < COLS ? st.terrain[r * COLS + c] : 0;
return TERRAIN_NAME[code] || null;
}
// ---------------------------------------------------------------------------------------------------------------
// install
export function install(battle) {
const st = {
byPlayer: new Map((battle.players || []).map((ps) => [ps.playerId, playerDeviceOverrides(ps)])),
global: addOverrides(new Map(), battle.opts?.deviceOverrides),
terrain: new Uint8Array(ROWS * COLS),
hasTerrain: false,
flow: new Map(),
turrets: new Map(),
platformOn: new Set(),
platformOff: new Set(),
crateOff: new Set(),
ticking: false,
};
STATE.set(battle, st);
const devices = battle.stage?.devices || [];
buildTerrain(battle, st); // grid, stage `special` and overrides are known at construction
ensureTick(battle, st);
// overrides that differ from the data (per half owner): crates removed, platforms added/removed
for (const d of devices) {
if (!battle.grid.inRect(d.row, d.col)) continue;
const on = overrideOf(battle, st, d);
if (on === undefined || on === dataActive(d)) continue;
const k = d.row * COLS + d.col;
if (d.role === 'crate' && !on) st.crateOff.add(k);
if (d.role === 'platform' || d.role === 'mound') (on ? st.platformOn : st.platformOff).add(k);
}
battle.on('deploy', (ctx) => {
const u = ctx.unit;
if (!u || u.side !== 'ally') return;
const k = u.tileR * COLS + u.tileC;
if (u.kind === 'device') {
// stage crates switched off by a map card vanish before anybody deploys on their tile
if (u.defId === CRATE_KEY && st.crateOff.has(k) && !st.started) { u.mem.removedByOverride = true; battle.retreat(u, { reason: 'removed', permanent: true }); }
return;
}
if (st.platformOn.has(k)) u.ground = false;
else if (st.platformOff.has(k)) u.ground = battle.grid.isLow(u.tileR, u.tileC);
}, { priority: 100 });
// an enemy stopped by a crate breaks it (the engine lets attackers chop it; this also covers harmless walkers)
battle.on('blocked', ({ blocker, enemy }) => {
if (!blocker || blocker.kind !== 'device' || blocker.defId !== CRATE_KEY) return;
const delay = Math.max(0.1, Math.min(10, num(enemy.s.interval, 1)));
battle.after(delay, () => {
if (blocker.alive && enemy.alive && enemy.blockedBy === blocker) {
battle.fx('crateBreak', { x: blocker.x, y: blocker.y, id: blocker.id });
battle.kill(blocker, enemy);
}
});
});
battle.on('battleStart', () => {
st.started = true;
// platforms switched by map cards (the engine applied the data state)
for (const k of st.platformOn) battle.setObstacle((k / COLS) | 0, k % COLS, true);
for (const k of st.platformOff) battle.setObstacle((k / COLS) | 0, k % COLS, false);
// hidden crates switched on by an override
for (const d of devices) {
if (d.role !== 'crate' || dataActive(d) || !isActive(battle, st, d) || !battle.grid.inRect(d.row, d.col) || battle.unitAt(d.row, d.col)) continue;
battle.spawnDevice(d.key, d.row, d.col, { hp: num(d.stats?.maxHp ?? d.raw?.stats?.maxHp, 100), obstacle: true, name: d.name });
}
// “双眼皮” turrets switched on (band 机械援助 / overrides)
for (const d of devices) if ((d.key === TURRET_KEY || d.role === 'turret') && isActive(battle, st, d)) spawnTurret(battle, st, d);
ensureTick(battle, st);
}, { priority: 50 });
}
export function registerMeta(registry) {} // map cards / band 机械援助 reach the battle through deviceOverrides