Files
ark-stronghold-protocol/server/sim/battle/spawns.js
T
sganggs fdb3ae1b46 feedback5 CB4: 自制投石机's targeting ignores its block (索敌不受阻挡影响) — blocked by an operator on the 排气格栅 with nobody else in range it does not attack, as in the official game (community report of 2026-10-06, item 24)
The 「投石车」 is 自制投石机 (enemy_10162_mnctpt), the 「蒸汽地板」 the 排气格栅 smog (隐匿 to enemy ranged attacks). PRTS
天赋 「索敌不受阻挡影响」: the blocker exception (a blocked enemy attacks its blocker whatever its selectability) does not
apply to it. New enemy profile field `blockFree` (spawns.js): attackTargets selects as if unblocked, canTargetAlly
refuses a 隐匿 / 迷彩 blocker to it, sortAllyTargets puts no blocker first. Other enemies unchanged.

Golden: standin-06 (the bounty catapult, blocked by 忍冬, now shoots by 仇恨值 — checked instrumented).
Tests: test/sim/feedback5-catapult-smog.test.js (3; 2 fail on the parent). Docs: DESIGN §25.18.7, docs/SIM.md.
2026-10-07 02:07:51 +08:00

205 lines
10 KiB
JavaScript

// server/sim/battle/spawns.js — Battle methods: enemies: the spawn queue (counted in `total` up front), routes and the
// owner of a tile, spawnEnemy, and the enemies on the field — hidden ones, the shared boss pool sync, the field clamp
// and the list compaction.
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
import { MAX_ALIVE_ENEMIES } from '../constants.js';
import { Unit } from '../units.js';
import { compileRoute } from '../ai.js';
import { unitInfo } from '../snapshot.js';
import { normalizeRoute, normalizeEnemy } from '../simdata.js';
import { fin } from './util.js';
const FALLBACK_ENEMY = Object.freeze({
type: 'enemy', id: 'enemy_unknown', key: 'enemy_unknown', name: '未知敌人', rank: 'NORMAL', maxHp: 2000, atk: 200, def: 100, res: 0,
aspd: 100, bat: 2, rangeRadius: 0, moveSpeed: 1, massLevel: 1, lpr: 1, motion: 'WALK', applyWay: 'MELEE', dmgType: 'phys',
immune: new Set(), tauntLevel: 0, blockCnt: 1, epResistance: 0, epDamageResistance: 0, hpRecoveryPerSec: 0, notCountInTotal: false,
tags: [], abilities: [], skills: [], talent: {}, spine: 'unknown', avatar: 'unknown', raw: {},
});
export class BattleSpawns {
_queueSpawn(s, precount) {
if (Number(s.time) === Infinity) return; // "never" — not scheduled, not counted
// capped: a bogus count (Infinity, 1e9) would otherwise hang the constructor / exhaust memory
const count = Math.min(MAX_ALIVE_ENEMIES, Math.max(1, Math.floor(fin(s.count ?? 1, 1)) || 1));
const interval = Math.max(0, fin(s.interval, 0));
const def = this.data.getEnemy(s.enemyKey);
for (let i = 0; i < count; i++) {
const p = {
time: Math.max(0, fin(s.time, 0)) + i * interval, enemyKey: s.enemyKey, routeIndex: s.routeIndex ?? 0, route: s.route ?? null,
mods: s.mods ?? null, sourcePlayerId: s.sourcePlayerId ?? null, bounty: s.bounty ?? null, tag: s.tag ?? null,
ownerPlayerId: s.ownerPlayerId ?? null, pos: s.pos ?? null, seq: ++this._spawnSeq, countInTotal: s.countInTotal,
};
p.counted = p.countInTotal ?? (!(def && def.notCountInTotal) && p.tag !== 'boss' && p.tag !== 'part');
if (precount && p.counted) {
this.total++;
const owner = p.ownerPlayerId ?? this._ownerForTile(p.pos ?? this._routeFor(p.routeIndex, p.route)?.start);
const pp = this._pp(owner);
if (pp) pp.total++;
p.precounted = true;
}
this._pending.push(p);
}
}
_processSpawns() {
const now = this.time + 1e-9;
while (this._pending.length && this._pending[0].time <= now) {
const p = this._pending.shift();
this._safe(() => this.spawnEnemy(p.enemyKey, { ...p, _precounted: p.precounted }), 'spawnEnemy');
}
}
_routeFor(idx, explicit = null) {
if (explicit) return normalizeRoute(explicit);
return this.routes[idx] ?? this.routes[0] ?? this._defaultRoute();
}
_defaultRoute() {
if (this._defRoute !== undefined) return this._defRoute;
const starts = this.grid.specialTiles('start');
const ends = this.grid.specialTiles('end');
this._defRoute = starts.length && ends.length ? { motion: 'WALK', start: starts[0], end: ends[0], checkpoints: [] } : null;
return this._defRoute;
}
_ownerForTile(tile) {
if (!this.players.length) return null;
if (this.players.length === 1 || !tile) return this.players[0].playerId;
const c = Array.isArray(tile) ? tile[1] : tile.c;
const half = c >= 11 ? 'R' : 'L';
return (this.players.find((p) => p.half === half) ?? this.players[0]).playerId;
}
/**
* Spawn an enemy now. opts: { routeIndex, route, pos:[r,c], mods:{hpMul,atkMul,defMul,resMul,speedMul}, tag,
* sourcePlayerId, ownerPlayerId, bounty:{coins,ownerPlayerId}, countInTotal }
*/
spawnEnemy(enemyKey, opts = {}) {
if (this.enemies.length >= MAX_ALIVE_ENEMIES && this.aliveEnemies().length >= MAX_ALIVE_ENEMIES) {
this._handlerError('spawnEnemy', null, new Error(`more than ${MAX_ALIVE_ENEMIES} living enemies; spawn of ${enemyKey} refused`));
return null;
}
// opts.def: an inline record (data/enemies.json shape, or an already normalised EnemyDef) for keys absent from data
let def = null;
if (opts.def && typeof opts.def === 'object') {
try { def = opts.def.type === 'enemy' && opts.def.immune instanceof Set ? opts.def : normalizeEnemy(enemyKey, opts.def); } catch (e) { this._handlerError('spawnEnemy.def', null, e); }
}
if (!def) def = this.data.getEnemy(enemyKey);
if (!def) { this.log(`unknown enemy ${enemyKey}`); def = { ...FALLBACK_ENEMY, id: enemyKey, key: enemyKey }; }
const ov = this.enemyOverrides[def.key] ?? this.enemyOverrides[enemyKey];
if (ov && ov.stats) {
const st = ov.stats;
def = { ...def,
maxHp: Math.max(1, fin(st.maxHp ?? st.hp, def.maxHp)), atk: Math.max(0, fin(st.atk, def.atk)), def: Math.max(0, fin(st.def, def.def)),
res: Math.max(0, fin(st.res, def.res)), moveSpeed: Math.max(0, fin(st.moveSpeed, def.moveSpeed)),
bat: Math.max(0.1, fin(st.bat, def.bat)), aspd: fin(st.aspd, def.aspd) || def.aspd,
rangeRadius: Math.max(0, fin(st.rangeRadius, def.rangeRadius)), massLevel: fin(st.massLevel, def.massLevel),
lpr: fin(st.lpr, def.lpr), blockCnt: Math.max(1, fin(st.blockCnt, def.blockCnt)) };
}
const route = opts.route ? normalizeRoute(opts.route) : this._routeFor(opts.routeIndex ?? 0);
let start = opts.pos ?? route?.start ?? [this.rect.r0, this.rect.c1];
if (!Array.isArray(start) || !Number.isFinite(start[0]) || !Number.isFinite(start[1])) start = route?.start ?? [this.rect.r0, this.rect.c1];
const R = this.rect; // spawn inside the field (same bounds as _clampPos)
start = [Math.max(R.r0 - 0.5, Math.min(R.r1 + 0.5, start[0])), Math.max(R.c0 - 0.5, Math.min(R.c1 + 0.5, start[1]))];
// multipliers: finite and ≥ 0 (hp > 0), anything else counts as 1
const mm = (v, pos = false) => { const n = fin(v, 1); return n < 0 || (pos && n <= 0) ? 1 : n; };
const m0 = opts.mods || {};
const m = { hpMul: mm(m0.hpMul, true), atkMul: mm(m0.atkMul), defMul: mm(m0.defMul), resMul: mm(m0.resMul), speedMul: mm(m0.speedMul) };
const e = new Unit({
id: ++this._idSeq, side: 'enemy', kind: 'enemy', def, defId: def.key ?? enemyKey, name: def.name,
x: start[1], y: start[0], motion: def.motion,
base: {
maxHp: def.maxHp * (m.hpMul ?? 1), atk: def.atk * (m.atkMul ?? 1), def: def.def * (m.defMul ?? 1), res: def.res * (m.resMul ?? 1),
aspd: def.aspd, bat: def.bat, blockCnt: 0, moveSpeed: def.moveSpeed * (m.speedMul ?? 1), spRecovery: 0,
tauntLevel: def.tauntLevel, massLevel: def.massLevel, hpRecoveryPerSec: def.hpRecoveryPerSec, rangeRadius: def.rangeRadius,
},
});
e.alive = true;
e.deployed = true;
e.blockWeight = def.blockCnt ?? 1;
e.hitArea = def.hitArea ?? null; // 巨型单位 hit rectangle (body.js); null = a point
e.lpr = def.lpr;
e.mods = opts.mods ?? null;
e.tag = opts.tag ?? null;
e.bounty = opts.bounty ?? null;
e.sourcePlayerId = opts.sourcePlayerId ?? null;
e.ownerId = opts.ownerPlayerId ?? this._ownerForTile(start);
e.spawnX = e.x; e.spawnY = e.y;
e.spawnSeq = ++this._spawnSeq;
e.deploySeq = ++this._deploySeq;
e.deployedAt = this.time;
e.atkCd = 0;
e.pauseUntil = -Infinity; // content holds (暴鸰's drop)
e.atkStandUntil = -Infinity; // standing for its attack clip (ai.js attackStand)
e.swing = false; // a normal attack swung, its damage frame not reached yet (ai.js enemyAttack)
// every enemy profile starts with the same fields (stable object shapes keep the hot loop's property reads fast);
// `dmgType` null = the data's (content may arm a data-unarmed enemy: ai.js enemyAttack); `blockFree` = its 索敌不受阻挡
// 影响 (content: 自制投石机 — targeting.js canTargetAlly / sortAllyTargets, ai.js attackTargets)
e.profile = { noAttack: def.dmgType === 'none', maxTargets: 1, atkScale: 1, dmgType: null, blockFree: false };
e.route = { legs: route ? compileRoute(route, this.rect) : [], legIdx: 0, pts: null, ptIdx: 0, suffix: null, version: -1, waitLeft: null };
if (!e.route.legs.length) {
const end = this.grid.specialTiles('end')[0];
if (end) e.route.legs.push({ t: 'move', r: end[0], c: end[1], final: true });
}
e.counted = opts.countInTotal ?? (!def.notCountInTotal && e.tag !== 'boss' && e.tag !== 'part');
if (e.counted && !opts._precounted) {
this.total++;
const pp = this._pp(e.ownerId);
if (pp) pp.total++;
}
if (e.tag === 'boss') {
e.isBoss = true;
if (this.sharedBoss) { e.bossPool = this.sharedBoss; this._syncBossHp(e); }
}
e.hp = e.bossPool ? e.hp : e.s.maxHp;
this.units.push(e);
this.enemies.push(e);
this._ev(['spawn', unitInfo(e)]);
if (this._hooks.deploy) this.emit('deploy', { unit: e, initial: false });
if (this._hooks.enemySpawn) this.emit('enemySpawn', { enemy: e });
return e;
}
// =============================================================================================================
// enemies on the field: hidden, boss pool, clamp, compaction
_setHidden(e, on) {
if (e.hidden === on) return;
e.hidden = on;
if (on) this._unblock(e);
this.fx(on ? 'disappear' : 'appear', { x: e.x, y: e.y, id: e.id });
}
_syncBossHp(e) {
const pool = e.bossPool;
if (!pool) return;
if (Number.isFinite(pool.maxHp) && pool.maxHp > 0 && e.base.maxHp !== pool.maxHp) { e.base.maxHp = pool.maxHp; e.markDirty(); }
// a broken pool (NaN hp) must not leak NaN into the unit: show it full until the pool is sane again
const ratio = pool.maxHp > 0 ? (Number.isFinite(pool.hp) ? Math.max(0, pool.hp) / pool.maxHp : 1) : 0;
e.hp = e.s.maxHp * Math.min(1, ratio);
}
_bossSync() {
if (!this.sharedBoss) return;
for (const e of this.enemies) if (e.alive && e.bossPool) {
this._syncBossHp(e);
if (e.bossPool.hp <= 0) this.kill(e, null);
}
}
_clampPos(e) {
const R = this.rect;
if (e.x < R.c0 - 0.5) e.x = R.c0 - 0.5;
if (e.x > R.c1 + 0.5) e.x = R.c1 + 0.5;
if (e.y < R.r0 - 0.5) e.y = R.r0 - 0.5;
if (e.y > R.r1 + 0.5) e.y = R.r1 + 0.5;
}
_compactEnemies() {
if (!this._enemiesDirty) return;
this.enemies = this.enemies.filter((e) => e.alive);
this._enemiesDirty = false;
}
}