Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price
- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them; PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air - #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included - #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed - #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the blocker dies, withdraws or is stunned; melee blockers attack what they block - #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右, operators before summons; special priorities break ties by taunt then deploy order - #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views - #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages audited against the official level files - #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones - #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable - #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects - DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and every browser suite green; dev recordings regenerated
This commit is contained in:
1 parent
d4f868cd19
commit
15291c2293
104 files changed
+5539
-662
No files matched your search
+125
-32
@@ -5,8 +5,10 @@
|
||||
// b.step(); b.finished; b.forceEnd(reason); b.time; b.result(); b.snapshot(); b.drainEvents(); b.on/off(...)
|
||||
//
|
||||
// Construction creates every ally unit (undeployed) and installs content (kits + domain modules). The first
|
||||
// step() (or an explicit start()) deploys everything (top→bottom, left→right; mirrored for the right boss side),
|
||||
// fires `deploy` (initial) for each unit and then `battleStart`.
|
||||
// step() (or an explicit start()) deploys everything (operators top→bottom, left→right — mirrored for the right boss
|
||||
// side —, then the summon pieces the same way; the players of a shared field side by side), fires `deploy` (initial)
|
||||
// for each unit, forces out the operators that enter already knocked out (`carryState.down`, 联防: constants.js
|
||||
// FORCED_EXIT — down on their tile, redeploy timer running) and then fires `battleStart`.
|
||||
// Tick order: scheduled callbacks → spawns → DP → buffs → enemies (attack, move, block) → enemy index →
|
||||
// allies (skill tick, attack) → projectiles → redeploys → boss sync → `tick` hook → release hooks of removed units →
|
||||
// time += TICK → end checks. A forceEnd() requested mid-step ends the step after the current phase (docs/SIM.md §1.4).
|
||||
@@ -18,14 +20,15 @@
|
||||
// Robustness: every content callback and every step phase is wrapped; errors are logged once per key and the
|
||||
// battle continues. After MAX_INTERNAL_ERRORS the battle force-ends as a timeout.
|
||||
|
||||
import { TICK, ROWS, COLS, DP_DEFAULTS, DOWN_STATE, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY } from './constants.js';
|
||||
import { TICK, ROWS, COLS, BLOCK_RADIUS_SQ, DP_DEFAULTS, DOWN_STATE, FORCED_EXIT, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY } from './constants.js';
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
import { createRng } from './rng.js';
|
||||
import { Grid } from './grid.js';
|
||||
import { Unit } from './units.js';
|
||||
import { makeBuff, STATUS, RESIST_STATUSES } from './buffs.js';
|
||||
import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff, elementView } from './damage.js';
|
||||
import { absoluteRangeKeys, canTargetEnemy } from './targeting.js';
|
||||
import { absoluteRangeKeys, canTargetEnemy, meleeUnit } from './targeting.js';
|
||||
import { bodyKeys, bodyInKeys, bodyInRadius } from './body.js';
|
||||
import { normDir, mirrorDir, localOrder, localBefore } from './dir.js';
|
||||
import { ProjectileSystem } from './projectiles.js';
|
||||
import { SkillRuntime } from './skills.js';
|
||||
@@ -319,12 +322,40 @@ export class Battle {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
this._safe(() => this._spawnStageDevices(), 'stageDevices');
|
||||
for (const ps of this.players) {
|
||||
const order = ps.units.filter((u) => u.kind === 'op' || u.kind === 'token').slice().sort((a, b) =>
|
||||
b.homeR - a.homeR || (ps.mirror ? b.homeC - a.homeC : a.homeC - b.homeC) || a.id - b.id);
|
||||
for (const u of order) this._safe(() => this._deploy(u, { initial: true }), 'initialDeploy', u);
|
||||
// PRTS 卫戍协议/帮助: "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物(如果有)" — per player the operators
|
||||
// top row first, left to right within a row (the mirrored right boss side: right to left in field columns,
|
||||
// research 01 §4.3 "嘲諷優先鏡射"), then the summon pieces in the same order. [ASSUMED] On a shared field (联防, boss)
|
||||
// the players' fields deploy at the same time (PRTS: one unit after another with a fixed delay, from the battle
|
||||
// start), so the i-th operators of all players come in together (in `players` order), then the summons likewise
|
||||
const seq0 = this._deploySeq;
|
||||
const lists = this.players.map((ps) => ps.units.filter((u) => u.kind === 'op' || u.kind === 'token').slice().sort((a, b) =>
|
||||
b.homeR - a.homeR || (ps.mirror ? b.homeC - a.homeC : a.homeC - b.homeC) || a.id - b.id));
|
||||
for (const kind of ['op', 'token']) {
|
||||
const per = lists.map((l) => l.filter((u) => u.kind === kind));
|
||||
const n = Math.max(0, ...per.map((l) => l.length));
|
||||
for (let i = 0; i < n; i++) for (const l of per) if (l[i]) this._safe(() => this._deploy(l[i], { initial: true }), 'initialDeploy', l[i]);
|
||||
}
|
||||
// 仇恨 (targeting.js sortAllyTargets: the later deployed is attacked first): every summon that came in during the
|
||||
// initial deployment — also one an operator's deploy brought along (a tactician's 援军, a start-of-battle summon)
|
||||
// — ranks after all the operators (of every player on the field [ASSUMED]), in the order it came
|
||||
const summons = this.allyUnits.filter((u) => u.kind === 'token' && u.alive && u.deployed && u.deploySeq > seq0).sort((a, b) => a.deploySeq - b.deploySeq);
|
||||
for (const t of summons) t.aggroSeq = ++this._deploySeq;
|
||||
// 联防 (PRTS 卫戍协议/帮助 "部署完成后…上一阶段为退场状态的干员强制退场"): an operator knocked out at the end of its
|
||||
// own combat is withdrawn right after the deployment — down on its tile, its redeploy timer starting now (re-read
|
||||
// after battleStart below; DESIGN §5.5). Its HP ratio is the end-of-phase one (0, as after kill()) until it
|
||||
// redeploys at full HP.
|
||||
for (const u of this.allyUnits) {
|
||||
if (u.kind === 'op' && u.alive && u.carry && u.carry.down === true) {
|
||||
this._safe(() => { u.hp = 0; this.retreat(u, { reason: FORCED_EXIT }); }, 'forcedExit', u);
|
||||
}
|
||||
}
|
||||
this.emit('battleStart', {});
|
||||
// redeploy-time effects that start with the battle (机变 征召 "所有干员的再部署时间-50%", added by a battleStart
|
||||
// handler) cover the operators forced out above too: their timer is re-read with them, as a later knock-out's is
|
||||
for (const u of this.allyUnits) {
|
||||
if (u.kind !== 'op' || u.alive || u.removed || u.removeReason !== FORCED_EXIT) continue;
|
||||
u.respawnAt = u.deathAt + Math.max(0, u.base.respawnTime * u.persist.redeployMul * u.s.redeployMul);
|
||||
}
|
||||
}
|
||||
|
||||
step() {
|
||||
@@ -741,6 +772,7 @@ export class Battle {
|
||||
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;
|
||||
@@ -804,6 +836,7 @@ export class Battle {
|
||||
u.x = C0; u.y = R0; u.tileR = R0; u.tileC = C0;
|
||||
u.blocking = [];
|
||||
u.deploySeq = ++this._deploySeq;
|
||||
u.aggroSeq = u.deploySeq;
|
||||
u.deployedAt = this.time;
|
||||
u.atkCd = 0;
|
||||
u.lastAttackAt = -Infinity;
|
||||
@@ -1001,28 +1034,66 @@ export class Battle {
|
||||
// =============================================================================================================
|
||||
// blocking
|
||||
|
||||
/** Try to block enemy `e` at its current tile. Returns true if it became blocked. */
|
||||
/**
|
||||
* Try to block enemy `e` where it stands. Returns true if it is (now) blocked. Official contact rule (constants.js
|
||||
* BLOCK_RADIUS, PRTS 游戏数据基础 §阻挡半径): the enemy's position within the blocker's block radius of the blocker's
|
||||
* centre (ground 0.7071 — the tile's circumscribed circle, reaching 0.21 tile into the side neighbours; air 0.8944;
|
||||
* devices 0.4472), and the blocker has free capacity for the enemy's block weight. Checked every tick for every
|
||||
* unblocked enemy, moving or not, so an enemy overlapping an operator is taken over as soon as its blocker is gone or
|
||||
* the operator's capacity frees up (user playtest #5 item 4). Several blockers in contact → the nearest [ASSUMED],
|
||||
* ties → the first in row-then-column scan order.
|
||||
*/
|
||||
_checkBlock(e) {
|
||||
if (e.blockedBy || e.hidden || !e.alive) return !!e.blockedBy;
|
||||
const f = e.s.flags;
|
||||
if (f.unblockable || f.levitate || f.fear) return false;
|
||||
const r = Math.round(e.y), c = Math.round(e.x);
|
||||
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return false;
|
||||
const u = this._occ[r * COLS + c];
|
||||
if (!u || !u.alive || !u.deployed || u.hidden || u.s.flags.noBlock || u.s.flags.sleep) return false;
|
||||
const r0 = Math.round(e.y), c0 = Math.round(e.x);
|
||||
const w = e.blockWeight ?? 1;
|
||||
let u = null, bd = Infinity;
|
||||
const fly = e.isFlying;
|
||||
for (let r = r0 - 1; r <= r0 + 1; r++) {
|
||||
if (r < 0 || r >= ROWS) continue;
|
||||
for (let c = c0 - 1; c <= c0 + 1; c++) {
|
||||
// a diagonal neighbour's centre is ≥ √0.5 away: out of reach of the ground (and device) radius
|
||||
if (c < 0 || c >= COLS || (!fly && r !== r0 && c !== c0)) continue;
|
||||
const o = this._occ[r * COLS + c];
|
||||
if (!o || !this._blockerFor(o, e, w)) continue;
|
||||
const d2 = (e.x - c) * (e.x - c) + (e.y - r) * (e.y - r);
|
||||
const r2 = o.kind === 'device' ? BLOCK_RADIUS_SQ.device : fly ? BLOCK_RADIUS_SQ.fly : BLOCK_RADIUS_SQ.ground;
|
||||
if (d2 < r2 && d2 < bd) { u = o; bd = d2; }
|
||||
}
|
||||
}
|
||||
if (!u) return false;
|
||||
e.blockedBy = u;
|
||||
u.blocking.push(e);
|
||||
e.moving = false;
|
||||
if (this._hooks.blocked) this.emit('blocked', { blocker: u, enemy: e });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The enemies ally `u` blocks that it may target with `profile` — always selectable by a melee blocker, inside its
|
||||
* range or not ("可以选择且优先选择阻挡单位", PRTS 选择器; ai.js acquireTargets, the skills' DEFAULT trigger). A ranged
|
||||
* operator on a melee tile gets none: it attacks what its range holds (targeting.js meleeUnit, PRTS 索敌的概念
|
||||
* "阻挡(近战限定)").
|
||||
*/
|
||||
blockedTargets(u, profile) {
|
||||
const out = [];
|
||||
if (!u || !u.blocking || !u.blocking.length || !meleeUnit(u)) return out;
|
||||
for (const e of u.blocking) if (e.blockedBy === u && canTargetEnemy(u, e, profile)) out.push(e);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Can ally/device `u` block enemy `e` (block weight `w`) right now — everything but the contact distance. */
|
||||
_blockerFor(u, e, w) {
|
||||
if (!u.alive || !u.deployed || u.hidden || u.s.flags.noBlock || u.s.flags.sleep) return false;
|
||||
if (e.isFlying && !(u.s.flags.blockFly || (u.profile && u.profile.blockFly))) return false;
|
||||
if (!e.isFlying && !u.ground) return false;
|
||||
const cap = u.s.blockCnt;
|
||||
if (cap <= 0) return false;
|
||||
let used = 0;
|
||||
for (const x of u.blocking) used += x.blockWeight ?? 1;
|
||||
const w = e.blockWeight ?? 1;
|
||||
if (used + w > cap) return false;
|
||||
e.blockedBy = u;
|
||||
u.blocking.push(e);
|
||||
e.moving = false;
|
||||
if (this._hooks.blocked) this.emit('blocked', { blocker: u, enemy: e });
|
||||
return true;
|
||||
return used + w <= cap;
|
||||
}
|
||||
|
||||
_unblock(e) {
|
||||
@@ -1182,6 +1253,10 @@ export class Battle {
|
||||
if (!(duration > 0)) return false;
|
||||
const immune = target.def && target.def.immune;
|
||||
if (!opts.force && tpl.immune && immune && immune.has(tpl.immune)) return false;
|
||||
// 浮空 Buff (PRTS 异常效果: "若单位数据上为飞行单位且不持有缚地异常或是持有浮空异常则Buff取消"; 行动方式 "行动类型(数据)为
|
||||
// 飞行的单位、以及已持有浮空异常的单位无法被施加浮空Buff"): refused on data flyers (`motion` FLY) and units already
|
||||
// levitated — a hovering 近地悬浮 enemy is WALK in its data, so it can be levitated (no 缚地 / 浮空强化 in this mode)
|
||||
if (key === 'levitate' && (target.motion === 'FLY' || target.s.flags.levitate)) return false;
|
||||
if (this._hooks.beforeStatus) {
|
||||
const c = this.emit('beforeStatus', { source: opts.source ?? null, target, status: key, duration, value, cancel: false });
|
||||
if (c.cancel || !target.alive) return false;
|
||||
@@ -1291,13 +1366,14 @@ export class Battle {
|
||||
/**
|
||||
* HP loss that ignores DEF/RES, dodge and shields (流失). May kill. opts: { source, silent, tags, from } — `from` = the
|
||||
* DamageInfo this loss derives from (damage passed on to a leader, split, shared…): its tags are inherited and it is
|
||||
* kept as `dmg.origin`, so `damaged` handlers that skip their own tagged damage also skip what it turned into.
|
||||
* kept as `dmg.origin`, so `damaged` handlers that skip their own tagged damage also skip what it turned into; a loss
|
||||
* derived from 无来源 damage (`from.sourceless`, element bursts) is 无来源 too (hooks see no source, `source` is credited).
|
||||
*/
|
||||
loseHp(target, amount, { source = null, silent = false, tags = null, from = null } = {}) {
|
||||
if (!target || !target.alive || !(amount > 0)) return 0;
|
||||
const t = ['hpLoss'];
|
||||
for (const list of [from && from.tags, tags]) if (Array.isArray(list)) for (const x of list) if (!t.includes(x)) t.push(x);
|
||||
return applyHpLoss(this, source, target, amount, { type: 'true', tags: t, noSp: true, silent, origin: from ?? null });
|
||||
return applyHpLoss(this, source, target, amount, { type: 'true', tags: t, noSp: true, silent, origin: from ?? null, sourceless: !!(from && from.sourceless) });
|
||||
}
|
||||
|
||||
reduceElement(target, amount, element = null) { return reduceElement(target, amount, element); }
|
||||
@@ -1307,11 +1383,19 @@ export class Battle {
|
||||
// =============================================================================================================
|
||||
// queries
|
||||
|
||||
/**
|
||||
* Tile buckets of the living enemies: a regular enemy on the tile of its position, a huge one (body.js) on every tile
|
||||
* it occupies.
|
||||
*/
|
||||
_buildEnemyIndex() {
|
||||
for (const k of this._ebUsed) this._eb[k].length = 0;
|
||||
this._ebUsed.length = 0;
|
||||
for (const e of this.enemies) {
|
||||
if (!e.alive || e.hidden) continue;
|
||||
if (e.hitArea) {
|
||||
for (const k of bodyKeys(e)) { const b = this._eb[k]; if (!b.length) this._ebUsed.push(k); b.push(e); }
|
||||
continue;
|
||||
}
|
||||
const r = Math.round(e.y), c = Math.round(e.x);
|
||||
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) continue;
|
||||
const k = r * COLS + c;
|
||||
@@ -1321,14 +1405,14 @@ export class Battle {
|
||||
}
|
||||
}
|
||||
|
||||
/** Targetable enemies standing on any of `keys` (absolute tile keys). */
|
||||
/** Targetable enemies whose body is on any of `keys` (absolute tile keys; a huge enemy is listed once). */
|
||||
enemiesInKeys(keys, attacker, profile) {
|
||||
const out = [];
|
||||
if (!keys) return out;
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const b = this._eb[keys[i]];
|
||||
if (!b || !b.length) continue;
|
||||
for (const e of b) if (e.alive && canTargetEnemy(attacker, e, profile)) out.push(e);
|
||||
for (const e of b) if (e.alive && (!e.hitArea || !out.includes(e)) && canTargetEnemy(attacker, e, profile)) out.push(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1364,7 +1448,7 @@ export class Battle {
|
||||
const list = want === 'enemy' ? this.enemies : this.allyUnits;
|
||||
for (const x of list) {
|
||||
if (!x.alive || !x.deployed || x.hidden) continue;
|
||||
if (set.has(Math.round(x.y) * COLS + Math.round(x.x))) out.push(x);
|
||||
if (bodyInKeys(x, set)) out.push(x);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1375,11 +1459,17 @@ export class Battle {
|
||||
return this.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC));
|
||||
}
|
||||
|
||||
enemiesInRadius(x, y, r) {
|
||||
/**
|
||||
* Living enemies whose body (body.js: a huge enemy's hit rectangle, else its position) is within `r` of (x, y).
|
||||
* `centre` = a 中点判定 radius — splash around a struck / marked target (PRTS 作战机制: "中点判定…案例:阻挡,酒神1天赋的1.3
|
||||
* 溅射半径"): every enemy counts by its position (判定中心), a huge one too.
|
||||
*/
|
||||
enemiesInRadius(x, y, r, centre = false) {
|
||||
const out = [];
|
||||
const r2 = r * r + 1e-9;
|
||||
for (const e of this.enemies) {
|
||||
if (!e.alive || e.hidden) continue;
|
||||
if (e.hitArea && !centre) { if (bodyInRadius(e, x, y, r)) out.push(e); continue; }
|
||||
const dx = e.x - x, dy = e.y - y;
|
||||
if (dx * dx + dy * dy <= r2) out.push(e);
|
||||
}
|
||||
@@ -1558,6 +1648,7 @@ export class Battle {
|
||||
u.markDirty();
|
||||
u.hp = u.s.maxHp;
|
||||
u.deploySeq = ++this._deploySeq;
|
||||
u.aggroSeq = u.deploySeq;
|
||||
u.deployedAt = this.time;
|
||||
u.rangeKeys = [];
|
||||
this._occ[row * COLS + col] = u;
|
||||
@@ -1618,9 +1709,11 @@ export class Battle {
|
||||
/**
|
||||
* Displace an enemy (hook/push). dir = {x,y} (normalised internally), distance in tiles. Heavier enemies move
|
||||
* less: effective = distance × clamp(1 − 0.25·(massLevel − force), 0, 1) (current massLevel: 失重 counts).
|
||||
* 失衡免疫 (flag `noDisplace`: 近地悬浮, 浮空 — PRTS 异常效果 "不会被位移影响") ⇒ no movement. The tiles it may cross
|
||||
* follow its movement (`motion`): a hovering enemy walks the ground, so it stays on ground-passable tiles.
|
||||
*/
|
||||
displace(e, dir, distance, { force = 1 } = {}) {
|
||||
if (!e || !e.alive || e.side !== 'enemy' || e.isBoss) return 0;
|
||||
if (!e || !e.alive || e.side !== 'enemy' || e.isBoss || e.s.flags.noDisplace) return 0;
|
||||
const len = Math.hypot(dir.x, dir.y);
|
||||
if (!(len > 0)) return 0;
|
||||
const eff = distance * Math.max(0, Math.min(1, 1 - 0.25 * Math.max(0, e.s.massLevel - force)));
|
||||
@@ -1631,7 +1724,7 @@ export class Battle {
|
||||
while (moved + 1e-9 < eff) {
|
||||
const nx = e.x + ux * stepLen, ny = e.y + uy * stepLen;
|
||||
const r = Math.round(ny), c = Math.round(nx);
|
||||
const ok = e.isFlying ? this.grid.inRect(r, c) : this.grid.groundPassable(r, c);
|
||||
const ok = e.motion === 'FLY' ? this.grid.inRect(r, c) : this.grid.groundPassable(r, c);
|
||||
if (!ok) break;
|
||||
e.x = nx; e.y = ny; moved += stepLen;
|
||||
}
|
||||
@@ -1863,12 +1956,12 @@ export class Battle {
|
||||
|
||||
/**
|
||||
* A knocked-out operator waiting to redeploy on its own tile (DESIGN §5.5: after its respawn time, when the tile is
|
||||
* free and DP ≥ cost): killed — not withdrawn, not removed for good — after it was deployed. The client keeps its
|
||||
* model on the field knocked down with a redeploy countdown (b.snap `down`, render/units.js); summons, devices and
|
||||
* enemies simply leave.
|
||||
* free and DP ≥ cost): killed — or entering the battle knocked out (FORCED_EXIT, 联防) — not withdrawn, not removed
|
||||
* for good, after it was deployed. The client keeps its model on the field 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 === 'killed'
|
||||
return !!u && u.side === 'ally' && u.kind === 'op' && !u.alive && !u.removed && (u.removeReason === 'killed' || u.removeReason === FORCED_EXIT)
|
||||
&& u.deploySeq > 0 && Number.isFinite(u.respawnAt);
|
||||
}
|
||||
|
||||
|
||||
+23
-10
@@ -7,11 +7,17 @@
|
||||
// of the leg's target (grid.js, research 08 §3.4): from the tile it stands on the enemy walks straight to the centre
|
||||
// of `next[tile]`, then on to `next[next[tile]]` … (tile centre to tile centre, so the Bresenham line-of-sight check
|
||||
// of the smoothing guarantees it never clips a wall or crate corner); the plan is re-read when the grid version
|
||||
// changes (obstacles) or content displaces the enemy (route.pts = null). FLY legs fly straight between checkpoints. Ground enemies entering a
|
||||
// tile held by an ally with free block capacity get blocked. Blocked enemies fight their blocker (ranged ones may
|
||||
// pick anyone in range, blocker first). Unblocked ranged enemies attack allies within their radius and pause
|
||||
// ATTACK_PAUSE seconds after each attack. Reaching the final leg's end = leak. An `attract` (诱导) status suspends the
|
||||
// route: the enemy walks to the status point instead (moveAttracted) and re-plans its route when released.
|
||||
// changes (obstacles) or content displaces the enemy (route.pts = null). FLY legs fly straight between checkpoints; the
|
||||
// path always follows `motion` — a hovering (近地悬浮) enemy is an air unit for targeting and blocking (Unit.isFlying)
|
||||
// but walks the ground. An unblocked enemy touching an ally with free block capacity — within its block radius (0.7071
|
||||
// ground, 0.8944 air, devices 0.4472; Battle._checkBlock) — is blocked, moving or not, so an enemy overlapping an
|
||||
// operator is taken over once its blocker is gone. Blocked enemies fight their blocker (ranged ones may pick anyone in
|
||||
// range, blocker first); a melee blocker may always target the enemies it blocks, in range or not, a ranged operator on
|
||||
// a melee tile only what its range holds (acquireTargets, targeting.js meleeUnit).
|
||||
// Unblocked ranged enemies attack allies within their radius and pause ATTACK_PAUSE seconds after each attack; the
|
||||
// candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered blocker → taunt → latest deployed
|
||||
// (targeting.js sortAllyTargets). Reaching the final leg's end = leak. An `attract` (诱导) status suspends the route:
|
||||
// the enemy walks to the status point instead (moveAttracted) and re-plans its route when released.
|
||||
|
||||
import { ATTACK_PAUSE, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, BOOMERANG_RETURN_SPEED, COLS } from './constants.js';
|
||||
import { sortEnemyTargets, sortAllyTargets, canTargetEnemy, canTargetAlly, tileKeyOf } from './targeting.js';
|
||||
@@ -113,6 +119,10 @@ export function acquireTargets(b, u, prof) {
|
||||
if (t.length) return t;
|
||||
}
|
||||
const cands = b.enemiesInKeys(u.rangeKeys, u, prof);
|
||||
// "可以选择且优先选择阻挡单位" (PRTS 选择器): the enemies a melee unit blocks are always selectable by it — the block
|
||||
// radius (0.7071) reaches past its own tile, so a blocked enemy may stand outside a short range (user playtest #5 item
|
||||
// 4); a ranged operator on a melee tile gets none (Battle.blockedTargets: "阻挡(近战限定)")
|
||||
if (u.blocking.length) for (const e of b.blockedTargets(u, prof)) if (!cands.includes(e)) cands.push(e);
|
||||
if (!cands.length) return cands;
|
||||
if (prof.allInRange) return cands;
|
||||
const n = Math.max(1, Math.floor((prof.maxTargets || 1) + u.s.maxTargets));
|
||||
@@ -210,11 +220,11 @@ export function resolveHit(b, u, prof, target, info, x, y) {
|
||||
if (each) each(target, dealtMain, 'main');
|
||||
x = target.x; y = target.y;
|
||||
}
|
||||
// splash
|
||||
// splash: a 中点判定 radius around the target's position — a huge enemy counts by its 判定中心 (Battle.enemiesInRadius)
|
||||
if (prof.splashRadius > 0) {
|
||||
const r = prof.splashRadius;
|
||||
const sc = prof.splashScale ?? 1;
|
||||
for (const e of b.enemiesInRadius(x, y, r)) {
|
||||
for (const e of b.enemiesInRadius(x, y, r, true)) {
|
||||
if (e === target) continue;
|
||||
if (prof.groundOnly && e.isFlying) continue;
|
||||
if (!prof.canHitFly && e.isFlying && !prof.splashHitsFly) continue;
|
||||
@@ -484,7 +494,7 @@ function moveAttracted(b, e, dt) {
|
||||
if (!A.pts || A.ver !== b.grid.version) {
|
||||
const sr = Math.round(e.y), sc = Math.round(e.x);
|
||||
let pts = null;
|
||||
if (!e.isFlying) {
|
||||
if (e.motion !== 'FLY') { // (a hovering 近地悬浮 enemy is an air unit but walks the ground: motion decides the path)
|
||||
const path = b.grid.waypoints(sr, sc, A.r, A.c) || b.grid.waypoints(sr, sc, A.r, A.c, { ignoreObstacles: true });
|
||||
if (path) {
|
||||
pts = [];
|
||||
@@ -583,18 +593,21 @@ function enemyAttack(b, e) {
|
||||
// Content may flip it with `e.profile.melee = false`.
|
||||
const melee = e.profile?.melee ?? def.applyWay === 'MELEE';
|
||||
const radius = melee ? 0 : e.base.rangeRadius;
|
||||
// `e.profile.canTarget(ally)`: the enemy's own target rule (只攻击地面单位, 不会攻击飞行单位 …; content/enemies.js),
|
||||
// applied to the candidates before the priority sort and the target count
|
||||
const own = e.profile && typeof e.profile.canTarget === 'function' ? e.profile.canTarget : null;
|
||||
let targets = [];
|
||||
if (e.blockedBy) {
|
||||
const bl = e.blockedBy;
|
||||
if (radius > 0) {
|
||||
targets = b.alliesInRadius(e.x, e.y, radius, null).filter((a) => a === bl || canTargetAlly(e, a, true));
|
||||
if (!targets.includes(bl) && bl.alive) targets.push(bl);
|
||||
sortAllyTargets(e, targets);
|
||||
} else if (bl.alive && bl.deployed) targets = [bl];
|
||||
} else if (radius > 0) {
|
||||
targets = b.alliesInRadius(e.x, e.y, radius, null).filter((a) => canTargetAlly(e, a, true));
|
||||
sortAllyTargets(e, targets);
|
||||
}
|
||||
if (own && targets.length) targets = targets.filter((a) => own(a));
|
||||
if (targets.length > 1) sortAllyTargets(e, targets);
|
||||
if (!targets.length) return;
|
||||
// 麻痹 (ba.palsy): each stack interrupts one normal attack
|
||||
const palsy = e.buffs.length ? e.findBuff('palsy') : null;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// server/sim/body.js — where a unit can be hit: the ONE helper behind every range test that targets enemies (user
|
||||
// playtest #5 item 10: "大体型的关底boss实际受击判定范围很小…原版是很大的一片区域").
|
||||
//
|
||||
// Regular units are a point: in a grid range when the tile containing their position (round(y), round(x)) is a range
|
||||
// tile, in a radius when their position is (DESIGN §3). Huge units (巨型单位 — enemy data `hitArea`, tools/build-data.mjs
|
||||
// HIT_AREAS, from PRTS "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0") are hit anywhere on a rectangle
|
||||
// `w` tiles along the columns × `h` along the rows, centred on their position moved `dx` columns right and `dy` rows
|
||||
// up:
|
||||
// * grid ranges / tile rules: the unit occupies every tile its rectangle overlaps (PRTS 作战机制 "巨型BOSS单位的每一个
|
||||
// 占据的格子都可以让其本身通过格子判定"; an operator's range — 1×1 tile squares — touches the rectangle exactly then):
|
||||
// a 4.95 × 2.95 area centred on a tile covers that 5 × 3 block;
|
||||
// * radius rules (auras, AoE around a point or a unit): the distance from the point to the rectangle (0 inside it)
|
||||
// [ASSUMED: PRTS 作战机制 calls collider tests "the most common"];
|
||||
// * splash around a struck / marked target is a 中点判定 (PRTS 作战机制: "中点判定…案例:阻挡,酒神1天赋的1.3溅射半径"):
|
||||
// every enemy, a huge one too, counts by its position (Battle.enemiesInRadius(x, y, r, true) — the engine's
|
||||
// profession splash and aftershocks, and the kits' splash centred on an enemy).
|
||||
// The unit's own position stays its 判定中心: projectiles fly to it, its own attacks and radii measure from it, and it
|
||||
// is the centre of splash on it (PRTS 分支特性信息: 投掷物会抵达目标的判定中心…巨型单位的模型中心具有一定偏移). Body size
|
||||
// plays no part in blocking or movement — every huge unit here is 自缚 + 无法被阻挡 (PRTS 天赋; content/bosses.js
|
||||
// SELF_BOUND: persistent noMove + unblockable from spawn). Also by position: movement, pathing and terrain on the
|
||||
// enemy's tile, tile-entry tracking (雪境 tiles), placement heuristics and distance-scaled numbers, and the range keys
|
||||
// taken from blocked enemies (a huge unit is never blocked).
|
||||
// Pure functions of (x, y, hitArea); no battle state — also used for the client's picking (render/pick.js mirrors
|
||||
// `hitRect`).
|
||||
|
||||
import { ROWS, COLS } from './constants.js';
|
||||
|
||||
/**
|
||||
* Validated hit area of a data record (`{ w, h, dx, dy }`, tiles) or null for a regular (point) unit.
|
||||
* @param {any} a
|
||||
*/
|
||||
export function normHitArea(a) {
|
||||
if (!a || typeof a !== 'object') return null;
|
||||
const w = Number(a.w), h = Number(a.h), dx = Number(a.dx ?? 0), dy = Number(a.dy ?? 0);
|
||||
if (!(w > 0) || !(h > 0) || !Number.isFinite(w) || !Number.isFinite(h) || !Number.isFinite(dx) || !Number.isFinite(dy)) return null;
|
||||
return Object.freeze({ w, h, dx, dy });
|
||||
}
|
||||
|
||||
/** The world rectangle `{ x0, x1, y0, y1 }` of a huge unit, or null for a point unit (no `hitArea`). */
|
||||
export function hitRect(u) {
|
||||
const a = u && u.hitArea;
|
||||
if (!a) return null;
|
||||
const cx = u.x + a.dx, cy = u.y + a.dy;
|
||||
return { x0: cx - a.w / 2, x1: cx + a.w / 2, y0: cy - a.h / 2, y1: cy + a.h / 2 };
|
||||
}
|
||||
|
||||
/** Tile key of a unit's position (-1 off the grid). */
|
||||
function posKey(u) {
|
||||
const r = Math.round(u.y), c = Math.round(u.x);
|
||||
return r < 0 || r >= ROWS || c < 0 || c >= COLS ? -1 : r * COLS + c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tile keys (row × COLS + col) the unit occupies: a point unit the tile of its position ([] off the grid), a huge unit
|
||||
* every tile its rectangle overlaps (open intervals: a tile only touched along an edge is not overlapped).
|
||||
*/
|
||||
export function bodyKeys(u) {
|
||||
const R = hitRect(u);
|
||||
if (!R) { const k = posKey(u); return k < 0 ? [] : [k]; }
|
||||
const out = [];
|
||||
const r0 = Math.max(0, Math.floor(R.y0 + 0.5 + 1e-9)), r1 = Math.min(ROWS - 1, Math.ceil(R.y1 - 0.5 - 1e-9));
|
||||
const c0 = Math.max(0, Math.floor(R.x0 + 0.5 + 1e-9)), c1 = Math.min(COLS - 1, Math.ceil(R.x1 - 0.5 - 1e-9));
|
||||
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) out.push(r * COLS + c);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Does the unit occupy tile (r, c)? */
|
||||
export function bodyOnTile(u, r, c) {
|
||||
const R = hitRect(u);
|
||||
if (!R) return Math.round(u.y) === r && Math.round(u.x) === c;
|
||||
return c + 0.5 > R.x0 + 1e-9 && c - 0.5 < R.x1 - 1e-9 && r + 0.5 > R.y0 + 1e-9 && r - 0.5 < R.y1 - 1e-9;
|
||||
}
|
||||
|
||||
/** Does the unit occupy any tile of `keys` (tile keys: a Set / Map — anything with `has` — or an array)? */
|
||||
export function bodyInKeys(u, keys) {
|
||||
if (!u || !keys) return false;
|
||||
const has = typeof keys.has === 'function' ? (k) => keys.has(k) : (k) => keys.includes(k);
|
||||
if (!u.hitArea) { const k = posKey(u); return k >= 0 && has(k); }
|
||||
for (const k of bodyKeys(u)) if (has(k)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Distance (tiles) from point (x, y) to the unit's body: its position, or its rectangle (0 inside). */
|
||||
export function bodyDist(u, x, y) {
|
||||
const R = hitRect(u);
|
||||
if (!R) return Math.hypot(u.x - x, u.y - y);
|
||||
const dx = x < R.x0 ? R.x0 - x : x > R.x1 ? x - R.x1 : 0;
|
||||
const dy = y < R.y0 ? R.y0 - y : y > R.y1 ? y - R.y1 : 0;
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
/** Is the unit's body within `r` tiles of point (x, y)? (the same ≤ r + 1e-9 test as Battle.enemiesInRadius) */
|
||||
export function bodyInRadius(u, x, y, r) {
|
||||
return bodyDist(u, x, y) <= r + 1e-9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chebyshev reach in tiles from tile (r, c) to the unit's body (0 = it occupies that tile; 1 = one of the 8 tiles
|
||||
* around it; …): "周围八格"-style square areas centred on a tile.
|
||||
*/
|
||||
export function bodyTileReach(u, r, c) {
|
||||
if (!u.hitArea) return Math.max(Math.abs(Math.round(u.y) - r), Math.abs(Math.round(u.x) - c));
|
||||
let best = Infinity;
|
||||
for (const k of bodyKeys(u)) {
|
||||
const kr = Math.floor(k / COLS), kc = k % COLS;
|
||||
const d = Math.max(Math.abs(kr - r), Math.abs(kc - c));
|
||||
if (d < best) best = d;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
+11
-7
@@ -25,14 +25,14 @@ export const ADD_KEYS = Object.freeze([
|
||||
/** Multiplicative mod keys (product; ^ stacks). */
|
||||
export const MUL_KEYS = Object.freeze([
|
||||
'atkMul', 'defMul', 'hpMul', 'resMul', 'moveMul', 'dmgDealtMul', 'dmgTakenMul', 'physTakenMul', 'artsTakenMul',
|
||||
'trueTakenMul', 'elemTakenMul', 'healingDealtMul', 'healingTakenMul', 'spRecoveryMul', 'redeployMul',
|
||||
'trueTakenMul', 'elemTakenMul', 'elementalTakenMul', 'healingDealtMul', 'healingTakenMul', 'spRecoveryMul', 'redeployMul',
|
||||
'atkScaleMul', 'physDealtMul', 'artsDealtMul',
|
||||
]);
|
||||
/** Boolean flag keys (OR). `taunt` is also accepted as a numeric mod. */
|
||||
export const FLAG_KEYS = Object.freeze([
|
||||
'stun', 'freeze', 'sleep', 'silence', 'disarm', 'stealth', 'invulnerable', 'unblockable', 'levitate', 'fear',
|
||||
'cold', 'reveal', 'bind', 'noHeal', 'untargetable', 'blockFly', 'noMove', 'noSp', 'burstLock', 'hidden',
|
||||
'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract',
|
||||
'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract', 'float', 'noDisplace',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -58,7 +58,9 @@ export const STATUS = Object.freeze({
|
||||
fragile: { mods: (v) => ({ dmgTakenMul: 1 + (v ?? 0.3) }), valued: 0.3 },
|
||||
artsFragile: { mods: (v) => ({ artsTakenMul: 1 + (v ?? 0.3) }), valued: 0.3 },
|
||||
physFragile: { mods: (v) => ({ physTakenMul: 1 + (v ?? 0.3) }), valued: 0.3 },
|
||||
elemFragile: { mods: (v) => ({ elemTakenMul: 1 + (v ?? 0.2) }), valued: 0.2 },
|
||||
// 元素脆弱 (ba.elementfragile "受到的元素伤害提升相应比例(同名效果取最高)"): 元素伤害 (the 'elemental' HP damage) only —
|
||||
// never the element gauge (元素损伤 has its own multiplier, `elemTakenMul`; damage.js)
|
||||
elemFragile: { mods: (v) => ({ elementalTakenMul: 1 + (v ?? 0.2) }), valued: 0.2 },
|
||||
silence: { flags: { silence: true }, immune: 'silence' },
|
||||
// 恐惧: 无法被阻挡并四散逃跑 (no attacks, unblockable, stops advancing along its route)
|
||||
fear: { flags: { fear: true, unblockable: true }, immune: 'feared' },
|
||||
@@ -68,10 +70,12 @@ export const STATUS = Object.freeze({
|
||||
stealth: { flags: { stealth: true } },
|
||||
reveal: { flags: { reveal: true } },
|
||||
invulnerable: { flags: { invulnerable: true } },
|
||||
// 浮空: 无法移动、攻击及使用技能; 对重量大于3的单位持续时间减半
|
||||
levitate: { flags: { levitate: true, stun: true, unblockable: true }, immune: 'levitate' },
|
||||
// 麻痹: value = stacks (default 1, max 3); each stack cancels one enemy normal attack; lasts until consumed
|
||||
palsy: { palsy: true },
|
||||
// 浮空: 变为空中单位 (Unit.isFlying),无法移动、攻击及使用技能; 对重量大于3的单位持续时间减半; the state holds 不可阻挡 +
|
||||
// 失衡免疫 (noDisplace: "不会被位移影响", PRTS 异常效果)
|
||||
levitate: { flags: { levitate: true, stun: true, unblockable: true, noDisplace: true }, immune: 'levitate' },
|
||||
// 麻痹: value = stacks (default 1, max 3); each stack cancels one enemy normal attack; lasts until consumed; honours
|
||||
// 麻痹免疫 (enemy_database palsyImmune, e.g. 假想敌:铳 — PRTS 元素: a 神经 burst gives 麻痹 only "若单位不具有麻痹免疫")
|
||||
palsy: { palsy: true, immune: 'palsy' },
|
||||
// value = taunt level delta (default +1)
|
||||
taunt: { mods: (v) => ({ taunt: v ?? 1 }) },
|
||||
// value = ATK fraction removed (default 0.3)
|
||||
|
||||
+19
-1
@@ -15,6 +15,14 @@ export const COLS = GEO.COLS;
|
||||
export const MOVE_SCALE = 0.5;
|
||||
/** Ranged enemies stop moving this long after each attack (DESIGN §5.5). */
|
||||
export const ATTACK_PAUSE = 0.35;
|
||||
/**
|
||||
* Block contact (PRTS 游戏数据基础 §阻挡半径; 作战机制 §碰撞体积与位置识别 "中点判定 … 案例: 阻挡"): an unblocked enemy whose
|
||||
* position lies within the blocker's radius of the blocker's centre touches it — compared on squared distances, as the
|
||||
* official client does. Ground blocking 0.70709997 (² 0.49999037); air blocking (起飞 / blockFly units against flyers)
|
||||
* 0.8944 (² 0.79995137); devices override it (阻隔工事 / 障碍物 0.4472). Used by Battle._checkBlock (user playtest #5).
|
||||
*/
|
||||
export const BLOCK_RADIUS = Object.freeze({ ground: 0.70709997, fly: 0.8944, device: 0.4472 });
|
||||
export const BLOCK_RADIUS_SQ = Object.freeze({ ground: 0.49999037, fly: 0.79995137, device: 0.4472 * 0.4472 });
|
||||
/** Default projectile speed in tiles/s for ranged operators/enemies. */
|
||||
export const PROJECTILE_SPEED = 12;
|
||||
/**
|
||||
@@ -38,7 +46,8 @@ export const ELEMENTS = Object.freeze(['burn', 'neural', 'necrosis', 'apoptosis'
|
||||
* Element bursts — official term table (gamedata_const termDescriptionDict):
|
||||
* `ally` = an operator/summon hit by enemy damage (ba.dt.burning / neural / apoptosis / erosion),
|
||||
* `enemy` = an enemy hit by operators (the "·我方" terms ba.dt.burning2 / neural2 / apoptosis2 / erosion2;
|
||||
* `elemDamage` is 元素伤害: HP damage ignoring DEF/RES, × elemTakenMul).
|
||||
* `elemDamage` is 元素伤害: HP damage through 元素抗性 instead of DEF/RES, × elementalTakenMul = 元素脆弱).
|
||||
* Burst damage is 无来源 (PRTS 元素): no damage-dealt multiplier or penetration of the unit that filled the gauge.
|
||||
* `duration` = the burst's 爆发冷却 (PRTS 元素 table "持续时间": 10 s, 凋亡 15 s, 侵蚀 on enemies 8 s — the operators'
|
||||
* 侵蚀 burst has its 10 s cooldown too): while it runs NO element of the unit fills or recovers, and when it ends every
|
||||
* gauge of the unit resets (damage.js).
|
||||
@@ -93,6 +102,15 @@ export const DP_DEFAULTS = Object.freeze({ dpInit: 10, dpPerSec: 1, dpMax: 99 })
|
||||
* free (WAIT_TILE). render/units.js mirrors these codes.
|
||||
*/
|
||||
export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
|
||||
/**
|
||||
* Removal reason of an operator that enters a battle already knocked out: a 联防 helper's operator down at the end of
|
||||
* its own combat (PlayerBattleInput `carryState.down`; PRTS 卫戍协议/帮助 §联防阶段 "上一阶段为退场状态的干员强制退场").
|
||||
* Battle.start deploys it with everyone, then withdraws it at once with HP 0 — down on its own tile like a knocked-out
|
||||
* operator (Battle.isDown, b.snap `down`), its redeploy timer running from then — without the knock-out hooks (`kill`,
|
||||
* `death` with reason 'killed'), which fired in its own combat. render/app.js mirrors the string (no fall, no death
|
||||
* burst).
|
||||
*/
|
||||
export const FORCED_EXIT = 'forcedExit';
|
||||
|
||||
/** Safety cap for battles with an infinite time limit (boss rounds are force-ended by the match). */
|
||||
export const MAX_BATTLE_TIME = 3600;
|
||||
|
||||
@@ -34,11 +34,10 @@
|
||||
// say), `death` 10 (不屈, the bond slot of the revive/redeploy convention); everything else 0.
|
||||
|
||||
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { localOrder, localBefore } from '../../../dir.js';
|
||||
import {
|
||||
num, bondRecord, buffParams, bondTier, bondLayers, isMember, isElite, isGroundOp, onField, playerOps, passiveBuff,
|
||||
fxOn, N4, N8,
|
||||
fxOn, N4, N8, bodyInKeys,
|
||||
} from '../../support/index.js';
|
||||
|
||||
export const ID = Object.freeze({
|
||||
@@ -216,7 +215,6 @@ function updateAura(battle, st) {
|
||||
|
||||
function raidTile(battle, u, e) {
|
||||
const er = Math.round(e.y), ec = Math.round(e.x);
|
||||
const ek = er * COLS + ec;
|
||||
const ranged = u.def?.position === 'RANGED';
|
||||
const grid = u.rangeGrid || [[0, 0]];
|
||||
const ext = num(u.s.rangeExtend);
|
||||
@@ -226,7 +224,7 @@ function raidTile(battle, u, e) {
|
||||
const r = er + dr, c = ec + dc;
|
||||
if (!battle.grid.inRect(r, c) || battle.isReservedTile(r, c)) continue;
|
||||
if (!battle.grid.canStand(r, c, { ranged })) continue;
|
||||
const covers = absoluteRangeKeys(grid, r, c, u.dir, ext).includes(ek) ? 0 : 1;
|
||||
const covers = bodyInKeys(e, absoluteRangeKeys(grid, r, c, u.dir, ext)) ? 0 : 1;
|
||||
const d = Math.max(Math.abs(dr), Math.abs(dc)) + 0.01 * (Math.abs(dr) + Math.abs(dc));
|
||||
// last tie-break: the offset in the unit's facing-RIGHT frame (sim/dir.js localOrder; for a RIGHT-facing unit the
|
||||
// plain tile-key order), so the landing tile turns with its direction
|
||||
|
||||
@@ -44,13 +44,15 @@
|
||||
// RES +45, animates 2; 斥退 at 999 s.
|
||||
// boss_7 “萨米的意志” 冰凌 (its normal attack) hits a whole column; 自然涌动 stun + arts DoT; <50 %: damage taken
|
||||
// ×(1−0.6), 2 targets; Doom at 600 s (LP −30). Targets via fairOrder (two players alternate).
|
||||
// The huge leaders (SELF_BOUND: 胄 ×2, 管 ×2, 昆图斯, 阿利斯泰尔, 萨米的意志 — the 巨型单位 with a data `hitArea`) are
|
||||
// 自缚 + 无法被阻挡 (PRTS 天赋): a persistent noMove + unblockable buff from spawn, so they never walk their route.
|
||||
// Every leader (tag boss) ignores 侵蚀 gauge damage ("最终攻势中,敌方领袖不会受到侵蚀损伤").
|
||||
// LP effects ('lpLoss' hook + result.lpLoss) must be applied by the match (see the report of this module's owner).
|
||||
// fx kinds: 'beam' 'shell' 'explode' 'telegraph' 'charge' 'link' 'dash' 'column' 'tide' 'rockfall' 'tentacle' 'equip'
|
||||
// 'sword' 'vest' 'blink' 'summon' 'grow' 'phase' 'lpLoss' (x, y + extra {id, r, tiles, kind, tx, ty …}).
|
||||
|
||||
import { MOVE_SCALE } from '../constants.js';
|
||||
import { canTargetAlly } from '../targeting.js';
|
||||
import { canTargetAlly, aggroCmp } from '../targeting.js';
|
||||
import { compileRoute } from '../ai.js';
|
||||
import { normalizeRoute } from '../simdata.js';
|
||||
import {
|
||||
@@ -82,6 +84,17 @@ const ASPD_FLOOR = 20, CHARGE_RADIUS = 0.35;
|
||||
const LINK_WIDTH = 0.5;
|
||||
/** 碎铳之簧 bullet bounce falloff / reach [ASSUMED], combo = 十连击; tag of the damage share passed between springs. */
|
||||
const SPRING_BOUNCE_FALLOFF = 0.85, SPRING_BOUNCE_RANGE = 2, SPRING_COMBO_HITS = 10, SPRING_SHARE_TAG = 'springShare';
|
||||
/**
|
||||
* Leaders that are 自缚 and cannot be blocked — the 天赋 line of every 巨型单位 page (PRTS 假想敌:胄 (both copies) "自缚、
|
||||
* 不可阻挡", 假想敌:管 (+ 隐秘核心) / 盐风主教昆图斯 / 阿利斯泰尔,帝国余晖 / “萨米的意志” "自缚 … 无法被阻挡"). 自缚 (PRTS
|
||||
* 异常效果 UNMOVABLE_PRIVATE) = 无法移动, like 束缚 but never recognised as 束缚: flag noMove, not bind. They stand where
|
||||
* they spawn whatever their route says (the official routes walk to a goal; only the talent holds them), so their
|
||||
* hit area (body.js) stays on the pipe block. These are exactly the enemies with a data `hitArea`.
|
||||
*/
|
||||
export const SELF_BOUND = Object.freeze([
|
||||
'enemy_9013_acstmk', 'enemy_9013_acstmk_2', 'enemy_9021_acduml', 'enemy_9021_acduml_2', 'enemy_1521_dslily',
|
||||
'enemy_9032_aclionk', 'enemy_9033_acdeer',
|
||||
]);
|
||||
/** 阿利斯泰尔: enemy SP per second [ASSUMED] and 莫非王土 delay after each 王权号令 [ASSUMED]. */
|
||||
const ENEMY_SP_PER_SEC = 1, ANIMATE_DELAY = 2;
|
||||
const EQUIP_KEYS = Object.freeze(['enemy_10028_vtswd', 'enemy_10029_vtshld', 'enemy_10030_vtwand']);
|
||||
@@ -172,7 +185,7 @@ function branchSpawn(b, tpl, name, phaseIdx, { fallback = null, mods = null, at
|
||||
export function install(battle) {
|
||||
ensureInstalled(battle);
|
||||
battle.on('enemySpawn', ({ enemy }) => onSpawn(battle, enemy), { priority: 90 });
|
||||
// "最终攻势中,敌方领袖不会受到侵蚀损伤" (research 06 §10.1): leaders ignore erosion gauge damage
|
||||
// "最终攻势中,敌方领袖不会受到侵蚀损伤" (PRTS 卫戍协议:盟约/PRTS盟约记录 规则; research 06 §10.1): leaders ignore erosion gauge damage
|
||||
battle.on('elementHit', (c) => { if (c.target && c.target.isBoss && c.dmg.element === 'erosion') c.dmg.cancel = true; }, { priority: 100 });
|
||||
}
|
||||
export function registerMeta() {}
|
||||
@@ -180,6 +193,7 @@ export function registerMeta() {}
|
||||
function onSpawn(b, e) {
|
||||
const kit = BOSS_KITS[e.defId];
|
||||
if (typeof kit !== 'function') return;
|
||||
if (SELF_BOUND.includes(e.defId)) b.addBuff(e, { key: 'boss:selfBound', persist: true, flags: { noMove: true, unblockable: true } });
|
||||
const tpl = templateOf(b);
|
||||
const ab = abOf(b, e);
|
||||
let list = [];
|
||||
@@ -302,7 +316,7 @@ function kitHelm(ab, e, b, tpl) {
|
||||
cd: s1.cd, icd: s1.icd, cond: (b2) => shellTargets(b2).length > 0,
|
||||
fire(b2, e2) { // 【灭顶之灾】
|
||||
const n = P.low ? 2 : 1;
|
||||
const ts = shellTargets(b2).sort((p, q) => q.s.atk - p.s.atk || q.deploySeq - p.deploySeq).slice(0, n);
|
||||
const ts = shellTargets(b2).sort((p, q) => q.s.atk - p.s.atk || aggroCmp(p, q)).slice(0, n);
|
||||
for (const t of ts) fireShell(b2, e2, t);
|
||||
},
|
||||
},
|
||||
@@ -398,7 +412,7 @@ function kitBlade(ab, e, b, tpl) {
|
||||
s && {
|
||||
cd: s.cd, icd: s.icd, cond: (b2) => P.state === 'hover' && opsOnly(allTargets(b2, e)).length > 0,
|
||||
fire(b2, e2) { // fly at the lowest-ATK operator
|
||||
const t = opsOnly(allTargets(b2, e2)).sort((p, q) => p.s.atk - q.s.atk || p.deploySeq - q.deploySeq)[0];
|
||||
const t = opsOnly(allTargets(b2, e2)).sort((p, q) => p.s.atk - q.s.atk || aggroCmp(p, q))[0];
|
||||
if (!t) return;
|
||||
P.state = 'dive'; P.hits = 0; P.target = { r: t.tileR, c: t.tileC };
|
||||
hover(b2, false);
|
||||
@@ -425,7 +439,7 @@ function kitGun(ab, e, b) {
|
||||
const list = [
|
||||
{
|
||||
spawn(b2, e2) { b2.addBuff(e2, { key: 'boss:unblockable', persist: true, flags: { unblockable: true } }); setAspd(b2); },
|
||||
before(c, b2, e2) { const l = targetsNear(b2, e2, range()); if (l.length) c.targets = [l.sort((p, q) => q.s.def - p.s.def || q.deploySeq - p.deploySeq)[0]]; }, // 优先攻击防御力最高的单位
|
||||
before(c, b2, e2) { const l = targetsNear(b2, e2, range()); if (l.length) c.targets = [l.sort((p, q) => q.s.def - p.s.def || aggroCmp(p, q))[0]]; }, // 优先攻击防御力最高的单位
|
||||
attack(c, b2) {
|
||||
const t = c.targets[0];
|
||||
if (t === P.last) P.n = Math.min(maxN, P.n + 1); else { P.last = t; P.n = 0; }
|
||||
@@ -451,7 +465,7 @@ function kitGun(ab, e, b) {
|
||||
s1 && {
|
||||
cd: s1.cd, icd: s1.icd, cond: (b2) => groundTargets(b2).length > 0 && !P.charge,
|
||||
fire(b2, e2) { // 【最终之罚】 charge at the highest-DEF ground unit
|
||||
const t = groundTargets(b2).sort((p, q) => q.s.def - p.s.def || q.deploySeq - p.deploySeq)[0];
|
||||
const t = groundTargets(b2).sort((p, q) => q.s.def - p.s.def || aggroCmp(p, q))[0];
|
||||
if (!t) return;
|
||||
const dur = T(ab, '3.duration') ?? 0;
|
||||
P.charge = { target: { x: t.x, y: t.y }, until: b2.time + dur, hit: new Set() };
|
||||
@@ -531,13 +545,14 @@ function kitSpring(ab, e) {
|
||||
},
|
||||
burst(c, b) { if (kind === 'element' && P.up) drop(b); }, // 自身元素损伤爆发时,护盾消失
|
||||
taken(c, b, e2) {
|
||||
if (!c.source || c.source.side !== 'ally' || !(c.amount > 0)) return;
|
||||
const src = c.source || c.credit; // a 无来源 burst still passes on (credited)
|
||||
if (!src || src.side !== 'ally' || !(c.amount > 0)) return;
|
||||
if (c.dmg.tags && c.dmg.tags.includes(SPRING_SHARE_TAG)) return; // a share never passes on again
|
||||
// 【盲信之誓】 受到伤害时以一定比例传递给假想敌:铳 (and, split, to the other springs — 隐秘核心 text)
|
||||
const g = gun(b);
|
||||
if (g) b.loseHp(g, c.amount * PART_TRANSFER, { source: c.source, from: c.dmg });
|
||||
if (g) b.loseHp(g, c.amount * PART_TRANSFER, { source: src, from: c.dmg });
|
||||
const others = b.enemies.filter((o) => o.alive && o !== e2 && isSpring(o));
|
||||
for (const o of others) b.loseHp(o, (c.amount * PART_TRANSFER) / others.length, { source: c.source, from: c.dmg, tags: [SPRING_SHARE_TAG] });
|
||||
for (const o of others) b.loseHp(o, (c.amount * PART_TRANSFER) / others.length, { source: src, from: c.dmg, tags: [SPRING_SHARE_TAG] });
|
||||
},
|
||||
tick(b, e2, a, dt) {
|
||||
if (!P.up && P.downAt != null && b.time - P.downAt >= regen) raise(b);
|
||||
@@ -567,7 +582,7 @@ function kitSpring(ab, e) {
|
||||
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'springBullet' });
|
||||
elem(b, e2, t, 'erosion', e2.s.atk * ratio * Math.pow(SPRING_BOUNCE_FALLOFF, k));
|
||||
const prev = t;
|
||||
t = b.alliesInRadius(prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u)).sort((p, q) => Math.hypot(p.x - prev.x, p.y - prev.y) - Math.hypot(q.x - prev.x, q.y - prev.y) || q.deploySeq - p.deploySeq)[0];
|
||||
t = b.alliesInRadius(prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u)).sort((p, q) => Math.hypot(p.x - prev.x, p.y - prev.y) - Math.hypot(q.x - prev.x, q.y - prev.y) || aggroCmp(p, q))[0];
|
||||
}
|
||||
} else { // 十连击
|
||||
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t0.id, kind: 'springCombo' });
|
||||
@@ -614,7 +629,7 @@ function kitEcho(ab, e) {
|
||||
return [
|
||||
{
|
||||
spawn(b, e2) { setHits(e2, e2.def.maxHp); hitCount(b, e2, true); e2.blockWeight = ECHO_BLOCK_WEIGHT; setEchoForm(b, e2, 'dark'); },
|
||||
taken(c, b, e2) { if (c.source && c.source.side === 'ally') echoHit(b, e2); }, // 受到伤害时以自身为中心造成一次范围伤害
|
||||
taken(c, b, e2) { const s = c.source || c.credit; if (s && s.side === 'ally') echoHit(b, e2); }, // 受到伤害时以自身为中心造成一次范围伤害 (a 无来源 burst too)
|
||||
},
|
||||
s && {
|
||||
cd: s.cd, icd: s.icd,
|
||||
@@ -718,7 +733,7 @@ function kitQuintus(ab, e, b, tpl) {
|
||||
const list = [{
|
||||
spawn(b2, e2) { P.t0 = b2.time; P.stageAt = b2.time; e2.profile.maxTargets = 2; },
|
||||
before(c, b2, e2) { // 同时攻击防御最高的两个单位
|
||||
const l = targetsNear(b2, e2, e2.base.rangeRadius || 99).sort((p, q) => q.s.def - p.s.def || q.deploySeq - p.deploySeq);
|
||||
const l = targetsNear(b2, e2, e2.base.rangeRadius || 99).sort((p, q) => q.s.def - p.s.def || aggroCmp(p, q));
|
||||
if (l.length) c.targets = l.slice(0, 2);
|
||||
},
|
||||
dealt(c, b2, e2) { elem(b2, e2, c.target, 'neural', e2.s.atk * atkRatio); },
|
||||
@@ -746,7 +761,7 @@ const QUINTUS = {
|
||||
},
|
||||
Rockfall(b, e, s, P) { // 【崩坍】 delayed strikes on the N highest-DEF units
|
||||
const n = ROCKFALL_TARGETS[Math.min(P.stage, ROCKFALL_TARGETS.length - 1)];
|
||||
const ts = allTargets(b, e).sort((p, q) => q.s.def - p.s.def || q.deploySeq - p.deploySeq).slice(0, n);
|
||||
const ts = allTargets(b, e).sort((p, q) => q.s.def - p.s.def || aggroCmp(p, q)).slice(0, n);
|
||||
for (const t of ts) {
|
||||
b.fx('rockfall', { x: t.x, y: t.y, id: t.id, dur: ROCKFALL_DELAY });
|
||||
b.after(ROCKFALL_DELAY, () => { if (e.alive && t.alive) hurt(b, e, t, e.s.atk * (s.bb.atk_scale ?? 0), 'phys'); }, { owner: e });
|
||||
|
||||
@@ -42,7 +42,9 @@
|
||||
// global_special_choice_all_activated always on
|
||||
// …prep_finish_bench_at_least / _at_most on when params.prepOk (fallback: contentInfo.handUnits)
|
||||
// global_special_choice_prep_finish_same_row_at_least 征召: on when, at battle start, some row holds ≥ count of
|
||||
// the player's deployed operators
|
||||
// the player's deployed operators ([ASSUMED] an operator
|
||||
// forced out at the start of 联防 — down on its tile — does
|
||||
// not count; the −50 % still covers its timer, Battle.start)
|
||||
// env_gbuff_new_with_verify act1autochess_debuff_3 {value} 自愈: every damage instance an own unit (operator or
|
||||
// summon) takes heals it `value` (not HP loss 流失, not gauge
|
||||
// fills, not while a 禁疗 status is on)
|
||||
@@ -394,7 +396,10 @@ function prepGateOn(battle, ps, ref, gate) {
|
||||
return n == null ? false : benchGate(gate, n);
|
||||
}
|
||||
|
||||
/** Largest number of the player's deployed operators sharing one row. */
|
||||
/**
|
||||
* Largest number of the player's deployed operators sharing one row ([ASSUMED] a knocked-out operator lying on its tile —
|
||||
* one forced out at the start of 联防 — is not deployed and does not count).
|
||||
*/
|
||||
function maxRowOps(battle, pid) {
|
||||
const rows = new Map();
|
||||
for (const u of battle.allyUnits) if (isOwnOp(u, pid) && u.alive && u.deployed) rows.set(u.tileR, (rows.get(u.tileR) ?? 0) + 1);
|
||||
|
||||
@@ -354,9 +354,12 @@ function tickMire(battle, st, u) {
|
||||
battle.addBuff(u, { key: BUFF[TERRAIN.mire], refresh: 'replace', mods: { aspd: M.aspdPer * n, moveMul: Math.max(0, 1 + M.movePer * n) } });
|
||||
}
|
||||
|
||||
/** Terrain code under a unit (0 = none). Flyers and hidden units ignore ground terrain; smog only helps allies. */
|
||||
/**
|
||||
* Terrain code under a unit (0 = none). Air units (Unit.isFlying: flyers, hovering 近地悬浮 and levitated enemies) and
|
||||
* hidden units ignore ground terrain; smog only helps allies.
|
||||
*/
|
||||
function terrainFor(battle, st, u) {
|
||||
if (u.hidden || u.motion === 'FLY') return 0;
|
||||
if (u.hidden || u.isFlying) return 0;
|
||||
let k;
|
||||
if (u.side === 'ally') k = u.tileR * COLS + u.tileC;
|
||||
else {
|
||||
|
||||
+158
-56
@@ -11,6 +11,8 @@
|
||||
// list [abilities], times, hitShield, atkType, immune, … }`. An ability is a plain object with optional methods
|
||||
// spawn(b,e,a,ab) · dealt(c,…) ('damaged' by one of e's ATTACKS on an ally, non-element) · taken(c,…) ('damaged' on e,
|
||||
// non-element) · hitIn(c,…) ('hit' on e, late: after every other hit handler) · hitOut(c,…) ('hit' from e, early) ·
|
||||
// (`c.source` is null for 无来源 damage — element bursts; target-side reactions that must still fire, e.g. damage
|
||||
// sharing, read `c.source || c.credit`, `credit` naming the unit that filled the gauge) ·
|
||||
// before(c,…) (beforeAttack of e) · attack(c,…) · blocked(c,…) · killed(c,…) (return true = revived; the kill is then
|
||||
// hidden from later 'kill' handlers) · death(c,…) · status(c,…) (statusApplied on e) · burst(c,…) (elementBurst on e)
|
||||
// · tick(b,e,a,dt) every `iv` s (every tick when iv is 0) · skill: `cd`/`left`(=icd)/`cond`/`fire` (cooldown runs
|
||||
@@ -22,7 +24,11 @@
|
||||
// rebirth of Reborn.duration s, untargetable and inert, then the new form); artsBarrier() — "吸收法术伤害的屏障"
|
||||
// (absorbs arts after RES); frontGuard() — "来自正面的伤害降低" (facing = walking direction or the bigger crowd);
|
||||
// unbalanced() — 失衡 detection (displacement beyond the enemy's own speed; engine displace() has no hook);
|
||||
// attackLoop() — normal attacks for data-unarmed enemies once a form arms them.
|
||||
// attackLoop() — normal attacks for data-unarmed enemies once a form arms them; float() — 近地悬浮 (an air unit that
|
||||
// keeps its ground path, Unit.isFlying; kitSyufo / kitParrot lose it when stunned). `e.profile.canTarget(ally)` = the
|
||||
// enemy's own target rule (只攻击地面单位 …), applied by the engine to the candidates before its priority order; a
|
||||
// special priority (优先攻击防御力最高的… / 生命上限最高的…) sorts by its key, ties by taunt then latest deployed
|
||||
// (targeting.js aggroCmp — PRTS 索敌: 特殊优先级 → 仇恨值).
|
||||
//
|
||||
// Special types (factions.json):
|
||||
// FLY — engine (FLY motion, ranged-only targeting). Flyer kits below (御4, 护障, 寒霜, 萨科塔之翼/眼, 黑云 …).
|
||||
@@ -49,7 +55,7 @@
|
||||
// Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss.
|
||||
|
||||
import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE } from '../constants.js';
|
||||
import { canTargetAlly, sortAllyTargets, sortEnemyTargets } from '../targeting.js';
|
||||
import { canTargetAlly, sortAllyTargets, aggroCmp } from '../targeting.js';
|
||||
import { mitigate } from '../damage.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
@@ -59,6 +65,9 @@ import { mitigate } from '../damage.js';
|
||||
export const EROSION = 'erosion';
|
||||
/** Official erosion burst on operators (gamedata_const termDescriptionDict ba.dt.erosion; engine constants ELEMENT.erosion.ally). */
|
||||
export const EROSION_BURST = Object.freeze({ defDown: ELEMENT.erosion.ally.defDown, physDamage: ELEMENT.erosion.ally.damage });
|
||||
/** 假想敌:淤困: element damage its host's burst spreads to the allies of the 4 tiles around (PRTS 假想敌:淤困 "附着对象元素爆发时
|
||||
* …对附着对象及周围4格内的所有我方单位…造成1000同类型元素损伤"; the host itself is in its 爆发冷却). */
|
||||
const PARASITE_SPREAD = 1000;
|
||||
/** Death-explosion radius when the enemy has no official radius [ASSUMED]. */
|
||||
const BOOM_RADIUS = 1.25;
|
||||
/** Pollution zone radius of 萨卡兹枯朽战车's ranged 污染秽蚀 [ASSUMED]. */
|
||||
@@ -115,7 +124,7 @@ const ELK_FAIL_SCALE = 2;
|
||||
const STATE = new WeakMap();
|
||||
function stOf(b) {
|
||||
let st = STATE.get(b);
|
||||
if (!st) { st = { lpLoss: 0, floats: 0, freedAll: false, deathWatch: new Set() }; STATE.set(b, st); }
|
||||
if (!st) { st = { lpLoss: 0, freedAll: false, deathWatch: new Set() }; STATE.set(b, st); }
|
||||
return st;
|
||||
}
|
||||
|
||||
@@ -252,21 +261,7 @@ function onDamaged(b, c) {
|
||||
|
||||
function onBeforeAttack(b, c) {
|
||||
const a = c.attacker;
|
||||
if (!a) return;
|
||||
if (a.side === 'enemy') { if (a.mem.ab) dispatch(b, a, 'before', c); return; }
|
||||
// 近地悬浮 (ba.float "无法被阻挡或近战攻击"): melee operators cannot hit floating enemies — they pick others instead
|
||||
if (stOf(b).floats > 0 && a.profile && a.profile.attack !== 'ranged' && c.targets) {
|
||||
const ok = (t) => !(t.mem && t.mem.ab && t.mem.ab.float);
|
||||
const kept = c.targets.filter(ok);
|
||||
if (kept.length === c.targets.length) return;
|
||||
if (!kept.length && a.rangeKeys && c.targets.some((t) => t.side === 'enemy')) {
|
||||
const prof = c.profile || a.profile;
|
||||
const cands = b.enemiesInKeys(a.rangeKeys, a, prof).filter(ok);
|
||||
sortEnemyTargets(b, a, cands, prof.priority);
|
||||
kept.push(...cands.slice(0, Math.max(1, Math.floor(prof.maxTargets || 1))));
|
||||
}
|
||||
c.targets = kept;
|
||||
}
|
||||
if (a && a.side === 'enemy' && a.mem.ab) dispatch(b, a, 'before', c);
|
||||
}
|
||||
|
||||
function onKill(b, c) {
|
||||
@@ -467,16 +462,20 @@ export function expose(b, u, dur, scale) {
|
||||
b.fx('expose', { x: u.x, y: u.y, id: u.id });
|
||||
}
|
||||
|
||||
/** Final HP damage the pipeline would deal for a phys/arts/true `dmg` before shields (mitigation is linear in the amount). */
|
||||
/**
|
||||
* Final HP damage the pipeline would deal for a phys/arts/true/elemental `dmg` before shields (mitigation is linear in
|
||||
* the amount) — the same terms as damage.js dealDamage (元素伤害: 元素抗性 + elementalTakenMul, no dmgTakenMul).
|
||||
*/
|
||||
export function expectedFinal(src, tgt, dmg) {
|
||||
const ts = tgt.s, ss = src && src.s ? src.s : null, ty = dmg.type;
|
||||
const ts = tgt.s, ss = src && src.s && !dmg.sourceless ? src.s : null, ty = dmg.type; // 无来源 (bursts): no source stats
|
||||
const v = mitigate(dmg.amount, ty, ts, {
|
||||
defIgnorePct: (dmg.defIgnorePct || 0) + (ss ? ss.defIgnorePct : 0), defIgnoreFlat: (dmg.defIgnoreFlat || 0) + (ss ? ss.defIgnoreFlat : 0),
|
||||
resIgnorePct: (dmg.resIgnorePct || 0) + (ss ? ss.resIgnorePct : 0), resIgnoreFlat: (dmg.resIgnoreFlat || 0) + (ss ? ss.resIgnoreFlat : 0),
|
||||
elementalRes: ty === 'elemental' ? (tgt.def?.epDamageResistance ?? 0) : 0,
|
||||
});
|
||||
let mul = (dmg.mul ?? 1) * ts.dmgTakenMul;
|
||||
let mul = (dmg.mul ?? 1) * (ty === 'elemental' ? 1 : ts.dmgTakenMul); // 脆弱 skips 元素伤害 (damage.js)
|
||||
if (ss) mul *= ss.dmgDealtMul * (ty === 'phys' ? ss.physDealtMul : ty === 'arts' ? ss.artsDealtMul : 1);
|
||||
mul *= ty === 'phys' ? ts.physTakenMul : ty === 'arts' ? ts.artsTakenMul : ts.trueTakenMul;
|
||||
mul *= ty === 'phys' ? ts.physTakenMul : ty === 'arts' ? ts.artsTakenMul : ty === 'elemental' ? ts.elementalTakenMul : ts.trueTakenMul;
|
||||
const f = v * mul;
|
||||
return Number.isFinite(f) && f > 0 ? f : 0;
|
||||
}
|
||||
@@ -608,12 +607,23 @@ const refraction = (res, hpPct = 0) => ({
|
||||
},
|
||||
});
|
||||
|
||||
/** 近地悬浮: unblockable, immune to melee attacks. */
|
||||
const float = () => ({
|
||||
spawn(b, e, a, ab) { ab.float = true; stOf(b).floats++; b.addBuff(e, { key: 'ab:float', flags: { unblockable: true }, persist: true }); },
|
||||
hitIn(c, b, e) { const s = c.source; if (c.dmg.isAttack && s && s.side === 'ally' && s.profile && s.profile.attack !== 'ranged') c.dmg.cancel = true; },
|
||||
death(c, b) { stOf(b).floats = Math.max(0, stOf(b).floats - 1); },
|
||||
});
|
||||
/**
|
||||
* 近地悬浮 (ba.float "无法被阻挡或近战攻击"; PRTS 术语释义: "此类效果开始时,单位强制解除阻挡,随后行动方式变为飞行(算作空中
|
||||
* 单位)… 无论飞行与否,单位只会采用地面寻路"): while floating the enemy carries the 'ab:float' buff — unblockable, an air unit for
|
||||
* every targeting / ground-only rule (flag `float` ⇒ Unit.isFlying: melee operators, 迷迭香's ground-only shots and
|
||||
* splash, ground traps and terrain skip it; ranged attacks hit it) with 失衡免疫 (`noDisplace`, "初始模式:近地悬浮,失衡
|
||||
* 免疫" on the enemies' PRTS pages) — its `motion` stays WALK, so it keeps the ground path (and 浮空 can still lift it:
|
||||
* Battle.applyStatus refuses data flyers only). Losing the float is each
|
||||
* enemy's own ability (PRTS; 缚地 "使部分近地悬浮敌人掉落" is "实为敌人自身的能力"): kitSyufo, kitParrot.
|
||||
*/
|
||||
const FLOAT_KEY = 'ab:float';
|
||||
const setFloat = (b, e, on) => {
|
||||
if (on) b.addBuff(e, { key: FLOAT_KEY, flags: { unblockable: true, float: true, noDisplace: true }, persist: true });
|
||||
else b.removeBuff(e, FLOAT_KEY);
|
||||
};
|
||||
const float = () => ({ spawn(b, e) { setFloat(b, e, true); } });
|
||||
/** The enemies whose kit spawns them hovering (float()): the match's bot counts them as air units (bot.js fieldModel). */
|
||||
export const HOVER_KEYS = Object.freeze(['enemy_2025_syufo', 'enemy_10045_parrot']);
|
||||
|
||||
/** "生命值首次降至一半以下时,在数秒内陷入恐惧" (SelfFear). */
|
||||
const selfFear = (ab) => ({
|
||||
@@ -1030,12 +1040,77 @@ function kitDsubrl(ab, e) {
|
||||
return [hitAllInRadius(e.base.rangeRadius || 1.6), ep('neural', T(ab, 'EpDamage.ep_damage_ratio') ?? 0), resist(['sluggish'])];
|
||||
}
|
||||
|
||||
/** 掠海漂移体 爬行模式: the stun it takes on dropping (PRTS "进入爬行模式并晕眩0.5秒"; not in its blackboard). */
|
||||
const SYUFO_CRAWL_STUN = 0.5;
|
||||
/** 吉兆飞鳞 【失温坠落】: the stun when a freeze ends (PRTS "冻结结束时,获得0.05秒晕眩"). */
|
||||
const PARROT_CHILL_STUN = 0.05;
|
||||
|
||||
/**
|
||||
* 掠海漂移体 (PRTS): 初始模式 近地悬浮 + 失衡免疫, 不会攻击飞行单位; 受晕眩/无法行动/沉睡/冻结/缚地影响后进入爬行模式 for
|
||||
* good and is stunned 0.5 s — a ground unit (blockable, melee operators hit it) that "仅进行阻挡攻击" (only its blocker).
|
||||
* The engine has 晕眩 / 冻结 / 沉睡 (no operator here applies 无法行动 or 缚地). Erosion on every attack.
|
||||
*/
|
||||
function kitSyufo(ab) {
|
||||
return [float(), ep('erosion', T(ab, 'EpDamage.attack@ep_damage_ratio') ?? 0), {
|
||||
spawn(b, e) { e.profile.canTarget = (u) => !u.isFlying; },
|
||||
status(c, b, e, a) {
|
||||
if (a.crawl || !(c.status === 'stun' || c.status === 'freeze' || c.status === 'sleep')) return;
|
||||
a.crawl = true;
|
||||
setFloat(b, e, false);
|
||||
e.profile.melee = true;
|
||||
b.applyStatus(e, 'stun', { duration: SYUFO_CRAWL_STUN, source: null });
|
||||
b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'crawl' });
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* 吉兆飞鳞 (PRTS): 近地悬浮 (失衡免疫), no normal attack; 受晕眩/无法行动/沉睡/缚地影响后进入晕眩模式 — the float is off, it
|
||||
* cannot be blocked and is stunned `Stun.duration` (8) s — and floats again once none of those effects holds it
|
||||
* (初始模式); 冻结 grounds it only when the freeze ends (【失温坠落】: 0.05 s stun). 首次受到伤害后 its speed is ×
|
||||
* `M0SpeedUp.move_speed` for `M0SpeedUp.duration` s ("最终提升至300%"; never in 晕眩模式, dropped when its mode
|
||||
* changes). 搬运模式 (carrying a 寻险水手) needs a level checkpoint (`ThrowEnemy.checkpoint` 0 = never) — not in this mode.
|
||||
*/
|
||||
function kitParrot(ab) {
|
||||
const down = T(ab, 'Stun.duration') ?? 0;
|
||||
return [float(), {
|
||||
taken(c, b, e, a) {
|
||||
if (a.ran || a.down) return;
|
||||
a.ran = true;
|
||||
b.addBuff(e, { key: 'ab:parrotRun', duration: T(ab, 'M0SpeedUp.duration') ?? 0, mods: { moveMul: T(ab, 'M0SpeedUp.move_speed') ?? 1 } });
|
||||
},
|
||||
status(c, b, e, a) {
|
||||
if (c.status === 'freeze') { a.chill = true; return; }
|
||||
if (a.down || !(c.status === 'stun' || c.status === 'sleep')) return;
|
||||
a.down = true;
|
||||
setFloat(b, e, false);
|
||||
b.removeBuff(e, 'ab:parrotRun');
|
||||
b.addBuff(e, { key: 'ab:parrotDown', flags: { unblockable: true }, persist: true });
|
||||
if (down > 0) b.applyStatus(e, 'stun', { duration: down, source: null });
|
||||
b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'grounded' });
|
||||
},
|
||||
tick(b, e, a) {
|
||||
if (a.chill && !e.s.flags.freeze) { a.chill = false; b.applyStatus(e, 'stun', { duration: PARROT_CHILL_STUN, source: null }); }
|
||||
// "离开上述异常效果影响后进入初始模式": s.flags.stun also holds while asleep / frozen (units.js), so a sleep that
|
||||
// outlasts the mode stun keeps it down
|
||||
if (a.down && !e.s.flags.stun) {
|
||||
a.down = false;
|
||||
b.removeBuff(e, 'ab:parrotDown');
|
||||
b.removeBuff(e, 'ab:parrotRun');
|
||||
setFloat(b, e, true);
|
||||
b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'float' });
|
||||
}
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
function kitTank(ab) {
|
||||
const every = (T(ab, 'Empty.sp') ?? 2) + 1, melee = T(ab, 'Empty.attack@chuang_atk_scale') ?? 1;
|
||||
const s = ab.sk.PollutedRangedAtk ? ab.sk.PollutedRangedAtk.bb : {};
|
||||
return [{
|
||||
spawn(b, e, a) { a.n = 0; },
|
||||
before(c, b, e) { c.targets = c.targets.filter((u) => u.ground); }, // 只攻击地面单位
|
||||
// 只攻击地面单位 — PRTS 萨卡兹枯朽战车 天赋 "普通攻击只攻击位于低地的我方单位,且不会攻击飞行单位" (a candidate filter,
|
||||
// ai.js enemyAttack: a FLY ally such as the 炎佑 dragon on a low tile is no target either)
|
||||
spawn(b, e, a) { a.n = 0; e.profile.canTarget = (u) => !!u.ground && !u.isFlying; },
|
||||
hitOut(c, b, e) { if (c.dmg.isAttack && c.target === e.blockedBy) c.dmg.amount *= melee; }, // 近战攻击造成更高伤害
|
||||
attack(c, b, e, a) {
|
||||
a.n++;
|
||||
@@ -1113,17 +1188,28 @@ function kitJazz(ab, e) {
|
||||
spawn(b, e2) { e2.profile.noAttack = true; },
|
||||
tick(b, e2) { e2.profile.noAttack = !revealed(e2); }, // 平时不攻击,失去隐匿时反击
|
||||
}, s ? {
|
||||
sil: true, cd: s.cd, icd: s.icd, cond: (b, e2) => revealed(e2),
|
||||
// 狂欢式演奏 (PRTS 节日爵士乐手, 反击模式): "仅攻击范围内存在我方单位时可触发:锁定目标持续施法,最多持续10.6s,每0.5s对目标
|
||||
// 造成攻击力20%的法术伤害和攻击力10%的灼燃损伤 ※施法期间受到沉默影响后,立即结束技能" — ONE locked target (its own
|
||||
// priority: the blocker first), floor(10.6 / 0.5) = 21 ticks at 0.5 … 10.5 s. The channel also ends when that target
|
||||
// is gone, makes no normal attacks meanwhile and is never re-cast over itself — the 10 s cooldown (enemy_database
|
||||
// skill 'fire') runs out mid-channel, the next cast waits for its end [ASSUMED "持续施法", as 死亡之眼].
|
||||
sil: true, cd: s.cd, icd: s.icd, cond: (b, e2) => revealed(e2) && !(e2.mem.jazzChannel && !e2.mem.jazzChannel.cancelled) && targetsNear(b, e2, r).length > 0,
|
||||
fire(b, e2) {
|
||||
const dur = s.bb['enemy_cnvsax[cd].duration'] ?? 0, iv = s.bb.hit_interval ?? 0.5;
|
||||
let left = dur;
|
||||
b.fx('zone', { x: e2.x, y: e2.y, r, dur, kind: 'jazzFire', id: e2.id });
|
||||
const t = byPriority(e2, targetsNear(b, e2, r))[0];
|
||||
if (!t || !(dur > 0) || !(iv > 0)) return;
|
||||
const n = Math.max(1, Math.floor(dur / iv + 1e-9));
|
||||
const buff = b.addBuff(e2, { key: 'ab:channel', duration: dur, flags: { disarm: true } });
|
||||
let k = 0;
|
||||
const end = () => { h.cancel(); e2.mem.jazzChannel = null; if (buff) b.removeBuff(e2, buff); };
|
||||
const h = b.every(iv, () => {
|
||||
if (!e2.alive || e2.s.flags.silence) { h.cancel(); return; }
|
||||
for (const u of b.alliesInRadius(e2.x, e2.y, r)) { hurt(b, e2, u, e2.s.atk * (s.bb.atk_scale ?? 0), 'arts'); elem(b, e2, u, 'burn', e2.s.atk * (s.bb.ep_damage_ratio ?? 0)); }
|
||||
left -= iv;
|
||||
if (left <= 1e-6) h.cancel();
|
||||
if (!e2.alive || e2.s.flags.silence || !t.alive || !t.deployed) { end(); return; }
|
||||
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'jazzFire' });
|
||||
hurt(b, e2, t, e2.s.atk * (s.bb.atk_scale ?? 0), 'arts');
|
||||
elem(b, e2, t, 'burn', e2.s.atk * (s.bb.ep_damage_ratio ?? 0));
|
||||
if (++k >= n) end();
|
||||
}, { owner: e2 });
|
||||
e2.mem.jazzChannel = h;
|
||||
},
|
||||
} : null];
|
||||
}
|
||||
@@ -1204,6 +1290,21 @@ function kitExposeOnDeath(ab) {
|
||||
return [{ sil: true, death(c, b, e) { if (c.reason !== 'killed') return; b.fx('explode', { x: e.x, y: e.y, r, kind: 'expose' }); for (const u of b.alliesInRadius(e.x, e.y, r)) expose(b, u, dur, scale); } }];
|
||||
}
|
||||
|
||||
/**
|
||||
* 淤困's "受到的元素损伤提高至130%" on its host: an `elementHit` multiplier (SIM.md §7.2 — every "受到的元素损伤±N%"; on an
|
||||
* operator at priority 20, before a 损伤屏障), never 元素伤害. One battle-wide handler, installed with the first host.
|
||||
*/
|
||||
function ensureParasiteHook(b) {
|
||||
const st = stOf(b);
|
||||
if (st.parasiteHook) return;
|
||||
st.parasiteHook = true;
|
||||
b.on('elementHit', (c) => {
|
||||
const bf = c.target && c.dmg && c.dmg.type === 'element' ? c.target.findBuff('ab:parasite') : null;
|
||||
const m = bf && bf.data ? bf.data.epMul : 1;
|
||||
if (m > 0 && m !== 1) c.dmg.mul *= m;
|
||||
}, { priority: 20 });
|
||||
}
|
||||
|
||||
function kitParasite(ab) {
|
||||
const scale = T(ab, '1.atk_scale') ?? 0, elMul = T(ab, '1.ep_damage_scale') ?? 1;
|
||||
return [taunt(-1), {
|
||||
@@ -1213,9 +1314,11 @@ function kitParasite(ab) {
|
||||
if (a.host && a.host !== host) { b.removeBuff(a.host, 'ab:parasite'); a.host = null; }
|
||||
if (!host || a.host === host) return;
|
||||
a.host = host;
|
||||
ensureParasiteHook(b);
|
||||
b.fx('beam', { x: e.x, y: e.y, from: e.id, to: host.id, kind: 'parasite' });
|
||||
b.addBuff(host, {
|
||||
key: 'ab:parasite', visible: true, interval: 1, mods: { elemTakenMul: elMul }, data: { src: e, spread: e.s.atk * elMul },
|
||||
// "受到的元素损伤提高至130%": a 元素损伤 multiplier on the element hit (ensureParasiteHook), not 元素伤害
|
||||
key: 'ab:parasite', visible: true, interval: 1, data: { src: e, spread: PARASITE_SPREAD, epMul: elMul },
|
||||
onTick: ({ battle, unit }) => { if (e.alive) battle.dealDamage(e, unit, { amount: e.s.atk * scale, type: 'arts', canDodge: false, tags: ['enemyAbility', 'parasite'] }); },
|
||||
});
|
||||
},
|
||||
@@ -1295,12 +1398,9 @@ function kitRegen(ab) {
|
||||
function kitSteal(ab) {
|
||||
const n = T(ab, 'DamageOrBullet.attack@minus_bullet') ?? 1;
|
||||
return [selfFear(ab), {
|
||||
// PRTS “萨科塔之眼”: "不会攻击飞行单位" (the 炎佑 is one) — its next ground target in range instead
|
||||
before(c, b, e) {
|
||||
if (!c.targets || !c.targets.some((t) => t.isFlying)) return;
|
||||
const l = targetsNear(b, e, e.base.rangeRadius).filter((t) => !t.isFlying);
|
||||
c.targets = byPriority(e, l).slice(0, 1);
|
||||
},
|
||||
// PRTS “萨科塔之眼” 天赋: "不会攻击飞行单位" (the 炎佑 dragon is one) — a candidate filter (ai.js enemyAttack): its next
|
||||
// ground target in range instead
|
||||
spawn(b, e) { e.profile.canTarget = (u) => !u.isFlying; },
|
||||
hitOut(c, b, e) {
|
||||
if (!c.dmg.isAttack) return;
|
||||
const t = c.target;
|
||||
@@ -1389,10 +1489,11 @@ function kitFlameVine(ab) {
|
||||
dealt(c, b, e, a) {
|
||||
const t = c.target;
|
||||
if (a.first) {
|
||||
// 首次攻击对目标和周围造成法术伤害和灼燃损伤, then the ATK ramp ends
|
||||
// 首次攻击 (PRTS 灼藤): every other ally of the target's 3×3 (range x-4, tile test) takes 100 % ATK arts splash and
|
||||
// everyone there 25 % ATK burn; then the ATK ramp ends and later attacks add 20 %
|
||||
a.first = false;
|
||||
b.fx('explode', { x: t.x, y: t.y, r: 1, kind: 'flameVine' });
|
||||
for (const u of alliesInTiles(b, t.tileR, t.tileC, 'plus', 1)) { if (u !== t) hurt(b, e, u, e.s.atk, 'arts'); elem(b, e, u, 'burn', e.s.atk * first); }
|
||||
b.fx('explode', { x: t.x, y: t.y, r: 1.5, kind: 'flameVine' });
|
||||
for (const u of alliesInTiles(b, t.tileR, t.tileC, 'box', 1)) { if (u !== t) hurt(b, e, u, e.s.atk, 'arts'); elem(b, e, u, 'burn', e.s.atk * first); }
|
||||
b.removeBuff(e, 'ab:pow');
|
||||
} else elem(b, e, t, 'burn', e.s.atk * normal);
|
||||
},
|
||||
@@ -1496,14 +1597,14 @@ function kitLeaderMisc(key, ab, e) {
|
||||
return [bar, lowHpBuff(T(ab, 'enrage.hp_ratio') ?? 0.5, { dmgDealtMul: T(ab, 'enrage.damage_scale') ?? 1 }),
|
||||
// 【唱沙】 the highest-max-HP unit on the field and everything in its cross: physical `damage`
|
||||
skill(ds, (b, e2) => {
|
||||
const t = allTargets(b, e2).sort((p, q) => q.s.maxHp - p.s.maxHp || q.deploySeq - p.deploySeq)[0];
|
||||
const t = allTargets(b, e2).sort((p, q) => q.s.maxHp - p.s.maxHp || aggroCmp(p, q))[0];
|
||||
if (!t) return;
|
||||
b.fx('telegraph', { x: t.tileC, y: t.tileR, r: DRIFT_REACH, kind: 'driftSand', tiles: 'plus', id: e2.id });
|
||||
for (const u of alliesInTiles(b, t.tileR, t.tileC, 'plus', DRIFT_REACH)) hurt(b, e2, u, ds.bb.damage ?? 0, 'phys');
|
||||
}, { cond: (b, e2) => allTargets(b, e2).length > 0 }),
|
||||
// 【沙狱】 the lowest-max-HP unit and those around it: ATK −`atk` and `damage` arts per second for `duration` s
|
||||
skill(ss, (b, e2) => {
|
||||
const t = allTargets(b, e2).sort((p, q) => p.s.maxHp - q.s.maxHp || q.deploySeq - p.deploySeq)[0];
|
||||
const t = allTargets(b, e2).sort((p, q) => p.s.maxHp - q.s.maxHp || aggroCmp(p, q))[0];
|
||||
if (!t) return;
|
||||
const dur = ss.bb.duration ?? 0, x = t.x, y = t.y;
|
||||
b.fx('zone', { x, y, r: SANDSTORM_RADIUS, dur, kind: 'sandStorm', id: e2.id });
|
||||
@@ -1771,7 +1872,7 @@ function kitWarden(ab) {
|
||||
function kitXi(ab) {
|
||||
const cross = ab.sk.CrossAttack, sb = ab.sk.ShieldBurst, sb2 = ab.sk.ShieldBurstReborn;
|
||||
const P = { form2: false };
|
||||
const byDist = (b, e) => allTargets(b, e).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y) || q.deploySeq - p.deploySeq);
|
||||
const byDist = (b, e) => allTargets(b, e).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y) || aggroCmp(p, q));
|
||||
const crossAt = (b, e, t) => {
|
||||
const r0 = t.tileR, c0 = t.tileC;
|
||||
b.fx('telegraph', { x: c0, y: r0, r: XI_CROSS_REACH, kind: 'xiCross', tiles: 'plus', id: e.id });
|
||||
@@ -1982,7 +2083,7 @@ export const KITS = Object.freeze({
|
||||
enemy_1389_winbab_2: kitStealth, // 访问团强攻冠军 · stealth (供暖器 priority n/a)
|
||||
enemy_1404_msnip: kitCrossbow, // 重弩突袭者 · stealth + 直击 (row/column bolt: arts + stun, reveals itself)
|
||||
enemy_10031_cnvsld: kitStealth, // 业余竞演者 · stealth
|
||||
enemy_10034_cnvsax: kitJazz, // 节日爵士乐手 · stealth, attacks only when revealed; fire aura (arts + burn)
|
||||
enemy_10034_cnvsax: kitJazz, // 节日爵士乐手 · stealth, attacks only when revealed; channel on one target (arts + burn)
|
||||
enemy_10042_prtrop: kitStealth, // 架桥船工 · stealth (bridges n/a)
|
||||
enemy_10042_prtrop_2: kitStealth, // 扶桥老手 · stealth
|
||||
enemy_9008_acbunn: kitBoneSpike, // 假想敌:骨刺 · stealth; hits 3 targets while stealthed
|
||||
@@ -2033,15 +2134,15 @@ export const KITS = Object.freeze({
|
||||
enemy_1305_mhslim: kitEp('burn', 'EpDamage.attack@ep_damage_ratio'), // 灼热源石虫 · burn on hit
|
||||
enemy_1305_mhslim_2: kitEp('burn', 'EpDamage.attack@ep_damage_ratio'), // 炽焰源石虫 · burn on hit
|
||||
enemy_2021_syfish: kitEp('erosion', 'EpDamage.attack@ep_damage_ratio'), // 骨海漂流体 · erosion on hit
|
||||
enemy_2025_syufo: (ab) => [float(), ep('erosion', T(ab, 'EpDamage.attack@ep_damage_ratio') ?? 0)], // 掠海漂移体 · 近地悬浮 + erosion
|
||||
enemy_2025_syufo: kitSyufo, // 掠海漂移体 · 近地悬浮 (drops to 爬行模式 when stunned) + erosion
|
||||
enemy_1229_darmy: kitEp('apoptosis', 'epdamage.attack@ep_damage_ratio'), // 萨卡兹王庭军战士 · apoptosis on hit
|
||||
enemy_1229_darmy_2: kitEp('apoptosis', 'epdamage.attack@ep_damage_ratio'), // 萨卡兹王庭军精锐战士 · apoptosis on hit
|
||||
enemy_1275_dwlock_2: kitDeathEye, // 萨卡兹王庭军精锐术师 · apoptosis on hit + DeathEye channel → group apoptosis
|
||||
enemy_1439_dslntf: kitNucleus, // 元核孽生者 · first damage: combat state (speed ×, neural pulse around)
|
||||
enemy_1439_dslntf_2: kitNucleus, // 异光体孽生者 · same
|
||||
enemy_9007_acelem: kitParasite, // 假想敌:淤困 · taunt −1; parasitises its blocker (arts/s, element taken ×, burst spreads)
|
||||
enemy_9007_acelem: kitParasite, // 假想敌:淤困 · taunt −1; parasitises its blocker (arts/s, element taken ×, its burst spreads 1000)
|
||||
enemy_10065_ftzlc: kitIgnitable, // 卷心籽 · ignited by 灼藤 → arts death blast
|
||||
enemy_10067_ftsjc: kitFlameVine, // 灼藤 · ATK ramps until the 1st attack (AoE arts + burn), burn on hit, ignites 卷心籽
|
||||
enemy_10067_ftsjc: kitFlameVine, // 灼藤 · ATK ramps until the 1st attack (3×3 arts + burn), burn on hit, ignites 卷心籽
|
||||
|
||||
// --- DOT 持续
|
||||
enemy_1234_dsubrl: kitDsubrl, // 深溟巢涌者 · pulse hits every ally in range + neural; 抵抗, immune 停顿
|
||||
@@ -2254,7 +2355,7 @@ export const KITS = Object.freeze({
|
||||
},
|
||||
}];
|
||||
},
|
||||
enemy_10045_parrot: (ab) => [float(), { taken(c, b, e, a) { if (a.on) return; a.on = true; b.addBuff(e, { key: 'ab:parrotRun', duration: T(ab, 'M0SpeedUp.duration') ?? 0, mods: { moveMul: 1 + (T(ab, 'M0SpeedUp.move_speed') ?? 0) } }); } }], // 吉兆飞鳞 · 近地悬浮; sprints when first hit
|
||||
enemy_10045_parrot: kitParrot, // 吉兆飞鳞 · 近地悬浮 (grounded 8 s when stunned); sprints when first hit
|
||||
enemy_10087_hlchgr: (ab) => [{ // 圣堂剑士 · spends ammo (every 6 s, max 5) for permanent speed/ATK
|
||||
iv: T(ab, 'SkillTrigger.interval') ?? 6,
|
||||
tick(b, e, a) {
|
||||
@@ -2386,7 +2487,8 @@ function chaliceShare(b, c) {
|
||||
if (!a || !(a.share > 0) || Math.hypot(o.x - t.x, o.y - t.y) > a.r) continue;
|
||||
const part = c.dmg.amount * a.share;
|
||||
c.dmg.amount -= part;
|
||||
b.dealDamage(c.source, o, { amount: part, type: c.dmg.type, canDodge: false, tags: [...(c.dmg.tags || []), 'chalice'] });
|
||||
// a 无来源 burst's share stays 无来源, credited like the burst (damage.js)
|
||||
b.dealDamage(c.dmg.sourceless ? c.credit : c.source, o, { amount: part, type: c.dmg.type, canDodge: false, sourceless: c.dmg.sourceless, tags: [...(c.dmg.tags || []), 'chalice'] });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,9 @@
|
||||
// * "同一行有3名干员" / "同一行每有1名干员" count the trait's owner (board chess of that row, self included).
|
||||
// * POSITION "使自身及身后/身前一格干员的已激活盟约分别各层数+N": per target operator (a shared bond gets +N twice).
|
||||
// * 瑰盐 "优先…更靠上的和更靠右的": highest row first (DESIGN §3.1: row 0 is the bottom), then highest column.
|
||||
// * 购买价格 (SERVER_CHESS_PRICE) replaces the base price; earlier price deltas (远见 discount …) are kept.
|
||||
// * 至简 costs bb.price = 2 (its text says 1; blackboards are authoritative).
|
||||
// * 购买价格为N (SERVER_CHESS_PRICE): bb.price is a discount off the tier price — 至简 (Ⅲ, 3) has 2 → 1, 红豆 (Ⅰ, 2)
|
||||
// has 1 → 1, exactly the N both official texts give (user playtest #5: 至简 costs 1). The dispatcher runs it before
|
||||
// every other onPrice modifier, so 远见's discount (never below 1) and strategy caps act on the lowered price.
|
||||
// * [ASSUMED] 余 SERVER_MOST_BOND: ties between most-member bonds are shuffled; the chess is a copy-weighted pool roll
|
||||
// of any tier (the text gives no tier cap); a bond without an available chess falls through to the next tied one.
|
||||
// * [ASSUMED] 松果: the "免费特殊招募" is a free pick-one offer of `rewardOffer.count` (3) chess of the pool's tier.
|
||||
@@ -196,16 +197,14 @@ H.SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT = {
|
||||
},
|
||||
};
|
||||
|
||||
// "购买价格为N": the trait replaces the chess's base price (bb.price — 至简's data says 2 although its text says 1;
|
||||
// blackboards are authoritative). Modifiers that ran earlier in the dispatch (bonds such as 远见's discount) keep their
|
||||
// delta relative to the base price instead of being overwritten.
|
||||
// "购买价格为N": bb.price is the discount off the chess's tier price — 至简 (Ⅲ, 3 资金) carries 2 and 红豆 (Ⅰ, 2 资金)
|
||||
// carries 1, and both texts say 购买价格为1 (read as the new price, 至简 cost 2; user playtest #5). The dispatcher runs
|
||||
// this first on onPrice (effectsMeta.js), so bonds (远见 −1, never below 1) and strategies see the lowered price.
|
||||
H.SERVER_CHESS_PRICE = {
|
||||
onPrice(ctx, ev) {
|
||||
const p = ctx.source.bb.price;
|
||||
if (!Number.isFinite(p) || !ev) return;
|
||||
const base = ev.slot && Number.isFinite(ev.slot.basePrice) ? ev.slot.basePrice : null;
|
||||
const cur = Number.isFinite(Number(ev.price)) ? Number(ev.price) : base;
|
||||
ctx.setPrice(base == null || cur == null ? p : p + (cur - base));
|
||||
ctx.modifyPrice(-p);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
num, itemRecord, itemKeyOf, buffsOf, isOp, onField, unitBonds, isMember, bondActive, isGroundOp, frontTile,
|
||||
alliesAround, passiveBuff, fxOn, battleStore, contentInfo, itemsOf,
|
||||
} from '../support/index.js';
|
||||
import { mitigate } from '../../damage.js';
|
||||
import { mitigate, hasHp } from '../../damage.js';
|
||||
|
||||
// =====================================================================================================================
|
||||
// data helpers
|
||||
@@ -244,9 +244,10 @@ function hammerAcquire(battle, rt, u) {
|
||||
hs.scope = S;
|
||||
hs.aspdCur = 0;
|
||||
S.every(0.5, () => hammerRefresh(battle, rt, u, hs), { immediate: true });
|
||||
// 灼燃: arts damage also deals burn (元素损伤) = damage_scale × damage × m
|
||||
// 灼燃: arts damage also deals burn (元素损伤) = damage_scale × damage × m — not on a killing blow (the hook runs
|
||||
// before the kill, at 0 HP: no burst on the corpse)
|
||||
S.on('damaged', (c) => {
|
||||
if (c.source !== u || c.type !== 'arts' || !(c.amount > 0) || isProc(c.dmg) || !c.target || c.target.side !== 'enemy' || !c.target.alive) return;
|
||||
if (c.source !== u || c.type !== 'arts' || !(c.amount > 0) || isProc(c.dmg) || !c.target || c.target.side !== 'enemy' || !hasHp(c.target)) return;
|
||||
const p = hammerParams(hs, 'burn');
|
||||
if (!p) return;
|
||||
const m = hammerMul(battle, rt, u, hs, 'burn');
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// Profession defaults (professions.js) are reused: kits only add what the trait text / module says beyond them.
|
||||
//
|
||||
// Covered (normal + elite): 1_01 隐现 1_02 角峰 1_03 惊蛰 1_04 深巡 1_05 红豆(H) 1_06 刺玫 1_07 普罗旺斯 1_08 德克萨斯
|
||||
// 1_09 跃跃 1_10 古米 1_11 地灵(H) 1_12 艾丝黛尔 1_13 波登可 1_14 格雷伊 1_15 盟约·辅助干员(H) 1_16 锡人(H) 1_17 深靛
|
||||
// 1_18 宴 1_19 野鬃 1_20 雷蛇. (H = hidden in the shop pool, still authored.)
|
||||
// 1_09 跃跃 1_10 古米 1_11 地灵(H) 1_12 艾丝黛尔 1_13 波登可 1_14 格雷伊 1_16 锡人(H) 1_17 深靛
|
||||
// 1_18 宴 1_19 野鬃 1_20 雷蛇. (H = hidden in the shop pool, still authored.) 1_15 盟约·辅助干员(H) lives in tier6.js.
|
||||
// Operator loadouts (DESIGN §16): every selectable non-default skill of the 16 visible chess is authored in the kit's
|
||||
// `skills: { [skillId]: SkillSpec }` map from its own SkillRecord (skillRec / skillBbOf — Lv4 normal, Lv7 elite);
|
||||
// talents / traits read the resolved record, so a module choice ('none' ⇒ traitBase / talentsBase, module.active
|
||||
@@ -22,6 +22,7 @@
|
||||
import { COLS } from '../../constants.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets } from '../../targeting.js';
|
||||
import { frontOf, offsetTile } from '../../dir.js';
|
||||
import { bodyInKeys, bodyOnTile, bodyTileReach } from '../../body.js';
|
||||
|
||||
// =================================================================================================================
|
||||
// shared helpers (named exports; content/index.js only merges the default export)
|
||||
@@ -51,8 +52,10 @@ export const moduleOn = (chess) => !!(chess?.module && chess.module.active);
|
||||
|
||||
export const up = (u) => !!u && u.alive && u.deployed;
|
||||
export const posKey = (u) => Math.round(u.y) * COLS + Math.round(u.x);
|
||||
/** Chebyshev tile distance between two units. */
|
||||
export const cheb = (a, b) => Math.max(Math.abs(Math.round(a.y) - Math.round(b.y)), Math.abs(Math.round(a.x) - Math.round(b.x)));
|
||||
/** Chebyshev tile distance between two units (a huge enemy: from the nearest tile it occupies — sim/body.js). */
|
||||
export const cheb = (a, b) => (b.hitArea ? bodyTileReach(b, Math.round(a.y), Math.round(a.x))
|
||||
: a.hitArea ? bodyTileReach(a, Math.round(b.y), Math.round(b.x))
|
||||
: Math.max(Math.abs(Math.round(a.y) - Math.round(b.y)), Math.abs(Math.round(a.x) - Math.round(b.x))));
|
||||
/** Normal attack hit on its primary target (no splash, no chain jump). */
|
||||
export const isMainHit = (dmg) => !!dmg && dmg.isAttack && !dmg.isSplash && !(dmg.tags && dmg.tags.includes('chain'));
|
||||
/** Damage ctx caused by an enemy's attack. */
|
||||
@@ -152,7 +155,7 @@ export function installReveal(battle, unit, interval = 0.2) {
|
||||
battle.every(interval, () => {
|
||||
if (!up(unit) || !unit.rangeKeySet) return;
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive || e.hidden || !e.s.flags.stealth || !unit.rangeKeySet.has(posKey(e))) continue;
|
||||
if (!e.alive || e.hidden || !e.s.flags.stealth || !bodyInKeys(e, unit.rangeKeySet)) continue;
|
||||
const was = !!e.s.flags.reveal;
|
||||
if (battle.applyStatus(e, 'reveal', { duration: interval + 0.1, source: unit }) && !was) battle.fx('reveal', { x: e.x, y: e.y, id: e.id });
|
||||
}
|
||||
@@ -544,7 +547,7 @@ export default {
|
||||
}
|
||||
if (!isMainHit(dmg)) return;
|
||||
const [fr, fc] = frontOf(unit.tileR, unit.tileC, unit.dir);
|
||||
const front = Math.round(target.y) === fr && Math.round(target.x) === fc;
|
||||
const front = bodyOnTile(target, fr, fc);
|
||||
const p = front ? num(t.prob2) : num(t.prob);
|
||||
if (p > 0 && battle.rng.chance(p)) {
|
||||
dmg.amount *= num(t.atk_scale, 1);
|
||||
@@ -557,8 +560,8 @@ export default {
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 1_08 德克萨斯 剑雨: +cost DP; every enemy around (skill grid) takes two hits of atk_scale × ATK arts and is
|
||||
// stunned `stun` s. 战术快递: +cost initial DP. (Elite hidden runtime_cost −4 "首次部署时部署费用-4": the initial
|
||||
// deployment is free in battle ⇒ no effect.)
|
||||
// stunned `stun` s — air units too (PRTS 备注 "※可对空"). 战术快递: +cost initial DP. (Elite hidden runtime_cost −4
|
||||
// "首次部署时部署费用-4": the initial deployment is free in battle ⇒ no effect.)
|
||||
// Alternate S1 冲锋号令·γ型 (AUTO, no target): +cost DP at once. An AUTO skill keeps its normal rule in this mode
|
||||
// (research 03 §1.4: only MANUAL skills are converted by the trigger table) ⇒ it fires as soon as SP is full.
|
||||
chess_char_1_08_a: (bb, chess, def) => ({
|
||||
@@ -724,7 +727,7 @@ export default {
|
||||
battle.on('death', (ctx) => {
|
||||
const e = ctx.unit;
|
||||
if (e.side !== 'enemy' || ctx.reason !== 'killed' || !up(unit)) return;
|
||||
const near = grid ? absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0).includes(posKey(e)) : cheb(unit, e) <= 1;
|
||||
const near = grid ? bodyInKeys(e, absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)) : cheb(unit, e) <= 1;
|
||||
if (near) battle.heal(unit, unit, unit.s.maxHp * num(t.hp_ratio), { self: true, tags: ['talent'] });
|
||||
}, { owner: unit });
|
||||
if (tb.damage_resistance != null) {
|
||||
@@ -800,28 +803,7 @@ export default {
|
||||
};
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 1_15 盟约·辅助干员 (hidden, band 优等生) 战术咏唱·双型: ASPD +attack_speed, attack@max_target targets.
|
||||
// 迭代元素: each hit adds ep_damage_ratio × ATK element damage — 神经 first, else 灼燃, else 凋亡 (the first element
|
||||
// not bursting on the target); targets not in a burst are preferred. Elite module: ep_damage_ratio_boss vs
|
||||
// ELITE/BOSS enemies.
|
||||
chess_char_1_15_a: (bb, chess) => {
|
||||
const t = talentBb(chess, 0);
|
||||
const ratio = num(t.ep_damage_ratio), ratioBoss = num(t.ep_damage_ratio_boss, ratio);
|
||||
return {
|
||||
trait: {
|
||||
priority: 'notBurst',
|
||||
afterHit(battle, u, target) {
|
||||
if (!target || !target.alive || target.side !== 'enemy' || !(ratio > 0)) return;
|
||||
const el = ['neural', 'burn', 'apoptosis'].find((k) => !target.findBuff(k + 'Burst'));
|
||||
if (!el) return;
|
||||
const r = target.def?.rank === 'ELITE' || target.def?.rank === 'BOSS' || target.isBoss ? ratioBoss : ratio;
|
||||
battle.dealDamage(u, target, { type: 'element', element: el, amount: u.s.atk * r, tags: ['talent'] });
|
||||
},
|
||||
},
|
||||
skill: { kind: 'duration', mods: { aspd: num(bb.attack_speed) }, targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 1))) } },
|
||||
};
|
||||
},
|
||||
// 1_15 盟约·辅助干员 (hidden, band 优等生): its kit is tier6.js `pithst` (迭代元素 — one implementation only).
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 1_16 锡人 (hidden tier-1 entry of chess_char_2_19): see tinmanKit.
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
|
||||
import { COLS, ELEMENT_GAUGE_MAX } from '../../constants.js';
|
||||
import { canTargetEnemy } from '../../targeting.js';
|
||||
import { bodyInKeys } from '../../body.js';
|
||||
import {
|
||||
num, talentBb, moduleBb, traitBb, up, posKey, cheb, byEnemyAttack, onHitBy, onHitOn, onDamagedOn, enemiesInGrid,
|
||||
num, talentBb, moduleBb, traitBb, up, cheb, byEnemyAttack, onHitBy, onHitOn, onDamagedOn, enemiesInGrid,
|
||||
alliesInGridOf, enemyInRange, statBuff, toggleBuff, installAura, spTimeBonus, freeTileAround, summonTileFree, instantKind,
|
||||
tinmanKit, batMod,
|
||||
} from './tier1.js';
|
||||
@@ -48,8 +49,8 @@ function tokenLifetime(battle, tokId, owner, desc) {
|
||||
return m ? +m[1] : 0;
|
||||
}
|
||||
|
||||
/** Largest element gauge of a unit. */
|
||||
const elemLoad = (a) => Math.max(a.elem.burn, a.elem.neural, a.elem.necrosis, a.elem.apoptosis);
|
||||
/** Largest element gauge of a unit (every element — 侵蚀 included: the bosses' attacks fill it on operators). */
|
||||
const elemLoad = (a) => Math.max(a.elem.burn, a.elem.neural, a.elem.necrosis, a.elem.apoptosis, a.elem.erosion ?? 0);
|
||||
|
||||
/**
|
||||
* Whether the SELECTED skill of a loadout-resolved chess record is its default skill (DESIGN §16: `chess.skill` is the
|
||||
@@ -211,7 +212,9 @@ export default {
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 2_05 哈洛德 重症优先: ASPD +attack_speed, heals the ally with the heaviest element damage first; on targets whose
|
||||
// gauge is over half, the element recovery is trait_scale × (the wandermedic trait: ep_heal_ratio × ATK).
|
||||
// 我即军营: allies in range whose gauge is over half take −ep_damage_resistance element damage.
|
||||
// 我即军营: allies in range whose gauge is over half take −ep_damage_resistance 元素损伤 — checked on the element hit
|
||||
// itself (`elementHit`, a 元素损伤 multiplier: PRTS 元素 "…后续可应用元素损伤倍率提升/降低等效果"; not `elemTakenMul`,
|
||||
// which is 元素伤害 / 元素脆弱); several 哈洛德 do not stack — the strongest cut applies.
|
||||
// S1 治疗强化·γ型 (alt): ATK +atk; the 重症优先 target order belongs to S2 only.
|
||||
chess_char_2_05_a: (bb, chess) => {
|
||||
const t = talentBb(chess, 0);
|
||||
@@ -235,14 +238,17 @@ export default {
|
||||
if (cands.length) ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length));
|
||||
unit.mem.haroldHalf = new Set(ctx.targets.filter((a) => elemLoad(a) > half).map((a) => a.id));
|
||||
}, { owner: unit });
|
||||
const r = num(t.ep_damage_resistance);
|
||||
const r = Math.max(0, Math.min(1, num(t.ep_damage_resistance)));
|
||||
if (r > 0) {
|
||||
battle.every(0.2, () => {
|
||||
if (!up(unit)) return;
|
||||
for (const a of alliesInGridOf(battle, unit)) {
|
||||
if (elemLoad(a) > half) battle.addBuff(a, { key: 'harold:camp', duration: 0.3, mods: { elemTakenMul: 1 - r }, source: unit, tags: ['talent'] });
|
||||
}
|
||||
}, { owner: unit });
|
||||
battle.on('elementHit', (ctx) => {
|
||||
const a = ctx.target, d = ctx.dmg;
|
||||
if (!up(unit) || !a || a.side !== 'ally' || !d || d.type !== 'element' || !(elemLoad(a) > half)) return;
|
||||
if (!(unit.rangeKeySet || new Set(unit.rangeKeys || [])).has(a.tileR * COLS + a.tileC)) return;
|
||||
const prev = d.haroldCut || 0; // the strongest 我即军营 of the hit only
|
||||
if (r <= prev) return;
|
||||
d.mul *= (1 - r) / (1 - prev);
|
||||
d.haroldCut = r;
|
||||
}, { owner: unit, priority: 20 });
|
||||
}
|
||||
} }],
|
||||
};
|
||||
@@ -287,7 +293,7 @@ export default {
|
||||
unit.mem.papyrsHook = battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !skill.active) return;
|
||||
const L = unit.mem.papyrsLock;
|
||||
if (L && up(L) && unit.rangeKeySet?.has(posKey(L))) ctx.targets = [L];
|
||||
if (L && up(L) && bodyInKeys(L, unit.rangeKeySet)) ctx.targets = [L];
|
||||
}, { owner: unit, priority: 10 });
|
||||
battle.fx('buff', { x: lock.x, y: lock.y, id: lock.id, kind: 'lock' });
|
||||
},
|
||||
@@ -477,7 +483,7 @@ export default {
|
||||
if (s2) battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !unit.skill?.active) return;
|
||||
const L = unit.mem.rockLock;
|
||||
if (L && L.alive && unit.rangeKeySet?.has(posKey(L)) && canTargetEnemy(unit, L, ctx.profile || unit.profile)) ctx.targets = [L];
|
||||
if (L && L.alive && bodyInKeys(L, unit.rangeKeySet) && canTargetEnemy(unit, L, ctx.profile || unit.profile)) ctx.targets = [L];
|
||||
else unit.mem.rockLock = ctx.targets[0] ?? null;
|
||||
}, { owner: unit });
|
||||
const iv = num(t.interval), max = Math.floor(num(t.max_stack_cnt)), v = num(t.atk);
|
||||
@@ -497,7 +503,8 @@ export default {
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 2_11 风丸 纸艺·双影: lose hp_ratio of current HP, ATK +atk, summon the <替身> (overrideTokenKey) on a free melee tile
|
||||
// around her for the skill's duration [ASSUMED lifetime]. 折纸生花: whenever a <替身> appears (the summon, and the
|
||||
// dollkeeper substitution) enemies on the 8 surrounding tiles take damage_scale × its ATK arts.
|
||||
// dollkeeper substitution) enemies on the 8 surrounding tiles take damage_scale × its ATK arts — air units too [ASSUMED:
|
||||
// no 对空 note on PRTS; the <替身> itself "可对空"].
|
||||
// Trait substitution: she fights with the <替身>'s stats (the engine already swaps in its HP; the kit swaps ATK/DEF
|
||||
// with flat deltas so %-buffs still apply). Elite module (PUM-X, trait atk): while substituted, ATK +atk.
|
||||
// S1 纸艺·迅击 (alt, attack SP): the next attack hits at atk_scale × ATK and she loses hp_ratio of her MAX HP (PRTS:
|
||||
@@ -759,8 +766,9 @@ export default {
|
||||
onStart({ battle, unit, skill }) {
|
||||
if (unit.mem.sundial) battle.off(unit.mem.sundial);
|
||||
const p = num(bb.prob);
|
||||
unit.mem.sundial = p > 0 ? onHitOn(battle, unit, ({ source, dmg }) => {
|
||||
if (!skill.active || dmg.cancel || dmg.type !== 'phys' || !source || source.side !== 'enemy') return;
|
||||
unit.mem.sundial = p > 0 ? onHitOn(battle, unit, ({ source, credit, dmg }) => { // 抵挡 is target-side: a 无来源 burst counts via its credit
|
||||
const src = source || credit;
|
||||
if (!skill.active || dmg.cancel || dmg.type !== 'phys' || !src || src.side !== 'enemy') return;
|
||||
if (battle.rng.chance(p)) { dmg.cancel = true; battle.fx('block', { x: unit.x, y: unit.y, id: unit.id }); }
|
||||
}, 5) : null;
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../../targeting.js';
|
||||
import { COLS } from '../../constants.js';
|
||||
import { bodyDist, bodyInKeys, bodyOnTile, bodyTileReach } from '../../body.js';
|
||||
import { normalizeChess, normalizeSkill } from '../../simdata.js';
|
||||
import { tacticalPoint as sharedTacticalPoint } from '../tokens.js';
|
||||
|
||||
@@ -80,8 +81,8 @@ function profileMul(battle, unit, target) {
|
||||
/** Targetable enemies within `r` tiles of (x, y), nearest first (spawn order breaks ties), excluding `skip`. */
|
||||
function enemiesAround(battle, unit, x, y, r, skip = null) {
|
||||
const out = battle.enemiesInRadius(x, y, r).filter((e) => !(skip && skip.has(e)) && canTargetEnemy(unit, e, { canHitFly: true }));
|
||||
const d2 = (e) => (e.x - x) ** 2 + (e.y - y) ** 2;
|
||||
return out.sort((a, b) => d2(a) - d2(b) || (a.spawnSeq ?? a.id) - (b.spawnSeq ?? b.id));
|
||||
const d = (e) => bodyDist(e, x, y);
|
||||
return out.sort((a, b) => d(a) - d(b) || (a.spawnSeq ?? a.id) - (b.spawnSeq ?? b.id));
|
||||
}
|
||||
/**
|
||||
* "范围内存在地面敌人时攻击速度+N" (能天使 MAR-Y trait, 空弦 MAR-Y talent): ASPD buff `key` while at least `cnt` ground
|
||||
@@ -104,6 +105,8 @@ function groundAspd(key, aspd, cnt = 1) {
|
||||
const AROUND_R = 1.5;
|
||||
const alive = (u) => !!(u && u.alive && u.deployed);
|
||||
const tileKeyOf = (u) => (u.side === 'ally' ? u.tileR * COLS + u.tileC : Math.round(u.y) * COLS + Math.round(u.x));
|
||||
/** On a tile of `set`: an ally by its tile, an enemy by its body (every tile a huge enemy occupies — sim/body.js). */
|
||||
const onTiles = (x, set) => (x.side === 'ally' ? set.has(tileKeyOf(x)) : bodyInKeys(x, set));
|
||||
const gridKeys = (grid, u, ext = 0) => absoluteRangeKeys(grid || [[0, 0]], u.tileR, u.tileC, u.dir, ext);
|
||||
const fx = (battle, kind, u, extra = {}) => battle.fx(kind, { x: u.x, y: u.y, id: u.id, ...extra });
|
||||
const copyGrid = (g) => (Array.isArray(g) && g.length ? g.map((p) => [p[0], p[1]]) : null);
|
||||
@@ -171,7 +174,7 @@ function aura(battle, unit, o) {
|
||||
const list = o.side === 'ally' ? battle.allyUnits : battle.enemies;
|
||||
for (const x of list) {
|
||||
if (!x.alive || !x.deployed || x.hidden || x.kind === 'device') continue;
|
||||
if (!set.has(tileKeyOf(x)) || (o.filter && !o.filter(x))) continue;
|
||||
if (!onTiles(x, set) || (o.filter && !o.filter(x))) continue;
|
||||
const mods = typeof o.mods === 'function' ? o.mods(x) : o.mods;
|
||||
if (!mods) continue;
|
||||
battle.addBuff(x, { key: o.key, duration: dur, refresh: 'extend', mods, source: unit, tags: ['aura'] });
|
||||
@@ -391,7 +394,8 @@ const KITS = {
|
||||
// 大买家: coin at skill start + coin & ATK stack per trait payment; 破财消灾: DP-paid revive (cost doubles)
|
||||
// S1 仗义疏财 (passive, 2 coins): an attack spends a coin to heal the most injured ally (< 70 % HP) of the 8
|
||||
// surrounding tiles for attack@heal_scale × ATK. S3 千金一掷 (持续时间无限): attacks hit twice, kills give a coin;
|
||||
// closing it spends every coin on random enemies of the front range (atk_scale phys + small push forward).
|
||||
// closing it spends every coin on random ground enemies of the front range (atk_scale phys + small push forward;
|
||||
// PRTS 备注: 地面敌方单位, 弹道不可对空).
|
||||
// Auto-close (the mode casts everything itself; the player's "主动关闭" is not available): once the purse is full
|
||||
// (10) and an enemy stands in range. 精锐 module MER-Y: ATK +4 % per trait payment (≤ 5 stacks).
|
||||
chess_char_3_04_a: (bb, chess, def) => {
|
||||
@@ -420,7 +424,7 @@ const KITS = {
|
||||
if (!bomb.alive || !bomb.deployed) return;
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive || e.hidden || e.isFlying || e.s.flags.untargetable) continue;
|
||||
if (Math.round(e.x) !== bomb.tileC || Math.round(e.y) !== bomb.tileR) continue;
|
||||
if (!bodyOnTile(e, bomb.tileR, bomb.tileC)) continue;
|
||||
// first enemy touching it; after switchT s on the field the bomb deals its damage one extra time
|
||||
const hits = battle.time - bomb.deployedAt >= switchT - 1e-9 ? 2 : 1;
|
||||
for (let i = 0; i < hits && e.alive; i++) {
|
||||
@@ -467,15 +471,18 @@ const KITS = {
|
||||
kind: 'toggle',
|
||||
attack: { hits: 2 },
|
||||
onTick({ battle, unit, skill }) {
|
||||
if ((unit.mem.coins ?? 0) >= full && enemiesOn(battle, unit, unit.rangeKeys).length) skill.end('manual');
|
||||
if ((unit.mem.coins ?? 0) >= full && enemiesOn(battle, unit, unit.rangeKeys, 0, { ...unit.profile, canHitFly: false }).length) skill.end('manual');
|
||||
},
|
||||
onEnd({ battle, unit, reason }) {
|
||||
if (reason !== 'manual' || !unit.alive) return;
|
||||
const n = unit.mem.coins ?? 0;
|
||||
unit.mem.coins = 0;
|
||||
let spent = 0;
|
||||
// PRTS 备注: 【金币标记】 goes on "前方范围内的地面敌方单位与自身阻挡的所有单位" and the coins are "弹道(不可对空)" —
|
||||
// air units (FLY, 近地悬浮, 浮空) are never paid
|
||||
const ground = { ...unit.profile, canHitFly: false };
|
||||
for (let i = 0; i < n; i++) {
|
||||
const e = battle.rng.pick(enemiesOn(battle, unit, unit.rangeKeys));
|
||||
const e = battle.rng.pick(enemiesOn(battle, unit, unit.rangeKeys, 0, ground));
|
||||
if (!e) break;
|
||||
spent++;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * cash, type: 'phys', isSkill: true, tags: ['skill', 'swire2Cash'] });
|
||||
@@ -596,11 +603,17 @@ const KITS = {
|
||||
return kit;
|
||||
},
|
||||
|
||||
// ---- 3_06 菲莱 · 本源铁卫 — S2 冥河诅咒: stops attacking, HP +; when attacked blasts ground enemies around (arts +
|
||||
// apoptosis, aoe_cd); ATK + once hit by element damage; 神河谕使: element taken −10 %, +SP on apoptosis
|
||||
// 精锐 module PRP-X: own element damage ×ep_damage_scale while blocking
|
||||
// ---- 3_06 菲莱 · 本源铁卫 — S2 冥河诅咒: stops attacking, HP +; when attacked blasts the ground enemies of the 3×3
|
||||
// around her (PRTS 备注: range x-4; arts + ep_damage_ratio × ATK apoptosis, aoe_cd); ATK + once hit by element
|
||||
// damage; 神河谕使: 元素损伤 taken −damage_resistance, +SP on apoptosis
|
||||
// 精锐 module PRP-X "阻挡敌人时,自身造成的元素损伤提升15%": EVERY element fill she deals (the S2 blast, a 灼燃维式重锤
|
||||
// she carries …) ×ep_damage_scale while she blocks — an `elementHit` multiplier, like 余's
|
||||
// S1 灵河护佑 (TAKE_DAMAGE): HP +, clears her element gauges and gives a shield_value 损伤屏障 (absorbs element
|
||||
// damage — gauge fills — until spent or the skill ends)
|
||||
// 神河谕使 and the barrier act on the element hit before it lands (PRTS 备注: "减伤与技力回复效果于伤害计算前处理;即使
|
||||
// 受到0点的凋亡损伤依然可以回复技力", "损伤屏障于伤害计算前、第一天赋后处理"): the talent's cut and SP first (priority 20),
|
||||
// then the barrier absorbs what is left (`dmg.amount × dmg.mul`). The cut is a 元素损伤 multiplier (PRTS 元素 "受到的
|
||||
// 元素损伤 = 损伤值 × (1 − 损伤抵抗 × 0.01),后续可应用元素损伤倍率提升/降低等效果"), not `elemTakenMul` (元素伤害 / 元素脆弱).
|
||||
chess_char_3_06_a: (bb, chess, def) => {
|
||||
const d = defOf(chess, def);
|
||||
const t0 = talentBb(d, 0);
|
||||
@@ -612,13 +625,13 @@ const KITS = {
|
||||
const ep = num(bb.ep_damage_ratio, 0);
|
||||
const cd = num(bb.aoe_cd, 2);
|
||||
const epScale = num(tb.ep_damage_scale, 1);
|
||||
// 损伤屏障 of S1: absorbs the gauge gain an element hit would add (after her element-taken multipliers)
|
||||
const cut = Math.max(0, Math.min(1, num(t0.damage_resistance)));
|
||||
// 损伤屏障 of S1: absorbs the gauge gain an element hit would add (after 神河谕使)
|
||||
const installBarrier = (battle, unit) => {
|
||||
battle.on('elementHit', (ctx) => {
|
||||
if (ctx.target !== unit || !(unit.mem.philaeBarrier > 0) || !unit.skill?.active) return;
|
||||
const dmg = ctx.dmg;
|
||||
const f = num(dmg.mul, 1) * unit.s.elemTakenMul * (1 - Math.max(0, Math.min(1, num(unit.def?.epDamageResistance, 0))));
|
||||
const eff = num(dmg.amount) * f;
|
||||
const eff = num(dmg.amount) * num(dmg.mul, 1);
|
||||
if (!(eff > 0)) return;
|
||||
const take = Math.min(unit.mem.philaeBarrier, eff);
|
||||
unit.mem.philaeBarrier -= take;
|
||||
@@ -648,36 +661,47 @@ const KITS = {
|
||||
}),
|
||||
}),
|
||||
talents: [{ install(battle, unit) {
|
||||
battle.addBuff(unit, { key: 'talent:philae_elem', mods: { elemTakenMul: 1 - num(t0.damage_resistance) }, persist: true, allowDead: true });
|
||||
// 神河谕使 (every skill): 元素损伤 taken ×(1 − damage_resistance); "受到凋亡损伤时回复2点技力" — even for 0 damage
|
||||
battle.on('elementHit', (ctx) => {
|
||||
const dmg = ctx.dmg;
|
||||
if (ctx.target !== unit || !dmg || dmg.type !== 'element') return;
|
||||
if (dmg.element === 'apoptosis' && !unit.skill?.active) giveSp(unit, num(t0.sp), 'talent');
|
||||
if (cut > 0) dmg.mul *= 1 - cut;
|
||||
}, { owner: unit, priority: 20 });
|
||||
} }],
|
||||
install(battle, unit) {
|
||||
if (sel === S1) installBarrier(battle, unit);
|
||||
// module PRP-X: her element damage ×ep_damage_scale while she blocks
|
||||
if (epScale > 1) {
|
||||
battle.on('elementHit', (ctx) => {
|
||||
if (ctx.source === unit && ctx.dmg?.type === 'element' && ctx.target?.side === 'enemy' && unit.blocking.length) ctx.dmg.mul *= epScale;
|
||||
}, { owner: unit });
|
||||
}
|
||||
battle.on('damaged', (ctx) => {
|
||||
if (ctx.target !== unit || !unit.alive) return;
|
||||
const sk = unit.skill;
|
||||
if (ctx.type === 'element') {
|
||||
// 神河谕使 (every skill): "受到凋亡损伤时回复2点技力"
|
||||
if (ctx.dmg?.element === 'apoptosis' && !(sk && sk.active)) giveSp(unit, num(t0.sp), 'talent');
|
||||
if (isS2 && sk && sk.active && !unit.findBuff('skill:philae_rage')) battle.addBuff(unit, { key: 'skill:philae_rage', mods: { atkPct: num(bb.atk) }, visible: true });
|
||||
return;
|
||||
}
|
||||
if (!isS2 || !sk || !sk.active || !ctx.dmg?.isAttack || !ctx.source || ctx.source.side !== 'enemy') return;
|
||||
if (battle.time < (unit.mem.philaeCd ?? -Infinity)) return;
|
||||
unit.mem.philaeCd = battle.time + cd;
|
||||
const epMul = unit.blocking.length ? epScale : 1;
|
||||
for (const e of battle.enemiesInRadius(unit.x, unit.y, 1.5)) {
|
||||
// "周围的地面敌人" = range x-4, the 3×3 tiles around her (PRTS 备注)
|
||||
for (const e of battle.unitsInGrid(unit, NINE, { side: 'enemy' })) {
|
||||
if (e.isFlying) continue;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', canDodge: false, isSkill: true, tags: ['skill', 'counter'] });
|
||||
if (ep > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'apoptosis', amount: unit.s.atk * ep * epMul, tags: ['skill'] });
|
||||
if (ep > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'apoptosis', amount: unit.s.atk * ep, tags: ['skill'] });
|
||||
}
|
||||
fx(battle, 'aoe', unit, { radius: 1.5, dmgType: 'arts', skill: 'philae_2' });
|
||||
fx(battle, 'aoe', unit, { radius: 1.5, tiles: 'box', dmgType: 'arts', skill: 'philae_2' }); // box: the 3×3 tiles
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// ---- 3_07 见行者 · 推击手 (hidden) — S2 惊爆射击: push every enemy in the skill range forward + stun (longer when
|
||||
// slammed into a wall, collided enemies stunned too); 技巧射击: ignore DEF vs heavy enemies;
|
||||
// slammed into a wall, collided enemies stunned too; air units too [ASSUMED: "范围内所有敌人", no 对空 note on
|
||||
// PRTS] — a 失衡免疫 enemy is not pushed but still stunned); 技巧射击: ignore DEF vs heavy enemies;
|
||||
// 精锐 module PUS-X: redeployed on a ranged tile ⇒ half the deployment cost back
|
||||
chess_char_3_07_a: (bb, chess, def) => {
|
||||
const d = defOf(chess, def);
|
||||
@@ -693,7 +717,7 @@ const KITS = {
|
||||
kind: 'instant',
|
||||
onStart({ battle, unit }) {
|
||||
const keys = new Set(gridKeys(skillGrid ?? unit.rangeGrid, unit));
|
||||
const victims = battle.enemies.filter((e) => e.alive && !e.hidden && !e.s.flags.untargetable && keys.has(tileKeyOf(e)));
|
||||
const victims = battle.enemies.filter((e) => e.alive && !e.hidden && !e.s.flags.untargetable && onTiles(e, keys));
|
||||
const pushed = new Set(victims);
|
||||
const dist = 0.5 + 0.5 * force; // engine displacement convention (docs/SIM.md §12)
|
||||
for (const e of victims) {
|
||||
@@ -844,7 +868,7 @@ const KITS = {
|
||||
battle.on('kill', (ctx) => {
|
||||
const v = ctx.victim;
|
||||
if (!unit.skill?.active || !alive(unit) || v.side !== 'enemy' || !isNormalEnemy(v)) return;
|
||||
if (!unit.rangeKeySet?.has(tileKeyOf(v))) return;
|
||||
if (!unit.rangeKeySet || !onTiles(v, unit.rangeKeySet)) return;
|
||||
unit.mem.hainiMul = Math.min(upMax, (unit.mem.hainiMul ?? 1) + up);
|
||||
}, { owner: unit });
|
||||
} }],
|
||||
@@ -971,8 +995,9 @@ const KITS = {
|
||||
// 剑盾骑士: hurt-SP skills of the team also gain SP on attack; 仁慈: attacks sleeping enemies (first, ×atk_scale);
|
||||
// 精锐 module GUA-Y: damage taken −15 %
|
||||
// S1 光芒涌动 (TAKE_DAMAGE, charges): next attack ×atk_scale phys + heals the most injured ally of the 3×3 (herself
|
||||
// included) for heal_scale × ATK; S2 慑敌辉光: ATK +, puts every ground enemy on her own tile to sleep (for the
|
||||
// skill's duration: no own value in the data) and heals every ally of the skill range by ATK × ratio each second;
|
||||
// included) for heal_scale × ATK; S2 慑敌辉光: ATK +, puts every ground enemy on her own tile and every enemy she
|
||||
// blocks to sleep (PRTS 备注; for the skill's duration: no own value in the data) and heals every ally of the
|
||||
// skill range by ATK × ratio each second;
|
||||
// 精锐 module GUA-X: her heals on allies under hp_ratio HP × heal_scale
|
||||
chess_char_3_12_a: (bb, chess, def) => {
|
||||
const d = defOf(chess, def);
|
||||
@@ -984,7 +1009,7 @@ const KITS = {
|
||||
const mercy = num(t1.atk_scale, 1);
|
||||
const sleepersInRange = (battle, unit) => {
|
||||
const set = unit.rangeKeySet;
|
||||
return set ? battle.enemies.filter((e) => e.alive && !e.hidden && e.s.flags.sleep && !e.s.flags.untargetable && !e.isFlying && set.has(tileKeyOf(e))) : [];
|
||||
return set ? battle.enemies.filter((e) => e.alive && !e.hidden && e.s.flags.sleep && !e.s.flags.untargetable && !e.isFlying && onTiles(e, set)) : [];
|
||||
};
|
||||
const kit = {
|
||||
trait: { hitSleep: true }, // 仁慈: 自身可以攻击…沉睡的目标 (沉睡 = 无敌 for everyone else)
|
||||
@@ -1027,8 +1052,11 @@ const KITS = {
|
||||
onStart({ battle, unit, skill }) {
|
||||
const dur = skill.timeLeft > 0 ? skill.timeLeft : Math.max(0.1, num(s.duration, 10));
|
||||
let n = 0;
|
||||
// PRTS 备注: "技能生效对象实际为“自身这格内的所有地面敌人及自身阻挡的敌人”,即使阻挡的是飞行敌人" — a blocked
|
||||
// enemy stands at the block radius, outside her tile (Battle._checkBlock)
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive || e.hidden || e.isFlying || Math.round(e.x) !== unit.tileC || Math.round(e.y) !== unit.tileR) continue;
|
||||
if (!e.alive || e.hidden) continue;
|
||||
if (e.blockedBy !== unit && (e.isFlying || !bodyOnTile(e, unit.tileR, unit.tileC))) continue;
|
||||
if (battle.applyStatus(e, 'sleep', { duration: dur, source: unit })) n++;
|
||||
}
|
||||
fx(battle, 'aoe', unit, { radius: 0.5, skill: 'blemsh_2', status: 'sleep', n });
|
||||
@@ -1180,7 +1208,7 @@ const KITS = {
|
||||
const r = (k / COLS) | 0, c = k % COLS;
|
||||
if (!freeTile(battle, r, c) || !battle.grid.canStand(r, c, { ranged: true })) continue;
|
||||
let s = 0;
|
||||
for (const e of foes) if (Math.abs(Math.round(e.y) - r) <= reach && Math.abs(Math.round(e.x) - c) <= reach) s++;
|
||||
for (const e of foes) if (bodyTileReach(e, r, c) <= reach) s++;
|
||||
const dd = Math.abs(r - unit.tileR) + Math.abs(c - unit.tileC);
|
||||
if (s > bs || (s === bs && dd < bd)) { best = [r, c]; bs = s; bd = dd; }
|
||||
}
|
||||
@@ -1301,7 +1329,7 @@ const KITS = {
|
||||
if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); }
|
||||
} },
|
||||
}),
|
||||
skchr_vulpis_2: (s) => {
|
||||
skchr_vulpis_2: (s) => { // 坠刃拷问 — air units too (PRTS 备注 "※可对空")
|
||||
const grid = copyGrid(s.rangeGrid);
|
||||
const n = Math.max(1, Math.floor(num(s.bb.max_target, 6)));
|
||||
return {
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
// `node tools/kit-coverage.mjs --tier 4 --strict`; tests: test/content/kits_alt_t4.test.js.
|
||||
|
||||
import { COLS } from '../../constants.js';
|
||||
import { bodyDist, bodyInKeys } from '../../body.js';
|
||||
import { absoluteRangeKeys, canTargetEnemy, sortEnemyTargets } from '../../targeting.js';
|
||||
import { normalizeChess } from '../../simdata.js';
|
||||
import { aggregateMods } from '../../buffs.js';
|
||||
@@ -74,7 +75,7 @@ function ownTokenVariant(battle, tokenId, owner) {
|
||||
function enemiesOnRange(battle, unit, keys = null) {
|
||||
const set = keys || keySet(unit);
|
||||
const out = [];
|
||||
for (const e of battle.enemies) if (e.alive && !e.hidden && set.has(tileKey(e))) out.push(e);
|
||||
for (const e of battle.enemies) if (e.alive && !e.hidden && bodyInKeys(e, set)) out.push(e);
|
||||
return out;
|
||||
}
|
||||
/** Targetable enemies in the unit's current range (flyers included), best targets first. */
|
||||
@@ -510,7 +511,7 @@ const kits = {
|
||||
for (const e of battle.aliveEnemies()) {
|
||||
if (!canTargetEnemy(unit, e, { canHitFly: true })) continue;
|
||||
const t = L2 > 1e-9 ? Math.max(0, Math.min(1, ((e.x - ax) * (bx - ax) + (e.y - ay) * (by - ay)) / L2)) : 0;
|
||||
const d = Math.hypot(e.x - (ax + t * (bx - ax)), e.y - (ay + t * (by - ay)));
|
||||
const d = bodyDist(e, ax + t * (bx - ax), ay + t * (by - ay));
|
||||
if (d <= w + 1e-9) hits.push({ e, t, d });
|
||||
}
|
||||
hits.sort((a, b) => a.t - b.t || a.d - b.d || a.e.id - b.e.id);
|
||||
@@ -880,7 +881,7 @@ const kits = {
|
||||
if (!e.alive) { floating.delete(id); continue; }
|
||||
if (e.findBuff('levitate')) continue;
|
||||
floating.delete(id);
|
||||
if (S2 && unit.alive && unit.deployed && skillActive(unit) && keySet(unit).has(tileKey(e))) {
|
||||
if (S2 && unit.alive && unit.deployed && skillActive(unit) && bodyInKeys(e, keySet(unit))) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * land, type: 'arts', isSkill: true, tags: ['skill', 'landing'] });
|
||||
battle.fx('splash', { x: e.x, y: e.y, id: e.id });
|
||||
}
|
||||
@@ -1041,7 +1042,7 @@ const kits = {
|
||||
skill: {
|
||||
kind: 'duration',
|
||||
onStart({ battle, unit, skill }) {
|
||||
const foes = targetsInRange(battle, unit).sort((a, b) => Math.hypot(b.x - unit.x, b.y - unit.y) - Math.hypot(a.x - unit.x, a.y - unit.y) || a.id - b.id);
|
||||
const foes = targetsInRange(battle, unit).sort((a, b) => bodyDist(b, unit.x, unit.y) - bodyDist(a, unit.x, unit.y) || a.id - b.id);
|
||||
const t = foes[0];
|
||||
unit.mem.tornado = t ? { x: t.x, y: t.y, acc: 0 } : null;
|
||||
if (!t) return;
|
||||
@@ -1254,6 +1255,7 @@ const kits = {
|
||||
// S1 细雨无声 (passive, for the skill duration: ATK up; hits silence the target 5 s/8 s — 失去特殊能力 — with
|
||||
// 260/320 arts per s meanwhile); S2 阵雨连绵 (passive: deploy burst 150 %/180 % arts + RES −15 %/−20 % around her;
|
||||
// for the skill duration ATK up and attacks become arts double hits). Talent 德克萨斯传统 applies to every passive.
|
||||
// The S3 rain hits air units (PRTS 备注 "效果可对空"); so does the S2 deploy burst [ASSUMED: no note].
|
||||
chess_char_4_16_a: (bb, chess, def) => {
|
||||
const t0 = tbb(def, 0), t1 = tbb(def, 1);
|
||||
const tb = def.traitBb || {};
|
||||
@@ -1375,8 +1377,9 @@ const kits = {
|
||||
skill: { kind: 'passive', mods: { defPct: num(bb.def) } },
|
||||
talents: [
|
||||
{ install(battle, unit) { // 战术装甲: 25 % 伤害抵挡 — negates an enemy phys/arts damage instance ("抵挡一次物理或法术伤害")
|
||||
battle.on('hit', (c) => {
|
||||
if (c.target !== unit || !c.source || c.source.side !== 'enemy' || c.dmg.cancel || (c.dmg.type !== 'phys' && c.dmg.type !== 'arts')) return;
|
||||
battle.on('hit', (c) => { // 抵挡 is target-side: a 无来源 burst (source null) counts via the enemy credited with it
|
||||
const src = c.source || c.credit;
|
||||
if (c.target !== unit || !src || src.side !== 'enemy' || c.dmg.cancel || (c.dmg.type !== 'phys' && c.dmg.type !== 'arts')) return;
|
||||
if (battle.rng.chance(num(t0.prob, 0.25))) { c.dmg.cancel = true; battle.fx('block', { x: unit.x, y: unit.y, id: unit.id }); }
|
||||
}, { owner: unit });
|
||||
} },
|
||||
@@ -1492,8 +1495,8 @@ const kits = {
|
||||
|
||||
// ===== 焰尾 (pioneer) S3 焰心 — 8 DP over the skill, faster, +ATK, block +1, 60 % dodge; talents 前锋剑术 / 红松骑士团团长
|
||||
// S1 迅敏直觉 (+6 DP, dodges the next physical attack); S2 “红松林” (+11/12 DP; ≤ 6 enemies around: 2 × 180 %/210 %
|
||||
// phys + 0.5 s stun; allies around +40 %/45 % physical dodge for 10 s); module SOL-X (她们的未来): ATK/DEF +8 %
|
||||
// while blocking. Her dodges (any source) feed talent 前锋剑术.
|
||||
// phys + 0.5 s stun, air units too — PRTS 备注 "※可对空"; allies around +40 %/45 % physical dodge for 10 s);
|
||||
// module SOL-X (她们的未来): ATK/DEF +8 % while blocking. Her dodges (any source) feed talent 前锋剑术.
|
||||
chess_char_4_19_a: (bb, chess, def) => {
|
||||
const t1 = tbb(def, 1);
|
||||
const tb = def.traitBb || {};
|
||||
@@ -1646,7 +1649,7 @@ const kits = {
|
||||
battle.on('beforeAttack', (c) => {
|
||||
if (c.attacker !== unit || !skillActive(unit) || !unit.extraRangeKeys) return;
|
||||
const own = new Set(absoluteRangeKeys(unit.rangeGrid, unit.tileR, unit.tileC, unit.dir, unit.s.rangeExtend));
|
||||
const ok = (e) => own.has(tileKey(e)) || !!(e.blockedBy && e.blockedBy.side === 'ally' && e.blockedBy.alive);
|
||||
const ok = (e) => bodyInKeys(e, own) || !!(e.blockedBy && e.blockedBy.side === 'ally' && e.blockedBy.alive);
|
||||
if (c.targets.every(ok)) return;
|
||||
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile).filter(ok);
|
||||
sortEnemyTargets(battle, unit, cands, c.profile?.priority ?? null);
|
||||
@@ -1663,7 +1666,7 @@ const kits = {
|
||||
const ds = num(tb.damage_scale, 0), lo = num(tb.min_dist, 1), hi = num(tb.max_dist, 4.5);
|
||||
battle.on('hit', (c) => {
|
||||
if (c.source !== unit || !c.dmg.isAttack) return;
|
||||
if (S3 && skillActive(unit) && unit.baseRangeKeys && !unit.baseRangeKeys.includes(tileKey(c.target))) c.dmg.mul *= num(bb.damage_scale, 1.25);
|
||||
if (S3 && skillActive(unit) && unit.baseRangeKeys && !bodyInKeys(c.target, unit.baseRangeKeys)) c.dmg.mul *= num(bb.damage_scale, 1.25);
|
||||
if (ds > 0) { const d = Math.hypot(c.target.x - unit.x, c.target.y - unit.y); c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo))); }
|
||||
}, { owner: unit });
|
||||
},
|
||||
@@ -2064,7 +2067,7 @@ const kits = {
|
||||
talents: [{ install(battle, unit) { // 血液样本回收: an enemy falls in range ⇒ +2 SP to her and a random ally in range
|
||||
battle.on('death', (c) => {
|
||||
const e = c.unit;
|
||||
if (c.reason !== 'killed' || e.side !== 'enemy' || !unit.alive || !unit.deployed || !keySet(unit).has(tileKey(e))) return;
|
||||
if (c.reason !== 'killed' || e.side !== 'enemy' || !unit.alive || !unit.deployed || !bodyInKeys(e, keySet(unit))) return;
|
||||
unit.skill?.gainSp(num(t0['bldsk_t_1[self].sp'], 2), 'talent');
|
||||
// SP cannot be gained while a timed skill runs (engine gainSp ignores it): such allies are not picked
|
||||
const pick = battle.rng.pick(battle.alliesInGrid(unit).filter((a) => a !== unit && a.skill && !a.skill.noSkill && a.skill.kind !== 'passive' && !(a.skill.active && a.skill.isTimed)));
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
// Conventions shared by the kits of this file:
|
||||
// - `base_attack_time` in skill blackboards is an absolute delta in seconds (AK attribute ADDITION: 烛煌 1.6 s − 1.3 s,
|
||||
// 号角 2.8 s − 1.2 s, 白面鸮 2.85 s − 1.8 s, 寒檀 2.9 s − 2.4 s); it is converted into `batPct` of the chess's base BAT.
|
||||
// - 元素伤害 (elemental HP damage, not a gauge) is the engine DamageInfo type 'elemental' (no DEF/RES/dodge, × elemTakenMul),
|
||||
// with `element` set for the client colour.
|
||||
// - 元素伤害 (elemental HP damage, not a gauge) is the engine DamageInfo type 'elemental' (no DEF/RES/dodge,
|
||||
// × elementalTakenMul = 元素脆弱), with `element` set for the client colour.
|
||||
// - Mechanics missing from the chess text follow the PRTS 备注 of the base operator (verified 2026-09-28): 号角 S3 overload
|
||||
// is the second half of the 24 s duration; 圣约送葬人's extra attack consumes no ammo; 夕 S1 splash 1.7; 烛煌 revive stun
|
||||
// radius 1.7; 寒檀 icicles splash 1.5 and cycle left row → right row → own row; 失重 = weight −1 level; 魔王 motes orbit
|
||||
@@ -29,9 +29,10 @@
|
||||
// 'meltdown', 'soul', 'sleepGuard', 'weightless'; engine kinds used: 'dodge'.
|
||||
|
||||
import { COLS } from '../../constants.js';
|
||||
import { bodyInKeys, bodyInRadius, bodyKeys } from '../../body.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets } from '../../targeting.js';
|
||||
import { frontOf, rotateOffset, toLocal } from '../../dir.js';
|
||||
import { mitigate } from '../../damage.js';
|
||||
import { mitigate, hasHp } from '../../damage.js';
|
||||
|
||||
// ---- text-only constants (the official blackboards carry no key for these) --------------------------------------
|
||||
/** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 山 module "生命值高于50%时". */
|
||||
@@ -77,8 +78,8 @@ const HORN_S2_OVERLOAD_AT = 0.5;
|
||||
// ---- helpers ------------------------------------------------------------------------------------------------------
|
||||
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||||
const on = (u) => !!u && u.alive && u.deployed && !u.removed;
|
||||
const tileKey = (u) => Math.round(u.y) * COLS + Math.round(u.x);
|
||||
const inRange = (unit, x) => !!unit.rangeKeySet && unit.rangeKeySet.has(tileKey(x));
|
||||
/** In the unit's current range: an ally by its tile, an enemy by its body (a huge one's every tile — sim/body.js). */
|
||||
const inRange = (unit, x) => !!unit.rangeKeySet && bodyInKeys(x, unit.rangeKeySet);
|
||||
const talent = (chess, i) => (chess?.talents || []).find((t) => t && t.index === i)?.bb ?? {};
|
||||
const talentRec = (chess, i) => (chess?.talents || []).find((t) => t && t.index === i) ?? null;
|
||||
const traitBb = (chess) => chess?.trait?.bb ?? {};
|
||||
@@ -158,7 +159,7 @@ function meleeAttacker(e, t) {
|
||||
/** Enemy not moving along its route (blocked, stunned, rooted, waiting…). */
|
||||
const isStill = (e) => !!(e.blockedBy || !e.moving || e.s.flags.stun || e.s.flags.noMove || !(e.s.moveSpeed > 0));
|
||||
|
||||
/** Elemental HP damage (元素伤害): engine type 'elemental' (× elemTakenMul in the pipeline, never trueTakenMul). */
|
||||
/** Elemental HP damage (元素伤害): engine type 'elemental' (× elementalTakenMul in the pipeline, never trueTakenMul). */
|
||||
function elementHit(battle, src, tgt, amount, tag, element = null) {
|
||||
if (!tgt || !tgt.alive || !(amount > 0)) return 0;
|
||||
return battle.dealDamage(src, tgt, { amount, type: 'elemental', element, canDodge: false, tags: ['element', tag] });
|
||||
@@ -264,7 +265,7 @@ function enemiesInGrid(battle, unit, grid, n = 0, { ignoreStealth = false } = {}
|
||||
let list;
|
||||
if (ignoreStealth) {
|
||||
const set = new Set(keys);
|
||||
list = battle.enemies.filter((e) => e.alive && !e.hidden && e.deployed && !e.s.flags.untargetable && set.has(Math.round(e.y) * COLS + Math.round(e.x)));
|
||||
list = battle.enemies.filter((e) => e.alive && !e.hidden && e.deployed && !e.s.flags.untargetable && bodyInKeys(e, set));
|
||||
} else list = battle.enemiesInKeys(keys, unit, { canHitFly: true });
|
||||
sortEnemyTargets(battle, unit, list, null);
|
||||
return n > 0 && list.length > n ? list.slice(0, n) : list;
|
||||
@@ -591,7 +592,7 @@ const KITS = {
|
||||
const dmgTick = unit.mem.blazeAcc2 >= 1 - 1e-9;
|
||||
if (dmgTick) unit.mem.blazeAcc2 -= 1;
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive || e.hidden || e.isFlying || !tiles.has(Math.round(e.y) * COLS + Math.round(e.x))) continue;
|
||||
if (!e.alive || e.hidden || e.isFlying || !bodyInKeys(e, tiles)) continue;
|
||||
battle.addBuff(e, { key: `blaze2:ground:${unit.id}`, duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + num(bb.move_speed)) } });
|
||||
if (!dmgTick) continue;
|
||||
const d = battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'blazeGround'] });
|
||||
@@ -759,7 +760,9 @@ const KITS = {
|
||||
// T1 本性的坚守: heal 100 (160 below 50 %) on every hit taken. T2 血脉的哺养: per kill +120 max HP / +30 ATK (×9),
|
||||
// other Abyssal Hunters +50 %. Module (elite): healing received ×1.2.
|
||||
// S1 必须促成的接触 (instant): the anchor lands on the best enemy of the skill range (beyond his own range, unblocked
|
||||
// first) and drags up to max_target enemies around it (RING1) in front of him (中等力度), atk_scale × ATK phys each.
|
||||
// first) and drags up to max_target enemies around it (RING1) in front of him (中等力度), atk_scale × ATK phys each;
|
||||
// a 捕网 — ground enemies only ([ASSUMED] like 雪雉's "不对空" net). S3's anchor blast has no such note: it hits air
|
||||
// units too [ASSUMED].
|
||||
// S2 必须维系的界限 (toggle, 持续时间无限): T1 ×talent_scale, block +1, max HP +, ATK +.
|
||||
chess_char_5_05_a: (bb, chess, def) => {
|
||||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
|
||||
@@ -778,9 +781,11 @@ const KITS = {
|
||||
// unblocked ground enemies, then the usual target order
|
||||
const list = enemiesInGrid(battle, unit, skillGrid(chess, def) ?? unit.rangeGrid);
|
||||
const reach = (e) => inRange(unit, e);
|
||||
const main = list.find((e) => !reach(e) && !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.isFlying) ?? list[0];
|
||||
// the anchor is a 捕网 (PRTS 备注 "实际效果为捕网而非拖拽"); 雪雉's 捕网 is "不对空" (PRTS 雪雉 备注) — [ASSUMED] the same
|
||||
// for this one: it never lands on or catches air units (FLY, 近地悬浮, 浮空)
|
||||
const main = list.find((e) => !reach(e) && !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.isFlying);
|
||||
if (!main) return;
|
||||
const near = battle.enemiesInRadius(main.x, main.y, RING1).filter((e) => !e.s.flags.untargetable && !e.s.flags.sleep)
|
||||
const near = battle.enemiesInRadius(main.x, main.y, RING1).filter((e) => !e.isFlying && !e.s.flags.untargetable && !e.s.flags.sleep)
|
||||
.sort((a, b) => (a === main ? -1 : b === main ? 1 : 0) || dist(a, main) - dist(b, main) || a.spawnSeq - b.spawnSeq)
|
||||
.slice(0, Math.max(1, num(bb.max_target, 2)));
|
||||
const fr = unit.tileR + unit.fwd[0], fc = unit.tileC + unit.fwd[1];
|
||||
@@ -874,7 +879,8 @@ const KITS = {
|
||||
// max HP and take −10 % phys damage afterwards. Reaper trait (heal per hit, module 60) comes from the profession.
|
||||
// S1 玫影觅迹 (instant, attack SP): next attack atk_scale × ATK, twice. S2 绯红壁合 (duration): no attacks; blood sickles
|
||||
// on herself and on one other ground unit (the ally with the most enemies around it) cut every enemy around them (RING1)
|
||||
// for atk_scale × ATK phys every `interval` s. Module REA-Y (elite): ASPD +12 with ≥ 2 enemies in range.
|
||||
// for atk_scale × ATK phys every `interval` s — ground enemies only unless the carrier has taken off (PRTS 备注).
|
||||
// Module REA-Y (elite): ASPD +12 with ≥ 2 enemies in range.
|
||||
chess_char_5_06_a: (bb, chess, def) => {
|
||||
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
|
||||
const hpScale = num(bb['attack@max_hp_scale'], 0.6), defScale = num(bb['attack@def_scale'], 1), resScale = num(bb['attack@magic_resistance_scale'], 1);
|
||||
@@ -900,7 +906,11 @@ const KITS = {
|
||||
unit.mem.sickleAcc -= iv;
|
||||
for (const a of unit.mem.sickles || []) {
|
||||
if (!a.alive || !a.deployed) continue;
|
||||
// PRTS 备注 "被添加血镰的单位处于起飞时,血镰可对空": a sickle on the ground spares air units (FLY, 近地悬浮, 浮空);
|
||||
// 起飞 = an airborne skywalker (蒂比's skill: off the ground, flag blockFly)
|
||||
const air = !a.ground && !!a.s.flags.blockFly;
|
||||
for (const e of battle.enemiesInRadius(a.x, a.y, RING1)) {
|
||||
if (e.isFlying && !air) continue;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'bloodSickle'] });
|
||||
}
|
||||
}
|
||||
@@ -1224,7 +1234,7 @@ const KITS = {
|
||||
unit.mem.flares = fl.filter((f) => f.until > battle.time + 1e-9);
|
||||
const r = num(bb.projectile_range, 1.7);
|
||||
for (const f of unit.mem.flares) for (const e of battle.enemies) {
|
||||
if (e.alive && !e.hidden && Math.hypot(e.x - f.x, e.y - f.y) <= r + 1e-9) battle.applyStatus(e, 'reveal', { duration: AURA_DUR, source: unit });
|
||||
if (e.alive && !e.hidden && bodyInRadius(e, f.x, f.y, r)) battle.applyStatus(e, 'reveal', { duration: AURA_DUR, source: unit });
|
||||
}
|
||||
}, { owner: unit });
|
||||
}
|
||||
@@ -1616,7 +1626,8 @@ const KITS = {
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 归溟幽灵鲨 — dollkeeper. S2 生存的渴望 (15/17 s): ATK/ASPD +, HP never below 1; afterwards she counts as knocked out
|
||||
// (→ substitute, or death if already one). T1 拥抱自我: the substitute slows nearby enemies −40 % and deals 40 % ATK
|
||||
// arts/s to them. T2 阿戈尔的深邃: Abyssal Hunters in the team max HP +20 %. Module (elite): substitute ATK +15 %.
|
||||
// arts/s to them (PRTS 备注 "伤害与减速不可对空": ground enemies only). T2 阿戈尔的深邃: Abyssal Hunters in the team
|
||||
// max HP +20 %. Module (elite): substitute ATK +15 %.
|
||||
// S1 生存的技巧 (duration): swaps HP ratios with the other operator of the skill area (周围) with the lowest HP ratio,
|
||||
// ATK +. S3 生存的重压 (duration): BAT +1 s, hits every blocked enemy, ATK +, max HP +; an attacked enemy whose HP ratio
|
||||
// is ≥ hers takes attack@atk_scale_ex × ATK phys more, otherwise she loses attack@hp_ratio of her max HP.
|
||||
@@ -1667,11 +1678,11 @@ const KITS = {
|
||||
const slow = num(t0.move_speed), scale = num(t0.atk_scale);
|
||||
whileOn(battle, unit, AURA_IV, () => {
|
||||
if (!unit.trait.doll || !slow) return;
|
||||
for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) battle.addBuff(e, { key: 'ghost2:embrace', duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + slow) } });
|
||||
for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.addBuff(e, { key: 'ghost2:embrace', duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + slow) } });
|
||||
});
|
||||
whileOn(battle, unit, 1, () => {
|
||||
if (!unit.trait.doll || !(scale > 0)) return;
|
||||
for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', tags: ['talent', 'embrace'] });
|
||||
for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', tags: ['talent', 'embrace'] });
|
||||
});
|
||||
} },
|
||||
{ install(battle, unit) { // 阿戈尔的深邃
|
||||
@@ -1704,9 +1715,10 @@ const KITS = {
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 凛御银灰 — S2 御敌的锋锐 (2 charges): the 6 front-most enemies in the skill range take 260 % ATK phys, cold + reveal
|
||||
// for 4 s; the waiting (knocked-out) op nearest to 风雪之眼 by cost gets −11 redeploy cost (right side guard/caster/
|
||||
// sniper first); waiting ops left of the eye (cost < eye cost) cast the AoE with their own ATK when they redeploy (≤2).
|
||||
// 凛御银灰 — S2 御敌的锋锐 (2 charges; PRTS 备注 "可对空"): the 6 front-most enemies in the skill range take 260 % ATK
|
||||
// phys, cold + reveal for 4 s; the waiting (knocked-out) op nearest to 风雪之眼 by cost gets −11 redeploy cost (right
|
||||
// side guard/caster/sniper first); waiting ops left of the eye (cost < eye cost) cast the AoE with their own ATK when
|
||||
// they redeploy (≤2).
|
||||
// T1 开放性开局: ops left of the eye and himself: +4 SP at deployment, redeploy time −20 %.
|
||||
// T2 雪境先驱: Kjerag ops freeze-immune, DEF +60, 1.5 % max HP/s regen; doubled after 15 s on the field.
|
||||
// The eye follows the selected skill (its overrideTokenKey: S1 eagle1 cost 16, S2 eagle2 14, S3 eagle3 19).
|
||||
@@ -1846,13 +1858,15 @@ const KITS = {
|
||||
const tok = battle.tokenDef(eyeId, unit); // the owner's skill variant (DESIGN §16)
|
||||
unit.mem.eyeCost = num(tok?.stats?.cost, 14);
|
||||
if (selectedId(chess, def) === 'skchr_svash2_3') {
|
||||
// S3: every attack hits the enemies of the skill range on the target's line (his facing frame)
|
||||
// S3: every attack hits the enemies of the skill range on the target's line (his facing frame): the line of
|
||||
// the target's position; another enemy is on it when its body is (a huge one: any tile — body.js)
|
||||
battle.on('beforeAttack', (c) => {
|
||||
if (c.attacker !== unit || !unit.skill?.active || !c.targets.length) return;
|
||||
const main = c.targets[0];
|
||||
const lat = (e) => toLocal(Math.round(e.y) - unit.tileR, Math.round(e.x) - unit.tileC, unit.dir)[0];
|
||||
const l0 = lat(main);
|
||||
const line = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).filter((e) => e !== main && lat(e) === l0);
|
||||
const lat = (r, cc) => toLocal(r - unit.tileR, cc - unit.tileC, unit.dir)[0];
|
||||
const l0 = lat(Math.round(main.y), Math.round(main.x));
|
||||
const onLine = (e) => bodyKeys(e).some((k) => lat(Math.floor(k / COLS), k % COLS) === l0);
|
||||
const line = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).filter((e) => e !== main && onLine(e));
|
||||
if (line.length) c.targets = [main, ...line];
|
||||
}, { owner: unit, priority: 10 });
|
||||
}
|
||||
@@ -2424,7 +2438,8 @@ const KITS = {
|
||||
if (c.source !== unit || c.type === 'element' || c.target.side !== 'enemy' || !c.target.alive || !c.dmg) return;
|
||||
// (S1 has no attack override: its attacks are the skill's while it runs)
|
||||
const skillAtk = c.dmg.isSkill || (sid === 'skchr_nymph_1' && !!unit.skill?.active);
|
||||
if (skillAtk && c.dmg.isAttack && ep > 0 && c.amount > 0) {
|
||||
// (not on a killing blow: the target is at 0 HP here — no burst on the corpse)
|
||||
if (skillAtk && c.dmg.isAttack && ep > 0 && c.amount > 0 && hasHp(c.target)) {
|
||||
battle.dealDamage(unit, c.target, { type: 'element', element: 'apoptosis', amount: c.amount * ep, tags: ['skill', 'nymph'] });
|
||||
}
|
||||
if (c.dmg.isAttack && c.target.alive) soul(battle, unit, c.target, c.dmg.isSkill ? Math.max(skillSoul, soulBase) : soulBase);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// reinforcement …) are kept and built upon.
|
||||
//
|
||||
// Simplifications (one line per id; see also the report of the content phase):
|
||||
// 1_15 盟约·辅助干员 element pick = neural → burn → apoptosis, skipping the elements already bursting on the target.
|
||||
// 1_15 盟约·辅助干员 every damage she deals attaches all three elements, neural → burn → apoptosis (see pithst).
|
||||
// 6_01 蕾缪安 locks every 0.5 s while an enemy is in range (ends early — and bombs — when the range empties);
|
||||
// the shells then follow one every 0.3 s (PRTS), each landing 0.3 s after it is fired [ASSUMED flight];
|
||||
// the 1.5 radius (PRTS "碰撞箱判定") is read as the centre distance like every engine radius; knocked
|
||||
@@ -102,6 +102,8 @@ import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../../targe
|
||||
import { aggregateMods } from '../../buffs.js';
|
||||
import { COLS, ROWS } from '../../constants.js';
|
||||
import { rotateOffset } from '../../dir.js';
|
||||
import { bodyDist, bodyInKeys, bodyInRadius, bodyKeys } from '../../body.js';
|
||||
import { hasHp } from '../../damage.js';
|
||||
import { summonToken, TOKEN_IDS } from '../tokens.js';
|
||||
|
||||
// ------------------------------------------------------------------------------------------------------------------
|
||||
@@ -279,8 +281,9 @@ function onElementHit(battle, unit, fn) {
|
||||
}, { owner: unit });
|
||||
}
|
||||
|
||||
/** Deal `amount` of element `el` (元素损伤) — nothing on a target at 0 HP (a lethal hit's `damaged` hook, see hasHp). */
|
||||
const elementDmg = (battle, src, tgt, el, amount, tags = ['skill']) =>
|
||||
(amount > 0 && tgt && tgt.alive ? battle.dealDamage(src, tgt, { type: 'element', element: el, amount, tags }) : 0);
|
||||
(amount > 0 && hasHp(tgt) ? battle.dealDamage(src, tgt, { type: 'element', element: el, amount, tags }) : 0);
|
||||
|
||||
/**
|
||||
* 鼓舞 (ba.inspire "获得额外附加的基础属性加成(同类属性取最高)"): +`val` ATK after the target's own multipliers (flat
|
||||
@@ -325,23 +328,44 @@ function aura(battle, unit, period, fn) {
|
||||
// ------------------------------------------------------------------------------------------------------------------
|
||||
// 盟约·辅助干员 chess_char_1_15 (巫役) — S1 战术咏唱·双型; 迭代元素
|
||||
|
||||
/** 迭代元素's elements in the order they are applied: 神经损伤 "(优先)", then 灼燃损伤, then 凋亡损伤. */
|
||||
export const PITHST_ELEMENTS = Object.freeze(['neural', 'burn', 'apoptosis']);
|
||||
|
||||
/**
|
||||
* 迭代元素 "攻击同时附带18%攻击力的神经损伤(优先)、灼燃损伤、凋亡损伤;优先攻击未处于损伤爆发的目标":
|
||||
* * timing = every damage she deals (PRTS 盟约·辅助干员 corrects "攻击同时" to "造成伤害时"): HP damage > 0 — a dodged
|
||||
* or fully absorbed hit deals none; her own element fills and the bursts they cause never re-trigger it; a killing
|
||||
* blow attaches nothing (the target is dead — no burst on the corpse);
|
||||
* * each such damage attaches ep_damage_ratio × ATK of ALL THREE elements, 神经 first, then 灼燃, then 凋亡 (user
|
||||
* playtest #5 #3: the kit used to pick one element "not bursting" — but a burst's 爆发冷却 locks every gauge of the
|
||||
* unit, so it only ever filled 神经). PRTS 元素: when one unit applies several elements that would each fill their
|
||||
* gauge, the element applied first bursts — from her alone 神经 bursts (the "(优先)"), while her 灼燃 / 凋亡 add to
|
||||
* the gauges the team builds (余, 塑心 …). [ASSUMED: simultaneous application — the reading of the official text]
|
||||
* * elite module RIT-X "对精英和领袖敌人造成的元素损伤提升18%" = ep_damage_ratio_boss (0.2124) vs ELITE / BOSS enemies:
|
||||
* a talent ratio, not an "元素损伤提升" (PRTS 备注), so it changes nothing else she deals (e.g. a 灼燃维式重锤).
|
||||
* The target pick prefers enemies not in a burst (`priority: 'notBurst'`).
|
||||
*/
|
||||
function pithst(bb, chess, def) {
|
||||
const t0 = tbb(def, 0);
|
||||
const ratio = num(t0.ep_damage_ratio);
|
||||
const ratioBoss = num(t0.ep_damage_ratio_boss, ratio); // elite module: ×1.18 vs elite/leader enemies
|
||||
const ratioBoss = num(t0.ep_damage_ratio_boss, ratio);
|
||||
return {
|
||||
skill: {
|
||||
kind: 'duration',
|
||||
mods: { aspd: num(bb.attack_speed) },
|
||||
targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 1))) },
|
||||
},
|
||||
trait: {
|
||||
priority: 'notBurst',
|
||||
afterHit(battle, unit, target) {
|
||||
if (!target || !target.alive || !(ratio > 0)) return;
|
||||
const el = ['neural', 'burn', 'apoptosis'].find((k) => !target.findBuff(k + 'Burst'));
|
||||
if (el) elementDmg(battle, unit, target, el, unit.s.atk * (isElite(target) ? ratioBoss : ratio), ['talent']);
|
||||
},
|
||||
trait: { priority: 'notBurst' },
|
||||
install(battle, unit) {
|
||||
if (!(ratio > 0)) return;
|
||||
battle.on('damaged', (ctx) => {
|
||||
const t = ctx.target, d = ctx.dmg;
|
||||
// hasHp: a killing blow attaches nothing (the hook runs before battle.kill, see hasHp)
|
||||
if (ctx.source !== unit || !t || t.side !== 'enemy' || !hasHp(t) || !(ctx.amount > 0)) return;
|
||||
if (ctx.type === 'element' || ctx.type === 'elemental' || (d && d.tags && d.tags.includes('burst'))) return;
|
||||
const amount = unit.s.atk * (isElite(t) ? ratioBoss : ratio);
|
||||
for (const el of PITHST_ELEMENTS) elementDmg(battle, unit, t, el, amount, ['talent', 'pithst']);
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -370,9 +394,8 @@ function ensureWanted(battle) {
|
||||
const lat = battle.allyUnits.filter((a) => live(a) && a.kind === 'op' && hasBond(a, 'lateranoShip'));
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive || e.hidden || !isElite(e) || e.findBuff('lemuen:wanted')) continue;
|
||||
const k = keyOf(e);
|
||||
// "停留超过8秒": a continuous stay — leaving every 拉特兰 range restarts the count
|
||||
if (!lat.some((a) => a.rangeKeySet && a.rangeKeySet.has(k))) { W.time.delete(e); continue; }
|
||||
if (!lat.some((a) => a.rangeKeySet && bodyInKeys(e, a.rangeKeySet))) { W.time.delete(e); continue; }
|
||||
const t = (W.time.get(e) ?? 0) + 0.25;
|
||||
W.time.set(e, t);
|
||||
if (t >= need - 1e-9) {
|
||||
@@ -423,8 +446,8 @@ function lemuen(bb, chess, def) {
|
||||
battle.fx('bombard', { x, y, id: unit.id, r: d2 });
|
||||
for (const e of battle.enemiesInRadius(x, y, d2)) {
|
||||
if (e.s.flags.untargetable) continue;
|
||||
const d = Math.hypot(e.x - x, e.y - y);
|
||||
battle.dealDamage(unit, e, { amount: atk * (d <= d1 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] });
|
||||
const d = bodyDist(e, x, y);
|
||||
battle.dealDamage(unit, e, { amount: atk * (d <= d1 + 1e-9 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] });
|
||||
}
|
||||
};
|
||||
const bombard = (battle, unit, locks) => {
|
||||
@@ -534,7 +557,7 @@ function lemuen(bb, chess, def) {
|
||||
battle.on('tick', () => { // wanted targets' tiles join her range (engine extra range keys, kept across rebuilds)
|
||||
if (!live(unit)) return;
|
||||
const keys = [];
|
||||
for (const e of battle.enemies) if (e.alive && !e.hidden && e.findBuff('lemuen:wanted')) keys.push(keyOf(e));
|
||||
for (const e of battle.enemies) if (e.alive && !e.hidden && e.findBuff('lemuen:wanted')) keys.push(...bodyKeys(e));
|
||||
const s = keys.join(',');
|
||||
if (s === sig) return;
|
||||
sig = s;
|
||||
@@ -655,7 +678,7 @@ function sbell2(bb, chess, def) {
|
||||
if (dotAcc + 1e-9 < 1) return;
|
||||
dotAcc -= 1;
|
||||
for (const e of battle.enemies) {
|
||||
if (e.alive && !e.hidden && !e.isFlying && snow.has(keyOf(e))) battle.dealDamage(unit, e, { amount: unit.s.atk * S2.dot, type: 'arts', isSkill: true, tags: ['skill', 'snow'] });
|
||||
if (e.alive && !e.hidden && !e.isFlying && bodyInKeys(e, snow)) battle.dealDamage(unit, e, { amount: unit.s.atk * S2.dot, type: 'arts', isSkill: true, tags: ['skill', 'snow'] });
|
||||
}
|
||||
}, { owner: unit });
|
||||
} },
|
||||
@@ -781,7 +804,8 @@ function yu(bb, chess, def) {
|
||||
// ep_damage_ratio × ATK 灼燃损伤 on the attacker (install below)
|
||||
skchr_yu_1: { kind: 'duration', mods: { hpPct: num(bb.max_hp), defPct: num(bb.def) } },
|
||||
// S2 厚礼上宾: atk_scale × ATK arts on every enemy of the skill range + the ground-reachable ones teleported onto his
|
||||
// tile; block +block_cnt, HP / ATK +, normal attacks deal arts damage
|
||||
// tile; block +block_cnt, HP / ATK +, normal attacks deal arts damage. The burst hits air units too [ASSUMED: no 对空
|
||||
// note on PRTS]; the teleport takes ground units only ("地面可达目标")
|
||||
skchr_yu_2: {
|
||||
kind: 'duration',
|
||||
mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), blockCnt: num(bb.block_cnt) },
|
||||
@@ -1064,7 +1088,7 @@ function pasngr(bb, chess, def) {
|
||||
let best = null, bd = Infinity;
|
||||
for (const x of battle.enemiesInRadius(prev.x, prev.y, ch.radius || 1.8)) {
|
||||
if (hit.has(x.id) || !canTargetEnemy(unit, x, ANY)) continue;
|
||||
const d = Math.hypot(x.x - prev.x, x.y - prev.y);
|
||||
const d = bodyDist(x, prev.x, prev.y);
|
||||
if (d < bd - 1e-9) { bd = d; best = x; }
|
||||
}
|
||||
prev = best;
|
||||
@@ -1132,7 +1156,7 @@ function pasngr(bb, chess, def) {
|
||||
const atk = num(t1.atk), spr = num(t1.sp_recovery_per_sec);
|
||||
aura(battle, unit, 0.25, () => {
|
||||
const near = new Set(absoluteRangeKeys(N4, unit.tileR, unit.tileC, 1, 0));
|
||||
if (battle.enemies.some((e) => e.alive && !e.hidden && near.has(keyOf(e)))) return;
|
||||
if (battle.enemies.some((e) => e.alive && !e.hidden && bodyInKeys(e, near))) return;
|
||||
battle.addBuff(unit, { key: 'pasngr:lone', mods: { atkPct: atk, spRecoveryFlat: spr }, duration: 0.4, refresh: 'replace' });
|
||||
});
|
||||
} },
|
||||
@@ -1472,7 +1496,7 @@ function reed2(bb, chess, def) {
|
||||
battle.after(0, () => {
|
||||
if (!live(unit)) return;
|
||||
battle.fx('scorchBurst', { x, y, id: unit.id, r: aoeR });
|
||||
for (const e of battle.enemiesInRadius(x, y, aoeR)) {
|
||||
for (const e of battle.enemiesInRadius(x, y, aoeR, true)) { // splash around the victim: 中点判定
|
||||
if (!e.alive) continue;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * aoe, type: 'arts', isSkill: true, isSplash: true, tags: ['skill', 'scorch'] });
|
||||
scorch(battle, unit, e);
|
||||
@@ -1552,12 +1576,16 @@ function cello(bb, chess, def) {
|
||||
install(battle, unit) {
|
||||
if (sid !== 'skchr_cello_2') return;
|
||||
const ratio = num(bb.ep_damage_ratio);
|
||||
battle.on('damaged', (ctx) => {
|
||||
const s = ctx.source, t = ctx.target;
|
||||
if (!(ratio > 0) || !unit.skill?.active || !live(unit) || !s || !t || t.side !== 'enemy' || !t.alive || !(ctx.amount > 0)) return;
|
||||
if (ctx.type === 'element' || ctx.type === 'elemental' || (s !== unit && s !== unit.mem.celloPartner)) return;
|
||||
// PRTS 塑心 S2 备注: "受影响的干员即将造成伤害时,因该效果造成的凋亡损伤生效于当次触发的伤害之前,该造成的凋亡损伤的来源
|
||||
// 始终为塑心" — a late `hit` handler (after every handler that may cancel the damage), so the 凋亡 lands while the
|
||||
// target is alive and may burst before the damage; 无来源 bursts (source null) never trigger it. It rides the
|
||||
// damage about to be dealt, so a hit dodged or absorbed afterwards still carried it [ASSUMED].
|
||||
battle.on('hit', (ctx) => {
|
||||
const s = ctx.source, t = ctx.target, d = ctx.dmg;
|
||||
if (!(ratio > 0) || !unit.skill?.active || !live(unit) || !s || !t || t.side !== 'enemy' || !hasHp(t) || !d || d.cancel) return;
|
||||
if (d.type === 'element' || d.type === 'elemental' || !(d.amount > 0) || (s !== unit && s !== unit.mem.celloPartner)) return;
|
||||
elementDmg(battle, unit, t, 'apoptosis', unit.s.atk * ratio);
|
||||
}, { owner: unit });
|
||||
}, { owner: unit, priority: -1000 });
|
||||
},
|
||||
skill: {
|
||||
kind: 'charges',
|
||||
@@ -1591,10 +1619,9 @@ function cello(bb, chess, def) {
|
||||
battle.on('elementHit', (ctx) => {
|
||||
const t = ctx.target, d = ctx.dmg;
|
||||
if (!d || d.type !== 'element' || d.element !== 'apoptosis' || !t || t.side !== 'enemy') return;
|
||||
const k = keyOf(t);
|
||||
let best = 1;
|
||||
for (const c of S.cellos) {
|
||||
if (!live(c) || !(c.mem.celloWide || c.rangeKeySet?.has(k))) continue;
|
||||
if (!live(c) || !(c.mem.celloWide || bodyInKeys(t, c.rangeKeySet))) continue;
|
||||
const v = scaled(c.mem.celloAmp, c);
|
||||
if (v > best) best = v;
|
||||
}
|
||||
@@ -1636,7 +1663,7 @@ function nymph(bb, chess, def) {
|
||||
if (cur) { cur.data.scale = Math.max(cur.data.scale, scale); cur.timeLeft = Math.max(cur.timeLeft, burst.timeLeft); return; }
|
||||
battle.addBuff(t, {
|
||||
key, duration: Math.max(0.1, burst.timeLeft), interval: 1, source: unit, data: { scale },
|
||||
// 元素伤害 = the 'elemental' damage type (no DEF/RES, × elemTakenMul)
|
||||
// 元素伤害 = the 'elemental' damage type (no DEF/RES, × elementalTakenMul)
|
||||
onTick: ({ unit: e, buff }) => { if (e.findBuff('apoptosisBurst')) battle.dealDamage(unit, e, { amount: unit.s.atk * buff.data.scale, type: 'elemental', element: 'apoptosis', canDodge: false, tags: ['talent', 'elementDmg'] }); },
|
||||
});
|
||||
};
|
||||
@@ -1669,7 +1696,7 @@ function nymph(bb, chess, def) {
|
||||
battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.nymphKey = 0; }, { owner: unit });
|
||||
battle.on('elementBurst', ({ target, element }) => {
|
||||
if (element !== 'apoptosis' || !live(unit) || !target || target.side !== 'enemy') return;
|
||||
if (!fieldWide && !unit.rangeKeySet?.has(keyOf(target))) return;
|
||||
if (!fieldWide && !bodyInKeys(target, unit.rangeKeySet)) return;
|
||||
unit.mem.nymphKey = Math.min(max, (unit.mem.nymphKey || 0) + 1);
|
||||
const n = unit.mem.nymphKey;
|
||||
battle.addBuff(unit, { key: 'nymph:key', mods: { atkPct: atk * n, aspd: fieldWide && n >= max ? as : 0 } });
|
||||
@@ -2205,7 +2232,7 @@ function angel2(bb, chess, def) {
|
||||
sortEnemyTargets(battle, u, cands, null);
|
||||
const c = cands[0];
|
||||
battle.fx('airstrike', { x: c.x, y: c.y, id: unit.id, r: AIRSTRIKE_RADIUS });
|
||||
for (const e of battle.enemiesInRadius(c.x, c.y, AIRSTRIKE_RADIUS)) {
|
||||
for (const e of battle.enemiesInRadius(c.x, c.y, AIRSTRIKE_RADIUS, true)) { // splash around the target: 中点判定
|
||||
if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'phys', isSkill: true, isSplash: true, tags: ['talent', 'airstrike'] });
|
||||
}
|
||||
}, { owner: unit });
|
||||
@@ -2374,7 +2401,7 @@ function qiubai(bb, chess, def) {
|
||||
if (!unit.alive || unit.deploySeq !== seq) return;
|
||||
const x = target.x, y = target.y; // (a fallen target keeps its last position)
|
||||
battle.fx('aoe', { x, y, id: unit.id });
|
||||
for (const e of battle.enemiesInRadius(x, y, 1.2)) {
|
||||
for (const e of battle.enemiesInRadius(x, y, 1.2, true)) { // splash around the target: 中点判定
|
||||
if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.aoe_scale, 1), type: 'arts', isSkill: true, isSplash: e !== target, tags: ['skill'] });
|
||||
}
|
||||
}, { owner: unit });
|
||||
@@ -2463,7 +2490,7 @@ function halo2(bb, chess, def) {
|
||||
let best = null, bd = Infinity;
|
||||
for (const e of battle.enemiesInRadius(px, py, R)) {
|
||||
if (e === prev || !canTargetEnemy(unit, e, ANY)) continue;
|
||||
const d = Math.hypot(e.x - px, e.y - py);
|
||||
const d = bodyDist(e, px, py);
|
||||
if (d < bd - 1e-9) { bd = d; best = e; }
|
||||
}
|
||||
if (!best) break;
|
||||
@@ -2487,7 +2514,7 @@ function halo2(bb, chess, def) {
|
||||
if (!target) return;
|
||||
const R = num(bb.ability_range_radius, 2), k = Math.max(0, Math.floor(num(bb.max_target, 2)));
|
||||
const near = battle.enemiesInRadius(target.x, target.y, R).filter((e) => e !== target && canTargetEnemy(unit, e, ANY))
|
||||
.sort((a, b) => Math.hypot(a.x - target.x, a.y - target.y) - Math.hypot(b.x - target.x, b.y - target.y) || a.spawnSeq - b.spawnSeq)
|
||||
.sort((a, b) => bodyDist(a, target.x, target.y) - bodyDist(b, target.x, target.y) || a.spawnSeq - b.spawnSeq)
|
||||
.slice(0, k);
|
||||
for (const e of near) {
|
||||
battle.fx('link', { x: e.x, y: e.y, id: unit.id, ids: [target.id, e.id] });
|
||||
@@ -2523,7 +2550,7 @@ function halo2(bb, chess, def) {
|
||||
if (!isDef) return;
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !unit.skill?.active) return;
|
||||
const valid = (e) => e && e.alive && canTargetEnemy(unit, e, ANY) && unit.rangeKeySet?.has(keyOf(e));
|
||||
const valid = (e) => e && e.alive && canTargetEnemy(unit, e, ANY) && bodyInKeys(e, unit.rangeKeySet);
|
||||
const keep = (unit.mem.haloLocks || []).filter(valid);
|
||||
for (const e of ctx.targets) if (keep.length < n && !keep.includes(e)) keep.push(e);
|
||||
ctx.targets = keep.slice(0, n);
|
||||
@@ -2585,7 +2612,7 @@ function nearl2(bb, chess, def) {
|
||||
const keys = new Set(absoluteRangeKeys(N4, r, c, 1, 0));
|
||||
battle.fx('sunBurst', { x: c, y: r, id: unit.id });
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive || e.hidden || !keys.has(keyOf(e))) continue;
|
||||
if (!e.alive || e.hidden || !bodyInKeys(e, keys)) continue;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'true', isSkill: true, tags: ['skill'] });
|
||||
if (stun > 0 && e.alive) battle.applyStatus(e, 'stun', { duration: stun, source: unit });
|
||||
}
|
||||
@@ -2673,7 +2700,7 @@ function nearl2(bb, chess, def) {
|
||||
if (b && (b === unit || b === unit.mem.sun)) ctx.dmg.type = 'true';
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 不畏苦暗
|
||||
{ install(battle, unit) { // 不畏苦暗 — the 4 tiles around her, air units too [ASSUMED: no 对空 note on PRTS, like “耀阳”]
|
||||
const last = ensureDeployTracker(battle);
|
||||
const sc = num(t0.atk_scale), stun = num(t0.stun);
|
||||
const dawn = (times) => {
|
||||
@@ -2803,7 +2830,7 @@ function whitw2(bb, chess, def) {
|
||||
let best = null, bd = Infinity, bestFree = null, bfd = Infinity;
|
||||
for (const e of battle.enemies) {
|
||||
if (!ok(e)) continue;
|
||||
const dist = Math.hypot(e.x - d.x, e.y - d.y);
|
||||
const dist = bodyDist(e, d.x, d.y);
|
||||
if (dist < bd - 1e-9) { bd = dist; best = e; }
|
||||
if (!taken.has(e) && dist < bfd - 1e-9) { bfd = dist; bestFree = e; }
|
||||
}
|
||||
@@ -2814,7 +2841,7 @@ function whitw2(bb, chess, def) {
|
||||
if (!d.t) continue;
|
||||
const dx = d.t.x - d.x, dy = d.t.y - d.y, dist = Math.hypot(dx, dy), step = speed * dt;
|
||||
if (dist <= step) { d.x = d.t.x; d.y = d.t.y; } else { d.x += (dx / dist) * step; d.y += (dy / dist) * step; }
|
||||
if (d.locked !== d.t && Math.hypot(d.t.x - d.x, d.t.y - d.y) <= R) {
|
||||
if (d.locked !== d.t && bodyInRadius(d.t, d.x, d.y, R)) {
|
||||
d.locked = d.t;
|
||||
if (fear > 0) battle.applyStatus(d.t, 'fear', { duration: fear, source: unit });
|
||||
battle.fx('droneLock', { x: d.x, y: d.y, id: d.t.id, src: unit.id });
|
||||
@@ -3088,29 +3115,44 @@ function agoat2(bb, chess, def) {
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 氤氲
|
||||
// "普通治疗使目标每秒额外受到一次治疗量和元素损伤回复量为10%的增益治疗,持续6秒(最多叠加3层)" — PRTS 备注: "本天赋受
|
||||
// 特性治疗倍率影响,使用缓存攻击力;叠加时,重置持续时间并更新缓存攻击力": a 10 % heal shaped like her normal one —
|
||||
// HP heal_scale × ATK and 元素损伤 recovery heal_scale × the trait's ep_heal_ratio × ATK (0.5 / 0.6: 5 % / 6 %,
|
||||
// it used to be the full 10 %) — per stack, with the ATK of the last application; a new stack refreshes them all.
|
||||
const sc = num(t0.heal_scale), dur = num(t0.duration, 6), max = Math.max(1, Math.floor(num(t0.max_stack_cnt, 3)));
|
||||
if (!(sc > 0)) return;
|
||||
const epRatio = () => num(unit.profile?.heal?.elementHealRatio, num(tb.ep_heal_ratio, 0.5));
|
||||
battle.on('heal', (ctx) => {
|
||||
const t = ctx.target;
|
||||
if (ctx.source !== unit || ctx.opts?.hot || ctx.opts?.aura || ctx.opts?.regen || ctx.opts?.skillHeal || !t) return;
|
||||
battle.addBuff(t, {
|
||||
key: 'agoat2:mist', duration: dur, refresh: 'independent', maxStacks: max, interval: 1, source: unit,
|
||||
onTick: ({ unit: a }) => {
|
||||
const amt = unit.s.atk * sc;
|
||||
battle.reduceElement(a, amt);
|
||||
// keyed per 纯烬: two of them (co-op) each keep their own stacks, ATK and heal source
|
||||
const b = battle.addBuff(t, {
|
||||
key: `agoat2:mist:${unit.id}`, duration: dur, refresh: 'stack', stacks: 1, maxStacks: max, interval: 1, source: unit, data: {},
|
||||
onTick: ({ unit: a, buff }) => {
|
||||
const amt = num(buff.data.atk, unit.s.atk) * sc * Math.max(1, buff.stacks);
|
||||
battle.reduceElement(a, amt * epRatio());
|
||||
if (a.hp < a.s.maxHp) battle.heal(unit, a, amt, { hot: true });
|
||||
},
|
||||
});
|
||||
if (b) b.data.atk = unit.s.atk;
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 火山灰疗愈 (×talent_scale during S3)
|
||||
// "受到的元素损伤降低" is a 元素损伤 multiplier applied on the element hit (PRTS 元素 "…后续可应用元素损伤倍率
|
||||
// 提升/降低等效果"), not `elemTakenMul` (元素伤害 / 元素脆弱): the aura buff carries the cut, `elementHit` applies it
|
||||
const hp = num(t1.max_hp), er = num(t1.ep_damage_resistance), mulS = num(bb.talent_scale, 1);
|
||||
aura(battle, unit, 0.5, () => {
|
||||
const f = unit.skill?.active ? mulS : 1;
|
||||
for (const a of battle.alliesInGrid(unit)) {
|
||||
battle.addBuff(a, { key: 'agoat2:ash', mods: { hpPct: hp * f, elemTakenMul: Math.max(0, 1 - er * f) }, duration: 0.75, refresh: 'replace' });
|
||||
battle.addBuff(a, { key: 'agoat2:ash', mods: { hpPct: hp * f }, data: { epCut: Math.min(1, er * f) }, source: unit, duration: 0.75, refresh: 'replace' });
|
||||
}
|
||||
});
|
||||
battle.on('elementHit', (ctx) => {
|
||||
const d = ctx.dmg, b = ctx.target?.findBuff?.('agoat2:ash');
|
||||
if (!b || b.source !== unit || !d || d.type !== 'element') return;
|
||||
const cut = num(b.data?.epCut);
|
||||
if (cut > 0) d.mul *= Math.max(0, 1 - cut);
|
||||
}, { owner: unit, priority: 20 });
|
||||
} },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -18,8 +18,11 @@
|
||||
import { getData } from '../../../data.js';
|
||||
import { COLS } from '../../constants.js';
|
||||
import { frontOf, offsetTile } from '../../dir.js';
|
||||
import { bodyInKeys, bodyDist, bodyInRadius, bodyOnTile, bodyTileReach } from '../../body.js';
|
||||
|
||||
export { COLS };
|
||||
/** Where an enemy can be hit (a huge enemy's whole hit rectangle, sim/body.js) — for every range test on enemies. */
|
||||
export { bodyInKeys, bodyDist, bodyInRadius, bodyOnTile, bodyTileReach };
|
||||
|
||||
// =====================================================================================================================
|
||||
// numbers & data
|
||||
@@ -249,10 +252,17 @@ export function rowMates(battle, u) {
|
||||
for (const a of battle.allyUnits) if (a.kind === 'op' && a.ownerId === u.ownerId && onField(a) && a.tileR === u.tileR) out.push(a);
|
||||
return out;
|
||||
}
|
||||
/** Enemy / ally tile key used by range sets (`unit.rangeKeySet`). */
|
||||
/** Tile key used by range sets (`unit.rangeKeySet`): an ally's tile, an enemy's position tile (range tests: onKeys). */
|
||||
export const tileKey = (u) => (u.side === 'ally' ? u.tileR * COLS + u.tileC : Math.round(u.y) * COLS + Math.round(u.x));
|
||||
/** Is `target` on a tile of `keys` (Set or array)? An ally: its tile; an enemy: its body (a huge one: every tile). */
|
||||
export const onKeys = (target, keys) => {
|
||||
if (!target || !keys) return false;
|
||||
if (target.side !== 'ally') return bodyInKeys(target, keys);
|
||||
const k = tileKey(target);
|
||||
return keys instanceof Set ? keys.has(k) : keys.includes(k);
|
||||
};
|
||||
/** Is `target` inside `u`'s current attack range (grid ranges)? */
|
||||
export const inRange = (u, target) => !!(u && target && u.rangeKeySet && u.rangeKeySet.has(tileKey(target)));
|
||||
export const inRange = (u, target) => !!(u && target && u.rangeKeySet && onKeys(target, u.rangeKeySet));
|
||||
|
||||
// =====================================================================================================================
|
||||
// battle: buffs, fx, per-battle state
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
|
||||
import { COLS, ROWS, MOVE_SCALE } from '../constants.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../targeting.js';
|
||||
import { bodyInKeys, bodyOnTile } from '../body.js';
|
||||
import { hasHp } from '../damage.js';
|
||||
import { genericKit } from './generic.js';
|
||||
import { normDir, localOrder } from '../dir.js';
|
||||
|
||||
@@ -169,20 +171,22 @@ function scheduleLifetime(battle, unit, seconds) {
|
||||
battle.after(s, () => { if (unit.alive && unit.deploySeq === seq) battle.retreat(unit, { reason: 'expired', permanent: true }); }, { owner: unit });
|
||||
}
|
||||
|
||||
/** Enemies (visible) standing on any of the absolute tile keys. Auras/bursts hit stealthed enemies too. */
|
||||
/** Visible enemies whose body is on any of the absolute tile keys (sim/body.js); auras / bursts hit stealthed ones. */
|
||||
function enemiesOnKeys(battle, keys) {
|
||||
const set = keys instanceof Set ? keys : new Set(keys);
|
||||
const out = [];
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive || e.hidden) continue;
|
||||
const r = Math.round(e.y), c = Math.round(e.x);
|
||||
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) continue;
|
||||
if (set.has(r * COLS + c)) out.push(e);
|
||||
if (bodyInKeys(e, set)) out.push(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Appear burst: damage (optional) + status (optional) on the enemies of `grid` around the token. Returns victims. */
|
||||
/**
|
||||
* Appear burst: damage (optional) + status (optional) on the enemies of `grid` around the token. Returns victims. Air
|
||||
* units (Unit.isFlying) are hit too **[ASSUMED]**: PRTS has no 对空 note on any of them — 沙之碑 (蜜蜡 S2), 迷迭香的战术装备
|
||||
* ("对自身攻击范围内的敌人造成晕眩"), “耀阳”, 纸偶 (风丸 折纸生花) — so a stun here also drops a hovering 掠海漂移体.
|
||||
*/
|
||||
function burst(battle, unit, { grid, amount = 0, type = 'phys', stun = 0, hits = 1, source = unit, fx = 'summonBurst' }) {
|
||||
const keys = absoluteRangeKeys(grid || GRID_3X3, unit.tileR, unit.tileC, unit.dir, 0);
|
||||
const victims = enemiesOnKeys(battle, keys);
|
||||
@@ -451,7 +455,10 @@ function seaborn(bb, raw, def) {
|
||||
};
|
||||
}
|
||||
|
||||
/** “耀阳” (耀骑士临光 S3): appear burst (true, owner ATK × atk_scale, stun), blocks 2, lasts while the owner's skill runs. */
|
||||
/**
|
||||
* “耀阳” (耀骑士临光 S3): appear burst (true, owner ATK × atk_scale, stun — air units too [ASSUMED: no 对空 note on PRTS
|
||||
* “耀阳”]), blocks 2, lasts while the owner's skill runs.
|
||||
*/
|
||||
function radiantSword(bb, raw, def) {
|
||||
const grid = def?.skill?.rangeGrid ?? GRID_PLUS;
|
||||
// 精锐 owner's module (isToken part): "攻击被阻挡的敌人时攻击力提升至115%" — any blocked enemy
|
||||
@@ -759,7 +766,9 @@ function champagne(bb) {
|
||||
let hit = null;
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive || e.hidden || e.isFlying) continue;
|
||||
if (Math.abs(e.x - unit.tileC) <= 0.5 && Math.abs(e.y - unit.tileR) <= 0.5 && (!hit || e.spawnSeq < hit.spawnSeq)) hit = e;
|
||||
// touching its tile: a huge enemy on any tile of its body (body.js)
|
||||
const on = e.hitArea ? bodyOnTile(e, unit.tileR, unit.tileC) : Math.abs(e.x - unit.tileC) <= 0.5 && Math.abs(e.y - unit.tileR) <= 0.5;
|
||||
if (on && (!hit || e.spawnSeq < hit.spawnSeq)) hit = e;
|
||||
}
|
||||
if (!hit) return;
|
||||
const hits = battle.time - unit.deployedAt >= extraAfter - 1e-9 ? 2 : 1;
|
||||
@@ -885,8 +894,8 @@ function yanyouFallbackDef(battle) {
|
||||
* * normal attack: rangeRadius 2.0, arts, 3 targets ("可同时攻击3个目标", talent 1.attack@max_target);
|
||||
* * every damage it deals adds burn = ATK × 2.ep_damage_ratio (20 %: "自身造成伤害时,附加攻击力20%的灼燃损伤"; the
|
||||
* official 下半 notice: "祛恶之焰附带的灼燃损伤调整为基于炎佑的攻击力") — normal attacks AND flame ticks;
|
||||
* * 元素脆弱 aura: enemies within YANYOU_FRAGILE_RADIUS (1.5, PRTS "自身<固定半径>半径1.5范围内") take element damage
|
||||
* × 2.damage_scale (1.2);
|
||||
* * 元素脆弱 aura: enemies within YANYOU_FRAGILE_RADIUS (1.5, PRTS "自身<固定半径>半径1.5范围内") take 元素伤害 (burst
|
||||
* damage) × 2.damage_scale (1.2) — not the gauge fill (damage.js);
|
||||
* * immune to element damage ("即将受到元素损伤前取消此元素损伤");
|
||||
* * 祛恶之焰 [模式乙] (the 炎 bond's mode, Skill_2; cooldown / initCooldown 15, atk_scale 0.6, hit_duration 20,
|
||||
* range_radius 1.0): "触发索敌和普通攻击相同:锁定1个目标持续施法,最多持续20秒,每秒对目标周围半径1.0范围内的所有
|
||||
@@ -985,10 +994,12 @@ function yanyouKit(bb, raw) {
|
||||
unit.base.blockCnt = 0;
|
||||
const speed = moveSpeed * MOVE_SCALE;
|
||||
onDeploy(battle, unit, () => { unit.mem.keysAt = null; refreshKeys(unit); });
|
||||
// every damage it deals adds burn (not the burn itself, not element bursts)
|
||||
// every damage it deals adds burn (not the burn itself, not element bursts; not a killing blow — the hook runs
|
||||
// before the kill, at 0 HP: no burst on the corpse)
|
||||
if (epRatio > 0) {
|
||||
battle.on('damaged', (ctx) => {
|
||||
if (ctx.source !== unit || !ctx.target || ctx.target.side !== 'enemy' || !ctx.target.alive) return;
|
||||
const t = ctx.target;
|
||||
if (ctx.source !== unit || !t || t.side !== 'enemy' || !hasHp(t)) return;
|
||||
if (ctx.type === 'element' || ctx.type === 'elemental' || !(ctx.amount > 0)) return;
|
||||
battle.dealDamage(unit, ctx.target, { type: 'element', element: 'burn', amount: unit.s.atk * epRatio, tags: ['yanyou'] });
|
||||
}, { owner: unit });
|
||||
|
||||
+84
-27
@@ -2,33 +2,61 @@
|
||||
//
|
||||
// dealDamage order: (element → gauge path) | invulnerable? → 'hit' hook (mutable DamageInfo, may set cancel)
|
||||
// → dodge (phys/arts, canDodge) → mitigation (phys: DEF, arts: RES, true: none)
|
||||
// → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul × type-taken mul × dmg.mul
|
||||
// → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul (not for 元素伤害) × type-taken mul × dmg.mul
|
||||
// → shields (hit-negating barriers first, then HP shields) → HP loss (boss pool routing) → 'damaged' hook
|
||||
// → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
|
||||
// Phys: max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %·A); Arts: max(A×(1 − R′/100), 5 %·A) with
|
||||
// R′ = max(0, R×(1−resIgnorePct) − resIgnoreFlat); True: A.
|
||||
// Element damage ('element' type + element) fills a gauge instead of HP (1000; enemy leaders 2000), reduced by the
|
||||
// target's 损伤抵抗 (data `epResistance`, a percentage: PRTS 元素 "受到的元素损伤 = 损伤值 × (1 − 损伤抵抗 × 0.01)").
|
||||
// R′ = max(0, R×(1−resIgnorePct) − resIgnoreFlat); True: A; Elemental (元素伤害): max(A×(1 − 元素抗性/100), 5 %·A)
|
||||
// (PRTS 游戏数据基础 DMG_e; 元素抗性 = the target's data `epDamageResistance`: 0 on every enemy in data/enemies.json).
|
||||
// Element damage ('element' type + element, 元素损伤) fills a gauge instead of HP (1000; enemy leaders 2000):
|
||||
// gain = amount × dmg.mul × target elemTakenMul (元素损伤倍率: "受到的元素损伤提高/降低…") × max(5 %, 1 − 损伤抵抗/100)
|
||||
// with 损伤抵抗 = the target's data `epResistance` (EP_RESISTANCE, a percentage: PRTS 元素 "受到的元素损伤 = 损伤值 ×
|
||||
// (1 − 损伤抵抗 × 0.01),后续可应用元素损伤倍率"; the 5 % floor of DMG_e — PRTS 游戏数据基础 "目标受到元素损伤时也可以使用
|
||||
// 该公式计算,只需要将 D 值改为目标的损伤抵抗即可"; in data/enemies.json only 转译基底·α has 10, every other enemy — 海嗣
|
||||
// included — 0; newer event enemies of the full enemy_database go up to 15, operators are all 0). No
|
||||
// source-side multiplier touches it (a kit scales its own amount), no gauge ever decays (EP_RECOVERY_PER_SEC 0), and
|
||||
// 元素脆弱 (`elementalTakenMul`, "受到的元素伤害提升") never scales it — only 元素伤害.
|
||||
// A full gauge bursts with the official term-table effects (constants.js ELEMENT), which differ by the side hit:
|
||||
// operators (enemy damage): burn 1200 arts + RES −20 10 s · neural stun 10 s, then 1000 true · apoptosis 15 s: 阻回
|
||||
// (noSp) + 静默 (no skill activation), −1 SP/s, 100 arts/s · erosion permanent DEF −100, then 800 phys (10 s).
|
||||
// enemies (operator damage, "·我方"): burn 7000 元素伤害 + RES −20 (10 s) · neural 3 麻痹, then 6000 元素伤害 (10 s) ·
|
||||
// apoptosis 15 s: 50 % weaken recovering over the burst + 800 元素伤害/s · erosion 5000 元素伤害 + permanent DEF −120 (8 s).
|
||||
// necrosis (legacy spare gauge): 12 s of 100 true dmg/s and ATK −20 %.
|
||||
// Burst damage is 无来源 (PRTS 元素 "元素爆发通常造成无来源的伤害"; PRTS 伤害分类: 无来源 = "无法被追溯伤害来源", yet "无来源
|
||||
// 伤害的击杀也能追溯击杀来源"): DamageInfo `sourceless` — the damage-dealt multipliers and penetration of the unit that
|
||||
// filled the gauge do not apply (the target's own RES cuts do, "可享受法抗减少效果"), and the `hit` / `damaged` / `fatal`
|
||||
// hooks get `source: null`, so no content keyed on the attacker (ATK ×1.5 vs 海怪, 精准狙击镜, bond / module 伤害提升,
|
||||
// reflect, "受到来自…的伤害") touches it; the hook ctx's `credit` and the kill / stats keep the filler ("元素爆发本身来源于
|
||||
// 造成“爆条”的元素损伤的来源").
|
||||
// 爆发冷却 (PRTS 元素, "阻回" after a burst): for the burst's duration (the `<el>Burst` buff) NO element of the unit can
|
||||
// fill or be recovered (reduceElement) — the bursting gauge shows full — and when it ends EVERY gauge of the unit
|
||||
// resets to 0. A burst that is still resolving (its `elementBurst` hook runs before the `<el>Burst` lock exists) already
|
||||
// counts as locked (`unit.burstPending[el]`), so a hook that spreads an element back (淤困 parasite hosts next to each
|
||||
// other) cannot re-burst the unit recursively. The unit's shown gauge (the fullest, "当前损伤元素") and its cooldown
|
||||
// travel in b.snap `elem` (elementView).
|
||||
// 元素伤害 (HP damage of an element) is the DamageInfo type 'elemental' (+ optional `element` for display): no DEF/RES,
|
||||
// no dodge, × source dmgDealtMul × target dmgTakenMul × elemTakenMul. Sleeping units (沉睡: 无敌) take no damage
|
||||
// unless the attacker's profile has `hitSleep` or the damage carries `ignoreSleep`.
|
||||
// 元素伤害 (HP damage of an element) is the DamageInfo type 'elemental' (+ optional `element` for display): no DEF/RES
|
||||
// (元素抗性 instead), no dodge, × source dmgDealtMul × target elementalTakenMul (元素脆弱) only — the target's
|
||||
// dmgTakenMul (脆弱 and the other "受到的伤害±" effects) does not scale it: ba.fragile is "受到的物理、法术、真实伤害提升"
|
||||
// (gamedata_const termDescriptionDict), 元素伤害 has its own ba.elementfragile [ASSUMED for the damage_resistance-type
|
||||
// cuts, which share the multiplier]. An element fill on a target with no HP left (hasHp: a lethal hit's `damaged` hook
|
||||
// runs before battle.kill, while the target is still `alive` at 0 HP) is refused, so nothing bursts on a corpse.
|
||||
// Element healing (reduceElement) lowers every element type by the amount, each on its own (PRTS 菲莱 备注: "清除元素损伤"
|
||||
// = "一次等同于自身最大元素值的全类型元素损伤治疗"). Sleeping units (沉睡: 无敌) take no damage unless the attacker's profile
|
||||
// has `hitSleep` or the damage carries `ignoreSleep`.
|
||||
|
||||
import { MIN_DAMAGE_RATIO, ELEMENT, ELEMENT_ORDER, PALSY_MAX } from './constants.js';
|
||||
|
||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
|
||||
/**
|
||||
* Alive AND still has HP (a boss: its pool's HP). A `damaged` hook sees a lethal hit before `battle.kill`, while the
|
||||
* target is still `alive` at 0 HP — element riders attached to that damage (and applyElement itself) skip such a
|
||||
* target, or a burst resolves on the corpse (a second fatal, 烛煌 熔点引爆's heal, 妮芙's stack …).
|
||||
*/
|
||||
export function hasHp(u) {
|
||||
return !!u && u.alive && (u.bossPool ? u.bossPool.hp > 0 : u.hp > 0);
|
||||
}
|
||||
|
||||
/** Normalise a DamageInfo descriptor. */
|
||||
export function makeDamageInfo(d = {}) {
|
||||
const type = d.type ?? 'phys';
|
||||
@@ -52,6 +80,7 @@ export function makeDamageInfo(d = {}) {
|
||||
cancel: false,
|
||||
noSp: !!d.noSp,
|
||||
ignoreSleep: !!d.ignoreSleep,
|
||||
sourceless: !!d.sourceless,
|
||||
attackId: d.attackId ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -61,7 +90,10 @@ function sleepBlocks(target, source, dmg) {
|
||||
return !!target.s.flags.sleep && !dmg.ignoreSleep && !(source && source.profile && source.profile.hitSleep);
|
||||
}
|
||||
|
||||
/** Pure mitigation formula (exported for tests). */
|
||||
/**
|
||||
* Pure mitigation formula (exported for tests). `target` = the target's stats; `ign.elementalRes` = its 元素抗性
|
||||
* (epDamageResistance) for 'elemental' damage.
|
||||
*/
|
||||
export function mitigate(amount, type, target, ign = {}) {
|
||||
if (type === 'phys') {
|
||||
const D = target.def ?? 0;
|
||||
@@ -73,6 +105,10 @@ export function mitigate(amount, type, target, ign = {}) {
|
||||
const eff = Math.max(0, R * (1 - clamp01(ign.resIgnorePct ?? 0)) - (ign.resIgnoreFlat ?? 0));
|
||||
return Math.max(amount * (1 - Math.min(100, eff) / 100), MIN_DAMAGE_RATIO * amount);
|
||||
}
|
||||
if (type === 'elemental') {
|
||||
const D = Math.max(0, Math.min(100, Number.isFinite(ign.elementalRes) ? ign.elementalRes : 0));
|
||||
return Math.max(amount * (1 - D / 100), MIN_DAMAGE_RATIO * amount);
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
|
||||
@@ -115,14 +151,18 @@ export function dealDamage(battle, source, target, dmgIn) {
|
||||
if (!target || !target.alive || target.removed || target.hidden || !target.deployed) return 0;
|
||||
const dmg = dmgIn && dmgIn._norm ? dmgIn : makeDamageInfo(dmgIn);
|
||||
if (dmg.type === 'element') return applyElement(battle, source, target, dmg);
|
||||
// 无来源 (dmg.sourceless, element bursts): hooks see no source — nothing keyed on "damage dealt by X" (ATK-up vs a
|
||||
// tag, 伤害提升 items / bonds / modules, reflect, "受到来自…的伤害") can recognise it; `credit` names the unit that still
|
||||
// gets the stats and the kill (PRTS 伤害分类 无来源 ③)
|
||||
const hs = dmg.sourceless ? null : source;
|
||||
let ts = target.s;
|
||||
if (ts.flags.invulnerable || sleepBlocks(target, source, dmg)) return 0;
|
||||
if (ts.flags.invulnerable || sleepBlocks(target, hs, dmg)) return 0;
|
||||
if (battle._hooks.hit) {
|
||||
battle.emit('hit', { source, target, dmg });
|
||||
battle.emit('hit', { source: hs, target, dmg, credit: source });
|
||||
if (dmg.cancel || !target.alive || !target.deployed) return 0;
|
||||
if (dmg.type === 'element') return applyElement(battle, source, target, dmg);
|
||||
ts = target.s; // handlers may have changed the target's buffs (fragile, invulnerable, dodge…): never use stale stats
|
||||
if (ts.flags.invulnerable || sleepBlocks(target, source, dmg)) return 0;
|
||||
if (ts.flags.invulnerable || sleepBlocks(target, hs, dmg)) return 0;
|
||||
}
|
||||
const type = dmg.type;
|
||||
// dodge
|
||||
@@ -141,16 +181,19 @@ export function dealDamage(battle, source, target, dmgIn) {
|
||||
const counts = !(ts.flags.hitCountArts && !ts.flags.hitCount && type === 'phys');
|
||||
return applyHpLoss(battle, source, target, absorbShields(battle, target, counts ? 1 : 0), dmg);
|
||||
}
|
||||
const ss = source && source.s ? source.s : null;
|
||||
// 无来源 damage (element bursts) takes nothing from its source's stats; the source still gets the credit below
|
||||
const ss = source && source.s && !dmg.sourceless ? source.s : null;
|
||||
let final = mitigate(dmg.amount, type, ts, {
|
||||
defIgnorePct: dmg.defIgnorePct + (ss ? ss.defIgnorePct : 0),
|
||||
defIgnoreFlat: dmg.defIgnoreFlat + (ss ? ss.defIgnoreFlat : 0),
|
||||
resIgnorePct: dmg.resIgnorePct + (ss ? ss.resIgnorePct : 0),
|
||||
resIgnoreFlat: dmg.resIgnoreFlat + (ss ? ss.resIgnoreFlat : 0),
|
||||
elementalRes: type === 'elemental' ? (target.def?.epDamageResistance ?? 0) : 0,
|
||||
});
|
||||
let mul = dmg.mul * ts.dmgTakenMul;
|
||||
// 脆弱 / "受到的伤害±" (dmgTakenMul) scale 物理、法术、真实 only; 元素伤害 takes 元素脆弱 alone (header)
|
||||
let mul = dmg.mul * (type === 'elemental' ? 1 : ts.dmgTakenMul);
|
||||
if (ss) mul *= ss.dmgDealtMul * (type === 'phys' ? ss.physDealtMul : type === 'arts' ? ss.artsDealtMul : 1);
|
||||
mul *= type === 'phys' ? ts.physTakenMul : type === 'arts' ? ts.artsTakenMul : type === 'elemental' ? ts.elemTakenMul : ts.trueTakenMul;
|
||||
mul *= type === 'phys' ? ts.physTakenMul : type === 'arts' ? ts.artsTakenMul : type === 'elemental' ? ts.elementalTakenMul : ts.trueTakenMul;
|
||||
final *= mul;
|
||||
if (!(final > 0) || !Number.isFinite(final)) final = 0;
|
||||
final = absorbShields(battle, target, final);
|
||||
@@ -163,6 +206,7 @@ export function dealDamage(battle, source, target, dmgIn) {
|
||||
export function applyHpLoss(battle, source, target, amount, dmg) {
|
||||
if (!target.alive) return 0;
|
||||
let dealt = 0;
|
||||
const hs = dmg && dmg.sourceless ? null : source; // the source hooks see (无来源: none; `credit` keeps it)
|
||||
if (target.bossPool) {
|
||||
const pool = target.bossPool;
|
||||
const before = pool.hp;
|
||||
@@ -180,7 +224,7 @@ export function applyHpLoss(battle, source, target, amount, dmg) {
|
||||
if (target.hp <= 0) {
|
||||
target.hp = 0;
|
||||
if (battle._hooks.fatal) {
|
||||
const fctx = { unit: target, source, dmg, amount, prevented: false };
|
||||
const fctx = { unit: target, source: hs, credit: source, dmg, amount, prevented: false };
|
||||
battle.emit('fatal', fctx);
|
||||
if (fctx.prevented && target.alive) { if (target.hp < 1) target.hp = Math.min(1, target.s.maxHp); }
|
||||
}
|
||||
@@ -197,7 +241,7 @@ export function applyHpLoss(battle, source, target, amount, dmg) {
|
||||
const shown = dmg.type === 'element' ? dmg.element : dmg.type === 'elemental' ? (dmg.element || 'true') : dmg.type;
|
||||
battle._ev(['dmg', target.id, Math.round(amount), shown]);
|
||||
}
|
||||
if (battle._hooks.damaged) battle.emit('damaged', { source, target, amount, type: dmg ? dmg.type : 'true', dmg });
|
||||
if (battle._hooks.damaged) battle.emit('damaged', { source: hs, target, amount, type: dmg ? dmg.type : 'true', dmg, credit: source });
|
||||
if (target.side === 'ally' && target.skill && dmg && !dmg.noSp && dmg.type !== 'element') battle._skills.onDamaged(target);
|
||||
const dead = target.bossPool ? target.bossPool.hp <= 0 : target.hp <= 0;
|
||||
if (dead && target.alive) battle.kill(target, source);
|
||||
@@ -246,10 +290,21 @@ export function elementView(u, now) {
|
||||
return [best, fill, 0, 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The share of an element hit that reaches `target`'s gauge (header): its 元素损伤倍率 (`elemTakenMul`) × max(5 %,
|
||||
* 1 − 损伤抵抗 / 100) — the factor applyElement uses after the `elementHit` hook, exported for content that previews a
|
||||
* gauge gain.
|
||||
*/
|
||||
export function elementIntake(target) {
|
||||
const res = Number(target && target.def ? target.def.epResistance : 0) || 0;
|
||||
return target.s.elemTakenMul * Math.max(MIN_DAMAGE_RATIO, 1 - clamp01(res / 100));
|
||||
}
|
||||
|
||||
/** Element gauge accumulation + burst. Fires `elementHit` { source, target, dmg } first (mutable amount/mul, cancel). */
|
||||
export function applyElement(battle, source, target, dmg) {
|
||||
const el = dmg.element;
|
||||
if (!el || !(el in target.elem)) return 0;
|
||||
if (!hasHp(target)) return 0; // a killing blow's rider: the target is dead, nothing fills or bursts (header)
|
||||
if (target.s.flags.invulnerable || sleepBlocks(target, source, dmg)) return 0;
|
||||
if (burstLocked(target, el)) return 0;
|
||||
if (battle._hooks.elementHit) {
|
||||
@@ -258,8 +313,8 @@ export function applyElement(battle, source, target, dmg) {
|
||||
if (dmg.type !== 'element') return dealDamage(battle, source, target, dmg); // a handler converted it
|
||||
}
|
||||
const max = target.gaugeMax;
|
||||
// 损伤抵抗 (official EP_RESISTANCE = data epResistance, a percentage)
|
||||
let amt = dmg.amount * dmg.mul * target.s.elemTakenMul * (1 - clamp01((target.def?.epResistance ?? 0) / 100));
|
||||
// 元素损伤倍率 × max(5 %, 1 − 损伤抵抗 %) — official EP_RESISTANCE = data epResistance
|
||||
let amt = dmg.amount * dmg.mul * elementIntake(target);
|
||||
if (!(amt > 0) || !Number.isFinite(amt)) return 0;
|
||||
target.elem[el] = Math.min(max, target.elem[el] + amt);
|
||||
battle._ev(['dmg', target.id, Math.round(amt), el]);
|
||||
@@ -290,7 +345,8 @@ function resolveBurst(battle, source, target, el) {
|
||||
if (battle._hooks.elementBurst) battle.emit('elementBurst', { source, target, element: el });
|
||||
if (!target.alive) { reset(); return; }
|
||||
const tags = ['burst', el];
|
||||
const hit = (amount, type) => battle.dealDamage(source, target, { amount, type, element: el, canDodge: false, tags });
|
||||
// 无来源 damage credited to `source` (header)
|
||||
const hit = (amount, type) => battle.dealDamage(source, target, { amount, type, element: el, canDodge: false, sourceless: true, tags });
|
||||
const lock = (duration, extra = {}) => {
|
||||
if (!(duration > 0)) { reset(); return null; }
|
||||
return battle.addBuff(target, { key: `${el}Burst`, duration, visible: true, onExpire: reset, onRemove: reset, ...extra, flags: { burstLock: true, ...(extra.flags || {}) } });
|
||||
@@ -298,7 +354,7 @@ function resolveBurst(battle, source, target, el) {
|
||||
if (el === 'necrosis') {
|
||||
lock(cfg.duration, {
|
||||
mods: { atkMul: 1 - cfg.atkDownPct }, interval: 1,
|
||||
onTick: () => battle.dealDamage(source, target, { amount: cfg.dps, type: 'true', canDodge: false, tags: ['burst', 'necrosis'] }),
|
||||
onTick: () => battle.dealDamage(source, target, { amount: cfg.dps, type: 'true', canDodge: false, sourceless: true, tags: ['burst', 'necrosis'] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -319,11 +375,11 @@ function resolveBurst(battle, source, target, el) {
|
||||
}
|
||||
} else if (el === 'apoptosis') {
|
||||
if (enemy) {
|
||||
// 50 % 虚弱 that recovers linearly over the burst, 800 元素伤害 per second
|
||||
// 50 % 虚弱, 800 元素伤害 per second; each second the 虚弱 drops to 50 % × 剩余时间 ÷ 总持续时间 (PRTS 元素)
|
||||
lock(c.duration, {
|
||||
mods: { atkMul: 1 - c.weaken }, interval: 1,
|
||||
onTick: ({ buff }) => {
|
||||
buff.mods = { atkMul: 1 - c.weaken * Math.max(0, buff.timeLeft - 1) / c.duration };
|
||||
buff.mods = { atkMul: 1 - c.weaken * Math.max(0, buff.timeLeft) / c.duration };
|
||||
target.markDirty();
|
||||
hit(c.elemDps, 'elemental');
|
||||
},
|
||||
@@ -354,17 +410,18 @@ export function palsyBuff(n) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce an element gauge (e.g. wandermedic "回复元素损伤"). Returns the amount removed. Nothing recovers during a
|
||||
* burst cooldown ("爆发冷却状态下,单位所有类型的元素值均无法损失、无法被其他手段回复").
|
||||
* Element healing (e.g. wandermedic "回复元素损伤"): lowers element `el`, or — without `el` — EVERY element type, each
|
||||
* by `amount` on its own (PRTS 菲莱 备注: clearing = "一次等同于自身最大元素值(通常为1000)的全类型元素损伤治疗").
|
||||
* Returns the total removed. Nothing recovers during a burst cooldown ("爆发冷却状态下,单位所有类型的元素值均无法损失、
|
||||
* 无法被其他手段回复").
|
||||
*/
|
||||
export function reduceElement(target, amount, el = null) {
|
||||
let removed = 0;
|
||||
if (!target || !target.elem || burstLocked(target)) return 0;
|
||||
if (!target || !target.elem || burstLocked(target) || !(amount > 0)) return 0;
|
||||
const els = el ? [el] : Object.keys(target.elem);
|
||||
for (const k of els) {
|
||||
const take = Math.min(target.elem[k], amount - removed);
|
||||
const take = Math.min(target.elem[k] || 0, amount);
|
||||
if (take > 0) { target.elem[k] -= take; removed += take; }
|
||||
if (removed >= amount) break;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
+22
-14
@@ -21,7 +21,10 @@
|
||||
// Behaviour per subprofession is documented in docs/SIM.md §Professions. Front / side tests use the unit's direction
|
||||
// (`dir`, sim/dir.js): offsets are compared in its facing-RIGHT frame.
|
||||
|
||||
import { toLocal } from './dir.js';
|
||||
import { toLocal, frontOf } from './dir.js';
|
||||
import { absoluteRangeKeys } from './targeting.js';
|
||||
import { bodyInKeys, bodyKeys, bodyOnTile } from './body.js';
|
||||
import { COLS } from './constants.js';
|
||||
|
||||
const P = (o) => Object.freeze(o);
|
||||
const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||||
@@ -239,19 +242,24 @@ const installSkywalker = (battle, unit) => {
|
||||
};
|
||||
|
||||
/** Target's tile offset from the unit in the unit's facing-RIGHT frame ([dRow, dCol], sim/dir.js toLocal). */
|
||||
const localOffset = (unit, target) => toLocal(Math.round(target.y) - unit.tileR, Math.round(target.x) - unit.tileC, unit.dir);
|
||||
/** The target (its body: a huge enemy's every tile — body.js) on a tile of `grid` around the unit (its facing frame). */
|
||||
const inTraitGrid = (unit, target, grid) => bodyInKeys(target, absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0));
|
||||
|
||||
const inTraitGrid = (unit, target, grid) => {
|
||||
const [dr, dc] = localOffset(unit, target);
|
||||
for (const [r, c] of grid) if (r === dr && c === dc) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
/** 'reaperrange' default front test: the target stands on the unit's own line, at or ahead of it along its facing. */
|
||||
/**
|
||||
* 'reaperrange' default front test: the target stands on the unit's own line, at or ahead of it along its facing — a
|
||||
* huge enemy when any tile of its body does (body.js).
|
||||
*/
|
||||
const onFrontLine = (unit, target) => {
|
||||
const [fr, fc] = unit.fwd;
|
||||
const [lateral] = localOffset(unit, target);
|
||||
return lateral === 0 && (target.x - unit.x) * fc + (target.y - unit.y) * fr >= 0;
|
||||
const lateral = (r, c) => toLocal(r - unit.tileR, c - unit.tileC, unit.dir)[0];
|
||||
if (!target.hitArea) {
|
||||
return lateral(Math.round(target.y), Math.round(target.x)) === 0 && (target.x - unit.x) * fc + (target.y - unit.y) * fr >= 0;
|
||||
}
|
||||
for (const k of bodyKeys(target)) {
|
||||
const r = Math.floor(k / COLS), c = k % COLS;
|
||||
if (lateral(r, c) === 0 && (c - unit.tileC) * fc + (r - unit.tileR) * fr >= 0) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------------------------------------------
|
||||
@@ -269,7 +277,7 @@ export const SUB = Object.freeze({
|
||||
const n = Math.max(1, (unit.profile.shockTimes ?? 2) - 1);
|
||||
for (let i = 1; i <= n; i++) {
|
||||
battle.after(0.3 * i, () => {
|
||||
for (const e of battle.enemiesInRadius(info.x, info.y, unit.profile.splashRadius || 1)) {
|
||||
for (const e of battle.enemiesInRadius(info.x, info.y, unit.profile.splashRadius || 1, true)) { // splash: 中点判定
|
||||
if (e.isFlying) continue;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * (unit.profile.shockScale ?? 0.5), type: 'phys', isSplash: true, tags: ['aftershock'] });
|
||||
}
|
||||
@@ -342,8 +350,8 @@ export const SUB = Object.freeze({
|
||||
lord: P({ canHitFly: true,
|
||||
dmgMul: (battle, unit, target) => {
|
||||
if (target.blockedBy === unit) return 1;
|
||||
const [dr, dc] = localOffset(unit, target);
|
||||
return dr === 0 && (dc === 0 || dc === 1) ? 1 : (unit.profile.rangedScale ?? 0.8);
|
||||
const [fr, fc] = frontOf(unit.tileR, unit.tileC, unit.dir);
|
||||
return bodyOnTile(target, unit.tileR, unit.tileC) || bodyOnTile(target, fr, fc) ? 1 : (unit.profile.rangedScale ?? 0.8);
|
||||
} }),
|
||||
musha: P({ noHeal: true, install: installSelfHealOnHit(false) }),
|
||||
reaper: P({ noHeal: true, allInRange: true, install: installSelfHealOnHit(true) }),
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
// getSimData() the injected data (browser) or the generated data (Node), or null.
|
||||
|
||||
import { resolveRecordLoadout, composeStats, composeTalents, loadoutRecord } from '../../shared/loadoutRecord.js';
|
||||
import { normHitArea } from './body.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// generic helpers
|
||||
@@ -258,6 +259,7 @@ export function normalizeEnemy(key, e) {
|
||||
epDamageResistance: num(st.elementDmgRes ?? extra.epDamageResistance ?? e.epDamageResistance, 0),
|
||||
hpRecoveryPerSec: num(st.hpRecoveryPerSec ?? extra.hpRecoveryPerSec ?? e.hpRecoveryPerSec, 0),
|
||||
notCountInTotal: !!(e.notCountInTotal),
|
||||
hitArea: normHitArea(e.hitArea), // huge units only (body.js); null = a point
|
||||
tags: e.tags ?? [],
|
||||
abilities: e.abilities ?? [],
|
||||
skills: e.skills ?? [],
|
||||
|
||||
@@ -277,6 +277,9 @@ export class SkillRuntime {
|
||||
if (this.healSkill) return b.injuredAlliesInKeys(keys, u).length > 0;
|
||||
if (b.enemiesInKeys(keys, u, u.profile).length > 0) return true;
|
||||
}
|
||||
// the enemies a melee unit blocks are always its targets (Battle.blockedTargets), in range or not — PRTS 卫戍协议/帮助
|
||||
// "敌人被近战干员自身阻挡" satisfies the target condition of the basic strategy
|
||||
if (!this.healSkill && u.blocking.length && b.blockedTargets(u, u.profile).length > 0) return true;
|
||||
return !this.healSkill && this.triggerRanges.length > 0 && this._extraTriggerSatisfied();
|
||||
}
|
||||
|
||||
|
||||
+44
-11
@@ -2,13 +2,17 @@
|
||||
//
|
||||
// Grid ranges: `[dRow, dCol]` offsets relative to facing RIGHT, rotated by the unit's direction (sim/dir.js: RIGHT
|
||||
// (dr,dc), UP (dc,−dr), LEFT (−dr,−dc), DOWN (−dc,dr); row 0 = bottom). An enemy is in range when the tile containing
|
||||
// it (round(y), round(x)) is one of the absolute range tiles. `rangeExtend` (ability_range_forward_extend) adds N tiles
|
||||
// it (round(y), round(x)) is one of the absolute range tiles — a huge enemy (巨型单位) when any tile its hit rectangle
|
||||
// occupies is (body.js; user playtest #5 item 10). `rangeExtend` (ability_range_forward_extend) adds N tiles
|
||||
// past the furthest cell of every row, along +dCol BEFORE rotating (DESIGN §3).
|
||||
// Operator priority: (1) enemies it blocks, (2) profile priority (fly/lowDef/…), (3) higher enemy taunt,
|
||||
// (4) least remaining path distance to the goal, (5) earliest spawned.
|
||||
// Operator priority: (1) enemies it blocks (melee units only: meleeUnit), (2) profile priority (fly/lowDef/…),
|
||||
// (3) higher enemy taunt, (4) least remaining path distance to the goal, (5) earliest spawned. Air units
|
||||
// (Unit.isFlying: FLY, 近地悬浮, 浮空) need a profile that can hit them (`canHitFly`, never `groundOnly`). Enemy
|
||||
// priority: sortAllyTargets.
|
||||
|
||||
import { COLS, ROWS } from './constants.js';
|
||||
import { normDir, rotateOffset } from './dir.js';
|
||||
import { bodyDist } from './body.js';
|
||||
|
||||
/**
|
||||
* Build the absolute tile-key list of a grid for a unit standing at (r,c) facing `dir` ('UP'|'RIGHT'|'DOWN'|'LEFT';
|
||||
@@ -45,6 +49,18 @@ export function tileKeyOf(u) {
|
||||
return r * COLS + c;
|
||||
}
|
||||
|
||||
/**
|
||||
* A melee unit (近战位: data `position` MELEE — melee-tile operators and summons, 重装要塞 / 领主 / 哨戒铁卫 included):
|
||||
* it may always select, and selects first, the enemies it blocks — PRTS 选择器 "可以选择且优先选择阻挡单位" (the
|
||||
* BlockedOrAdvancedSelector of units whose range is smaller than their block radius), 索敌的概念 "我方索敌优先级:
|
||||
* 阻挡(近战限定)". A ranged operator standing on a melee tile (卫戍协议 "所有行动内远程干员可部署在近战位") blocks, but
|
||||
* targets by its range alone: "远程位干员通常而言不会优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在该干员的身后,该干员
|
||||
* 可能因为敌人未处于自身攻击范围而无法攻击到这个敌人" (索敌的概念). Used by Battle.blockedTargets and sortEnemyTargets.
|
||||
*/
|
||||
export function meleeUnit(u) {
|
||||
return String(u?.def?.position ?? 'MELEE').toUpperCase() === 'MELEE';
|
||||
}
|
||||
|
||||
/** Can `attacker` (ally) target enemy `e` at all (ignoring range)? */
|
||||
export function canTargetEnemy(attacker, e, profile) {
|
||||
if (!e.alive || e.hidden || !e.deployed) return false;
|
||||
@@ -56,7 +72,13 @@ export function canTargetEnemy(attacker, e, profile) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Can enemy `e` target ally `a`? `ranged` = the attack is a ranged (non-blocked) one. */
|
||||
/**
|
||||
* Can enemy `e` target ally `a`? `ranged` = the attack is a ranged (non-blocked) one. A stealthed ally (隐匿 / 迷彩, the
|
||||
* 排气格栅 tile) is a target only for the enemy it blocks — PRTS 作战机制 §隐匿 "我方干员并不会因为阻挡而解除隐匿" and 索敌的
|
||||
* 概念 "敌人会在自身被干员阻挡情况下强行无视对方可选性发动攻击" (the term text "不阻挡时…" is the short form; 异常效果:
|
||||
* "隐匿与'阻挡时解除'没有直接关系"). Devices are never targets (阻隔工事: obstacles nobody can select; “双眼皮”: 迷彩 and
|
||||
* off the enemy paths — PRTS).
|
||||
*/
|
||||
export function canTargetAlly(e, a, ranged) {
|
||||
if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') return false;
|
||||
const f = a.s.flags;
|
||||
@@ -89,10 +111,11 @@ export function sortEnemyTargets(battle, attacker, cands, priority) {
|
||||
if (cands.length <= 1) return cands;
|
||||
const pf = priority ? (PRIORITY_FNS[priority] || (priority === 'nearest' || priority === 'farthest' ? null : null)) : null;
|
||||
const ax = attacker.x, ay = attacker.y;
|
||||
const melee = meleeUnit(attacker);
|
||||
const keyed = cands.map((e) => ({
|
||||
e,
|
||||
b: e.blockedBy === attacker ? 0 : 1,
|
||||
p: pf ? pf(e) : priority === 'nearest' ? Math.hypot(e.x - ax, e.y - ay) : priority === 'farthest' ? -Math.hypot(e.x - ax, e.y - ay) : 0,
|
||||
b: melee && e.blockedBy === attacker ? 0 : 1,
|
||||
p: pf ? pf(e) : priority === 'nearest' ? bodyDist(e, ax, ay) : priority === 'farthest' ? -bodyDist(e, ax, ay) : 0,
|
||||
t: -(e.s.taunt || 0),
|
||||
d: battle.remainingDistance(e),
|
||||
s: e.spawnSeq,
|
||||
@@ -103,16 +126,26 @@ export function sortEnemyTargets(battle, attacker, cands, priority) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enemy target selection among allies: blocker first → highest taunt → latest deployed (DESIGN §5.5).
|
||||
* Enemy target selection among allies (PRTS 作战机制 索敌, 敌方: "阻挡→特殊优先级→仇恨值(更容易被攻击→…→最后部署的目标→
|
||||
* 不容易被攻击)→最早出现"): its blocker first → highest taunt level → latest deployed (`aggroSeq`: the deploy order —
|
||||
* Battle.start deploys the operators first and ranks the start-of-battle summons after them). Special priorities
|
||||
* (优先攻击…) are the enemies' own content: `e.profile.canTarget` filters, key sorts broken by `aggroCmp`.
|
||||
*/
|
||||
export function sortAllyTargets(enemy, cands) {
|
||||
if (cands.length <= 1) return cands;
|
||||
cands.sort((a, b) => {
|
||||
const ba = enemy.blockedBy === a ? 0 : 1, bb = enemy.blockedBy === b ? 0 : 1;
|
||||
if (ba !== bb) return ba - bb;
|
||||
const ta = a.s.taunt || 0, tb = b.s.taunt || 0;
|
||||
if (ta !== tb) return tb - ta;
|
||||
return b.deploySeq - a.deploySeq;
|
||||
return ba - bb || aggroCmp(a, b);
|
||||
});
|
||||
return cands;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仇恨值 order of two allies for an enemy (negative ⇒ `a` first): higher taunt level, then the latest deployed
|
||||
* (`aggroSeq`). The tie-break after an enemy's special priority (优先攻击防御力最高的… — PRTS 索敌: 特殊优先级 → 仇恨值).
|
||||
*/
|
||||
export function aggroCmp(a, b) {
|
||||
const ta = a.s.taunt || 0, tb = b.s.taunt || 0;
|
||||
if (ta !== tb) return tb - ta;
|
||||
return (b.aggroSeq || b.deploySeq) - (a.aggroSeq || a.deploySeq);
|
||||
}
|
||||
+15
-4
@@ -71,7 +71,8 @@ export class Unit {
|
||||
this.deployedAt = -Infinity;
|
||||
this.deathAt = -Infinity;
|
||||
this.respawnAt = Infinity;
|
||||
this.deploySeq = 0;
|
||||
this.deploySeq = 0; // deployment counter; also the identity of one deployment (content: `seq === u.deploySeq`)
|
||||
this.aggroSeq = 0; // 仇恨 order (the later the more attacked): = deploySeq, except summons of the initial deployment
|
||||
this.spawnSeq = 0;
|
||||
this.elem = { burn: 0, neural: 0, necrosis: 0, apoptosis: 0, erosion: 0 };
|
||||
this.burstPending = null; // { [element]: true } while that element's burst resolves (damage.js burstLocked)
|
||||
@@ -142,7 +143,8 @@ export class Unit {
|
||||
physTakenMul: m('physTakenMul'),
|
||||
artsTakenMul: m('artsTakenMul'),
|
||||
trueTakenMul: m('trueTakenMul'),
|
||||
elemTakenMul: m('elemTakenMul'),
|
||||
elemTakenMul: m('elemTakenMul'), // 元素损伤倍率: element gauge fills (damage.js applyElement)
|
||||
elementalTakenMul: m('elementalTakenMul'), // 元素脆弱: 元素伤害 (the 'elemental' HP damage type)
|
||||
healingDealtMul: m('healingDealtMul'),
|
||||
healingTakenMul: m('healingTakenMul'),
|
||||
atkScaleMul: m('atkScaleMul'),
|
||||
@@ -169,8 +171,17 @@ export class Unit {
|
||||
|
||||
hasFlag(k) { return !!this.s.flags[k]; }
|
||||
|
||||
/** Ground unit: enemies that WALK and aren't levitated; ally units standing on a LOW tile. */
|
||||
get isFlying() { return this.motion === 'FLY'; }
|
||||
/**
|
||||
* Air unit (空中单位) for every targeting / ground-only rule: FLY movers, and enemies that hover (近地悬浮, buff flag
|
||||
* `float` — PRTS 术语释义 ba.float "算作空中单位"; they keep walking the ground path) or are levitated (浮空, gamedata_const
|
||||
* ba.levitate "变为空中单位"). Movement and pathing read `motion`, never this.
|
||||
*/
|
||||
get isFlying() {
|
||||
if (this.motion === 'FLY') return true;
|
||||
if (this.side !== 'enemy') return false;
|
||||
const f = this.s.flags;
|
||||
return !!(f.float || f.levitate);
|
||||
}
|
||||
|
||||
get hpRatio() { const mh = this.s.maxHp; return mh > 0 ? this.hp / mh : 0; }
|
||||
|
||||
|
||||
Reference in new issue
Block a user