Seen: the sim moved a displaced enemy at once and it walked / swung again on the next tick; players compared with the official game (PR #392: 「被推/拉完的敌人会先站住一小会儿再走」). Research (scratchpad report r3): PRTS 失衡位移机制 — a force > 0 puts an enemy in the UNBALANCE state machine with a 0.1 s 失衡硬直 floor, ending when its speed is ≤ 0.1 m/s, on 浮空 or a forced state change; 异常效果图鉴/失衡免疫 「失衡期间无法自主移动、发动攻击、使用技能」; 异常效果 — not an abnormal status (水遁忍者's check still runs); 游戏数据基础 推力-位移近似对应表 位移时间 (whole frames at 30 fps); 推与拉 拉力 「作用时间默认为 1 s;若本次受力 等级 < −1,作用时间改为 0.5 s」, 急停 keeps the state to the window's end; 特殊机制 静态刚体 (0.1 s 保底); 术语释义 浮空. Now: constants.js PUSH_UNBALANCE {−2: 0.2, −1: 0.4, 0: 0.8, 1: 0.9, 2: 32/30, 3: 35/30}, PULL_UNBALANCE 1 / PULL_UNBALANCE_WEAK 0.5, UNBALANCE_MIN 0.1. battle/displacement.js: push() holds for its level's row (the −2 correction included), kept when the 特效 column or a wall shortens the slide [ASSUMED for the wall], the 0.1 s floor when a wall stops it at the first step [ASSUMED]; pull() holds for its window whatever the travel (≤ −3: none; an enemy the puller blocks: still no pull [ASSUMED, unchanged]); a 静态刚体 (data staticBody, not 失衡免疫 noDisplace) hit by force level ≥ −2 gets 0.1 s and no movement (_staticForce); _unbalance sets enemy.unbalanceUntil (a longer running one kept) and ends the attack clip's stand. ai.js updateEnemy: no walking (恐惧 / 诱导 too — PR #392's fear exemption is not taken), no new normal attack, a swing short of its frame cut, 浮空 clears it; a 重生 clears it (archetypes.js reborn). Not a stun: no status, no immunity, SP and the attack cooldown run. No global skill lock: enemy abilities keep ticking ([ASSUMED] scope). The move stays instant (the unbalanced() talents keyed off the jump unchanged). The displace fx carries `dur` (game s); render/units.js slideTo divides it by ctx.animRate (2× default: a 0.8 s push slides 0.4 real s); DISPLACE_SLIDE·√D is only the fallback for an fx without it. Not taken from #392: UNBALANCE_TRAVEL 0.6387·√tiles / the client's 0.3194 (derived from the page's μ = 0.5 单位假设, one frame high on every push row, the wrong shape for pulls) and the fear exemption. Tests: test/sim/unbalance.test.js (8: the push rows and walking after them; no attack in the hold, a feared enemy held; the −2 correction's 0.2 s; 特效 / wall-shortened rows and the first-step floor; pull windows incl. the 急停 and ≤ −3; 静态刚体 0.1 s without movement, 失衡免疫 never; 浮空 clears; the fx `dur` — the file fails on the parent); test/render/unitview.test.js (+1: the slide at 2× for a push and a pull, the fallback — fails on the parent); feedback1b-displacement B5 (the drone is now held 0.1 s per force, never moved). test/sim + test/content + test/render: 3433 pass. Docs: SIM.md (push / pull, displace, assumptions), PLAYING.md 推开 / 拉拽, research 13 §0 / §5. Golden — every moved battle has pushes / pulls (probe of the callers) and its enemies are now held; outcome moves: roster-002 (野鬃: killed 29→33, leaks 15→11), roster-003 (野鬃: deaths 3→7), roster-007 (薄绿: killed 110→118, leaks 53→47), roster-008 (薄绿: 45→47 / 42→40), roster-010 (歌蕾蒂娅: killed 45→44, leaks 57→58), roster-011 (莫斯提马: 78→79 / 4→3), roster-031 (钩索师 pulls, tier4: killed 64→72, leaks 12→4, deaths 6→5), roster-041 (钩索师: deaths 5→4), roster-043 (山: deaths 15→14), roster-044 (山: killed 107→103, leaks 12→16, deaths 15→14); bond-deputShip-low (薄绿: 18→21 killed, leaks 3→0, deaths 1→0), bond-kazimierzShip-high (野鬃: deaths 3→2); boss-boss_3-pair (锏: p2 deaths 3→2), boss-boss_4-pair (莫斯提马 / 歌蕾蒂娅: killed 11→12, boss HP 7442429→7375628, p2 leaks 2→1, deaths 19→18), boss-boss_6-pair (boss HP 6932482→6865243, p2 deaths 19→17), hidden-boss_8-pair (boss HP 12585770→12585798), hidden-boss_9-pair (锏: boss HP 3630612→3636991); standin-05 / 08 / 10 (预备干员-特种 pulls: killed 5→8 / 42→44 / 28→29, leaks down by 3 / 2 / 1); diy-101 (傀影: killed 22→21, leaks 92→93), diy-114 / 115 / 116 (黑键: 106→99 & 83→90 / 154→156 & 78→70 / 3→2 & 120→121), diy-137 (预备干员-特种: 68→79 / 79→68); matches through those battles — coop2-HARD-4, coop2-NORMAL-14-standins, coop2-NORMAL-3, coop2-NORMAL-70-diy, coop3-ABYSS-5, coop4-ABYSS-7, solo-ABYSS-2 (LP 2→4 at round 10), solo-HARD-2 (ai_0 now survives round 13: eliminated 13 → alive), solo-HARD-9-boosted (LP 399→398), solo-NORMAL-1. Other moves (damage, heals, snapshots only): roster-009/012/017/019/032/039/040/042/046, 13 bonds, boss-boss_2-pair, hidden-boss_10-pair, standin-03, diy-053/084/085/102/103/108/109, coop2-ABYSS-10-boosted, solo-ABYSS-1.
217 lines
12 KiB
JavaScript
217 lines
12 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.unbalanceUntil = -Infinity; // 失衡 (UNBALANCE) after a push / pull (battle/displacement.js _unbalance, ai.js)
|
|
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;
|
|
}
|
|
}
|