211 lines
13 KiB
JavaScript
211 lines
13 KiB
JavaScript
// server/sim/battle/tiles.js — Battle methods: field tiles: obstacles, relocation and 【移动】, knocked-out operators'
|
||
// bodies (isDown, downOn, restTile, _layBody) and the tiles no automatic placement may take, tactical points and the
|
||
// enemies' ground paths.
|
||
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
|
||
|
||
import { ROWS, COLS, DOWN_STATE } from '../constants.js';
|
||
import { localOrder, localBefore } from '../dir.js';
|
||
|
||
export class BattleTiles {
|
||
/** Toggle a ground obstacle (enemies re-read their flow field). `kind` 'block' (default, impassable) | 'crate'. */
|
||
setObstacle(r, c, on, kind = 'block') { this.grid.setObstacle(r, c, on, kind); }
|
||
|
||
/** Move an ally to another tile (keeps state); never onto a living unit or a knocked-out operator (downOn). */
|
||
relocate(unit, r, c) {
|
||
// only a living, deployed ally moves, and only onto an integer tile of this field (a dead unit left in _occ
|
||
// would later "block" from a tile it no longer stands on once it redeploys at home)
|
||
if (!unit || unit.side !== 'ally' || !unit.alive || !unit.deployed) return false;
|
||
if (!Number.isInteger(r) || !Number.isInteger(c) || !this.grid.inRect(r, c)) return false;
|
||
const k = r * COLS + c;
|
||
if (this._occ[k] && this._occ[k] !== unit && this._occ[k].alive) return false;
|
||
if (this.downOn(r, c)) return false;
|
||
const ok = unit.tileR * COLS + unit.tileC;
|
||
if (this._occ[ok] === unit) this._occ[ok] = null;
|
||
this.releaseBlocked(unit);
|
||
if (unit.obstacle) this.grid.setObstacle(unit.tileR, unit.tileC, false, unit.obstacleKind);
|
||
unit.tileR = r; unit.tileC = c; unit.x = c; unit.y = r;
|
||
unit.ground = this.grid.isLow(r, c) && !this._elevated?.has(k);
|
||
this._occ[k] = unit;
|
||
if (unit.obstacle) this.grid.setObstacle(r, c, true, unit.obstacleKind);
|
||
this._refreshRange(unit); // current + base range, CUSTOM_RANGE trigger keys
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 【移动】 (PRTS 术语释义 移动: "不退场,以当前血量在目标位置部署 … 本质上为一次特殊的撤退-再部署行为"): `unit` leaves its
|
||
* tile (relocate: same checks, false = refused, nothing changed) and is deployed on (r, c) at once. A new deployment —
|
||
* deploySeq / aggroSeq / deployedAt (the deploy animation; once-per-deployment effects re-arm) — announced by
|
||
* `deploy` { initial: false, move: true }: deploy effects fire again ("可以通过移动行为多次触发部署时触发的效果"). Not an
|
||
* exit: no `die` / `death`, so no knock-out, no redeploy timer or cost ("因移动撤退时不会积累再部署惩罚") — 不屈 and
|
||
* 阿戈尔's revive, which act on exits "因移动之外的原因" (PRTS 阿戈尔 备注), never see it. HP, buffs and a running skill
|
||
* stay: officially nothing carries over but what the mover's skill names (乌尔比安 S3: 技能进度 and the second talent's
|
||
* stacks); the remake keeps the rest too (owner's deviation, DESIGN §22.3). `clearSp`: the 技力 is emptied before the
|
||
* deploy handlers run ("部署时将清空技力"; 乌尔比安's 【返回】 "将清空技力,但仍可以享受后续由其他效果提供的技力").
|
||
*/
|
||
moveRedeploy(unit, r, c, { clearSp = false } = {}) {
|
||
if (!this.relocate(unit, r, c)) return false;
|
||
unit.deploySeq = ++this._deploySeq;
|
||
unit.aggroSeq = unit.deploySeq;
|
||
unit.deployedAt = this.time;
|
||
const sk = unit.skill;
|
||
if (clearSp && sk && !sk.noSkill && sk.kind !== 'passive' && !(sk.active && sk.isTimed)) {
|
||
sk.sp = 0;
|
||
sk.charges = sk.spCost <= 0 ? sk.maxCharges : 0; // (a free skill is available once per deployment: skills.js reset)
|
||
}
|
||
this._ev(['deploy', unit.id]);
|
||
if (this._hooks.deploy) this.emit('deploy', { unit, initial: false, move: true });
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* A tile no automatic placement may take (the 突袭 landing tile, tactical points, summon / device tiles): a living
|
||
* unit stands on it, or it is the rest tile (restTile) of an ally piece that has not deployed yet or waits to
|
||
* redeploy — the tile a knocked-out operator lies on ("倒地干员所在地块…所有我方单位在此处的部署行为将被阻止", PRTS
|
||
* 卫戍协议/帮助 §作战阶段 单位部署), else its home tile (a token parked there would keep that unit off the field).
|
||
*/
|
||
isReservedTile(r, c) {
|
||
if (!Number.isInteger(r) || !Number.isInteger(c) || r < 0 || r >= ROWS || c < 0 || c >= COLS) return true;
|
||
const u = this._occ[r * COLS + c];
|
||
if (u && u.alive) return true;
|
||
for (const a of this.allyUnits) {
|
||
if (a.alive || a.removed || (a.kind !== 'op' && a.kind !== 'token')) continue;
|
||
const [tr, tc] = this.restTile(a);
|
||
if (tr === r && tc === c) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* The knocked-out operator (isDown) lying on (r, c), other than `except`, or null. Official (PRTS 卫戍协议/帮助 §作战阶段
|
||
* 单位部署): "干员退场后…原地留下一个“倒地干员”…满足再部署条件时,移除场上的该倒地干员并自动部署至该位置" and "倒地干员所在
|
||
* 地块视为可部署,但所有我方单位在此处的部署行为将被阻止" — its tile (`body`, _layBody) takes no other ally: `_deploy`
|
||
* (initial deploys, redeploys, the 突袭 landing, summons), `spawnDevice` and `relocate` refuse it.
|
||
*/
|
||
downOn(r, c, except = null) {
|
||
for (const a of this.allyUnits) {
|
||
if (a === except || !this.isDown(a)) continue;
|
||
const [br, bc] = a.body ?? [a.tileR, a.tileC];
|
||
if (br === r && bc === c) return a;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* The tile a withdrawn ally comes back on (redeploy, _checkRedeploys): a down operator's body tile (isDown) — where it
|
||
* fell, or its home (_layBody) — else its home tile.
|
||
*/
|
||
restTile(u) {
|
||
return this.isDown(u) ? (u.body ? [u.body[0], u.body[1]] : [u.tileR, u.tileC]) : [u.homeR, u.homeC];
|
||
}
|
||
|
||
/**
|
||
* Where a knocked-out operator lies (it redeploys there): the tile it fell on, except — "若干员被击倒的位置为其他干员或
|
||
* 召唤物的初始位置,则在被击倒后,尝试返回其自身的初始位置" (PRTS 卫戍协议/帮助) — a tile that is another board piece's
|
||
* home (a piece with a board uid: the 初始位置 is the prep placement — whether that piece is on the field, waiting or
|
||
* gone: a summon only leaves its home free once it has expired or been killed, so a removed one must count too), where
|
||
* it goes back to its own home tile when that is in the rect and free (isReservedTile: no living unit, no other body,
|
||
* no other waiting piece's tile). It stays where it fell when its home is taken [ASSUMED: one attempt, at the
|
||
* knock-out]. Only an operator moved off its board tile — a 突袭 jump, 乌尔比安's anchor — can fall elsewhere. Sets
|
||
* `u.body` (b.snap `down` carries it); x / y / tileR / tileC keep where it fell, so the `kill` / `death` handlers
|
||
* (被击倒时 effects) still act there.
|
||
*/
|
||
_layBody(u) {
|
||
const r = u.tileR, c = u.tileC, hr = u.homeR, hc = u.homeC;
|
||
u.body = [r, c];
|
||
if (r === hr && c === hc) return;
|
||
if (!this.allyUnits.some((a) => a !== u && a.uid != null && (a.kind === 'op' || a.kind === 'token') && a.homeR === r && a.homeC === c)) return;
|
||
if (!this.grid.inRect(hr, hc) || this.isReservedTile(hr, hc)) return;
|
||
u.body = [hr, hc];
|
||
}
|
||
|
||
/**
|
||
* Tactical point (战术点) for tactician reinforcements: a free (isReservedTile) walkable ground tile of the unit's
|
||
* initial range (baseRangeKeys), on an enemy ground path first (groundPathTiles — where a player would put the
|
||
* blocker), then nearest to the unit (Chebyshev; the smaller sideways offset — in the unit's facing-RIGHT frame, so
|
||
* its forward line — breaks ties), then the lowest local (row, col) offset (sim/dir.js localOrder: for a RIGHT-facing
|
||
* unit exactly the lowest tile key). The choice turns with the unit's direction. null when none.
|
||
*/
|
||
findTacticalPoint(unit) {
|
||
if (!unit) return null;
|
||
const onPath = this.groundPathTiles();
|
||
let best = null, bp = 2, bd = Infinity, bo = null;
|
||
for (const k of unit.baseRangeKeys || unit.rangeKeys || []) {
|
||
const r = (k / COLS) | 0, c = k % COLS;
|
||
if (!this.grid.groundPassable(r, c) || !this.grid.canStand(r, c)) continue;
|
||
if (this.isReservedTile(r, c)) continue;
|
||
const o = localOrder(r - unit.tileR, c - unit.tileC, unit.dir);
|
||
const d = Math.max(Math.abs(r - unit.tileR), Math.abs(c - unit.tileC)) + 0.01 * Math.abs(o[0]);
|
||
if (!(d > 0)) continue;
|
||
const p = onPath.has(k) ? 0 : 1;
|
||
if (p < bp || (p === bp && (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && localBefore(o, bo))))) { bp = p; bd = d; bo = o; best = [r, c]; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/**
|
||
* Ground tiles the enemies of this field walk on: the flow-field routes (grid.findPath, live obstacles) of the
|
||
* non-FLY routes this battle's wave spawns on (every non-FLY route when no queued spawn names one — e.g. content-only
|
||
* battles), leg by leg (start → MOVE/APPEAR checkpoints → end, clamped to the rect). R1–R3 spawn at the lower gate
|
||
* only (research 08 §4.1), so the unused upper lane does not attract tactical points. Set of tile keys, cached per
|
||
* grid version.
|
||
*/
|
||
groundPathTiles() {
|
||
const ver = this.grid.version ?? 0;
|
||
const used = (this._pathRoutesUsed ??= new Set());
|
||
const before = used.size;
|
||
for (const p of this._pending || []) if (!p.route && Number.isInteger(p.routeIndex)) used.add(p.routeIndex);
|
||
if (this._pathTiles && this._pathTilesVer === ver && used.size === before) return this._pathTiles;
|
||
const set = new Set();
|
||
const R = this.rect;
|
||
const clampPt = (p) => [Math.max(R.r0, Math.min(R.r1, Math.round(p[0]))), Math.max(R.c0, Math.min(R.c1, Math.round(p[1])))];
|
||
const ok = (p) => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]);
|
||
const walk = (route) => route && route.motion !== 'FLY' && ok(route.start);
|
||
const routes = this.routes || [];
|
||
const any = [...used].some((i) => walk(routes[i]));
|
||
routes.forEach((route, i) => {
|
||
if (!walk(route) || (any && !used.has(i))) return;
|
||
const pts = [clampPt(route.start)];
|
||
for (const cp of route.checkpoints || []) if ((cp.type === 'MOVE' || cp.type === 'APPEAR') && ok(cp.pos)) pts.push(clampPt(cp.pos));
|
||
if (ok(route.end)) pts.push(clampPt(route.end));
|
||
for (let j = 1; j < pts.length; j++) {
|
||
const [a, b] = [pts[j - 1], pts[j]];
|
||
const p = this.grid.findPath(a[0], a[1], b[0], b[1]) || this.grid.findPath(a[0], a[1], b[0], b[1], { ignoreObstacles: true });
|
||
for (const [r, c] of p || []) set.add(r * COLS + c);
|
||
}
|
||
});
|
||
this._pathTiles = set;
|
||
this._pathTilesVer = ver;
|
||
return set;
|
||
}
|
||
|
||
/**
|
||
* An operator lying on the field, waiting to redeploy on that tile (DESIGN §5.5: after its respawn time, when the tile
|
||
* is free and DP ≥ cost; _layBody, downOn). PRTS 卫戍协议/帮助 §作战阶段 单位部署: "干员退场后,将返回隐藏的待部署区,并原地
|
||
* 留下一个“倒地干员”以供查看信息,满足再部署条件时,移除场上的该倒地干员并自动部署至该位置。" — every 退场 (GitHub #60, the owner's
|
||
* decision of 2026-10-04): knocked out ('killed'), entering the battle knocked out (FORCED_EXIT, 联防) and forced out
|
||
* by its own effects ('retreat': 史尔特尔's 余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗, 伊内丝 S3; 'merchant': a 商人 that cannot
|
||
* pay) — except the 突袭 retreat ('raid', redeployed at once on its landing tile); not removed for good, after it was
|
||
* deployed. A forced exit stays no kill: its 'die' / `death` reason is not 'killed' (no 被击倒 effect, 不屈, 阿戈尔, no
|
||
* knock-down count). The client keeps its model on that tile knocked down with a redeploy countdown (b.snap `down`,
|
||
* render/units.js); summons, devices and enemies simply leave.
|
||
*/
|
||
isDown(u) {
|
||
return !!u && u.side === 'ally' && u.kind === 'op' && !u.alive && !u.removed && u.removeReason !== 'raid'
|
||
&& u.deploySeq > 0 && Number.isFinite(u.respawnAt);
|
||
}
|
||
|
||
/**
|
||
* constants.js DOWN_STATE of a down operator: its timer runs, or it waits for its tile / the DP — the same checks as
|
||
* _checkRedeploys (a living unit or another body on its rest tile). No ally may take the tile it lies on (downOn), so
|
||
* WAIT_TILE is a safeguard only.
|
||
*/
|
||
_downState(u) {
|
||
if (this.time + 1e-9 < u.respawnAt) return DOWN_STATE.COUNTING;
|
||
const [r, c] = this.restTile(u);
|
||
const occ = this._occ[r * COLS + c];
|
||
if ((occ && occ.alive && occ !== u) || this.downOn(r, c, u)) return DOWN_STATE.WAIT_TILE;
|
||
const ps = this.getPlayer(u.ownerId);
|
||
return !ps || ps.dp + 1e-9 < u.base.cost ? DOWN_STATE.WAIT_DP : DOWN_STATE.COUNTING;
|
||
}
|
||
}
|