- Conflicts: the stats entry (merged fb7-stats) and the settings button both sit in the lobby / room top bars — both kept;
public/js/ui/settings.js (#321 diagnostics, voiceLang, SettingsButton); docs/PLAYING.md table rows resolved line by
line against the merge base (#368, #354, #333 edits kept); docs/SIM.md / design/network.md snapshot field lists
(resolved + ammo / wolves / neg / stand / standCut); design/client.md Screens line (#368's AI switch kept).
- Grok review: MERGE AFTER FIXES — the shared-field capsule finding fixed on the branch (1a880699).
- Golden: roster 23, bonds 2, fields 11, matches 18, diy 11 — #308 totals / snapshots and #333's shop RNG (union OK);
broad node --test 5473 (5472 pass, 0 fail, 1 skipped); lint 0 errors; typecheck; i18n strict clean.
216 lines
11 KiB
JavaScript
216 lines
11 KiB
JavaScript
// server/sim/battle/spawns.js — Battle methods: enemies: the spawn queue (the stage's own enemies: `inTotal`, counted in
|
|
// `total` up front — the HUD capsule's numerator/denominator, deploy.js killedInTotal / leakedInTotal), 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');
|
|
// inTotal: the enemies THIS STAGE scheduled that count — the HUD capsule's own set (DESIGN §14 "顶栏胶囊"). A
|
|
// runtime spawn (a split child, a summon, a part, a form change) is never in it, so the capsule's denominator stays
|
|
// the stage's own enemies while `counted` (LP, 破坏完美作战 and the kill counters) keeps counting them all.
|
|
p.inTotal = !!p.counted;
|
|
if (precount && p.inTotal) {
|
|
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, inTotal }
|
|
*/
|
|
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.atkStandCutAt = -Infinity; // display metadata: when its stand was last cut or ignored (snapshot standCut)
|
|
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');
|
|
// inTotal (the HUD capsule's own counter, DESIGN §14): only an enemy the stage scheduled (`_queueSpawn`, which
|
|
// pre-counts it) — or, for future content, an explicit `opts.inTotal: true` — and that counts. Everything spawned
|
|
// while the battle runs (a split child, a summon — bosses included —, a part, a 变身 copy) stays out of the capsule's
|
|
// numerator and denominator, while `counted` (LP, 破坏完美作战, `killed`) keeps counting it as before.
|
|
e.inTotal = opts.inTotal === true && e.counted;
|
|
if (e.inTotal && !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;
|
|
}
|
|
}
|