299 lines
15 KiB
JavaScript
299 lines
15 KiB
JavaScript
// server/sim/battle/queries.js — Battle methods: queries: the per-tick enemy tile index, enemies / allies by tile keys,
|
||
// grid, radius and selectability, the ally targets (allies our attacks select like enemies), players, and the range
|
||
// rebuild of an ally (current, base and extra range keys).
|
||
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
|
||
|
||
import { ROWS, COLS } from '../constants.js';
|
||
import { absoluteRangeKeys, canTargetEnemy, extendedGrid, enemyStealthed } from '../targeting.js';
|
||
import { bodyKeys, bodyInKeys, bodyInRadius } from '../body.js';
|
||
import { roundHalfEven } from '../detmath.js';
|
||
|
||
export class BattleQueries {
|
||
/**
|
||
* Tile buckets of the living enemies: a regular enemy on the tile of its position, a huge one (body.js) on every tile
|
||
* it occupies.
|
||
*/
|
||
_buildEnemyIndex() {
|
||
for (const k of this._ebUsed) this._eb[k].length = 0;
|
||
this._ebUsed.length = 0;
|
||
for (const e of this.enemies) {
|
||
if (!e.alive || e.hidden) continue;
|
||
if (e.hitArea) {
|
||
for (const k of bodyKeys(e)) { const b = this._eb[k]; if (!b.length) this._ebUsed.push(k); b.push(e); }
|
||
continue;
|
||
}
|
||
const r = roundHalfEven(e.y), c = roundHalfEven(e.x); // the tile body.js gives a point enemy
|
||
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) continue;
|
||
const k = r * COLS + c;
|
||
const b = this._eb[k];
|
||
if (!b.length) this._ebUsed.push(k);
|
||
b.push(e);
|
||
}
|
||
}
|
||
|
||
/** Targetable enemies whose body is on any of `keys` (absolute tile keys; a huge enemy is listed once). */
|
||
enemiesInKeys(keys, attacker, profile) {
|
||
const out = [];
|
||
if (!keys) return out;
|
||
for (let i = 0; i < keys.length; i++) {
|
||
const b = this._eb[keys[i]];
|
||
if (!b || !b.length) continue;
|
||
for (const e of b) if (e.alive && (!e.hitArea || !out.includes(e)) && canTargetEnemy(attacker, e, profile)) out.push(e);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Ally units an ally's attack selects like an enemy — the "ally targets" content registers (setAllyTarget): 白铁's
|
||
* 铁钳号·原型机, a summon of the enemy camp that "可被我方干员攻击但不受伤害" (PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方";
|
||
* kits/ops/op-ironmn.js). Those standing on one of `keys` (tile keys, or a Set of them), alive and deployed — never
|
||
* `attacker` itself, and an ally target selects none. ai.js acquireTargets appends them after the enemies (their 嘲讽等级
|
||
* −2 puts them last); a skill that acts on them counts them like an enemy for its automatic start (skills.js
|
||
* `_allyTargetIn` / `allyTargetsOk`). [] at
|
||
* once while none is registered, so every other battle runs exactly as before.
|
||
*/
|
||
allyTargetsInKeys(keys, attacker) {
|
||
const set = this._allyTargets;
|
||
if (!set || !set.size || !keys || !attacker || set.has(attacker)) return [];
|
||
const ks = keys instanceof Set ? keys : (keys === attacker.rangeKeys && attacker.rangeKeySet ? attacker.rangeKeySet : new Set(keys));
|
||
const out = [];
|
||
for (const a of set) if (a.alive && a.deployed && !a.hidden && ks.has(a.tileR * COLS + a.tileC)) out.push(a);
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The registered ally targets within `r` of (x, y) (centre distance) — the radius form of allyTargetsInKeys, for a kit
|
||
* whose area selection takes them too (a skill flagged `allyTargets`, skills.js allyTargetsOk). [] while none is registered.
|
||
*/
|
||
allyTargetsInRadius(x, y, r, attacker) {
|
||
const set = this._allyTargets;
|
||
if (!set || !set.size || !attacker || set.has(attacker)) return [];
|
||
const r2 = r * r + 1e-9;
|
||
const out = [];
|
||
for (const a of set) if (a.alive && a.deployed && !a.hidden && (a.x - x) * (a.x - x) + (a.y - y) * (a.y - y) <= r2) out.push(a);
|
||
return out;
|
||
}
|
||
|
||
/** Register (`on`) or drop an ally target (allyTargetsInKeys); one off the field is skipped while registered. */
|
||
setAllyTarget(unit, on = true) {
|
||
if (!unit || unit.side !== 'ally') return false;
|
||
if (on) (this._allyTargets ??= new Set()).add(unit);
|
||
else if (this._allyTargets) this._allyTargets.delete(unit);
|
||
return true;
|
||
}
|
||
|
||
/** Is `unit` a registered ally target (setAllyTarget)? */
|
||
isAllyTarget(unit) {
|
||
return !!(unit && this._allyTargets && this._allyTargets.has(unit));
|
||
}
|
||
|
||
/**
|
||
* A living enemy whose body is on any of `keys` — targetable or not (stealthed, untargetable, asleep, flying): the
|
||
* 技能范围 trigger "技能范围内存在敌人(无视其不可选中)" (skills.js SKILL_RANGE).
|
||
*/
|
||
anyEnemyInKeys(keys) {
|
||
if (!keys) return false;
|
||
for (let i = 0; i < keys.length; i++) {
|
||
const b = this._eb[keys[i]];
|
||
if (b && b.length) for (const e of b) if (e.alive && e.deployed) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Allies (not devices) within tiles `keys`, sorted by HP ratio (lowest first) that need healing. A `noHeal` (禁疗) ally
|
||
* is left out unless the healer's profile heals it through its 禁疗 (`healThrough(healer, ally)`: 凯尔希's Mon3tr —
|
||
* damage.js heal).
|
||
*/
|
||
injuredAlliesInKeys(keys, healer, includeElement = false) {
|
||
const out = [];
|
||
if (!keys) return out;
|
||
const set = keys instanceof Set ? keys : (healer && healer.rangeKeys === keys && healer.rangeKeySet ? healer.rangeKeySet : new Set(keys));
|
||
const through = healer && healer.profile && typeof healer.profile.healThrough === 'function' ? healer.profile.healThrough : null;
|
||
for (const a of this.allyUnits) {
|
||
if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') continue;
|
||
if (!set.has(a.tileR * COLS + a.tileC)) continue;
|
||
if (a !== healer && ((a.s.flags.noHeal && !(through && through(healer, a))) || (a.profile && a.profile.noHeal))) continue;
|
||
const injured = a.hp < a.s.maxHp - 1e-6;
|
||
const elem = includeElement && (a.elem.burn + a.elem.neural + a.elem.necrosis + a.elem.apoptosis + a.elem.erosion) > 0;
|
||
if (injured || elem) out.push(a);
|
||
}
|
||
out.sort((a, b) => a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq);
|
||
return out;
|
||
}
|
||
|
||
lowestHpAllyInRange(unit) {
|
||
const l = this.injuredAlliesInKeys(unit.rangeKeys, unit);
|
||
return l[0] ?? null;
|
||
}
|
||
|
||
/** Units of the opposite side (or `side`) whose tile is inside `grid` offsets relative to `unit`. */
|
||
unitsInGrid(unit, grid, { side = null, extend = 0 } = {}) {
|
||
const keys = absoluteRangeKeys(grid || unit.rangeGrid || [[0, 0]], Math.round(unit.y), Math.round(unit.x), unit.dir, extend);
|
||
const want = side ?? (unit.side === 'ally' ? 'enemy' : 'ally');
|
||
const set = new Set(keys);
|
||
const out = [];
|
||
const list = want === 'enemy' ? this.enemies : this.allyUnits;
|
||
for (const x of list) {
|
||
if (!x.alive || !x.deployed || x.hidden) continue;
|
||
if (bodyInKeys(x, set)) out.push(x);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Allies inside `unit`'s current range (for auras) — not the 孤立 ones ("无法被同阵营选中": 炎佑), `unit` aside. */
|
||
alliesInGrid(unit) {
|
||
const set = unit.rangeKeySet || new Set(unit.rangeKeys || []);
|
||
return this.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC) && this.allySelectable(a, unit));
|
||
}
|
||
|
||
/**
|
||
* May an ability of ally `by` select ally `a`? Never a 孤立 unit (炎佑, 从不混淆的方向; flag `isolated`) other than `by`
|
||
* itself — PRTS 选择器: selectors decide "天赋是否能给予某个干员Buff" and every heal / buff / aura target, and their 可选判定
|
||
* rejects a 孤立 target of a friendly selector ("若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"; user playtest #6
|
||
* item 18). Enemies still target it (a hostile selector). 禁疗 only keeps heals off (heal pipeline, injuredAlliesInKeys).
|
||
*/
|
||
allySelectable(a, by = null) {
|
||
return !!a && (a === by || !a.s.flags.isolated);
|
||
}
|
||
|
||
/** Deployed allies (no devices) an ability of ally `by` may select: allies(ownerId) without the 孤立 ones. */
|
||
alliesFor(by, ownerId = null) {
|
||
return this.allies(ownerId).filter((a) => this.allySelectable(a, by));
|
||
}
|
||
|
||
/**
|
||
* Living enemies whose body (body.js: a huge enemy's hit rectangle, else its position) is within `r` of (x, y).
|
||
* `centre` = a 中点判定 radius — splash around a struck / marked target (PRTS 作战机制: "中点判定…案例:阻挡,酒神1天赋的1.3
|
||
* 溅射半径"): every enemy counts by its position (判定中心), a huge one too.
|
||
*/
|
||
enemiesInRadius(x, y, r, centre = false) {
|
||
const out = [];
|
||
const r2 = r * r + 1e-9;
|
||
for (const e of this.enemies) {
|
||
if (!e.alive || e.hidden) continue;
|
||
if (e.hitArea && !centre) { if (bodyInRadius(e, x, y, r)) out.push(e); continue; }
|
||
const dx = e.x - x, dy = e.y - y;
|
||
if (dx * dx + dy * dy <= r2) out.push(e);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The enemies within `r` (as enemiesInRadius) an ally-side area effect can select — PRTS 作战机制 §AOE伤害判定 "AOE的判定是
|
||
* 对攻击范围内的每个可以被选中的敌人进行判定", 隐匿 "隐匿状态下的单位一般无法被敌方的索敌机制和Buff选择器选中为目标": no
|
||
* untargetable enemy and no 隐匿 one unless revealed, blocked or not hidden again yet after a block (targeting.js
|
||
* enemyStealthed; tile selectors — enemiesInKeys / canTargetEnemy — already skip them). Flying and asleep enemies stay
|
||
* the caller's choice. Enemy-side effects on other enemies (auras, heals) and physical collisions keep enemiesInRadius.
|
||
* Player report #8 after 0.1.0 (the 逐火 余烬): until 0.1.1 profession splash and skill circles still reached an
|
||
* unblocked 隐匿 enemy.
|
||
*/
|
||
foesInRadius(x, y, r, centre = false) {
|
||
const out = this.enemiesInRadius(x, y, r, centre);
|
||
let n = 0;
|
||
for (const e of out) {
|
||
const f = e.s.flags;
|
||
if (f.untargetable || (f.stealth && enemyStealthed(e))) continue;
|
||
out[n++] = e;
|
||
}
|
||
out.length = n;
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Every deployed ally within `r` of (x, y) — no selection rule: an enemy's area effect selects among them with
|
||
* targeting.js areaSelectable (content/enemies/helpers.js areaAllies: no 隐匿 ally, the blocker included, GitHub #97; DESIGN §22.12).
|
||
*/
|
||
alliesInRadius(x, y, r, ownerId = null, { includeDevices = false } = {}) {
|
||
const out = [];
|
||
const r2 = r * r + 1e-9;
|
||
for (const a of this.allyUnits) {
|
||
if (!a.alive || !a.deployed || a.hidden) continue;
|
||
if (!includeDevices && a.kind === 'device') continue;
|
||
if (ownerId != null && a.ownerId !== ownerId) continue;
|
||
const dx = a.x - x, dy = a.y - y;
|
||
if (dx * dx + dy * dy <= r2) out.push(a);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Alive deployed allies (ops + tokens), optionally of one player. */
|
||
allies(ownerId = null) {
|
||
return this.allyUnits.filter((a) => a.alive && a.deployed && a.kind !== 'device' && (ownerId == null || a.ownerId === ownerId));
|
||
}
|
||
|
||
aliveEnemies() { return this.enemies.filter((e) => e.alive); }
|
||
|
||
unitAt(r, c) { const u = this._occ[r * COLS + c]; return u && u.alive ? u : null; }
|
||
|
||
unitById(id) { return this.units.find((u) => u.id === id) ?? null; }
|
||
|
||
tileInfo(r, c) { return this.grid.tile(r, c); }
|
||
|
||
getPlayer(playerId) { return this.players.find((p) => p.playerId === playerId) ?? null; }
|
||
|
||
_pp(playerId) { return playerId == null ? null : this._perPlayer[playerId] ?? null; }
|
||
|
||
// =============================================================================================================
|
||
// ranges
|
||
|
||
/**
|
||
* Recompute a unit's absolute range tile keys: `rangeKeys` / `rangeKeySet` = current range (skill range override +
|
||
* rangeExtend — the unit's own 攻击距离 unless the running skill's range ignores it, `targeting.noRangeExtend`: 信仰搅拌机
|
||
* S3, PRTS 备注 "此技能的攻击范围不受'攻击距离'属性影响" — plus the skill's own `targeting.rangeExtend`) ∪
|
||
* `unit.extraRangeKeys` (content extra targets — setExtraRange); `baseRangeKeys` = the INITIAL range of the DEFAULT
|
||
* skill trigger: the unit's own grid + its permanent rangeExtend (`s.baseRangeExtend`: persistent, never-expiring
|
||
* buffs — talents, modules, bonds), no skill range, no temporary extend, no extra keys; `liveRangeGrid` = the
|
||
* relative grid (facing RIGHT) of the 攻击范围 the detail card shows (shared/protocol.js unitStatsEntry `range`;
|
||
* community report E1 after 0.1.0: the card kept the base grid while 烛煌 S3 attacked with 4-11): the grid behind
|
||
* `rangeKeys` without the extra keys (targeting.js extendedGrid; the grid itself when nothing extends it), or the
|
||
* unit's own range while a skill's grid only selects targets (`targeting.showOwnRange`: 荒芜拉普兰德 S1, no official
|
||
* range change). Rebuilt on deploy / relocate / skill range switches and whenever either extend changes (rangeChanged).
|
||
*/
|
||
_refreshRange(u) {
|
||
const tg = u.skill && u.skill.active ? u.skill.spec.targeting : null;
|
||
const grid = (tg && tg.rangeGrid) || u.rangeGrid || [[0, 0]];
|
||
const ext = (tg && tg.noRangeExtend ? 0 : u.s.rangeExtend) + ((tg && tg.rangeExtend) || 0);
|
||
u._rangeExtend = u.s.rangeExtend;
|
||
u._baseExtend = u.s.baseRangeExtend;
|
||
const keys = absoluteRangeKeys(grid, u.tileR, u.tileC, u.dir, ext);
|
||
const set = new Set(keys);
|
||
const extra = u.extraRangeKeys;
|
||
if (extra) for (const k of extra) if (!set.has(k)) { set.add(k); keys.push(k); }
|
||
u.rangeKeys = keys;
|
||
u.rangeKeySet = set;
|
||
const own = !!(tg && tg.showOwnRange);
|
||
const lg = own ? u.rangeGrid || [[0, 0]] : grid, le = own ? u.s.rangeExtend : ext;
|
||
u.liveRangeGrid = le > 0 ? extendedGrid(lg, le) : lg;
|
||
if (u.kind !== 'device') u.baseRangeKeys = absoluteRangeKeys(u.rangeGrid || [[0, 0]], u.tileR, u.tileC, u.dir, u.s.baseRangeExtend);
|
||
if (u.skill) u.skill._trigKeys = null; // CUSTOM_RANGE trigger grid is relative to the tile
|
||
}
|
||
|
||
/** True when a buff changed the unit's rangeExtend (total or permanent part) since its range was last built. */
|
||
rangeChanged(u) {
|
||
return u._rangeExtend !== undefined && (u._rangeExtend !== u.s.rangeExtend || u._baseExtend !== u.s.baseRangeExtend);
|
||
}
|
||
|
||
/** Public: rebuild a unit's range after content changed `unit.rangeGrid` (流形 copies) or its tile keys. */
|
||
refreshRange(unit) {
|
||
if (!unit || unit.side !== 'ally' || !unit.alive || !unit.deployed) return false;
|
||
this._refreshRange(unit);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Extra targetable tiles merged into the unit's range (蕾缪安 wanted targets, 维娜 S3, …): absolute tile keys
|
||
* (row × COLS + col), kept across every later range rebuild until content sets them again (null / [] clears).
|
||
* Not part of `baseRangeKeys` (the DEFAULT trigger range) — see SkillRuntime.addTriggerRange for that.
|
||
*/
|
||
setExtraRange(unit, keys) {
|
||
if (!unit || unit.side !== 'ally') return false;
|
||
const out = [];
|
||
const seen = new Set();
|
||
for (const k of keys || []) if (Number.isInteger(k) && k >= 0 && k < ROWS * COLS && !seen.has(k)) { seen.add(k); out.push(k); }
|
||
unit.extraRangeKeys = out.length ? out : null;
|
||
if (unit.alive && unit.deployed) this._refreshRange(unit);
|
||
return true;
|
||
}
|
||
}
|