Files
ark-stronghold-protocol/server/sim/content/devices.js
T

571 lines
28 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 a unit on it (allies and ground enemies) triggers 【陷入沼泽】 on entering and every intervalSec (1) s
// there: an enemy gains 1 layer — 2 at 重量 ≥ heavyWeight (the device's `value`, 3) — of ASPD aspdPerStack
// (fractions are ×100 ASPD) and move speed × (1 + moveMulPerStack × layers), an operator 1 layer of the
// ASPD part only; at most maxStacks layers; cleared on leaving
// 烟雾 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 a unit on it (allies and ground enemies) gets a timed effect: damage true dmg/s, ATK + atk, ASPD +
// attack_speed for `duration` s from its last contact — an enemy keeps it after walking off; one effect
// per unit, its time starts again while the unit is on the tile; the tiles never switch off
// “双眼皮” 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: 1, aspdPerStack: -0.05, moveMulPerStack: -0.05, maxStacks: 10, heavyWeight: 3 },
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') || {};
// the device skill's `value` (3) is the 重量 from which an enemy gains 2 layers, not an interval (PRTS 沼泽控制)
const mInterval = num(m.intervalSec, RESEARCH.mire.intervalSec);
st.mire = {
interval: mInterval > 0 ? mInterval : 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))),
heavyWeight: num(m.heavyWeight ?? mdev.value, RESEARCH.mire.heavyWeight),
};
// 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: `duration` is how long the effect lasts on a unit (PRTS tile template "部署于其上的我军和经过的
// 敌军在{duration}s内…"), not a lifetime of the tiles
const inf = sp.infection?.bb || {};
const infDur = num(inf.duration, RESEARCH.infection.duration);
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)),
duration: infDur > 0 ? infDur : 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;
// leaving a tile ends its effect — except 活性源石's, which runs out on its own (touchInfection)
if (m.terrain && m.terrain !== TERRAIN.infection) battle.removeBuff(u, BUFF[m.terrain]);
m.terrain = code;
m.terrainSince = battle.time;
m.mireStacks = 0;
m.mireTriggers = 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) });
}
// mire layers are applied by tickMire, 活性源石 by touchInfection (every tick on the tile)
}
/**
* 活性源石 contact (every tick on the tile). The tile gives a timed effect — damage true dmg/s, ATK + atk, ASPD +
* attack_speed for `duration` s (PRTS 特殊地形 tile template: "部署于其上的我军和经过的敌军在{duration}s内每秒受到{damage}
* 真实伤害,攻击力提升…,攻击速度增加…") — that stays on an enemy after it walks off: PRTS 危机合约 tag
* global_tile_infection_1 「目标:可控感染」 "踏过活性源石地块的敌人不再持续损失生命值" switches the lasting HP loss off,
* so without it the loss continues. One effect per unit (PRTS 作战机制: "同名buff的默认叠加策略buff只能表现出一个"): a
* unit that already carries it gets its full `duration` back and keeps its per-second rhythm — no second effect, no
* extra tick [ASSUMED: the time counts from the last contact — so an operator deployed on it, always in contact, drains
* past `duration`]. An operator moved off the tile (Battle.relocate: 乌尔比安 S3, 夕's 小自在 …) keeps it for its time;
* leaving the field drops it with every buff; a 重生 clears it (enemies.js rebirthCleanse: PRTS 特殊机制 §重生 "清空自身
* 身上除白名单外所有Buff") and contact gives it again while the unit is on the tile [ASSUMED]. The tick (terrainDamage)
* is true damage no unit deals (无来源), tagged 'terrain' = 环境伤害 (PRTS 自然环境 lists 活性源石), not 'dot' [ASSUMED:
* PRTS 伤害分类's list of BUFF damage does not name it].
*/
function touchInfection(battle, st, u) {
const I = st.infection;
const key = BUFF[TERRAIN.infection];
const b = u.findBuff(key);
if (b) { if (b.timeLeft < I.duration) b.timeLeft = I.duration; return; }
battle.addBuff(u, { key, duration: I.duration, mods: { atkPct: I.atk, aspd: I.aspd }, interval: 1, onTick: terrainDamage(battle, I.damage) });
}
/** Layers an enemy gains per 【陷入沼泽】 trigger at 重量 ≥ heavyWeight (PRTS 沼泽控制: "若其重量大于等于3,改为获得2层"). */
const MIRE_HEAVY_LAYERS = 2;
/**
* 沼泽 (PRTS 沼泽控制 备注): a unit in the mire triggers its 【陷入沼泽】 "每秒…一次" (the device skill charges in 1 s) — an
* enemy gains 1 layer of ASPD −5 % and move speed −5 % (2 layers at 重量 ≥ heavyWeight, the skill's `value` 3), any other
* unit 1 layer of ASPD −5 % only; at most 10 layers; "上述减益于单位不再位于沼泽之中时解除" (enterTerrain).
* [ASSUMED] the first trigger comes on entering, then one every second.
*/
function tickMire(battle, st, u) {
const M = st.mire, m = u.mem;
const due = 1 + Math.floor((battle.time - m.terrainSince + 1e-9) / M.interval);
if (due <= m.mireTriggers) return;
const enemy = u.side === 'enemy';
const per = enemy && num(u.s.massLevel, 0) >= M.heavyWeight ? MIRE_HEAVY_LAYERS : 1;
const n = Math.min(M.max, m.mireStacks + per * (due - m.mireTriggers));
m.mireTriggers = due;
if (n === m.mireStacks) return;
m.mireStacks = n;
battle.addBuff(u, { key: BUFF[TERRAIN.mire], refresh: 'replace', mods: enemy ? { aspd: M.aspdPer * n, moveMul: Math.max(0, 1 + M.movePer * n) } : { aspd: M.aspdPer * 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;
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);
else if (u.mem.terrain === TERRAIN.infection) touchInfection(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