Files
ark-stronghold-protocol/server/sim/battle/tiles.js
T
sganggs 8b7316d93e feedback5 WL: Touch follows PRTS — S3's extra heal is 30 % of the base with its own recipient's boost, 「低于」 half HP, 攫升 on every heal she outputs, 超脱 on a dying forced exit
The shared code of the 外勤医疗 strategy's Touch and the Touch 补位 stand-in (content/tokens.js touchGospel /
mapCharTalents), against PRTS Touch(卫戍协议) and its 备注 / 修正:
- S3 恳切福音's extra heal: 30 % of the main heal's base (before the ×1.35 / ×1.40), a heal of its own — its recipient
  below half HP gets the boost (and the elite's PHY-X), 备注 「额外治疗可触发技能后半段的治疗量提高效果」; it goes to the
  lowest HP ratio of the main target and its 4 orthogonal neighbours, a full-HP unit included (x-5,
  「可治疗生命值已满单位」). The touchExtra / touchMainHeal block is gone.
- The boost needs HP strictly below half (修正 「低于」, was 「不高于」).
- 攫升: every heal she outputs gives SP — a heal on herself and one that restores nothing too (备注 「无需实际产生治疗量」);
  still no device, no unit without a skill, and not her own HP-regeneration tick.
- 超脱: a forced exit that plays the death animation counts like a knock-out (备注 「如史尔特尔的天赋效果」). Battle.retreat
  takes `dying` and the death hook carries it: 史尔特尔's 余烬 and 骑士戒律 + 竞技旗 set it; planned exits (耀骑士临光 S2,
  伊内丝 S3), the 商人's automatic 撤退 and the 突袭 jump do not. A battle has no player 撤退 command.

Golden moved: roster-041 (the strategy's Touch heals differently: S3 extra heal / boost), standin-03, standin-04,
standin-08, standin-09, standin-10 (Touch standing in for 塞雷娅 / 塑心 / 纯烬艾雅法拉), coop2-NORMAL-14-standins (the
human seat plays 外勤医疗, so its Touch takes part). Docs: SIM.md (death hook `dying`, retreat option, the forced-exit
paragraph).
2026-10-05 20:47:06 +08:00

212 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 — but 不屈 rolls on it, and Touch's 超脱 counts a `dying` one: 史尔特尔's 余烬, 骑士戒律 + 竞技旗). 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;
}
}