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:
sganggs committed 2026-09-30 19:12:36 +08:00
1 parent d4f868cd19
commit 15291c2293
104 files changed
+5539 -662

No files matched your search

+2 -4
View File
@@ -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
+28 -13
View File
@@ -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 });
+7 -2
View File
@@ -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);
+5 -2
View File
@@ -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
View File
@@ -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;
}
}
+7 -8
View File
@@ -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);
},
};
+4 -3
View File
@@ -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');
+13 -31
View File
@@ -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.
+24 -16
View File
@@ -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;
},
+57 -29
View File
@@ -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 {
+14 -11
View File
@@ -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)));
+39 -24
View File
@@ -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);
+86 -44
View File
@@ -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 });
} },
],
};
+12 -2
View File
@@ -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
+22 -11
View File
@@ -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 });