- #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
3186 lines
178 KiB
JavaScript
3186 lines
178 KiB
JavaScript
// server/sim/content/kits/tier6.js — hand-authored kits for every tier-6 chess (阶 VI, DIY slots excluded) plus the
|
||
// hidden chess granted by effects (盟约·辅助干员 chess_char_1_15, band Pith "优等生"; 妮芙 chess_char_6_10 is the hidden
|
||
// tier-6 entry). `export default { [baseChessId]: (bb, chess, def) => Kit }` (docs/SIM.md §7.2).
|
||
//
|
||
// Every number comes from the blackboards (skill bb at the chess level: normal Lv4 / elite Lv7; talents from
|
||
// `def.talents[i].bb`, elite module upgrades included; module-only talent parts from `chess.talents[index −1]`; trait
|
||
// module upgrades from `def.traitBb`). A few numbers exist only in the official text; they are parsed from it
|
||
// (e.g. 锏 "总计10次斩击", 艾雅法拉 "5连发", 仇白 "额外攻击2个目标", 海嗣 duration). The profession defaults of
|
||
// professions.js (bard aura, phalanx guard, chain bounces, funnel ramp, bombarder aftershocks, geek drain, tactician
|
||
// reinforcement …) are kept and built upon.
|
||
//
|
||
// Simplifications (one line per id; see also the report of the content phase):
|
||
// 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
|
||
// out / withdrawn after the end, she fires no further shell (those already in the air still land);
|
||
// "wanted" needs a continuous 8 s stay in some 拉特兰 range; wanted targets are added to her range tiles.
|
||
// 6_02 圣聆初雪 "诱导" (ba.attract 无法被阻挡并向目标位置移动) = the engine `attract` status for attract_time: the enemy
|
||
// is unblockable and walks (own speed, grid path) to the nearest ground tile around her, then waits there;
|
||
// snow lives on the ground tiles of her current range; the frozen "保护目标" token is not used (she just
|
||
// freezes herself).
|
||
// 6_03 余 the fire wall is her column; "crossing" = source and target on opposite sides of it; bullet clearing =
|
||
// cancelling a ranged enemy hit that crosses it; talent 2 given to all ops = each op regenerates itself
|
||
// (elite: and gets the ×1.14 arts vs burning targets); 庇护 = phys/arts taken ×(1−0.25).
|
||
// 6_04 浊心斯卡蒂 S3 HP drain is non-lethal (like the geek drain); 鼓舞 = flat ATK added after the target's multipliers;
|
||
// 海嗣 is inert (no attack) and only extends her aura (and her DEFAULT trigger); it expires after its
|
||
// duration and is re-summoned after its redeploy time when DP ≥ its cost (auto-redeploy emulation).
|
||
// 6_05 异客 storm zone radius 1.5 tiles (not in data); strikes chain like her trait (4 targets, trait falloff);
|
||
// the storm stops when she leaves the field.
|
||
// 6_06 佩佩 splash stun via `damaged` (isSplash) hook; module ×1.15 when ≥3 enemies in the splash area.
|
||
// 6_07 维娜 "attack enemies blocked by allies in talent range" = those enemies' tiles are added to her range;
|
||
// 黄金盟誓 lasts the skill duration on the tile nearest to an enemy.
|
||
// 6_08 焰影苇草 灼痕 = marker (ATK −20 %) + the 法术脆弱 status (同名效果取最高); applied during S3 it lasts until the
|
||
// skill ends (duration = remaining skill time).
|
||
// 6_09 塑心 cannot normal attack; each skill charge is one attack; 精神逆构 multiplies every apoptosis gauge fill on
|
||
// enemies in her range (`elementHit`; several 塑心 do not stack — the strongest applies).
|
||
// 6_10 妮芙 失魂 = 元素伤害 ('elemental') per second while the target's apoptosis burst lasts.
|
||
// 6_11 缪尔赛思 流形 copies the nearest allied operator of its player on the field (no hand/bench in battle; same pick
|
||
// as content/tokens.js) when its own skill starts; it copies the listed stats + range + damage type (not
|
||
// splash/chain/multi-hit shapes); clones do not split again. MLYSS_WTRMAN trigger = DEFAULT or an enemy
|
||
// in a (copied) 流形's range. An uncopied 流形 never attacks and its copy skill waits (ready) until someone
|
||
// can be copied. "被击败后25秒后自动刷新": one pending respawn at a time, only while she is on the field
|
||
// and no 流形 of hers stands (her knock-out cancels it; her redeploy re-summons it as her 援军).
|
||
// 6_12 迷迭香 "溅射范围扩大" ×1.3 (not in data); 感知稳定 picks among the owner's deployed casters (none ⇒ no buff).
|
||
// 6_13 新约能天使 steal target = ally op in range with the highest ASPD; bombardment radius 1 tile (not in data).
|
||
// 6_14 流明 S3 heals an abnormal ally even at full HP (forced heal); 抵抗 = the engine `resist` status.
|
||
// 6_15 仇白 入隙 reads the target's sluggish/bind statuses; module adds 10 % ATK arts per hit.
|
||
// 6_16 溯光星源 link transfers the pre-mitigation arts amount × share to the other locked target(s); 能源解析 = the
|
||
// 脆弱 status (同名效果取最高).
|
||
// 6_17 耀骑士临光 "上一名部署干员" = the op of the same owner deployed right before her (deploy order).
|
||
// 6_18 荒芜拉普兰德 S3 drones are virtual (fx events), move at projectile_move_speed tiles/s, she stops attacking
|
||
// while they roam; 头狼 stage 2 "特殊能力失效" = silence; stage 3 = +1 drone (normal attacks hit once
|
||
// more, S3 releases one more drone). Base drone count 1.
|
||
// 6_19 锏 10 slashes every d_hit_interval, pulls every p_hit_interval, final blow (skill range) at the end.
|
||
// 6_20 纯烬艾雅法拉 5 shots are padded by cycling targets when fewer injured allies exist.
|
||
//
|
||
// Operator loadouts (DESIGN §16): every visible chess also authors its selectable NON-default skills in `skills`
|
||
// ({ [skillId]: SkillSpec }, built from the SELECTED skill's `bb`: normal Lv4 / elite Lv7; triggers from that skill's
|
||
// data). Talent / install hooks written for the default skill check `onDefaultSkill(chess)` (余 闲云隐市 for all, 维娜
|
||
// S3 targets, 焰影苇草 灼痕 until the skill ends, 流明 S3 bullets, 溯光星源 S3 locks, 耀骑士临光 S3 true damage, 缪尔赛思
|
||
// S3 bind, 浊心斯卡蒂 tide). Module choices read the loadout-resolved record (trait.bb, talents, hidden module parts);
|
||
// the module-specific code: 浊心斯卡蒂 新生代, 维娜 秩序圣“球”, 焰影苇草 “独属自己的一隅”, 塑心 音乐家的旅程, 缪尔赛思
|
||
// 落叶四季, 仇白 欲雪时, 耀骑士临光 “骑士家族”, 锏 新合同, 纯烬艾雅法拉 想要留下的生命 (生息演算 / 集成战略-only parts of
|
||
// the RA / IS modules are not modelled). Alternate-skill simplifications:
|
||
// 6_01 S2 the aim (disarmed meanwhile) spends its bullet when it begins; target = a wanted enemy (lowest DEF first).
|
||
// 6_02 S1 casts with an enemy in her range (data SEARCH read as "search in range" [ASSUMED]; the engine SEARCH =
|
||
// any enemy on the field wasted both charges); the snow spreads along her facing line (≤ trig_cnt tiles);
|
||
// S2 "目标点变为冻结状态" = the frozen 保护目标 token (icetgt, one at a time) on the tile reaching max snow, whose
|
||
// snow is used up; spreads go to the thinnest 4-neighbour; the 20 % DoT ticks once per second.
|
||
// 6_03 S2 teleport = the ground-reachable (grid path) enemies of the skill grid moved onto his tile.
|
||
// 6_04 S1 transfer: the ally takes ×(1 − share), she takes the rest as true damage from the attacker; module 新生代
|
||
// "30点伤害减免" = +30 effective DEF (physical) / −30 ÷ (1 − RES) before mitigation (arts).
|
||
// 6_06 S2 the ASPD stacks last the whole battle; S1 "异常状态时可以释放" = cleanse + cast (tick check).
|
||
// 6_07 S2 passive counts the OTHER allies of the talent-1 area.
|
||
// 6_08 S2 carriers = operators of her range (ground first, then lowest HP); a fireball shoots an enemy of the carrier's
|
||
// range (else hers).
|
||
// 6_09 S2 partner = the highest-ATK other operator of her range (re-picked every 0.5 s); S3 picks by base stats.
|
||
// 6_11 S1 one DP every interval, the remainder when it ends; module 落叶四季 "援军阻挡的敌人更容易受到我方的攻击" = the
|
||
// 流形 variant's taunt_level (+1) on the enemies it blocks.
|
||
// 6_05 S2 / 6_12 S3 base_attack_time scales the base attack time (akdata "乘算负数"); see batOf.
|
||
// 6_12 S1 the extra arts hit lands on the main + splash victims (not the aftershocks).
|
||
// 6_13 S1 "主动关闭" = skill.stop(); S3 the 投递坐标 (the player's pick) goes next to the first enemy entering her range
|
||
// (at the latest when S3 starts); delivered = the knocked-out non-ranged operator of hers with the latest
|
||
// respawnAt; the bombardment radius is AIRSTRIKE_RADIUS.
|
||
// 6_14 S1 the HoT covers the allies within 1.5 tiles of the healed target; S2 "蓄力" = cast with every charge full.
|
||
// 6_15 S1 the bind-end explosion radius is 1.2 tiles [ASSUMED].
|
||
// 6_16 S1 bounces are full hits within attack@projectile_range, never on the same enemy twice in a row.
|
||
// 6_18 S1 "非移动敌人" = blocked / not walking / immobilised enemies; S2 every drone ramps on its own target.
|
||
// 6_20 S2 the barrier is one pool over her range at the cast, absorbing gauge fills (before 元素抗性).
|
||
//
|
||
// Summons: skill summons (黄金盟誓, “耀阳”) are spawned without a kit so content/tokens.js's token kit applies (true
|
||
// damage / appear burst / lifetime); the fallbacks here only run when no token kit exists (`kit.fromTokens`).
|
||
// Owner-coupled behaviour of placeable summons (海嗣 aura/lifetime/redeploy, 流形 copy/steal/split/respawn) lives in
|
||
// these kits — tokens.js skips it for summoners with a hand-authored kit (its `managed()` rule).
|
||
// Engine notes: "extra targets" (蕾缪安 wanted, 维娜 S3) use the engine's extra range keys (`battle.setExtraRange`,
|
||
// merged into every range rebuild); 海嗣 / 流形 extend their summoner's DEFAULT trigger through
|
||
// `skill.addTriggerRange`; element-type damage skips the `hit` hook, so element (损伤) amplification uses the
|
||
// `elementHit` hook (`onElementHit`: × dmg.mul before the gauge fill).
|
||
|
||
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../../targeting.js';
|
||
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';
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// helpers
|
||
|
||
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
|
||
|
||
/** Blackboard value by exact key, else the first key ending with `.key` / `]key` (prefixed official keys). */
|
||
function bv(bb, key, d = 0) {
|
||
if (!bb) return d;
|
||
const v = bb[key];
|
||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||
for (const k of Object.keys(bb)) {
|
||
if (k.endsWith('.' + key) || k.endsWith(']' + key)) {
|
||
const x = bb[k];
|
||
if (typeof x === 'number' && Number.isFinite(x)) return x;
|
||
}
|
||
}
|
||
return d;
|
||
}
|
||
const tbb = (def, i) => (def && def.talents && def.talents[i] && def.talents[i].bb) || {};
|
||
const tdesc = (def, i) => String((def && def.talents && def.talents[i] && def.talents[i].description) || '');
|
||
/** Module-only (hidden, index −1) talent parts of a chess record, merged (first occurrence wins). */
|
||
function moduleBb(chess) {
|
||
const out = {};
|
||
for (const t of (chess && chess.talents) || []) {
|
||
if (!t || t.index !== -1 || !t.bb) continue;
|
||
for (const [k, v] of Object.entries(t.bb)) if (!(k in out)) out[k] = v;
|
||
}
|
||
return out;
|
||
}
|
||
const parseN = (text, re, d) => { const m = String(text ?? '').match(re); return m ? +m[1] : d; };
|
||
const live = (u) => !!u && u.alive && u.deployed && !u.removed && !u.hidden;
|
||
const isElite = (e) => !!e && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS');
|
||
const hasBond = (u, id) => !!(u && u.def && Array.isArray(u.def.bonds) && u.def.bonds.includes(id));
|
||
const keyOf = (u) => Math.round(u.y) * COLS + Math.round(u.x);
|
||
const opsOf = (battle, ownerId) => battle.allies(ownerId).filter((a) => a.kind === 'op');
|
||
const ANY = Object.freeze({ canHitFly: true });
|
||
const enemiesIn = (battle, unit, keys) => battle.enemiesInKeys(keys || unit.rangeKeys || [], unit, ANY);
|
||
const isTok = (t, id, owner) => !!t && t.kind === 'token' && t.defId === id && t.ownerUnit === owner;
|
||
|
||
/**
|
||
* Whether the SELECTED skill of a loadout-resolved chess record is its default skill (DESIGN §16: `chess.skill` is the
|
||
* selected SkillRecord, `chess.skills[]` flags the default one). Records without skill choices count as default.
|
||
* Talent / install hooks written for the default skill check it (they run under every selected skill).
|
||
*/
|
||
function onDefaultSkill(chess) {
|
||
const d = (chess?.skills ?? []).find((s) => s && s.isDefault);
|
||
return !d || !chess?.skill || d.skillId === chess.skill.skillId;
|
||
}
|
||
/** Id of the selected skill (loadout-resolved record, else the def). */
|
||
const selectedSkill = (chess, def) => chess?.skill?.skillId ?? def?.skill?.id ?? null;
|
||
/**
|
||
* Skill blackboard `base_attack_time` → engine batPct, the rule of the AK damage calculator (akdata attributes.js,
|
||
* checked against the game): a shortening (v < 0) is a FLAT change of the base attack time in seconds (送葬人 −0.5 on
|
||
* 2.3 s ⇒ 1.8 s, the tier1 `batMod` convention), a lengthening (v > 0) scales it (迷迭香 S2 +0.5 ⇒ ×1.5, 佩佩 S3 +0.2 ⇒
|
||
* ×1.2). `mul` = a skill the calculator lists as "攻击间隔缩短,但是是乘算负数" (异客 S2 −0.3 ⇒ ×0.7, 迷迭香 S3 −0.5 ⇒ ×0.5).
|
||
*/
|
||
function batOf(v, def, mul = false) {
|
||
const x = num(v);
|
||
if (!x) return 0;
|
||
if (x > 0 || mul) return Math.max(-0.9, x);
|
||
const bat = num(def?.stats?.bat, 1) || 1;
|
||
return Math.max(-0.9, x / bat);
|
||
}
|
||
/** Spec kind of an instant skill (charges when the data gives it several). */
|
||
const instantKind = (def) => ((def?.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant');
|
||
/** The selected skill's own range grid (null: the unit's range). */
|
||
const skillGridOf = (def) => (def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null);
|
||
|
||
/** 深海猎人 (character_table groupId "abyssal" — checked against the official table; data/chess.json has no group id). */
|
||
const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', 'char_4145_ulpia', 'char_1023_ghost2']);
|
||
/** 莱茵生命 (character_table groupId "rhine") members of this season's pool (checked against the official table). */
|
||
const RHINE = new Set(['char_108_silent', 'char_128_plosis', 'char_202_demkni', 'char_249_mlyss', 'char_1047_halo2']);
|
||
/**
|
||
* 异常状态 (ba.debuff "包括晕眩、寒冷、冻结等") — the control statuses cleansed by 流明 (抵抗 itself is the engine `resist`
|
||
* status: RESIST_STATUSES). Stat debuffs (虚弱/脆弱/…) are not 异常状态.
|
||
*/
|
||
const ABNORMAL = new Set(['stun', 'freeze', 'cold', 'sleep', 'silence', 'fear', 'attract', 'tremble', 'bind', 'levitate',
|
||
'disarm', 'palsy', 'sluggish', 'slow']);
|
||
const hasAbnormal = (u) => u.buffs.some((b) => b.status && ABNORMAL.has(b.status));
|
||
/** Remove every 异常状态 of `u`; returns how many were removed. */
|
||
function cleanseAbnormal(battle, u) {
|
||
let n = 0;
|
||
for (const b of u.buffs.slice()) if (b.status && ABNORMAL.has(b.status)) { battle.removeStatus(u, b.status); n++; }
|
||
if (n) battle.fx('cleanse', { x: u.x, y: u.y, id: u.id });
|
||
return n;
|
||
}
|
||
/**
|
||
* "传送至自身位置": a ground enemy that can reach `unit`'s tile on the ground grid is moved onto it (unblocked, its
|
||
* route re-planned from there — the engine then blocks it on that tile when capacity allows). Flyers / bosses stay.
|
||
*/
|
||
function teleportEnemy(battle, unit, e) {
|
||
if (!e || !e.alive || e.isFlying || e.isBoss) return false;
|
||
const r = unit.tileR, c = unit.tileC;
|
||
if (!battle.grid.groundPassable(r, c)) return false;
|
||
const er = Math.round(e.y), ec = Math.round(e.x);
|
||
if ((er !== r || ec !== c) && !battle.grid.findPath(er, ec, r, c)) return false;
|
||
e.x = c; e.y = r;
|
||
battle._unblock(e);
|
||
if (e.route) e.route.pts = null;
|
||
battle.fx('teleport', { x: c, y: r, id: e.id, src: unit.id });
|
||
return true;
|
||
}
|
||
const AROUND8 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
|
||
const N4 = Object.freeze([[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]);
|
||
/** Every offset of the 19×21 field: "攻击范围扩大至整个战场". */
|
||
const WHOLE_FIELD = (() => {
|
||
const g = [];
|
||
for (let dr = -(ROWS - 1); dr <= ROWS - 1; dr++) for (let dc = -(COLS - 1); dc <= COLS - 1; dc++) g.push([dr, dc]);
|
||
return Object.freeze(g);
|
||
})();
|
||
|
||
const STATE = new WeakMap();
|
||
/** Per-battle shared state for kits (trackers registered once per battle). */
|
||
function bstate(battle) {
|
||
let s = STATE.get(battle);
|
||
if (!s) { s = {}; STATE.set(battle, s); }
|
||
return s;
|
||
}
|
||
|
||
/**
|
||
* Free deployable tiles of `grid` around `unit` (melee ground tiles unless `ranged`). Skips `battle.isReservedTile`
|
||
* (a living unit, or the home tile of a piece that has not deployed yet / waits to redeploy: a summon parked there
|
||
* would keep that operator off the field).
|
||
*/
|
||
function freeTiles(battle, unit, grid, { ranged = false, ground = true } = {}) {
|
||
const out = [];
|
||
const own = unit.tileR * COLS + unit.tileC;
|
||
for (const k of absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)) {
|
||
if (k === own) continue;
|
||
const r = (k / COLS) | 0, c = k % COLS;
|
||
if (!battle.grid.inRect(r, c) || battle.isReservedTile(r, c)) continue;
|
||
if (!battle.grid.canStand(r, c, { ranged })) continue;
|
||
if (ground && !battle.grid.groundPassable(r, c, true)) continue;
|
||
out.push([r, c]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Tile closest to any living enemy (first tile when there is no enemy). */
|
||
function bestTile(battle, tiles) {
|
||
if (!tiles.length) return null;
|
||
const en = battle.enemies.filter((e) => e.alive && !e.hidden);
|
||
if (!en.length) return tiles[0];
|
||
let best = tiles[0], bd = Infinity;
|
||
for (const t of tiles) {
|
||
let d = Infinity;
|
||
for (const e of en) d = Math.min(d, Math.hypot(e.x - t[1], e.y - t[0]));
|
||
if (d < bd - 1e-9) { bd = d; best = t; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/** Barrier of `total` HP decaying linearly to 0 over `dur` seconds. */
|
||
function decayingShield(battle, u, key, total, dur) {
|
||
if (!(total > 0)) return null;
|
||
const decays = dur > 0;
|
||
return battle.addBuff(u, {
|
||
key, shield: total, duration: decays ? dur : Infinity, visible: true, interval: decays ? 0.5 : 0,
|
||
onTick: decays ? ({ unit, buff }) => { buff.shield = Math.max(0, buff.shield - (total * 0.5) / dur); unit.markDirty(); } : null,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Element (损伤) amplification: element gauge fills skip the `hit` hook but fire `elementHit` { source, target, dmg }
|
||
* before the fill — multiply `dmg.mul` there (one instance, exact burst threshold). `fn(ctx)` returns the factor.
|
||
*/
|
||
function onElementHit(battle, unit, fn) {
|
||
battle.on('elementHit', (ctx) => {
|
||
if (!ctx.dmg || ctx.dmg.type !== 'element' || !ctx.target) return;
|
||
const f = fn(ctx);
|
||
if (Number.isFinite(f) && f > 0 && f !== 1) ctx.dmg.mul *= f;
|
||
}, { 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 && hasHp(tgt) ? battle.dealDamage(src, tgt, { type: 'element', element: el, amount, tags }) : 0);
|
||
|
||
/**
|
||
* 鼓舞 (ba.inspire "获得额外附加的基础属性加成(同类属性取最高)"): +`val` ATK after the target's own multipliers (flat
|
||
* compensated), strongest source wins. Units flagged `mem.noInspire` (浊心斯卡蒂 "自身不受鼓舞影响") never receive it.
|
||
*/
|
||
function inspire(battle, target, val, src, stat = 'atk') {
|
||
if (!(val > 0) || !live(target) || target.mem.noInspire) return;
|
||
const key = stat === 'def' ? 'inspire:def' : 'inspire';
|
||
const cur = target.findBuff(key);
|
||
if (cur && cur.data && cur.data.src !== src.id && cur.data.val > val && cur.timeLeft > 0.1) return;
|
||
const { add, mul } = aggregateMods(target.buffs.filter((b) => b.key !== key));
|
||
const f = stat === 'def'
|
||
? Math.max(0, 1 + (add.defPct ?? 0)) * (mul.defMul ?? 1)
|
||
: Math.max(0, 1 + (add.atkPct ?? 0)) * (mul.atkMul ?? 1);
|
||
const flat = f > 1e-6 ? val / f : val;
|
||
battle.addBuff(target, { key, mods: stat === 'def' ? { defFlat: flat } : { atkFlat: flat }, duration: 0.75, refresh: 'replace', source: src, visible: true, data: { src: src.id, val } });
|
||
}
|
||
|
||
/** Deploy-order tracker: last op deployed per owner (read before the update by priority-0 deploy handlers). */
|
||
function ensureDeployTracker(battle) {
|
||
const S = bstate(battle);
|
||
if (S.lastOp) return S.lastOp;
|
||
S.lastOp = new Map();
|
||
battle.on('deploy', ({ unit }) => { if (unit.kind === 'op') S.lastOp.set(unit.ownerId, unit); }, { priority: -100 });
|
||
return S.lastOp;
|
||
}
|
||
|
||
/** Pull `e` toward `unit` (0.5 + 0.5·force tiles, never past `minDist`). */
|
||
function pullToward(battle, unit, e, force, minDist = 0.3) {
|
||
if (!e || !e.alive) return;
|
||
const dx = unit.x - e.x, dy = unit.y - e.y, len = Math.hypot(dx, dy);
|
||
if (len <= minDist) return;
|
||
const dist = Math.min(len - minDist, 0.5 + 0.5 * Math.max(0, force));
|
||
if (dist > 0) battle.displace(e, { x: dx, y: dy }, dist, { force: force + 1 });
|
||
}
|
||
|
||
/** Stat-aura pulse: short buffs refreshed every `period` s on `targets()`. */
|
||
function aura(battle, unit, period, fn) {
|
||
battle.every(period, () => { if (live(unit)) fn(); }, { owner: unit });
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 盟约·辅助干员 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);
|
||
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' },
|
||
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 });
|
||
},
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 蕾缪安 chess_char_6_01 (神射手) — S3 礼炮·强制追思; 跨境追缉许可; 逃犯引渡手续
|
||
|
||
/** S3 bombardment: one shell every 0.3 s after the skill ends, for at most 10 s (PRTS S3 note, not in the data). */
|
||
const LEMUEN_SHELL_INTERVAL = 0.3;
|
||
const LEMUEN_SHELL_WINDOW = 10;
|
||
/**
|
||
* Shell flight time [ASSUMED]: the first bombardment lands one interval after the skill ends, so every shell of the
|
||
* PRTS count (≤ 33 in the 10 s window) lands inside it; the renderer draws the shell over this time (fx bombardShell).
|
||
*/
|
||
const LEMUEN_SHELL_FLIGHT = 0.3;
|
||
|
||
function ensureWanted(battle) {
|
||
const S = bstate(battle);
|
||
if (S.wanted) return S.wanted;
|
||
const W = S.wanted = { lemuens: new Set(), time: new WeakMap() };
|
||
const activeLem = () => [...W.lemuens].filter(live);
|
||
battle.every(0.25, () => {
|
||
const lems = activeLem();
|
||
if (!lems.length) return;
|
||
const need = Math.min(...lems.map((u) => u.mem.wantedInterval));
|
||
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;
|
||
// "停留超过8秒": a continuous stay — leaving every 拉特兰 range restarts the count
|
||
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) {
|
||
battle.addBuff(e, { key: 'lemuen:wanted', visible: true });
|
||
battle.fx('wanted', { x: e.x, y: e.y, id: e.id });
|
||
}
|
||
}
|
||
});
|
||
battle.on('hit', (ctx) => {
|
||
const s = ctx.source, t = ctx.target;
|
||
if (!s || !t || s.side !== 'ally' || t.side !== 'enemy' || !hasBond(s, 'lateranoShip')) return;
|
||
if (!ctx.dmg.isAttack && !ctx.dmg.isSkill) return;
|
||
if (!t.findBuff('lemuen:wanted')) return;
|
||
const lems = activeLem();
|
||
if (lems.length) ctx.dmg.mul *= Math.max(...lems.map((u) => u.mem.wantedScale));
|
||
});
|
||
return W;
|
||
}
|
||
|
||
function lemuen(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {};
|
||
const aim = Math.max(0.05, num(bb['attack@aim_interval'], 0.5));
|
||
const d1 = num(bb['attack@dist_1'], 0.8);
|
||
const d2 = num(bb['attack@dist_2'], num(bb['attack@projectile_range'], 1.5));
|
||
const s1 = num(bb['attack@proj_atk_scale_1'], 1), s2 = num(bb['attack@proj_atk_scale_2'], 1);
|
||
const pickLock = (battle, unit, locks) => {
|
||
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile);
|
||
if (!cands.length) return null;
|
||
sortEnemyTargets(battle, unit, cands, 'lowDef');
|
||
const cnt = (e) => locks.reduce((n, L) => n + (L.e === e ? 1 : 0), 0);
|
||
let best = null;
|
||
for (const e of cands) if (!best || cnt(e) < cnt(best)) best = e;
|
||
return best;
|
||
};
|
||
// S3 bombardment (PRTS 蕾缪安 S3 note — the timing is not in the blackboard): after the skill ends ONE shell every
|
||
// LEMUEN_SHELL_INTERVAL s, in lock order, on a random point of the square of side 2 × emit_offset (PRTS "边长0.4",
|
||
// emit_offset 0.2) around its lock mark — the locked enemy while it is on the field ("持续追踪锁定目标"), else the spot
|
||
// it left ("锁定标记会留在原地") — landing LEMUEN_SHELL_FLIGHT s later; each shell hits every enemy within dist_2 once
|
||
// (limited_hit_time 1): proj_atk_scale_1 × ATK within dist_1 of its point, proj_atk_scale_2 × ATK beyond, all with the
|
||
// ATK cached when the skill ended ("缓存攻击力"); at most LEMUEN_SHELL_WINDOW s of shells ("最多产生33次轰炸").
|
||
const spread = Math.max(0, num(bb['attack@emit_offset'], 0.2));
|
||
const markOf = (L) => {
|
||
const e = L.e;
|
||
if (!L.gone && (!e.alive || !e.hidden)) { L.x = e.x; L.y = e.y; if (!e.alive) L.gone = true; }
|
||
return L;
|
||
};
|
||
const blast = (battle, unit, atk, x, y) => {
|
||
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 = 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) => {
|
||
const atk = unit.s.atk;
|
||
const seq = unit.deploySeq;
|
||
const n = Math.min(locks.length, Math.floor(LEMUEN_SHELL_WINDOW / LEMUEN_SHELL_INTERVAL + 1e-9));
|
||
const fire = (i) => {
|
||
// knocked out / withdrawn after the skill ended: the shells not fired yet are dropped (those in the air land)
|
||
if (!(unit.alive && unit.deployed && unit.deploySeq === seq)) return;
|
||
const L = markOf(locks[i]);
|
||
// the random offset is drawn in her facing frame (the square turns onto itself): a turned board plays alike
|
||
const [oy, ox] = rotateOffset(battle.rng.range(-spread, spread), battle.rng.range(-spread, spread), unit.dir);
|
||
const x = L.x + ox, y = L.y + oy;
|
||
battle.fx('bombardShell', { x, y, id: unit.id, r: d2, t: LEMUEN_SHELL_FLIGHT, i });
|
||
battle.after(LEMUEN_SHELL_FLIGHT, () => blast(battle, unit, atk, x, y), { owner: unit });
|
||
if (i + 1 < n) battle.after(LEMUEN_SHELL_INTERVAL, () => fire(i + 1), { owner: unit });
|
||
};
|
||
if (n > 0) fire(0);
|
||
};
|
||
// S2 归乡邀约: aimed snipe at a wanted enemy — ATK scale ramps main → fin by ex every interval (at most trig_cnt
|
||
// steps, aim_duration s), fired early once ATK × scale > the target's HP + DEF; ignores dodge; one bullet per aim
|
||
const aimS = {
|
||
main: num(bb['attack@main_atk_scale'], 1), ex: num(bb['attack@ex_atk_scale']), fin: num(bb['attack@fin_atk_scale'], 1),
|
||
iv: Math.max(0.05, num(bb['attack@interval'], 0.25)), steps: Math.floor(num(bb['attack@trig_cnt'], 10)), dur: num(bb['attack@aim_duration'], 2.5),
|
||
};
|
||
const endAim = (battle, unit) => { unit.mem.lemAim = null; battle.removeBuff(unit, 'lemuen:aim'); };
|
||
const skills = {
|
||
// S1 重逢问候: ammo 5, attacks at attack@atk_scale × ATK on one extra target
|
||
skchr_lemuen_1: {
|
||
kind: 'ammo',
|
||
ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 5))),
|
||
attack: { atkScale: num(bb['attack@atk_scale'], 1) },
|
||
targeting: { maxTargets: 2 },
|
||
},
|
||
skchr_lemuen_2: {
|
||
kind: 'ammo',
|
||
ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 6))),
|
||
mods: { aspd: num(bb.attack_speed), atkPct: num(bb.atk) },
|
||
onStart({ battle, unit }) { endAim(battle, unit); },
|
||
onTick({ battle, unit, skill, dt }) {
|
||
const m = unit.mem;
|
||
let A = m.lemAim;
|
||
if (A && (!A.e.alive || A.e.hidden || !A.e.findBuff('lemuen:wanted'))) { endAim(battle, unit); A = null; }
|
||
if (!unit.canAct) return;
|
||
if (!A) {
|
||
const cands = battle.enemies.filter((e) => e.alive && !e.hidden && e.findBuff('lemuen:wanted') && canTargetEnemy(unit, e, ANY));
|
||
if (!cands.length || skill.ammoLeft <= 0) return;
|
||
sortEnemyTargets(battle, unit, cands, 'lowDef');
|
||
A = m.lemAim = { e: cands[0], t: 0 };
|
||
battle.addBuff(unit, { key: 'lemuen:aim', flags: { disarm: true } }); // aiming: no normal attack meanwhile
|
||
skill.ammoLeft--; // "消耗子弹对其瞄准"
|
||
battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill });
|
||
battle.fx('lock', { x: A.e.x, y: A.e.y, id: A.e.id, src: unit.id });
|
||
}
|
||
A.t += dt;
|
||
const scale = Math.min(aimS.fin, aimS.main + aimS.ex * Math.min(aimS.steps, Math.floor((A.t + 1e-9) / aimS.iv)));
|
||
const e = A.e;
|
||
if (A.t + 1e-9 < aimS.dur && !(unit.s.atk * scale > e.hp + e.s.def)) return;
|
||
endAim(battle, unit);
|
||
battle.fx('crit', { x: e.x, y: e.y, id: e.id, src: unit.id });
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', canDodge: false, isSkill: true, tags: ['skill', 'snipe'] });
|
||
if (skill.active && skill.ammoLeft <= 0) skill.end('ammo');
|
||
},
|
||
onEnd({ battle, unit }) { endAim(battle, unit); },
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
skill: {
|
||
kind: 'ammo',
|
||
ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 5))),
|
||
attack: { noAttack: true },
|
||
onStart({ unit }) { unit.mem.lemLocks = []; unit.mem.lemAcc = aim; },
|
||
onTick({ battle, unit, skill, dt }) {
|
||
const m = unit.mem;
|
||
if (!m.lemLocks) return;
|
||
for (const L of m.lemLocks) markOf(L);
|
||
if (!unit.canAct) return;
|
||
m.lemAcc += dt;
|
||
if (m.lemAcc + 1e-9 < aim) return;
|
||
m.lemAcc -= aim;
|
||
const e = pickLock(battle, unit, m.lemLocks);
|
||
if (!e) { if (m.lemLocks.length) skill.end('ammo'); return; }
|
||
m.lemLocks.push({ e, x: e.x, y: e.y });
|
||
battle.fx('lock', { x: e.x, y: e.y, id: e.id, src: unit.id });
|
||
skill.ammoLeft--;
|
||
battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill });
|
||
if (skill.active && skill.ammoLeft <= 0) skill.end('ammo');
|
||
},
|
||
onEnd({ battle, unit, reason }) {
|
||
// the shells follow one by one after the end (bombard); knocked out / withdrawn mid-lock ('death'): none
|
||
const locks = unit.mem.lemLocks || [];
|
||
unit.mem.lemLocks = null;
|
||
if (reason !== 'death' && unit.alive) bombard(battle, unit, locks);
|
||
},
|
||
},
|
||
// elite module: "攻击的敌人未被击倒时自身额外获得1点技力"
|
||
trait: num(tb.sp) > 0 ? {
|
||
afterHit(battle, unit, target) { if (target && target.alive && unit.skill && !unit.skill.active) unit.skill.gainSp(num(tb.sp), 'trait'); },
|
||
} : null,
|
||
talents: [
|
||
{ install(battle, unit) { // 跨境追缉许可
|
||
unit.mem.wantedInterval = num(t0.interval, 8);
|
||
unit.mem.wantedScale = num(t0.damage_scale, 1);
|
||
ensureWanted(battle).lemuens.add(unit);
|
||
let sig = null;
|
||
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(...bodyKeys(e));
|
||
const s = keys.join(',');
|
||
if (s === sig) return;
|
||
sig = s;
|
||
battle.setExtraRange(unit, keys);
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 逃犯引渡手续
|
||
const iv = num(t1.interval, 20), atk = num(t1.atk), add = Math.floor(num(t1.add_count)), exAdd = Math.floor(num(t1.ex_add_count));
|
||
const ready = () => live(unit) && battle.time - unit.deployedAt >= iv - 1e-6;
|
||
battle.on('deploy', ({ unit: u }) => {
|
||
if (u !== unit) return;
|
||
const seq = unit.deploySeq;
|
||
battle.after(iv, () => {
|
||
if (!live(unit) || unit.deploySeq !== seq) return;
|
||
if (atk) battle.addBuff(unit, { key: 'lemuen:extradition', mods: { atkPct: atk } });
|
||
battle.fx('talent', { x: unit.x, y: unit.y, id: unit.id, name: 'extradition' });
|
||
}, { owner: unit });
|
||
}, { owner: unit });
|
||
battle.on('skillStart', ({ unit: u, skill }) => {
|
||
if (!ready() || !skill || skill.kind !== 'ammo') return;
|
||
if (u === unit) { if (add > 0) skill.addAmmo(add); }
|
||
else if (exAdd > 0 && u.side === 'ally' && u.kind === 'op' && hasBond(u, 'lateranoShip')) skill.addAmmo(exAdd);
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 圣聆初雪 chess_char_6_02 (阵法术师) — S3 群山俯首; 无垠的雪景; 圣山的祝福; module 千分之一的心
|
||
|
||
function sbell2(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), mod = moduleBb(chess);
|
||
const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null;
|
||
const maxL = Math.max(1, Math.floor(num(t0.max_cast_cnt, 5)));
|
||
const iceId = (chess?.tokens || []).find((t) => /icetgt/.test(String(t))) || 'token_10058_sbell2_icetgt';
|
||
/**
|
||
* S2 霜涛覆岭 while it runs (`unit.mem.sbellS2`): a layer landing on a tile already at max snow spreads one layer to
|
||
* a neighbouring ground tile (at most max_cast_tile_count spreads per activation); ground enemies on snow take
|
||
* s2_magic_scale × ATK arts per second; an enemy leaving snow gets `cold` s of 寒冷; a tile reaching max snow turns
|
||
* into the frozen 保护目标 (token icetgt: blocks 3, one at a time) and its snow is used up.
|
||
*/
|
||
const addSnow = (battle, unit, k) => {
|
||
const snow = unit.mem.snow;
|
||
if (!snow) return false;
|
||
const r = (k / COLS) | 0, c = k % COLS;
|
||
if (!battle.grid.inRect(r, c) || !battle.grid.groundPassable(r, c, true)) return false;
|
||
const S2 = unit.mem.sbellS2;
|
||
const cur = snow.get(k) ?? 0;
|
||
if (cur >= maxL) {
|
||
if (!S2 || S2.spreadLeft <= 0) return false;
|
||
// "积雪超过5层时会向周围扩散一层": the thinnest neighbouring ground tile gets the layer
|
||
let best = null, bl = Infinity;
|
||
for (const [dr, dc] of [[0, 1], [1, 0], [0, -1], [-1, 0]]) {
|
||
const nr = r + dr, nc = c + dc;
|
||
if (!battle.grid.inRect(nr, nc) || !battle.grid.groundPassable(nr, nc, true)) continue;
|
||
const L = snow.get(nr * COLS + nc) ?? 0;
|
||
if (L < maxL && L < bl) { bl = L; best = nr * COLS + nc; }
|
||
}
|
||
if (best == null) return false;
|
||
S2.spreadLeft--;
|
||
return addSnow(battle, unit, best);
|
||
}
|
||
snow.set(k, cur + 1);
|
||
if (S2 && cur + 1 >= maxL) freezeTile(battle, unit, k);
|
||
return true;
|
||
};
|
||
const freezeTile = (battle, unit, k) => {
|
||
const r = (k / COLS) | 0, c = k % COLS;
|
||
if (battle.allyUnits.some((t) => isTok(t, iceId, unit) && t.alive)) return;
|
||
if (battle.isReservedTile(r, c) || !battle.grid.canStand(r, c, { ranged: false })) return;
|
||
const ice = battle.spawnToken(unit, iceId, r, c);
|
||
if (!ice) return;
|
||
unit.mem.snow.delete(k);
|
||
battle.fx('summon', { x: c, y: r, id: ice.id, src: unit.id });
|
||
};
|
||
const talents = [
|
||
{ install(battle, unit) { // 无垠的雪景 (+ S2 snow rules)
|
||
const ivN = num(t0.interval, 5.5), ivS = num(bb.interval, ivN);
|
||
const slow = num(t0.move_speed), scale = num(t0.talent_magic_scale);
|
||
const snow = new Map();
|
||
unit.mem.snow = snow;
|
||
const last = new WeakMap();
|
||
let acc = 0, dotAcc = 0;
|
||
const lay = () => {
|
||
let n = 0;
|
||
for (const k of unit.rangeKeys || []) if (addSnow(battle, unit, k)) n++;
|
||
if (n) battle.fx('snow', { x: unit.x, y: unit.y, id: unit.id, tiles: n });
|
||
};
|
||
battle.on('deploy', ({ unit: u }) => {
|
||
if (u !== unit) return;
|
||
snow.clear();
|
||
acc = 0;
|
||
if (num(t0.first_snow) > 0) lay();
|
||
}, { owner: unit });
|
||
battle.on('tick', ({ dt }) => {
|
||
if (!live(unit)) return;
|
||
acc += dt;
|
||
if (acc + 1e-9 >= (unit.skill && unit.skill.active ? ivS : ivN)) { acc = 0; lay(); }
|
||
if (!snow.size) return;
|
||
const S2 = unit.mem.sbellS2;
|
||
for (const e of battle.enemies) {
|
||
if (!e.alive || e.hidden) continue;
|
||
const k = keyOf(e);
|
||
const prev = last.get(e);
|
||
if (prev !== k) {
|
||
last.set(e, k);
|
||
const hadSnow = prev !== undefined && !e.isFlying && snow.has(prev);
|
||
if (hadSnow) snow.delete(prev); // first enemy leaving clears it
|
||
if (!e.isFlying && snow.has(k) && scale > 0) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', tags: ['talent', 'snow'] });
|
||
if (S2 && hadSnow && !snow.has(k) && e.alive && S2.cold > 0) battle.applyStatus(e, 'cold', { duration: S2.cold, source: unit });
|
||
}
|
||
const L = snow.get(k);
|
||
if (L && e.alive && slow) battle.addBuff(e, { key: 'sbell2:snow', duration: 0.2, refresh: 'replace', mods: { moveMul: Math.max(0, 1 + slow * L) }, source: unit });
|
||
}
|
||
if (!S2 || !(S2.dot > 0)) { dotAcc = 0; return; }
|
||
dotAcc += dt;
|
||
if (dotAcc + 1e-9 < 1) return;
|
||
dotAcc -= 1;
|
||
for (const e of battle.enemies) {
|
||
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 });
|
||
} },
|
||
{ install(battle, unit) { // 圣山的祝福
|
||
const cold = num(t1.cold), selfFreeze = num(t1.freeze), eFreeze = num(t1.c2e_freeze), hr = num(t1.hp_ratio, 1);
|
||
battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.blessUsed = false; }, { owner: unit });
|
||
battle.on('damaged', (ctx) => {
|
||
if (ctx.target !== unit || !(cold > 0) || ctx.type === 'element') return;
|
||
const s = ctx.source;
|
||
if (s && s.side === 'enemy' && s.alive) battle.applyStatus(s, 'cold', { duration: cold, source: unit });
|
||
}, { owner: unit });
|
||
battle.on('fatal', (ctx) => {
|
||
if (ctx.unit !== unit || ctx.prevented || unit.mem.blessUsed) return;
|
||
ctx.prevented = true;
|
||
unit.mem.blessUsed = true;
|
||
unit.hp = Math.max(1, unit.s.maxHp * hr);
|
||
if (selfFreeze > 0) battle.applyStatus(unit, 'freeze', { duration: selfFreeze, source: unit, force: true });
|
||
if (eFreeze > 0) for (const e of enemiesIn(battle, unit)) battle.applyStatus(e, 'freeze', { duration: eFreeze, source: unit });
|
||
battle.fx('blessing', { x: unit.x, y: unit.y, id: unit.id });
|
||
}, { owner: unit, priority: -50 });
|
||
} },
|
||
];
|
||
if (num(mod.damage_scale) > 0) {
|
||
talents.push({ install(battle, unit) { // module: +3 % damage per enemy in range (max 5)
|
||
const per = num(mod.damage_scale), cap = Math.max(1, Math.floor(num(mod.max_valid_stack_cnt, 5)));
|
||
aura(battle, unit, 0.25, () => {
|
||
const n = Math.min(cap, enemiesIn(battle, unit).length);
|
||
if (n > 0) battle.addBuff(unit, { key: 'sbell2:heart', mods: { dmgDealtMul: 1 + per * n }, duration: 0.4, refresh: 'replace' });
|
||
});
|
||
} });
|
||
}
|
||
const skills = {
|
||
// S1 铃音吹雪 (SEARCH trigger, 2 charges): atk_scale × ATK arts + `cold` s of 寒冷 on every enemy in range, pushed
|
||
// (force 1 = 中等) along her direction; then one snow layer spreads forward over the ground (≤ trig_cnt tiles)
|
||
skchr_sbell2_1: {
|
||
kind: instantKind(def),
|
||
// data trigger SEARCH (the 阵法术师 profession row for S1): the engine reads it as "an enemy anywhere on the field",
|
||
// which dumped both charges on an enemy spawning at the gate, far outside her range (the burst hits "范围内所有
|
||
// 敌人"). SEARCH exists for units that never attack (DEFAULT fires on an attack; CUSTOM_RANGE_SEARCH_ENEMY is its
|
||
// custom-range twin) [ASSUMED]: an enemy inside her range, checked every tick — the engine's DEFAULT for a
|
||
// phalanx (noAttackUnlessSkill)
|
||
trigger: 'DEFAULT',
|
||
onStart({ battle, unit }) {
|
||
const [fr, fc] = unit.fwd;
|
||
const force = num(bb.force);
|
||
for (const e of enemiesIn(battle, unit)) {
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] });
|
||
if (!e.alive) continue;
|
||
if (num(bb.cold) > 0) battle.applyStatus(e, 'cold', { duration: num(bb.cold), source: unit });
|
||
battle.displace(e, { x: fc, y: fr }, 0.5 + 0.5 * force, { force: force + 1 });
|
||
}
|
||
const n = Math.max(0, Math.floor(num(bb.trig_cnt, 5)));
|
||
let laid = 0;
|
||
for (let d = 1; d <= n; d++) {
|
||
const r = unit.tileR + fr * d, c = unit.tileC + fc * d;
|
||
if (!battle.grid.inRect(r, c)) break;
|
||
if (addSnow(battle, unit, r * COLS + c)) laid++;
|
||
}
|
||
battle.fx('frostNova', { x: unit.x, y: unit.y, id: unit.id, tiles: laid });
|
||
},
|
||
},
|
||
// S2 霜涛覆岭 (toggle, 持续时间无限): group attacks at attack@atk_scale_s2 × ATK + the S2 snow rules (addSnow / talent)
|
||
skchr_sbell2_2: {
|
||
kind: 'toggle',
|
||
attack: { atkScale: num(bb['attack@atk_scale_s2'], 1) },
|
||
onStart({ unit }) {
|
||
unit.mem.sbellS2 = { spreadLeft: Math.floor(num(bb['talent@max_cast_tile_count'], 20)), dot: num(bb['talent@s2_magic_scale']), cold: num(bb['talent@cold']) };
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
unit.mem.sbellS2 = null;
|
||
for (const t of battle.allyUnits) if (isTok(t, iceId, unit) && t.alive) battle.retreat(t, { reason: 'expired', permanent: true });
|
||
},
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed), resIgnoreFlat: num(bb.magic_resist_penetrate_fixed) },
|
||
...(skillGrid ? { targeting: { rangeGrid: skillGrid } } : {}),
|
||
attack: { atkScale: num(bb['attack@atk_scale_s3'], 1) },
|
||
onStart({ battle, unit }) { // 立即诱导攻击范围内的所有敌人至自身周围的可达地面,持续 attract_time 秒
|
||
const dur = num(bb.attract_time);
|
||
if (!(dur > 0)) return;
|
||
let n = 0;
|
||
// 诱导 = the engine `attract` status: unblockable, walked (own speed, grid path) to the point, then its route
|
||
// resumes; it runs its full time even if she falls meanwhile
|
||
for (const e of enemiesIn(battle, unit)) {
|
||
if (e.isFlying || e.isBoss || !e.alive) continue;
|
||
const goal = lureGoal(battle, unit, e);
|
||
if (goal && battle.applyStatus(e, 'attract', { duration: dur, source: unit, point: goal })) n++;
|
||
}
|
||
battle.fx('lure', { x: unit.x, y: unit.y, id: unit.id, n });
|
||
},
|
||
},
|
||
talents,
|
||
};
|
||
}
|
||
|
||
/** The nearest ground tile around `unit` to `e` ("自身周围的可达地面"), or null. */
|
||
function lureGoal(battle, unit, e) {
|
||
let goal = null, bd = Infinity;
|
||
for (const [dr, dc] of AROUND8) {
|
||
const r = unit.tileR + dr, c = unit.tileC + dc;
|
||
if (!battle.grid.groundPassable(r, c)) continue;
|
||
const d = Math.hypot(e.x - c, e.y - r);
|
||
if (d < bd - 1e-9) { bd = d; goal = [r, c]; }
|
||
}
|
||
return goal;
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 余 chess_char_6_03 (本源铁卫) — S3 灶里乾坤; 礼尚往来; 闲云隐市; module 人间百味
|
||
|
||
function yu(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), mod = moduleBb(chess), tb = def?.traitBb || {};
|
||
const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def);
|
||
const wallProb = isDef ? num(bb.prob) : 0, wallBurn = isDef ? num(bb.ep_damage_ratio) : 0;
|
||
// "将第二天赋效果赋予全场所有干员" belongs to S3 (the default skill) only
|
||
const s3On = (unit) => isDef && !!unit.skill?.active;
|
||
const skills = {
|
||
// S1 今日做东 (TAKE_DAMAGE, hurt SP): passive taunt +taunt_level; active: HP / DEF +, every attack taken ⇒
|
||
// 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. 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) },
|
||
attack: { dmgType: 'arts' },
|
||
onStart({ battle, unit }) {
|
||
const grid = skillGridOf(def) || AROUND8;
|
||
const foes = battle.unitsInGrid(unit, grid, { side: 'enemy' }).filter((e) => canTargetEnemy(unit, e, ANY));
|
||
for (const e of foes) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] });
|
||
let n = 0;
|
||
for (const e of foes) if (teleportEnemy(battle, unit, e)) n++;
|
||
battle.fx('pull', { x: unit.x, y: unit.y, id: unit.id, n });
|
||
},
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
install(battle, unit) {
|
||
if (sid !== 'skchr_yu_1') return;
|
||
// 被动效果:自身更容易受到敌人攻击 (while this skill is carried)
|
||
battle.addBuff(unit, { key: 'yu:host', mods: { taunt: num(bb.taunt_level, 1) }, persist: true, allowDead: true });
|
||
battle.on('damaged', (ctx) => {
|
||
const s = ctx.source;
|
||
if (ctx.target !== unit || !unit.skill?.active || !s || s.side !== 'enemy' || !s.alive || !ctx.dmg?.isAttack) return;
|
||
elementDmg(battle, unit, s, 'burn', unit.s.atk * num(bb.ep_damage_ratio), ['skill']);
|
||
}, { owner: unit });
|
||
},
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), defPct: num(bb.def) },
|
||
// the wall runs through his tile perpendicular to his direction (RIGHT / LEFT: his column; UP / DOWN: his row —
|
||
// it turns with the deploy direction like a range) [ASSUMED orientation for UP / DOWN]
|
||
onStart({ battle, unit }) {
|
||
const vertical = unit.fwd[0] === 0;
|
||
unit.mem.yuWall = vertical ? { axis: 'x', v: unit.tileC } : { axis: 'y', v: unit.tileR };
|
||
battle.fx('firewall', { x: unit.tileC, y: unit.tileR, id: unit.id, dir: unit.dir, axis: vertical ? 'col' : 'row' });
|
||
},
|
||
onEnd({ unit }) { unit.mem.yuWall = null; },
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 礼尚往来: 庇护 while blocking + DoT on blocked enemies
|
||
const dr = num(t0.damage_resistance), sc = bv(t0, 'atk_scale'), er = bv(t0, 'ep_damage_ratio'), iv = Math.max(0.1, bv(t0, 'interval', 1));
|
||
// 庇护 (ba.protect): 受到的物理和法术伤害降低相应比例 — true / element damage is not reduced
|
||
aura(battle, unit, 0.1, () => {
|
||
if (dr > 0 && unit.blocking.length) battle.addBuff(unit, { key: 'yu:shelter', mods: { physTakenMul: 1 - dr, artsTakenMul: 1 - dr }, duration: 0.2, refresh: 'replace' });
|
||
});
|
||
aura(battle, unit, iv, () => {
|
||
for (const e of unit.blocking.slice()) {
|
||
if (!e.alive) continue;
|
||
if (sc > 0) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'arts', tags: ['talent'] });
|
||
if (er > 0) elementDmg(battle, unit, e, 'burn', unit.s.atk * er, ['talent']);
|
||
}
|
||
});
|
||
} },
|
||
{ install(battle, unit) { // 闲云隐市 (all operators while S3 runs)
|
||
const cnt = num(t1.cnt, 4), hr = num(t1.hp_recovery_per_sec_by_max_hp_ratio), er = num(t1.ep_heal_ratio), iv = Math.max(0.1, num(t1.interval, 1));
|
||
aura(battle, unit, iv, () => {
|
||
const ops = opsOf(battle, unit.ownerId);
|
||
if (ops.length < cnt) return;
|
||
for (const a of s3On(unit) ? ops : [unit]) {
|
||
if (hr > 0) battle.heal(unit, a, a.s.maxHp * hr * iv, { self: true, silent: true });
|
||
if (er > 0) battle.reduceElement(a, a.s.maxHp * er * iv);
|
||
}
|
||
});
|
||
} },
|
||
{ install(battle, unit) { // S3 fire wall
|
||
const crosses = (a, b) => {
|
||
const w = unit.mem.yuWall;
|
||
if (w == null) return false;
|
||
return w.axis === 'y' ? (a.y - w.v) * (b.y - w.v) < 0 : (a.x - w.v) * (b.x - w.v) < 0;
|
||
};
|
||
battle.on('damaged', (ctx) => {
|
||
const s = ctx.source, t = ctx.target;
|
||
if (!(wallBurn > 0) || unit.mem.yuWall == null || !live(unit) || ctx.type !== 'arts' || !(ctx.amount > 0)) return;
|
||
if (!s || s === unit || s.side !== 'ally' || !t || t.side !== 'enemy' || !t.alive || !crosses(s, t)) return;
|
||
elementDmg(battle, unit, t, 'burn', unit.s.atk * wallBurn, ['skill', 'firewall']);
|
||
}, { owner: unit });
|
||
battle.on('hit', (ctx) => {
|
||
const s = ctx.source, t = ctx.target;
|
||
if (!(wallProb > 0) || unit.mem.yuWall == null || !live(unit) || !s || s.side !== 'enemy' || !t || t.side !== 'ally') return;
|
||
if (!ctx.dmg.isAttack || !(s.base.rangeRadius > 0) || s.blockedBy === t || !crosses(s, t)) return;
|
||
if (battle.rng() < wallProb) {
|
||
ctx.dmg.cancel = true;
|
||
const w = unit.mem.yuWall;
|
||
battle.fx('firewallBlock', w.axis === 'y' ? { x: t.x, y: w.v, id: unit.id } : { x: w.v, y: t.y, id: unit.id });
|
||
}
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // elite: module element ×1.15 while blocking; 闲云隐市 arts ×1.14 vs burning targets (≥4 ops)
|
||
const epScale = num(tb.ep_damage_scale, 1), cnt = num(mod.cnt, Infinity), ds = num(mod.damage_scale, 1);
|
||
if (epScale > 1) onElementHit(battle, unit, (ctx) => (ctx.source === unit && live(unit) && unit.blocking.length ? epScale : 1));
|
||
// "将第二天赋效果赋予全场所有干员": while S3 runs every operator of hers gets the ×1.14 too
|
||
if (ds > 1) battle.on('hit', (ctx) => {
|
||
const s = ctx.source;
|
||
if (!s || ctx.dmg.type !== 'arts' || !ctx.target || ctx.target.side !== 'enemy' || !ctx.target.findBuff('burnBurst')) return;
|
||
if (s !== unit && !(s.kind === 'op' && s.ownerId === unit.ownerId && live(unit) && s3On(unit))) return;
|
||
if (opsOf(battle, unit.ownerId).length >= cnt) ctx.dmg.mul *= ds;
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 浊心斯卡蒂 chess_char_6_04 (吟游者) — S3 "潮涌,潮枯"; 远古血亲 (海嗣); 捕食习性; module 蜕化的残迹
|
||
|
||
function skadi2(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), mod = moduleBb(chess), tb = def?.traitBb || {};
|
||
const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def);
|
||
const tokId = def?.talents?.[0]?.tokenKey || (chess?.tokens || [])[0] || 'token_10017_skadi2_dedant';
|
||
const auraRatio = num(tb['attack@atk_to_hp_recovery_ratio'], 0.1);
|
||
// S1 / S2 raise the trait heal ("特性效果提高至N%") while they run; S3 (default) turns it into the tide
|
||
const skillRatio = num(bb['attack@atk_to_hp_recovery_ratio'], auraRatio);
|
||
const seaborns = (battle, unit) => battle.allyUnits.filter((t) => isTok(t, tokId, unit) && live(t));
|
||
const covered = (battle, unit, toks) => { // allies inside her range ∪ the seaborns' ranges
|
||
const set = new Set(battle.alliesInGrid(unit));
|
||
for (const t of toks) for (const a of battle.alliesInGrid(t)) set.add(a);
|
||
for (const t of toks) set.delete(t);
|
||
return [...set];
|
||
};
|
||
const inCover = (battle, unit, a) => {
|
||
const k = a.tileR * COLS + a.tileC;
|
||
if (unit.rangeKeySet?.has(k)) return true;
|
||
return seaborns(battle, unit).some((t) => t.rangeKeySet?.has(k));
|
||
};
|
||
const skills = {
|
||
// S1 同归殊途之吟 (SP_FULL): full self heal, max HP +max_hp, trait heal attack@atk_to_hp_recovery_ratio, and
|
||
// damage_resistance of the damage taken by every ally of her (+ 海嗣) range is transferred to her (install)
|
||
skchr_skadi2_1: {
|
||
kind: 'duration',
|
||
mods: { hpPct: num(bb.max_hp) },
|
||
onStart({ battle, unit }) { unit.hp = unit.s.maxHp; battle.fx('heal', { x: unit.x, y: unit.y, id: unit.id }); },
|
||
},
|
||
// S2 同葬无光之愿 (toggle): 鼓舞 ATK / DEF = atk / def × her ATK / DEF on every other ally of her (+ 海嗣) range,
|
||
// trait heal attack@atk_to_hp_recovery_ratio (trait pulse)
|
||
skchr_skadi2_2: { kind: 'toggle' },
|
||
};
|
||
return {
|
||
skills,
|
||
skill: {
|
||
kind: 'duration', // effects run in the trait pulse below ("特性变为…")
|
||
onStart({ battle, unit }) { battle.fx('tide', { x: unit.x, y: unit.y, id: unit.id }); },
|
||
},
|
||
trait: {
|
||
install(battle, unit) { // replaces the bard aura: heal normally, tide (true damage + 鼓舞 + self drain) during S3
|
||
unit.mem.noInspire = true; // 自身不受鼓舞影响
|
||
const isInspire = (b) => b.key === 'inspire' || b.status === 'inspire' || b.key.startsWith('inspire:') || b.key.endsWith(':inspire');
|
||
// "海嗣的攻击范围视为自身攻击范围的延伸": an enemy in a 海嗣's range also satisfies her DEFAULT trigger
|
||
if (unit.skill) unit.skill.addTriggerRange(() => seaborns(battle, unit));
|
||
let n = 0;
|
||
battle.every(0.5, () => {
|
||
if (!unit.canAct) return;
|
||
for (let i = unit.buffs.length - 1; i >= 0; i--) if (isInspire(unit.buffs[i])) battle.removeBuff(unit, unit.buffs[i]);
|
||
n++;
|
||
const toks = seaborns(battle, unit);
|
||
const allies = covered(battle, unit, toks);
|
||
const sk = unit.skill;
|
||
const on = !!(sk && sk.active);
|
||
if (on && isDef) {
|
||
const val = unit.s.atk * num(bb.atk);
|
||
for (const a of allies) if (a !== unit) inspire(battle, a, val, unit);
|
||
if (n % 2 === 0) {
|
||
const dmg = unit.s.atk * num(bb.atk_scale);
|
||
if (dmg > 0) for (const src of [unit, ...toks]) for (const e of enemiesIn(battle, src)) {
|
||
battle.dealDamage(unit, e, { amount: dmg, type: 'true', isSkill: true, tags: ['skill', 'tide'] });
|
||
}
|
||
const loss = unit.s.maxHp * num(bb.hp_ratio);
|
||
if (loss > 0) { if (unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit, silent: true }); else unit.hp = Math.min(unit.hp, 1); }
|
||
}
|
||
return;
|
||
}
|
||
if (on && sid === 'skchr_skadi2_2') {
|
||
const va = unit.s.atk * num(bb.atk), vd = unit.s.def * num(bb.def);
|
||
for (const a of allies) if (a !== unit) { inspire(battle, a, va, unit); inspire(battle, a, vd, unit, 'def'); }
|
||
}
|
||
if (n % 2 === 0) {
|
||
const amount = unit.s.atk * (on ? skillRatio : num(unit.profile?.auraRatio, auraRatio));
|
||
for (const a of allies) if (a.hp < a.s.maxHp) battle.heal(unit, a, amount, { aura: true });
|
||
}
|
||
}, { owner: unit });
|
||
},
|
||
},
|
||
install(battle, unit) {
|
||
// S1: "攻击范围内我方所有单位受到伤害的50%直接转移给浊心斯卡蒂承担(同类效果取最高)" — the ally takes (1 − share),
|
||
// she takes the transferred part as true damage from the same attacker
|
||
if (sid === 'skchr_skadi2_1') {
|
||
const share = clamp(num(bb.damage_resistance), 0, 1);
|
||
const TAG = `skadi2:transfer:${unit.id}`;
|
||
battle.on('hit', (ctx) => {
|
||
const t = ctx.target, d = ctx.dmg;
|
||
if (!(share > 0) || !t || t === unit || t.side !== 'ally' || t.kind === 'device' || !live(unit) || !unit.skill?.active) return;
|
||
if (d.type === 'element' || d.type === 'elemental' || d.skadiShare || d.tags?.includes('transfer') || !inCover(battle, unit, t)) return;
|
||
d.skadiShare = { share, tag: TAG }; // 同类效果取最高: one transfer per damage instance
|
||
d.mul *= 1 - share;
|
||
}, { owner: unit, priority: -20 });
|
||
battle.on('damaged', (ctx) => {
|
||
const x = ctx.dmg?.skadiShare;
|
||
if (!x || x.tag !== TAG || !(ctx.amount > 0) || !live(unit)) return;
|
||
battle.dealDamage(ctx.source, unit, { amount: ctx.amount * x.share / Math.max(1e-6, 1 - x.share), type: 'true', canDodge: false, tags: ['transfer'] });
|
||
}, { owner: unit });
|
||
}
|
||
// module 新生代: "自身技能期间,攻击范围内的友军获得30点物理与法术伤害减免" (flat reduction per hit)
|
||
const flat = -num(mod.damage_resistance);
|
||
if (flat > 0) {
|
||
battle.on('hit', (ctx) => {
|
||
const t = ctx.target, d = ctx.dmg;
|
||
if (!t || t.side !== 'ally' || t.kind === 'device' || !live(unit) || !unit.skill?.active || !inCover(battle, unit, t)) return;
|
||
if (d.type === 'phys') d.defIgnoreFlat -= flat; // +flat effective DEF ⇒ −flat damage (above the 5 % floor)
|
||
else if (d.type === 'arts') d.amount = Math.max(0, d.amount - flat / Math.max(0.05, 1 - (t.s.res ?? 0) / 100));
|
||
}, { owner: unit, priority: -10 });
|
||
}
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 远古血亲: the seaborn is an inert range extension with a limited life
|
||
battle.on('deploy', ({ unit: t }) => {
|
||
if (!isTok(t, tokId, unit)) return;
|
||
if (t.profile) t.profile.noAttack = true;
|
||
const dur = num(t.def?.talents?.[0]?.bb?.duration, parseN(tdesc(def, 0), /持续(\d+(?:\.\d+)?)秒/, 25));
|
||
const seq = t.deploySeq, r = t.tileR, c = t.tileC;
|
||
battle.after(dur, () => {
|
||
if (!t.alive || t.deploySeq !== seq) return;
|
||
battle.retreat(t, { reason: 'expired', permanent: true });
|
||
const wait = num(t.base.respawnTime, 30), cost = num(t.base.cost, 0);
|
||
let tries = 0;
|
||
const again = () => {
|
||
if (battle.finished || tries++ > 300) return;
|
||
const ps = battle.getPlayer(unit.ownerId);
|
||
if (!live(unit) || !ps || ps.dp + 1e-9 < cost || battle.unitAt(r, c)) { battle.after(1, again, { owner: unit }); return; }
|
||
battle.addDp(unit.ownerId, -cost);
|
||
if (!battle.spawnToken(unit, tokId, r, c)) { battle.addDp(unit.ownerId, cost); battle.after(1, again, { owner: unit }); }
|
||
};
|
||
battle.after(wait, again, { owner: unit });
|
||
}, { owner: t });
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 捕食习性 (module 新生代: ATK 9 / 20 %, DEF +8 %, +3 SP per op deployed in range) + module (≥2 other ops in range: ATK +8 %)
|
||
const a1 = num(t1['skadi2_e_003_t_2[atk][1].atk'], bv(t1, 'skadi2_t_2[atk][1].atk', 0));
|
||
const a2 = num(t1['skadi2_e_003_t_2[atk][2].atk'], bv(t1, 'skadi2_t_2[atk][2].atk', a1));
|
||
const d1 = num(t1['skadi2_e_003_t_2[def].def']), sp = num(t1.sp);
|
||
const mCnt = num(mod.cnt, Infinity), mAtk = num(mod.atk);
|
||
aura(battle, unit, 0.5, () => {
|
||
const toks = seaborns(battle, unit);
|
||
const ops = covered(battle, unit, toks).filter((a) => a !== unit && a.kind === 'op');
|
||
const val = ops.some((a) => ABYSSAL.has(a.def?.charId)) ? a2 : ops.length ? a1 : 0;
|
||
const own = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op').length;
|
||
const total = val + (own >= mCnt ? mAtk : 0);
|
||
const dPct = ops.length ? d1 : 0;
|
||
if (total > 0 || dPct > 0) battle.addBuff(unit, { key: 'skadi2:predator', mods: { atkPct: total, defPct: dPct }, duration: 0.75, refresh: 'replace' });
|
||
});
|
||
// "我方干员部署于自身或海嗣范围内后,自身立刻获得3点技力"
|
||
if (sp > 0) battle.on('deploy', ({ unit: u }) => {
|
||
if (!u || u === unit || u.kind !== 'op' || u.side !== 'ally' || !live(unit) || !unit.skill) return;
|
||
if (inCover(battle, unit, u)) unit.skill.gainSp(sp, 'talent');
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 异客 chess_char_6_05 (链术师) — S3 辉煌裂片; 机理分析; 孤卒
|
||
|
||
const STORM_RADIUS = 1.5; // [ASSUMED] storm zone radius (not in data)
|
||
|
||
function pasngr(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {};
|
||
const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null;
|
||
const strike = (battle, unit, first, scale) => {
|
||
const ch0 = unit.profile?.chain || { count: 4, falloff: 0.15, radius: 1.8 };
|
||
// elite module (电磁调节器) upgrades the skill's chain too: skill@chain.atk_scale / skill@sluggish
|
||
const ch = { ...ch0, falloff: tb['skill@chain.atk_scale'] != null ? 1 - num(tb['skill@chain.atk_scale']) : num(ch0.falloff, 0.15) };
|
||
const count = Math.max(1, Math.floor(num(bb['chain.max_target'], ch.count || 4)));
|
||
const slug = num(tb['skill@sluggish'], num(bb.sluggish, ch.sluggish ?? 0));
|
||
const hit = new Set();
|
||
let prev = first;
|
||
for (let i = 0; i < count && prev; i++) {
|
||
hit.add(prev.id);
|
||
battle.fx('lightning', { x: prev.x, y: prev.y, id: prev.id, src: unit.id });
|
||
battle.dealDamage(unit, prev, { amount: unit.s.atk * scale * Math.pow(1 - num(ch.falloff, 0.15), i), type: 'arts', isSkill: true, isAttack: true, tags: ['skill', 'storm'] });
|
||
if (slug > 0 && prev.alive) battle.applyStatus(prev, 'sluggish', { duration: slug, source: unit });
|
||
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 = bodyDist(x, prev.x, prev.y);
|
||
if (d < bd - 1e-9) { bd = d; best = x; }
|
||
}
|
||
prev = best;
|
||
}
|
||
};
|
||
// the chain of her profile (trait / module: bounce count, falloff, 停顿) with skill overrides
|
||
const chainOf = (unit, o) => ({ ...(unit.profile?.chain || { count: 4, falloff: 0.15, radius: 1.8, sluggish: 0.5 }), ...o });
|
||
const skills = {
|
||
// S1 电能之触: next attack at atk_scale × ATK, bouncing over max_target enemies with a sluggish-s 停顿 (the module's
|
||
// skill@pasngr_s_1.chain.atk_scale sets its falloff)
|
||
skchr_pasngr_1: {
|
||
kind: 'instant',
|
||
attack: { atkScale: bv(bb, 'atk_scale', 1) },
|
||
onStart({ unit, skill }) {
|
||
const o = { count: Math.max(1, Math.floor(bv(bb, 'max_target', 4))), sluggish: bv(bb, 'sluggish', 0.5) };
|
||
if (tb['skill@pasngr_s_1.chain.atk_scale'] != null) o.falloff = 1 - num(tb['skill@pasngr_s_1.chain.atk_scale']);
|
||
skill.spec.attack.chain = chainOf(unit, o);
|
||
},
|
||
},
|
||
// S2 聚焦指令: range +1, ATK +atk, base attack time ×0.7/×0.6 (akdata: 乘算负数), bounces raised to attack@max_target
|
||
skchr_pasngr_2: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk), batPct: batOf(bb.base_attack_time, def, true) },
|
||
targeting: { rangeExtend: num(bb.ability_range_forward_extend) },
|
||
attack: {},
|
||
onStart({ unit, skill }) { skill.spec.attack.chain = chainOf(unit, { count: Math.max(1, Math.floor(num(bb['attack@max_target'], 5))) }); },
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
skill: {
|
||
kind: 'charges',
|
||
onStart({ battle, unit }) {
|
||
const keys = skillGrid ? absoluteRangeKeys(skillGrid, unit.tileR, unit.tileC, unit.dir, unit.s.rangeExtend) : unit.rangeKeys;
|
||
const cands = enemiesIn(battle, unit, keys);
|
||
if (!cands.length) return;
|
||
const tgt = cands.reduce((a, b) => (b.hp > a.hp ? b : a));
|
||
const cx = tgt.x, cy = tgt.y;
|
||
const dur = num(bb.duration, 4), iv = Math.max(0.1, num(bb.interval, 0.5)), scale = num(bb.atk_scale, 1);
|
||
const n = Math.max(1, Math.round(dur / iv));
|
||
battle.fx('storm', { x: cx, y: cy, id: unit.id, duration: dur, r: STORM_RADIUS });
|
||
let k = 0;
|
||
const seq = unit.deploySeq;
|
||
const h = battle.every(iv, () => {
|
||
if (!live(unit) || unit.deploySeq !== seq) { h.cancel(); return; } // the storm ends when she leaves the field
|
||
if (++k >= n) h.cancel();
|
||
const zone = battle.enemiesInRadius(cx, cy, STORM_RADIUS).filter((e) => canTargetEnemy(unit, e, ANY));
|
||
const e = battle.rng.pick(zone);
|
||
if (e) strike(battle, unit, e, scale);
|
||
}, { owner: unit });
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 机理分析
|
||
const thr = num(t0.hp_ratio, 0.8), ds = bv(t0, 'damage_scale', 1), dur = bv(t0, 'duration', 3);
|
||
const key = `pasngr:enhance:${unit.id}`;
|
||
battle.on('hit', (ctx) => {
|
||
const t = ctx.target;
|
||
if (ctx.source !== unit || !t || t.side !== 'enemy' || ctx.dmg.type === 'element') return;
|
||
if (ctx.dmg.isAttack && t.hpRatio >= thr - 1e-9) battle.addBuff(t, { key, duration: dur, refresh: 'replace', source: unit });
|
||
if (t.findBuff(key)) ctx.dmg.mul *= ds;
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 孤卒: no enemy on the 4 surrounding tiles
|
||
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 && bodyInKeys(e, near))) return;
|
||
battle.addBuff(unit, { key: 'pasngr:lone', mods: { atkPct: atk, spRecoveryFlat: spr }, duration: 0.4, refresh: 'replace' });
|
||
});
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 佩佩 chess_char_6_06 (撼地者) — S3 时光震荡; 往昔传承; 弥漫莲香; module 晴雨
|
||
|
||
function pepe(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {};
|
||
const sid = selectedSkill(chess, def);
|
||
const stackAtk = num(bb['attack@atk']), maxStack = Math.max(0, Math.floor(num(bb['attack@max_stack_cnt'])));
|
||
const ext = num(bb['attack@ability_range_forward_extend']);
|
||
const stun = num(bb['attack@stun']), stunMain = num(bb['attack@stun_main'], stun);
|
||
// 往昔传承: "技能期间每击倒1名敌人,技能结束时获得N点技力,至多回复M点" (any timed skill of hers)
|
||
const pastSp = (unit, skill) => {
|
||
const gain = Math.min(num(t0.max_sp, Infinity), (unit.mem.pepeKills || 0) * num(t0.sp));
|
||
unit.mem.pepeKills = 0;
|
||
if (gain > 0 && unit.alive) skill.gainSp(gain, 'talent');
|
||
};
|
||
const skills = {
|
||
// S1 盖戳!: next attack at atk_scale × ATK (splash included); castable while under a 异常状态, which it cleanses
|
||
// (install below)
|
||
skchr_pepe_1: { kind: 'instant', attack: { atkScale: num(bb.atk_scale, 1) } },
|
||
// S2 阻遏混乱锤: skill range, ATK / ASPD +, random target in range; every use gives later casts ASPD
|
||
// +attack_speed_extra (≤ max_stack_cnt stacks)
|
||
skchr_pepe_2: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) },
|
||
...(skillGridOf(def) ? { targeting: { rangeGrid: skillGridOf(def) } } : {}),
|
||
onStart({ battle, unit }) {
|
||
unit.mem.pepeKills = 0;
|
||
const n = unit.mem.pepeRage || 0;
|
||
if (n > 0) battle.addBuff(unit, { key: 'pepe:rage', mods: { aspd: num(bb.attack_speed_extra) * n } });
|
||
unit.mem.pepeRage = Math.min(Math.floor(num(bb.max_stack_cnt, 2)), n + 1);
|
||
},
|
||
onEnd({ battle, unit, skill }) { battle.removeBuff(unit, 'pepe:rage'); pastSp(unit, skill); },
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
install(battle, unit) {
|
||
if (sid === 'skchr_pepe_1') {
|
||
battle.on('tick', () => { // "处于异常状态时可以释放技能并清除异常状态"
|
||
const sk = unit.skill;
|
||
if (!live(unit) || !sk || !sk.ready || sk.active || !hasAbnormal(unit)) return;
|
||
cleanseAbnormal(battle, unit);
|
||
sk.activate('abnormal');
|
||
}, { owner: unit });
|
||
}
|
||
if (sid === 'skchr_pepe_2') {
|
||
battle.on('beforeAttack', (ctx) => { // 随机攻击范围内的目标
|
||
if (ctx.attacker !== unit || !unit.skill?.active) return;
|
||
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
|
||
if (c.length) ctx.targets = [battle.rng.pick(c)];
|
||
}, { owner: unit });
|
||
}
|
||
},
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk), batPct: batOf(bb.base_attack_time, def) },
|
||
attack: {
|
||
splashRadius: 1,
|
||
onHit({ battle, unit, target }) { if (target && target.alive && stunMain > 0) battle.applyStatus(target, 'stun', { duration: stunMain, source: unit }); },
|
||
},
|
||
onStart({ unit, skill }) {
|
||
unit.mem.pepeStack = 0;
|
||
unit.mem.pepeKills = 0;
|
||
unit.mem.pepeR0 = num(unit.profile?.splashRadius, 1);
|
||
skill.spec.attack.splashRadius = unit.mem.pepeR0;
|
||
},
|
||
onAttack({ battle, unit, skill }) {
|
||
if (unit.mem.pepeStack >= maxStack) return;
|
||
unit.mem.pepeStack++;
|
||
battle.addBuff(unit, { key: 'pepe:tremor', mods: { atkPct: stackAtk * unit.mem.pepeStack } });
|
||
skill.spec.attack.splashRadius = unit.mem.pepeR0 + ext * unit.mem.pepeStack;
|
||
},
|
||
onEnd({ battle, unit, skill }) {
|
||
battle.removeBuff(unit, 'pepe:tremor');
|
||
skill.spec.attack.splashRadius = unit.mem.pepeR0 ?? 1;
|
||
pastSp(unit, skill);
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // S3 splash stun + 往昔传承 kill counter
|
||
battle.on('damaged', (ctx) => {
|
||
if (ctx.source !== unit || !unit.skill?.active || !ctx.dmg?.isSplash || !ctx.target.alive || stun <= 0) return;
|
||
battle.applyStatus(ctx.target, 'stun', { duration: stun, source: unit });
|
||
}, { owner: unit });
|
||
battle.on('kill', ({ killer, victim }) => {
|
||
if (killer === unit && victim.side === 'enemy' && unit.skill?.active) unit.mem.pepeKills = (unit.mem.pepeKills || 0) + 1;
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 弥漫莲香: every 近卫 op ATK +16 %
|
||
const atk = num(t1.atk);
|
||
if (atk) aura(battle, unit, 0.5, () => {
|
||
for (const a of battle.allyUnits) if (live(a) && a.kind === 'op' && a.def?.profession === 'WARRIOR') battle.addBuff(a, { key: 'pepe:lotus', mods: { atkPct: atk }, duration: 0.75, refresh: 'replace' });
|
||
});
|
||
} },
|
||
{ install(battle, unit) { // elite module: ≥ cnt enemies in the splash area ⇒ this attack ×1.15
|
||
const sc = num(tb.atk_scale_e, 1), cnt = num(tb.cnt, Infinity);
|
||
if (!(sc > 1)) return;
|
||
battle.on('beforeAttack', (ctx) => {
|
||
if (ctx.attacker !== unit) return;
|
||
const t = ctx.targets[0];
|
||
const r = num(ctx.profile?.splashRadius, 1);
|
||
unit.mem.pepeBoost = !!t && battle.enemiesInRadius(t.x, t.y, r).length >= cnt;
|
||
}, { owner: unit });
|
||
battle.on('hit', (ctx) => { if (ctx.source === unit && ctx.dmg.isAttack && unit.mem.pepeBoost) ctx.dmg.mul *= sc; }, { owner: unit });
|
||
battle.on('attack', (ctx) => { if (ctx.attacker === unit) unit.mem.pepeBoost = false; }, { owner: unit, priority: -100 });
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 维娜·维多利亚 chess_char_6_07 (术战者) — S3 俱以我之名; 诸王的叹息; 无拘的锋芒; module 城主的冒险
|
||
|
||
function siege2(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}, mod = moduleBb(chess);
|
||
const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def);
|
||
const tGrid = def?.talents?.[0]?.rangeGrid?.length ? def.talents[0].rangeGrid : [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]];
|
||
const tokId = (chess?.tokens || []).find((t) => /vlion/.test(String(t))) || 'token_10040_siege2_vlion';
|
||
const mine = (u, unit) => u === unit || (!!u && u.kind === 'token' && u.ownerUnit === unit);
|
||
const skills = {
|
||
// S1 重铸晖光: the next attack also deals atk_scale × ATK true damage to every ground enemy on the skill's 4
|
||
// surrounding tiles (+ her own)
|
||
skchr_siege2_1: {
|
||
kind: 'instant',
|
||
attack: {
|
||
onHit({ battle, unit }) {
|
||
for (const e of battle.unitsInGrid(unit, skillGridOf(def) || N4, { side: 'enemy' })) {
|
||
if (!e.isFlying && e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'true', isSkill: true, tags: ['skill'] });
|
||
}
|
||
battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id });
|
||
},
|
||
},
|
||
},
|
||
// S2 进赴故土 (toggle): range +1 (skill grid), ATK +atk, one extra target; passive: ≥buff_stack_cnt other allies in
|
||
// the talent-1 area ⇒ SP +sp_recovery_per_sec (install)
|
||
skchr_siege2_2: {
|
||
kind: 'toggle',
|
||
mods: { atkPct: num(bb.atk) },
|
||
targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 2))), ...(skillGridOf(def) ? { rangeGrid: skillGridOf(def) } : {}) },
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
install(battle, unit) {
|
||
if (sid === 'skchr_siege2_2') {
|
||
const need = Math.max(1, Math.floor(num(bb.buff_stack_cnt, 2))), spr = num(bb.sp_recovery_per_sec);
|
||
if (spr > 0) aura(battle, unit, 0.25, () => {
|
||
const n = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a !== unit && a.kind !== 'device').length;
|
||
if (n >= need) battle.addBuff(unit, { key: 'siege2:homeland', mods: { spRecoveryFlat: spr }, duration: 0.4, refresh: 'replace' });
|
||
});
|
||
}
|
||
// module 秩序圣“球”: enemies blocked by her or her summons take 10 % 脆弱 (trait); she and her summons deal ×1.15 to
|
||
// 战栗 targets (talent)
|
||
const frag = num(tb.damage_scale, 1) - 1, tremMul = num(mod.damage_scale, 1);
|
||
if (frag > 0) aura(battle, unit, 0.25, () => {
|
||
for (const e of battle.enemies) if (e.alive && e.blockedBy && mine(e.blockedBy, unit)) battle.applyStatus(e, 'fragile', { duration: 0.4, value: frag, source: unit });
|
||
});
|
||
if (tremMul > 1) battle.on('hit', (ctx) => {
|
||
const t = ctx.target;
|
||
if (!mine(ctx.source, unit) || !t || t.side !== 'enemy' || !(t.findBuff('tremble') || t.s.flags.tremble)) return;
|
||
ctx.dmg.mul *= tremMul;
|
||
}, { owner: unit });
|
||
},
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk), batPct: batOf(bb.base_attack_time, def) },
|
||
targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 1))) },
|
||
attack: { dmgType: 'true' },
|
||
onStart({ battle, unit, skill }) {
|
||
const tile = bestTile(battle, freeTiles(battle, unit, tGrid));
|
||
if (tile) {
|
||
const lion = battle.spawnToken(unit, tokId, tile[0], tile[1], { duration: skill.timeLeft });
|
||
unit.mem.vlion = lion;
|
||
if (lion) {
|
||
if (!lion.kit?.fromTokens && lion.profile) lion.profile.dmgType = 'true'; // "攻击造成真实伤害" without a token kit
|
||
battle.fx('summon', { x: lion.x, y: lion.y, id: lion.id, src: unit.id });
|
||
}
|
||
}
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
const lion = unit.mem.vlion;
|
||
unit.mem.vlion = null;
|
||
if (lion && lion.alive) battle.retreat(lion, { reason: 'expired', permanent: true });
|
||
battle.setExtraRange(unit, null);
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // S3: enemies blocked by allies inside the talent area become targetable
|
||
let sig = '';
|
||
if (!isDef) return;
|
||
battle.on('tick', () => { // (engine extra range keys: kept across range rebuilds, cleared at the skill end)
|
||
if (!live(unit) || !unit.skill?.active) { sig = ''; return; }
|
||
const keys = [];
|
||
for (const a of battle.unitsInGrid(unit, tGrid, { side: 'ally' })) for (const e of a.blocking) if (e.alive) keys.push(keyOf(e));
|
||
const s = keys.join(',');
|
||
if (s === sig) return;
|
||
sig = s;
|
||
battle.setExtraRange(unit, keys);
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 诸王的叹息
|
||
const dr = num(t0.damage_resistance), atk = num(t0.atk);
|
||
aura(battle, unit, 0.25, () => {
|
||
const allies = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a.kind !== 'device');
|
||
if (!allies.includes(unit)) allies.push(unit);
|
||
if (dr > 0) for (const a of allies) battle.addBuff(a, { key: 'siege2:sigh', mods: { physTakenMul: 1 - dr }, duration: 0.4, refresh: 'replace' });
|
||
const n = allies.filter((a) => a !== unit).length;
|
||
if (n > 0 && atk) battle.addBuff(unit, { key: 'siege2:kings', mods: { atkPct: atk * n }, duration: 0.4, refresh: 'replace' });
|
||
});
|
||
} },
|
||
{ install(battle, unit) { // 无拘的锋芒: first damage on each enemy ⇒ 战栗 (module 秩序圣“球”: 6 s, elite / leader 12 s)
|
||
const durN = num(t1.not_combat_normal, num(t1.not_combat)), durE = num(t1.not_combat_elite, durN);
|
||
const seen = new WeakSet();
|
||
battle.on('damaged', (ctx) => {
|
||
const t = ctx.target;
|
||
if (ctx.source !== unit || !t || t.side !== 'enemy' || seen.has(t)) return;
|
||
const dur = isElite(t) ? durE : durN;
|
||
if (!(dur > 0)) return;
|
||
seen.add(t);
|
||
if (t.alive) battle.applyStatus(t, 'tremble', { duration: dur, source: unit });
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // elite module: ASPD +8 while not blocking
|
||
const as = num(tb.attack_speed);
|
||
if (as) aura(battle, unit, 0.25, () => { if (!unit.blocking.length) battle.addBuff(unit, { key: 'siege2:free', mods: { aspd: as }, duration: 0.4, refresh: 'replace' }); });
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 焰影苇草 chess_char_6_08 (咒愈师) — S3 生命火种; 灼痕; 映耀
|
||
|
||
function reed2(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}, mod = moduleBb(chess);
|
||
const isDef = onDefaultSkill(chess);
|
||
const selfAtk = (() => { for (const k of Object.keys(bb)) if (!k.startsWith('talent@') && (k === 'atk' || k.endsWith('.atk'))) return num(bb[k]); return 0; })();
|
||
const sProb = num(bb['talent@prob'], num(t0.prob)), dot = num(bb['talent@s3_atk_scale']);
|
||
const aoe = num(bb['talent@aoe_scale']), aoeR = num(bb['talent@range_radius'], 1.7);
|
||
const scorchAtk = num(t0.atk), scorchFragile = num(t0.damage_scale, 1) - 1;
|
||
// 灼痕: ATK −20 % (marker buff, 不可叠加) + 30 % 法术脆弱 (the catalogue status: 同名效果取最高 with other sources)
|
||
const scorch = (battle, unit, e) => {
|
||
if (!e || !e.alive) return;
|
||
const sk = unit.skill;
|
||
// "灼痕效果持续至技能结束" — S3 (the default skill) only
|
||
const dur = isDef && sk && sk.active ? Math.max(0.1, sk.timeLeft) : num(t0.duration, 6);
|
||
battle.addBuff(e, { key: 'reed2:scorch', duration: dur, refresh: 'extend', mods: scorchAtk ? { atkPct: scorchAtk } : null, visible: true, source: unit });
|
||
if (scorchFragile > 0 && e.alive) battle.applyStatus(e, 'artsFragile', { duration: dur, value: scorchFragile, source: unit });
|
||
};
|
||
const skills = {
|
||
// S1 迅捷打击·γ型
|
||
'skcom_quickattack[3]': { kind: 'duration', mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) } },
|
||
// S2 枯荣共息: up to max_target operators of her range (ground ones first) carry three fireballs for
|
||
// projectile_life_time s: every `cooldown` s one enemy in the carrier's range (else hers) takes atk_scale × her ATK
|
||
// arts, and her trait heals only that carrier (trait scale × the damage)
|
||
skchr_reed2_2: {
|
||
kind: 'duration',
|
||
duration: num(bb.projectile_life_time) > 0 ? num(bb.projectile_life_time) : undefined,
|
||
onStart({ battle, unit, skill }) {
|
||
const n = Math.max(1, Math.floor(num(bb.max_target, 1)));
|
||
const cands = battle.alliesInGrid(unit).filter((a) => a.kind === 'op' && live(a));
|
||
cands.sort((a, b) => (a === unit) - (b === unit) || (b.ground ? 1 : 0) - (a.ground ? 1 : 0) || a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq);
|
||
unit.mem.reedFire = cands.slice(0, n).map((a) => ({ a, acc: 0 }));
|
||
for (const F of unit.mem.reedFire) {
|
||
battle.addBuff(F.a, { key: `reed2:fireball:${unit.id}`, duration: skill.timeLeft, visible: true, source: unit });
|
||
battle.fx('ember', { x: F.a.x, y: F.a.y, id: F.a.id, src: unit.id, n: 3 });
|
||
}
|
||
},
|
||
onTick({ battle, unit, dt }) {
|
||
const cd = Math.max(0.1, num(bb.cooldown, 1.5)), ratio = num(unit.profile?.healRatio, num(tb.scale, 0.5));
|
||
for (const F of unit.mem.reedFire || []) {
|
||
if (!live(F.a)) continue;
|
||
F.acc += dt;
|
||
if (F.acc + 1e-9 < cd) continue;
|
||
F.acc -= cd;
|
||
let c = enemiesIn(battle, F.a);
|
||
if (!c.length) c = enemiesIn(battle, unit);
|
||
if (!c.length) continue;
|
||
sortEnemyTargets(battle, F.a, c, null);
|
||
const dealt = battle.dealDamage(unit, c[0], { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill', 'fireball'] });
|
||
battle.fx('strike', { x: c[0].x, y: c[0].y, id: c[0].id, src: F.a.id });
|
||
if (dealt > 0 && ratio > 0) battle.heal(unit, F.a, dealt * ratio, { tags: ['incantation'] });
|
||
}
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
for (const F of unit.mem.reedFire || []) battle.removeBuff(F.a, `reed2:fireball:${unit.id}`);
|
||
unit.mem.reedFire = null;
|
||
},
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
install(battle, unit) {
|
||
// module “独属自己的一隅”: "攻击范围内存在已受伤的友方干员时,自身造成的伤害提升至110%"
|
||
const ds = num(mod.damage_scale, 1);
|
||
if (ds > 1) aura(battle, unit, 0.25, () => {
|
||
if (battle.injuredAlliesInKeys(unit.rangeKeys, unit).some((a) => a.kind === 'op' && a !== unit)) {
|
||
battle.addBuff(unit, { key: 'reed2:corner', mods: { dmgDealtMul: ds }, duration: 0.4, refresh: 'replace' });
|
||
}
|
||
});
|
||
},
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: { atkPct: selfAtk },
|
||
targeting: { maxTargets: Math.max(1, Math.floor(num(bb.max_target, 1))) },
|
||
onStart({ unit }) { unit.mem.reedAcc = 0; },
|
||
onTick({ battle, unit, dt }) {
|
||
unit.mem.reedAcc += dt;
|
||
if (unit.mem.reedAcc + 1e-9 < 1) return;
|
||
unit.mem.reedAcc -= 1;
|
||
if (!(dot > 0)) return;
|
||
for (const e of battle.enemies) if (e.alive && !e.hidden && e.findBuff('reed2:scorch')) {
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * dot, type: 'arts', isSkill: true, tags: ['skill', 'scorch'] });
|
||
}
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 灼痕 (+ S3 kill explosions)
|
||
battle.on('damaged', (ctx) => {
|
||
const t = ctx.target;
|
||
if (ctx.source !== unit || !t || t.side !== 'enemy' || !t.alive || ctx.type === 'element') return;
|
||
if (ctx.dmg?.tags?.includes('scorch')) return;
|
||
const p = unit.skill?.active ? sProb : num(t0.prob);
|
||
if (p >= 1 || (p > 0 && battle.rng() < p)) scorch(battle, unit, t);
|
||
}, { owner: unit });
|
||
battle.on('kill', ({ victim }) => {
|
||
if (!unit.skill?.active || !live(unit) || victim.side !== 'enemy' || !victim.findBuff('reed2:scorch') || !(aoe > 0)) return;
|
||
const x = victim.x, y = victim.y;
|
||
// deferred to the next scheduler pass: chained explosions must not nest kill → damage → kill hooks
|
||
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, 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);
|
||
}
|
||
}, { owner: unit });
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 映耀
|
||
const share = num(t1.scale), boost = num(t1.heal_scale, 1);
|
||
battle.on('heal', (ctx) => {
|
||
if (ctx.source !== unit || ctx.target === unit || ctx.opts?.reflect) return;
|
||
if (boost !== 1) ctx.amount *= boost;
|
||
if (share > 0 && ctx.amount > 0) battle.heal(unit, unit, ctx.amount * share, { self: true, reflect: true });
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 塑心 chess_char_6_09 (巫役) — S1 “黄金的狂喜”; 无词哀歌; 精神逆构; module 强弱法
|
||
|
||
function cello(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {}, mod = moduleBb(chess);
|
||
const sid = selectedSkill(chess, def);
|
||
const isS1 = sid === 'skchr_cello_1' || !(chess?.skills?.length); // S1 = the default (no loadout info ⇒ default)
|
||
// "第二天赋效果提升至N倍" (S3): every 精神逆构 number scaled around 1 (delta × N) while it runs
|
||
const boost = (c) => (c.mem.celloBoost > 0 ? c.mem.celloBoost : 1);
|
||
const scaled = (v, c) => 1 + (v - 1) * boost(c);
|
||
/** S2 partner: the other operator of her range with the highest ATK (re-picked every 0.5 s). */
|
||
const pickPartner = (battle, unit) => {
|
||
unit.mem.celloPick = 0.5;
|
||
const ops = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op');
|
||
unit.mem.celloPartner = ops.reduce((best, a) => (!best || a.s.atk > best.s.atk + 1e-9 ? a : best), null);
|
||
};
|
||
const skills = {
|
||
// S2 “安魂的弥撒”: ASPD +, one extra target; while it runs every damage she or the highest-ATK other operator of her
|
||
// range deals to an enemy also adds ep_damage_ratio × her ATK 凋亡损伤 (install)
|
||
skchr_cello_2: {
|
||
kind: 'duration',
|
||
mods: { aspd: num(bb.attack_speed) },
|
||
targeting: { maxTargets: 2 },
|
||
onStart({ battle, unit }) { pickPartner(battle, unit); },
|
||
onTick({ battle, unit, dt }) {
|
||
unit.mem.celloPick -= dt;
|
||
if (unit.mem.celloPick > 0 && live(unit.mem.celloPartner)) return;
|
||
pickPartner(battle, unit);
|
||
},
|
||
onEnd({ unit }) { unit.mem.celloPartner = null; },
|
||
},
|
||
// S3 “自由的探戈”: stops attacking, skill range, ATK +atk, 精神逆构 ×scale_delta_to_one; the other operators of her
|
||
// range with the highest max HP / ATK / DEF get +20 % of that stat
|
||
skchr_cello_3: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk) },
|
||
...(skillGridOf(def) ? { targeting: { rangeGrid: skillGridOf(def) } } : {}),
|
||
attack: { noAttack: true },
|
||
onStart({ unit }) { unit.mem.celloBoost = num(bb.scale_delta_to_one, 1); unit.mem.celloAcc = Infinity; },
|
||
onTick({ battle, unit, dt }) {
|
||
unit.mem.celloAcc += dt;
|
||
if (unit.mem.celloAcc < 0.5) return;
|
||
unit.mem.celloAcc = 0;
|
||
const ops = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op');
|
||
const top = (f) => ops.reduce((best, a) => (!best || f(a) > f(best) + 1e-9 ? a : best), null);
|
||
const give = (a, key, mods) => { if (a) battle.addBuff(a, { key, mods, duration: 0.75, refresh: 'replace', source: unit, visible: true }); };
|
||
give(top((a) => a.base.maxHp), 'cello:tango:hp', { hpPct: num(bb['cello_s_3[max_hp].max_hp']) });
|
||
give(top((a) => a.base.atk), 'cello:tango:atk', { atkPct: num(bb['cello_s_3[atk].atk']) });
|
||
give(top((a) => a.base.def), 'cello:tango:def', { defPct: num(bb['cello_s_3[def].def']) });
|
||
},
|
||
onEnd({ unit }) { unit.mem.celloBoost = 1; },
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
// S1 "技能未开启时无法普通攻击" and its 未处于损伤爆发期间 target pick belong to S1 only
|
||
trait: isS1 ? { noAttackUnlessSkill: true, priority: 'notBurst' } : null,
|
||
install(battle, unit) {
|
||
if (sid !== 'skchr_cello_2') return;
|
||
const ratio = num(bb.ep_damage_ratio);
|
||
// 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, priority: -1000 });
|
||
},
|
||
skill: {
|
||
kind: 'charges',
|
||
targeting: { priority: 'notBurst' },
|
||
attack: {
|
||
atkScale: num(bb.atk_scale, 1),
|
||
onHit({ battle, unit, target }) { if (target && target.alive) elementDmg(battle, unit, target, 'apoptosis', unit.s.atk * num(bb.ep_damage_ratio)); },
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 无词哀歌
|
||
const er = num(t0.ep_damage_ratio), slug = num(t0.sluggish);
|
||
aura(battle, unit, 1, () => {
|
||
for (const e of enemiesIn(battle, unit)) {
|
||
if (er > 0) elementDmg(battle, unit, e, 'apoptosis', unit.s.atk * er, ['talent']);
|
||
if (slug > 0 && e.alive) battle.applyStatus(e, 'sluggish', { duration: slug, source: unit });
|
||
}
|
||
});
|
||
} },
|
||
{ install(battle, unit) { // 精神逆构 (+ module element fragile / field-wide + DoT) and trait module (×1.18 vs elite/leader)
|
||
const amp = num(t1.ep_damage_scale, 1), frag = num(t1.damage_scale, 1) - 1, elite = num(tb.ep_damage_scale, 1);
|
||
// module 音乐家的旅程: "在场时,全场敌人…" (field-wide) + damage_value 元素伤害 per second during the burst
|
||
const dot = num(t1.damage_value), fieldWide = t1.damage_value != null, dotIv = Math.max(0.1, num(t1.interval, 1));
|
||
unit.mem.celloAmp = amp;
|
||
unit.mem.celloWide = fieldWide;
|
||
// one battle-wide handler: every apoptosis fill on an enemy inside some 塑心's range (or anywhere for a field-wide
|
||
// one) × the strongest 精神逆构 (the same effect from two 塑心 does not stack)
|
||
const S = bstate(battle);
|
||
if (!S.cellos) {
|
||
S.cellos = new Set();
|
||
battle.on('elementHit', (ctx) => {
|
||
const t = ctx.target, d = ctx.dmg;
|
||
if (!d || d.type !== 'element' || d.element !== 'apoptosis' || !t || t.side !== 'enemy') return;
|
||
let best = 1;
|
||
for (const c of S.cellos) {
|
||
if (!live(c) || !(c.mem.celloWide || bodyInKeys(t, c.rangeKeySet))) continue;
|
||
const v = scaled(c.mem.celloAmp, c);
|
||
if (v > best) best = v;
|
||
}
|
||
if (best > 1) d.mul *= best;
|
||
});
|
||
}
|
||
if (amp > 1) S.cellos.add(unit);
|
||
// 强弱法 (elite trait): her element damage vs elite / leader enemies ×1.18
|
||
if (elite > 1) onElementHit(battle, unit, (ctx) => (ctx.source === unit && ctx.target.side === 'enemy' && isElite(ctx.target) ? elite : 1));
|
||
// "凋亡损伤爆发期间受到5%的元素脆弱" — the 元素脆弱 status (同名效果取最高)
|
||
if (frag > 0) aura(battle, unit, 0.25, () => {
|
||
for (const e of enemiesIn(battle, unit)) if (e.findBuff('apoptosisBurst')) battle.applyStatus(e, 'elemFragile', { duration: 0.4, value: frag * boost(unit), source: unit });
|
||
});
|
||
if (dot > 0) aura(battle, unit, dotIv, () => {
|
||
for (const e of battle.enemies) {
|
||
if (e.alive && !e.hidden && e.findBuff('apoptosisBurst')) battle.dealDamage(unit, e, { amount: dot * boost(unit), type: 'elemental', element: 'apoptosis', canDodge: false, tags: ['talent', 'elementDmg'] });
|
||
}
|
||
});
|
||
// module 音乐家的旅程 trait: "攻击范围内敌人受到10%的元素脆弱"
|
||
const mFrag = num(mod.damage_scale, 1) - 1;
|
||
if (mFrag > 0) aura(battle, unit, 0.25, () => {
|
||
for (const e of enemiesIn(battle, unit)) battle.applyStatus(e, 'elemFragile', { duration: 0.4, value: mFrag, source: unit });
|
||
});
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 妮芙 chess_char_6_10 (本源术师, hidden tier-6 entry) — S2 怵然震爆; 失魂; 窥心钥; module 心声
|
||
|
||
function nymph(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {};
|
||
const soul = (battle, unit, t, scale) => {
|
||
const burst = t.findBuff('apoptosisBurst');
|
||
if (!burst || !(scale > 0)) return;
|
||
const key = `nymph:soul:${unit.id}`;
|
||
const cur = t.findBuff(key);
|
||
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, × 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'] }); },
|
||
});
|
||
};
|
||
return {
|
||
skill: {
|
||
kind: 'charges',
|
||
attack: {
|
||
atkScale: num(bb.atk_scale, 1),
|
||
splashRadius: num(bb.projectile_range, 1.5),
|
||
splashScale: 1,
|
||
onHit({ battle, unit, target }) {
|
||
if (target && target.alive && num(bb.fear) > 0) battle.applyStatus(target, 'fear', { duration: num(bb.fear), source: unit });
|
||
battle.fx('shockBlast', { x: target ? target.x : unit.x, y: target ? target.y : unit.y, id: unit.id });
|
||
},
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // skill apoptosis (per damaged target) + 失魂
|
||
battle.on('damaged', (ctx) => {
|
||
const t = ctx.target;
|
||
if (ctx.source !== unit || !t || t.side !== 'enemy' || ctx.type === 'element' || !ctx.dmg?.isAttack) return;
|
||
const skillHit = !!ctx.dmg.isSkill;
|
||
if (t.alive) soul(battle, unit, t, skillHit ? num(bb.element_atk_scale, num(t0.element_atk_scale)) : num(t0.element_atk_scale));
|
||
if (skillHit && t.alive) elementDmg(battle, unit, t, 'apoptosis', ctx.amount * num(bb.ep_damage_ratio));
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 窥心钥 (elite module: field-wide, +ASPD at max stacks)
|
||
const atk = num(t1.atk), max = Math.max(1, Math.floor(num(t1.max_stack_cnt, 10)));
|
||
const fieldWide = t1.stack_cnt_check != null, as = num(t1.attack_speed);
|
||
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 && !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 } });
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // elite module: ×1.1 vs targets in an element burst
|
||
const ds = num(tb.damage_scale, 1);
|
||
if (ds > 1) battle.on('hit', (ctx) => { if (ctx.source === unit && ctx.target.s.flags.burstLock) ctx.dmg.mul *= ds; }, { owner: unit });
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 缪尔赛思 chess_char_6_11 (战术家) — S3 浅层非熵适应; 净水即生命 (流形); 开源节流; module 梳妆流形
|
||
|
||
/**
|
||
* The operator a 流形 copies: the nearest (Chebyshev tiles) living operator of its player on the field, not the summoner;
|
||
* ties → higher base ATK → lower id. Same pick as content/tokens.js (unmanaged 流形), so both paths agree.
|
||
*/
|
||
function pickCopyTarget(battle, owner, t) {
|
||
let best = null, bs = null;
|
||
for (const a of battle.allyUnits) {
|
||
if (a.kind !== 'op' || !live(a) || a === owner || a.ownerId !== owner.ownerId) continue;
|
||
const s = [Math.max(Math.abs(a.tileR - t.tileR), Math.abs(a.tileC - t.tileC)), -a.base.atk, a.id];
|
||
let less = !bs;
|
||
if (!less) for (let i = 0; i < 3; i++) { if (s[i] < bs[i]) { less = true; break; } if (s[i] > bs[i]) break; }
|
||
if (less) { best = a; bs = s; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/**
|
||
* 流形 copy (token talent "复制目标90%的生命值、攻击力、防御力、法抗,以及阻挡数、攻击间隔、攻击范围、初始伤害类型(不攻击、
|
||
* 治疗类型则不继承)"): scale × HP/ATK/DEF/RES, block count, BAT/ASPD, range and damage type. The attack itself stays the
|
||
* token's single-target attack (melee or ranged after the copied position) — splash/chain/multi-hit shapes are not
|
||
* listed and not copied. `ranged` (近/远程位) decides the special trait (steal / split).
|
||
*/
|
||
function copyInto(battle, t, src, scale, ranged) {
|
||
const sb = src.base, b = t.base;
|
||
b.maxHp = Math.max(1, num(sb.maxHp, b.maxHp) * scale);
|
||
for (const k of ['atk', 'def', 'res']) if (Number.isFinite(sb[k])) b[k] = sb[k] * scale;
|
||
for (const k of ['blockCnt', 'bat', 'aspd']) if (Number.isFinite(sb[k])) b[k] = sb[k];
|
||
if (Array.isArray(src.rangeGrid) && src.rangeGrid.length) t.rangeGrid = src.rangeGrid;
|
||
const sp = src.profile || {};
|
||
const p = t.profile;
|
||
if (p) {
|
||
p.attack = ranged ? 'ranged' : 'melee';
|
||
p.projectile = ranged ? (sp.projectile && sp.projectile !== 'none' && sp.projectile !== 'orb' ? sp.projectile : 'bolt') : 'none';
|
||
p.canHitFly = ranged ? true : !!sp.canHitFly;
|
||
if (!(sp.dmgType === 'heal' || sp.dmgType === 'none' || sp.noAttack || sp.noAttackUnlessSkill)) p.dmgType = sp.dmgType;
|
||
p.heal = null;
|
||
p.noAttack = false;
|
||
p.noAttackUnlessSkill = false;
|
||
}
|
||
battle.removeBuff(t, 'mlyss:stolen');
|
||
t.mem.mlyss = { ranged, from: src.id, stolenAtk: 0, stolenDef: 0, attacks: 0 }; // "复制后重置"
|
||
t.markDirty();
|
||
t.hp = t.s.maxHp;
|
||
battle.refreshRange(t); // the copied grid: current + initial range, trigger keys
|
||
}
|
||
|
||
function mlyss(bb, chess, def) {
|
||
const tokId = def?.talents?.[0]?.tokenKey || (chess?.tokens || [])[0] || 'token_10030_mlyss_wtrman';
|
||
const t1 = tbb(def, 1), mod = moduleBb(chess);
|
||
const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def);
|
||
const reinfCut = parseN(chess?.trait?.moduleDesc, /伤害降低(\d+(?:\.\d+)?)%/, 0) / 100;
|
||
// S3 (default) only: melee pulse interval, ranged-copy bind
|
||
const pulseIv = Math.max(0.2, bv(bb, 'interval', 2)), bindDur = isDef ? bv(bb, 'duration', 1.5) : 0;
|
||
// "自身与流形攻击力+N%(攻击速度+N)" — every skill of hers buffs the 流形 too while it runs
|
||
const manifoldMods = { atkPct: num(bb.atk), ...(num(bb.attack_speed) ? { aspd: num(bb.attack_speed) } : {}) };
|
||
const buffManifold = (battle, t, dur) => battle.addBuff(t, { key: 'mlyss:s3', mods: manifoldMods, duration: dur });
|
||
// module 落叶四季 (开源节流): copying a 莱茵生命 operator ⇒ she gains `sp`; deploying the copied one ⇒ it gains `sp_other`
|
||
const rhineSp = num(mod.sp), rhineSpOther = num(mod.sp_other);
|
||
const tokTal = (t, key) => { for (const x of t.def?.talents || []) if (x?.bb && typeof x.bb[key] === 'number') return x.bb; return {}; };
|
||
// "其被击败后会在25秒后自动刷新" (token talent interval; the talent text as a fallback)
|
||
const respawnOf = (t) => num(tokTal(t, 'scale').interval, parseN(tdesc(def, 0), /(\d+(?:\.\d+)?)秒后自动刷新/, 25));
|
||
const isRanged = (src) => (src.def?.position ? src.def.position === 'RANGED' : src.profile?.attack === 'ranged');
|
||
|
||
const copyNearest = (battle, unit, t) => {
|
||
const src = pickCopyTarget(battle, unit, t);
|
||
if (!src) { // nobody to copy: the copy skill is not spent — it ends and stays ready (no attack without a copy)
|
||
if (t.skill?.active) { t.skill.end('noTarget'); t.skill.gainSp(t.skill.spCost, 'refund'); }
|
||
return;
|
||
}
|
||
copyInto(battle, t, src, num(tokTal(t, 'scale').scale, 1), isRanged(src));
|
||
battle.fx('copy', { x: t.x, y: t.y, id: t.id, src: src.id });
|
||
if (rhineSp > 0 && RHINE.has(src.def?.charId) && unit.skill) unit.skill.gainSp(rhineSp, 'talent');
|
||
};
|
||
const mineLive = (battle, unit) => battle.allyUnits.filter((x) => isTok(x, tokId, unit) && live(x));
|
||
|
||
const skills = {
|
||
// S1 渐进性润化: fake_cost DP over the skill (1 every mlyss_s_1[cost].interval s, the rest at its end), she and her
|
||
// 流形 ATK / ASPD +
|
||
skchr_mlyss_1: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) },
|
||
onStart({ battle, unit, skill }) {
|
||
unit.mem.mlyDp = { acc: 0, got: 0 };
|
||
for (const t of mineLive(battle, unit)) buffManifold(battle, t, skill.timeLeft);
|
||
},
|
||
onTick({ battle, unit, dt }) {
|
||
const m = unit.mem.mlyDp;
|
||
if (!m) return;
|
||
const iv = Math.max(0.1, bv(bb, 'interval', 1.364)), total = Math.floor(num(bb.fake_cost, 11)), per = bv(bb, 'cost', 1);
|
||
m.acc += dt;
|
||
while (m.acc + 1e-9 >= iv && m.got < total) { m.acc -= iv; m.got++; battle.addDp(unit.ownerId, per); }
|
||
},
|
||
onEnd({ battle, unit, reason }) {
|
||
const m = unit.mem.mlyDp;
|
||
unit.mem.mlyDp = null;
|
||
const total = Math.floor(num(bb.fake_cost, 11));
|
||
if (m && reason === 'duration' && m.got < total) battle.addDp(unit.ownerId, (total - m.got) * bv(bb, 'cost', 1));
|
||
},
|
||
},
|
||
// S2 生态耦合: +cost DP, she and her 流形 ATK +; melee copies regenerate hp ratio / s and take 庇护; ranged copies
|
||
// attack twice at random targets of their range (install)
|
||
skchr_mlyss_2: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk) },
|
||
onStart({ battle, unit, skill }) {
|
||
battle.addDp(unit.ownerId, num(bb.cost));
|
||
battle.fx('dp', { x: unit.x, y: unit.y, id: unit.id, n: num(bb.cost) });
|
||
for (const t of mineLive(battle, unit)) buffManifold(battle, t, skill.timeLeft);
|
||
},
|
||
},
|
||
};
|
||
|
||
return {
|
||
skills,
|
||
install(battle, unit) {
|
||
if (sid === 'skchr_mlyss_2') {
|
||
const regen = num(bb.hp_recovery_per_sec_by_max_hp_ratio), dr = num(bb.damage_resistance);
|
||
battle.every(0.25, () => {
|
||
const on = !!unit.skill?.active && live(unit);
|
||
for (const t of battle.allyUnits) {
|
||
if (!isTok(t, tokId, unit) || !t.profile) continue;
|
||
const m = t.mem.mlyss;
|
||
if (t.mem.mlyssHits == null) t.mem.mlyssHits = Math.max(1, Math.floor(num(t.profile.hits, 1)));
|
||
const ranged = !!(m && m.ranged);
|
||
t.profile.hits = on && ranged && live(t) ? t.mem.mlyssHits * 2 : t.mem.mlyssHits; // 二连击
|
||
if (on && m && !ranged && live(t)) {
|
||
battle.addBuff(t, { key: 'mlyss:eco', duration: 0.4, refresh: 'replace', visible: true, mods: { hpRegenRatio: regen, physTakenMul: 1 - dr, artsTakenMul: 1 - dr } });
|
||
}
|
||
}
|
||
}, { owner: unit });
|
||
battle.on('beforeAttack', (ctx) => { // ranged copies: 随机攻击范围内的目标
|
||
const t = ctx.attacker;
|
||
if (!isTok(t, tokId, unit) || !t.mem.mlyss?.ranged || !unit.skill?.active) return;
|
||
const c = battle.enemiesInKeys(t.rangeKeys, t, ctx.profile);
|
||
if (c.length) ctx.targets = [battle.rng.pick(c)];
|
||
}, { owner: unit });
|
||
}
|
||
if (rhineSpOther > 0) battle.on('deploy', ({ unit: u, initial }) => { // the copied 莱茵生命 operator is deployed
|
||
if (initial || !u || u.kind !== 'op' || u.ownerId !== unit.ownerId || !RHINE.has(u.def?.charId) || !u.skill) return;
|
||
if (battle.allyUnits.some((t) => isTok(t, tokId, unit) && live(t) && t.mem.mlyss?.from === u.id)) u.skill.gainSp(rhineSpOther, 'talent');
|
||
}, { owner: unit });
|
||
},
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk) },
|
||
onStart({ battle, unit, skill }) {
|
||
battle.addDp(unit.ownerId, num(bb.cost));
|
||
battle.fx('dp', { x: unit.x, y: unit.y, id: unit.id, n: num(bb.cost) });
|
||
const S = unit.mem;
|
||
let ranged = false;
|
||
for (const t of battle.allyUnits.filter((x) => isTok(x, tokId, unit) && live(x))) {
|
||
buffManifold(battle, t, skill.timeLeft);
|
||
if (t.mem.mlyss?.ranged) ranged = true;
|
||
}
|
||
if (ranged) { // 刷新所有流形: heal the living ones, respawn the destroyed ones now
|
||
for (const t of battle.allyUnits.filter((x) => isTok(x, tokId, unit) && live(x))) t.hp = t.s.maxHp;
|
||
for (const p of (S.mlyssPending || []).splice(0)) { p.timer?.cancel?.(); p.spawn(); }
|
||
}
|
||
S.mlyssPulse = battle.every(pulseIv, () => { // melee copies: pull the 8 surrounding tiles + stun blocked units
|
||
if (!unit.skill?.active) return;
|
||
for (const t of battle.allyUnits) {
|
||
if (!isTok(t, tokId, unit) || !live(t) || !t.mem.mlyss || t.mem.mlyss.ranged) continue;
|
||
for (const e of battle.unitsInGrid(t, AROUND8, { side: 'enemy' })) pullToward(battle, t, e, num(t.def?.skill?.bb?.force), 0.2);
|
||
for (const e of t.blocking) if (e.alive) battle.applyStatus(e, 'stun', { duration: pulseIv + 0.1, source: unit });
|
||
battle.fx('pulse', { x: t.x, y: t.y, id: t.id });
|
||
}
|
||
}, { owner: unit, immediate: true });
|
||
},
|
||
onEnd({ unit }) { if (unit.mem.mlyssPulse) unit.mem.mlyssPulse.cancel(); unit.mem.mlyssPulse = null; },
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 净水即生命: 流形 copy / respawn / melee steal / ranged split
|
||
unit.mem.mlyssPending = [];
|
||
const S = bstate(battle);
|
||
S.robbed ??= new WeakMap(); // enemy → { atk, def } stolen by any 流形
|
||
const mine = (t) => isTok(t, tokId, unit);
|
||
// her 流形 on the field (split clones aside: deployLimit 1 counts the 流形 itself)
|
||
const standing = () => battle.allyUnits.some((t) => mine(t) && live(t) && !t.mem.mlyssClone);
|
||
const cancelPending = () => { for (const p of unit.mem.mlyssPending.splice(0)) p.timer?.cancel?.(); };
|
||
battle.on('deploy', ({ unit: t }) => {
|
||
if (!mine(t)) return;
|
||
const clone = unit.mem.mlyssCloneOf;
|
||
if (clone) { // a split copy: same stats as its origin, never copies/splits/respawns itself
|
||
unit.mem.mlyssCloneOf = null;
|
||
t.mem.mlyssClone = true;
|
||
t.mem.isClone = true; // content/tokens.js skips clones too (her redeploy must bring a real 流形, not adopt a clone)
|
||
copyInto(battle, t, clone, 1, true);
|
||
return;
|
||
}
|
||
cancelPending(); // a 流形 stands again (tactical re-summon, S3 refresh): no second one later
|
||
t.mem.mlyssHome = [t.tileR, t.tileC];
|
||
const sp = num(tokTal(t, 'sp').sp);
|
||
if (sp > 0 && !unit.mem.mlyssFirst && t.skill) t.skill.gainSp(sp, 'talent'); // elite: first deployment +5 SP
|
||
unit.mem.mlyssFirst = true;
|
||
if (unit.skill?.active && unit.skill.isTimed) buffManifold(battle, t, unit.skill.timeLeft);
|
||
}, { owner: unit });
|
||
battle.on('skillStart', ({ unit: t }) => { if (mine(t) && !t.mem.mlyssClone) copyNearest(battle, unit, t); }, { owner: unit });
|
||
// trigger rule MLYSS_WTRMAN ("全部技能,受流形影响") [ASSUMED]: DEFAULT (engine), and an enemy inside the range of one
|
||
// of her copied 流形 also satisfies it (her S3 acts through the 流形: pull/stun or bind around them)
|
||
if (unit.skill) unit.skill.addTriggerRange(() => battle.allyUnits.filter((t) => mine(t) && live(t) && t.mem.mlyss));
|
||
battle.on('tick', () => { // without a dedicated token kit the copy fires once ready and someone can be copied
|
||
for (const t of battle.allyUnits) {
|
||
if (!mine(t) || !live(t) || t.mem.mlyssClone || !t.kit?.generic || !t.skill || t.skill.active || !t.skill.ready) continue;
|
||
if (pickCopyTarget(battle, unit, t)) t.skill.activate('MLYSS_WTRMAN');
|
||
}
|
||
}, { owner: unit });
|
||
// "其被击败后会在25秒后自动刷新": one pending respawn at a time, on its tile, only while she is on the field and no
|
||
// 流形 of hers stands (her redeploy summons it again as her 援军, tokens.js); expired / replaced ones do not return
|
||
battle.on('death', ({ unit: t, reason }) => {
|
||
if (t === unit) { cancelPending(); return; }
|
||
if (!mine(t) || t.mem.mlyssClone || reason !== 'killed' || !t.mem.mlyssHome || standing()) return;
|
||
cancelPending();
|
||
const [r, c] = t.mem.mlyssHome;
|
||
const entry = { spawn: null, timer: null };
|
||
let tries = 0;
|
||
entry.spawn = () => {
|
||
const i = unit.mem.mlyssPending.indexOf(entry);
|
||
if (i >= 0) unit.mem.mlyssPending.splice(i, 1);
|
||
if (battle.finished || !live(unit) || standing() || tries++ > 120) return;
|
||
if (battle.isReservedTile(r, c) || !battle.spawnToken(unit, tokId, r, c)) { // its tile is taken: retry in 1 s
|
||
entry.timer = battle.after(1, entry.spawn, { owner: unit });
|
||
unit.mem.mlyssPending.push(entry);
|
||
}
|
||
};
|
||
entry.timer = battle.after(respawnOf(t), entry.spawn, { owner: unit });
|
||
unit.mem.mlyssPending.push(entry);
|
||
}, { owner: unit });
|
||
battle.on('attack', ({ attacker: t, targets }) => {
|
||
if (!mine(t) || !t.mem.mlyss || !live(t)) return;
|
||
const tal = tokTal(t, 'steal_atk');
|
||
const m = t.mem.mlyss;
|
||
if (!m.ranged) { // melee copy: 每次攻击偷取敌方10点攻击力与防御力(最高250点)— a capped copy steals nothing more
|
||
const sa = num(tal.steal_atk), sd = num(tal.steal_def, sa);
|
||
const capA = num(tal.steal_atk_max, Infinity), capD = num(tal.steal_def_max, capA);
|
||
let changed = false;
|
||
for (const e of targets) {
|
||
if (!e.alive || e.side !== 'enemy') continue;
|
||
const ga = Math.max(0, Math.min(sa, capA - m.stolenAtk)), gd = Math.max(0, Math.min(sd, capD - m.stolenDef));
|
||
if (!(ga > 0 || gd > 0)) break;
|
||
m.stolenAtk += ga; m.stolenDef += gd;
|
||
const rb = S.robbed.get(e) || { atk: 0, def: 0 };
|
||
rb.atk += ga; rb.def += gd;
|
||
S.robbed.set(e, rb);
|
||
battle.addBuff(e, { key: 'mlyss:robbed', mods: { atkFlat: -rb.atk, defFlat: -rb.def }, source: t });
|
||
changed = true;
|
||
}
|
||
if (changed) battle.addBuff(t, { key: 'mlyss:stolen', mods: { atkFlat: m.stolenAtk, defFlat: m.stolenDef } });
|
||
} else if (!t.mem.mlyssClone) { // ranged copy: split every N attacks (the clone lasts `interval` s)
|
||
const every = Math.max(1, Math.floor(bv(tal, 'max_stack_cnt', 10)));
|
||
if (++m.attacks % every !== 0) return;
|
||
const tiles = N4.slice(1).map(([dr, dc]) => [t.tileR + dr, t.tileC + dc])
|
||
.filter(([r, c]) => battle.grid.inRect(r, c) && !battle.isReservedTile(r, c) && battle.grid.canStand(r, c, { ranged: true }));
|
||
const tile = bestTile(battle, tiles);
|
||
if (!tile) return;
|
||
unit.mem.mlyssCloneOf = t;
|
||
let cl = null;
|
||
try { cl = battle.spawnToken(unit, tokId, tile[0], tile[1], { duration: num(tal.interval, 25), kit: { skill: null } }); } finally { unit.mem.mlyssCloneOf = null; }
|
||
if (cl) battle.fx('split', { x: cl.x, y: cl.y, id: cl.id, src: t.id });
|
||
}
|
||
}, { owner: unit });
|
||
battle.on('damaged', (ctx) => { // S3, ranged copies: attacks bind
|
||
const t = ctx.source;
|
||
if (!mine(t) || !t.mem.mlyss?.ranged || !unit.skill?.active || !ctx.dmg?.isAttack || !ctx.target.alive || !(bindDur > 0)) return;
|
||
battle.applyStatus(ctx.target, 'bind', { duration: bindDur, source: unit });
|
||
}, { owner: unit });
|
||
battle.on('hit', (ctx) => { // module: 援军 / 流形 take less damage from the enemies they block
|
||
const t = ctx.target, s = ctx.source;
|
||
if (!t || !s || s.side !== 'enemy' || s.blockedBy !== t) return;
|
||
if (mine(t)) { // 流形 (her 援军): token module part, unless its token kit (content/tokens.js) applies it
|
||
if (!t.kit?.fromTokens) { const ds = num(tokTal(t, 'damage_scale').damage_scale, 1); if (ds > 0 && ds < 1) ctx.dmg.mul *= ds; }
|
||
} else if (reinfCut > 0 && t === unit.trait?.reinforcement) ctx.dmg.mul *= 1 - reinfCut; // engine 援军
|
||
}, { owner: unit });
|
||
// module 落叶四季: "援军阻挡的敌人更容易受到我方的攻击" — the 流形's module part (token talent taunt_level: +1) on
|
||
// the enemies it blocks: our operators pick higher-taunt enemies first (targeting.js sortEnemyTargets)
|
||
battle.every(0.25, () => {
|
||
for (const t of battle.allyUnits) {
|
||
if (!mine(t) || !live(t) || !t.blocking.length) continue;
|
||
const lv = num(tokTal(t, 'taunt_level').taunt_level);
|
||
if (!(lv > 0)) continue;
|
||
for (const e of t.blocking) if (e.alive) battle.addBuff(e, { key: 'mlyss:exposed', mods: { taunt: lv }, duration: 0.4, refresh: 'replace', source: t });
|
||
}
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 开源节流: 莱茵生命 ops of the owner cost less DP to redeploy
|
||
const S = bstate(battle);
|
||
S.rhine ??= new Set();
|
||
if (S.rhine.has(unit.ownerId)) return;
|
||
S.rhine.add(unit.ownerId);
|
||
const cut = num(t1.cost), first = num(t1.runtime_cost);
|
||
battle.on('battleStart', () => {
|
||
const rh = battle.allyUnits.filter((a) => a.kind === 'op' && a.ownerId === unit.ownerId && RHINE.has(a.def?.charId)).sort((a, b) => a.deploySeq - b.deploySeq);
|
||
rh.forEach((a, i) => { a.base.cost = Math.max(0, a.base.cost + cut + (i === 0 ? first : 0)); });
|
||
});
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 迷迭香 chess_char_6_12 (投掷手) — S2 末梢阻断; 歼灭战装备; 感知稳定
|
||
|
||
const ROSMON_SPLASH_MUL = 1.3; // [ASSUMED] "溅射范围扩大" (not in data)
|
||
|
||
function rosmon(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1);
|
||
const sid = selectedSkill(chess, def);
|
||
const prob = num(bb['attack@prob']), stun = num(bb['attack@stun']);
|
||
const gearId = (chess?.tokens || []).find((t) => /rosmon_shield/.test(String(t))) || 'token_10012_rosmon_shield';
|
||
const skills = {
|
||
// S1 思维膨大 (attack SP): the next attack also deals extra_atk_scale × ATK arts to every enemy it hits
|
||
skchr_rosmon_1: {
|
||
kind: 'instant',
|
||
attack: {
|
||
onEachHit({ battle, unit, target, kind }) {
|
||
if (!target || !target.alive || (kind !== 'main' && kind !== 'splash')) return;
|
||
battle.dealDamage(unit, target, { amount: unit.s.atk * num(bb.extra_atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] });
|
||
},
|
||
},
|
||
},
|
||
// S3 “如你所愿”: base attack time ×0.5 (akdata: 乘算负数), ATK +atk, 2 targets but blocked enemies only (install);
|
||
// two 战术装备 on melee tiles of her range (the token kit: appear stun, blocked enemies DEF −160, lifetime)
|
||
skchr_rosmon_3: {
|
||
kind: 'duration',
|
||
mods: { batPct: batOf(bb.base_attack_time, def, true), atkPct: num(bb.atk) },
|
||
targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 2))) },
|
||
onStart({ battle, unit }) {
|
||
let n = 0;
|
||
for (let i = 0; i < 2; i++) if (summonToken(battle, unit, gearId, 'melee')) n++;
|
||
battle.fx('summon', { x: unit.x, y: unit.y, id: unit.id, n });
|
||
},
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
install(battle, unit) {
|
||
if (sid !== 'skchr_rosmon_3') return;
|
||
battle.on('beforeAttack', (ctx) => { // "仅选择被阻挡的敌人作为目标"
|
||
if (ctx.attacker !== unit || !unit.skill?.active) return;
|
||
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile).filter((e) => !!e.blockedBy);
|
||
sortEnemyTargets(battle, unit, c, ctx.profile?.priority ?? null);
|
||
ctx.targets = c.slice(0, Math.max(1, Math.floor(num(ctx.profile?.maxTargets, 2))));
|
||
}, { owner: unit });
|
||
},
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: { batPct: batOf(bb.base_attack_time, def), atkPct: num(bb.atk) },
|
||
onStart({ unit }) {
|
||
const p = unit.profile;
|
||
unit.mem.rosSaved = { r: p.splashRadius, n: p.shockTimes };
|
||
p.splashRadius = num(p.splashRadius, 1) * ROSMON_SPLASH_MUL;
|
||
p.shockTimes = num(p.shockTimes, 2) + Math.floor(num(bb.add_times));
|
||
},
|
||
onEnd({ unit }) {
|
||
const s = unit.mem.rosSaved;
|
||
if (s) { unit.profile.splashRadius = s.r; unit.profile.shockTimes = s.n; }
|
||
unit.mem.rosSaved = null;
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // S2: attack & aftershock stun chance
|
||
battle.on('damaged', (ctx) => {
|
||
if (ctx.source !== unit || !unit.skill?.active || !(stun > 0) || !ctx.target.alive || ctx.target.side !== 'enemy') return;
|
||
if (!ctx.dmg?.isAttack && !ctx.dmg?.tags?.includes('aftershock')) return;
|
||
if (battle.rng() < prob) battle.applyStatus(ctx.target, 'stun', { duration: stun, source: unit });
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 歼灭战装备
|
||
const v = num(t0.def_penetrate_fixed);
|
||
if (v) battle.addBuff(unit, { key: 'rosmon:pierce', mods: { defIgnoreFlat: v }, persist: true, allowDead: true });
|
||
} },
|
||
{ install(battle, unit) { // 感知稳定
|
||
const atk = num(t1.atk);
|
||
battle.on('deploy', ({ unit: u }) => {
|
||
if (u !== unit || !atk) return;
|
||
battle.after(0, () => {
|
||
if (!live(unit)) return;
|
||
const casters = opsOf(battle, unit.ownerId).filter((a) => a.def?.profession === 'CASTER');
|
||
const pick = battle.rng.pick(casters);
|
||
if (!pick) return;
|
||
battle.addBuff(unit, { key: 'rosmon:stable', mods: { atkPct: atk } });
|
||
battle.addBuff(pick, { key: 'rosmon:stable', mods: { atkPct: atk } });
|
||
battle.fx('link', { x: pick.x, y: pick.y, id: pick.id, src: unit.id });
|
||
}, { owner: unit });
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 新约能天使 chess_char_6_13 (怪杰) — S2 开火成瘾症; 火力电台; 铳弹协约; module 新朋友圣城生活套组
|
||
|
||
const AIRSTRIKE_RADIUS = 1; // [ASSUMED] bombardment splash radius (not in data)
|
||
|
||
function angel2(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {};
|
||
const sid = selectedSkill(chess, def);
|
||
const steal = num(bb.steal), extra = Math.floor(num(bb.addtional_ammo_each));
|
||
const shieldRatio = num(bb.shield_max_hp_ratio), shieldDur = num(bb.shield_max_duration);
|
||
const coordId = (chess?.tokens || []).find((t) => /angel2_target/.test(String(t))) || TOKEN_IDS.deliveryTarget;
|
||
const perAttack = 5; // S3 "每次攻击消耗5发" (the 5 连击)
|
||
const coord = (battle, unit) => battle.allyUnits.find((t) => isTok(t, coordId, unit) && t.alive) ?? null;
|
||
const placeCoord = (battle, unit) => {
|
||
unit.mem.angelCoord = false;
|
||
if (coord(battle, unit)) return;
|
||
const t = summonToken(battle, unit, coordId, 'melee');
|
||
if (t) battle.fx('anchor', { x: t.x, y: t.y, id: t.id, src: unit.id });
|
||
};
|
||
const skills = {
|
||
// S1 天空大扫除: 8 bullets at attack@atk_scale × ATK, flyers first; stopped by hand ⇒ the remaining bullets are fired
|
||
// at random enemies of her range
|
||
skchr_angel2_1: {
|
||
kind: 'ammo',
|
||
ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 8))),
|
||
attack: { atkScale: num(bb['attack@atk_scale'], 1) },
|
||
targeting: { priority: 'fly' },
|
||
onAttack({ unit, skill }) { unit.mem.angelLeft = skill.ammoLeft - 1; },
|
||
onEnd({ battle, unit, skill, reason }) {
|
||
const left = Math.max(0, Math.floor(num(unit.mem.angelLeft)));
|
||
unit.mem.angelLeft = 0;
|
||
if (reason !== 'stopped' || !unit.alive || !left) return;
|
||
for (let i = 0; i < left; i++) {
|
||
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ANY);
|
||
const e = battle.rng.pick(c);
|
||
if (!e) break;
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['attack@atk_scale'], 1), type: 'phys', isAttack: true, isSkill: true, tags: ['skill', 'volley'] });
|
||
battle.emit('ammoUsed', { unit, left: left - i - 1, skill });
|
||
}
|
||
},
|
||
},
|
||
// S3 使命必达!: ATK +atk; attacks are 5 hits of attack@atk_scale × ATK spending 5 of the 50 bullets; at the start,
|
||
// with a 投递坐标 (placed after each deployment, install): attack@cannon_atk_scale × ATK physical splash there and the
|
||
// knocked-out ground operator with the longest remaining redeploy time lands on it with attack@sp SP
|
||
skchr_angel2_3: {
|
||
kind: 'ammo',
|
||
ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 50))),
|
||
mods: { atkPct: num(bb.atk) },
|
||
attack: { atkScale: num(bb['attack@atk_scale'], 1), hits: perAttack },
|
||
onStart({ battle, unit }) {
|
||
if (unit.mem.angelCoord) placeCoord(battle, unit);
|
||
const c = coord(battle, unit);
|
||
if (!c) return;
|
||
const r = c.tileR, col = c.tileC;
|
||
battle.fx('airstrike', { x: col, y: r, id: unit.id, r: AIRSTRIKE_RADIUS });
|
||
for (const e of battle.enemiesInRadius(col, r, AIRSTRIKE_RADIUS)) {
|
||
if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['attack@cannon_atk_scale'], 1), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'delivery'] });
|
||
}
|
||
const waiting = battle.allyUnits.filter((a) => a.kind === 'op' && a.ownerId === unit.ownerId && !a.alive && !a.removed && a !== unit
|
||
&& a.def?.position !== 'RANGED' && battle.grid.canStand(r, col, { ranged: false }));
|
||
if (!waiting.length) return;
|
||
waiting.sort((a, b) => (b.respawnAt ?? 0) - (a.respawnAt ?? 0) || b.base.respawnTime - a.base.respawnTime || a.id - b.id);
|
||
const a = waiting[0];
|
||
battle.retreat(c, { reason: 'expired', permanent: true });
|
||
if (battle.redeploy(a, { free: true, tile: [r, col] })) {
|
||
if (a.skill) a.skill.gainSp(num(bb['attack@sp']), 'skill');
|
||
battle.fx('appear', { x: col, y: r, id: a.id, src: unit.id });
|
||
}
|
||
},
|
||
onAttack({ battle, unit, skill }) { // bullets 2..5 of the 5 连击 (the engine spends the 1st)
|
||
for (let i = 1; i < perAttack && skill.ammoLeft > 1; i++) {
|
||
skill.ammoLeft--;
|
||
battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill });
|
||
}
|
||
},
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
install(battle, unit) {
|
||
if (sid === 'skchr_angel2_1') { // bullets loaded at the start (after every skillStart ammo bonus: 逃犯引渡手续 …)
|
||
battle.on('skillStart', ({ unit: u, skill }) => { if (u === unit) unit.mem.angelLeft = skill.ammoLeft; }, { owner: unit, priority: -500 });
|
||
}
|
||
if (sid !== 'skchr_angel2_3') return;
|
||
// "部署后获得投递坐标": one coordinate per deployment, placed (the player's choice in the original) on a ground tile
|
||
// of her range on the enemy path next to the first enemy that enters her range (at the latest when S3 starts)
|
||
battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.angelCoord = !coord(battle, unit); }, { owner: unit });
|
||
battle.on('tick', () => {
|
||
if (!unit.mem.angelCoord || !live(unit) || !battle.enemiesInKeys(unit.rangeKeys, unit, ANY).length) return;
|
||
placeCoord(battle, unit);
|
||
}, { owner: unit });
|
||
},
|
||
skill: {
|
||
kind: 'ammo',
|
||
ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 10))),
|
||
mods: { batPct: batOf(bb.base_attack_time, def) },
|
||
attack: { atkScale: num(bb['attack@atk_scale'], 1) },
|
||
onStart({ battle, unit, skill }) {
|
||
const cands = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op' && live(a));
|
||
cands.sort((a, b) => b.s.aspd - a.s.aspd || a.deploySeq - b.deploySeq);
|
||
const victim = steal > 0 ? cands[0] ?? null : null;
|
||
unit.mem.angelVictim = victim;
|
||
decayingShield(battle, unit, 'angel2:barrier', unit.s.maxHp * shieldRatio, shieldDur);
|
||
if (victim) {
|
||
battle.addBuff(victim, { key: 'angel2:stolen', mods: { aspd: -steal }, source: unit, visible: true });
|
||
battle.addBuff(unit, { key: 'angel2:steal', mods: { aspd: steal } });
|
||
decayingShield(battle, victim, 'angel2:barrier', victim.s.maxHp * shieldRatio, shieldDur);
|
||
if (extra > 0) skill.addAmmo(extra);
|
||
battle.fx('steal', { x: victim.x, y: victim.y, id: victim.id, src: unit.id });
|
||
}
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
const v = unit.mem.angelVictim;
|
||
unit.mem.angelVictim = null;
|
||
if (v) battle.removeBuff(v, 'angel2:stolen');
|
||
battle.removeBuff(unit, 'angel2:steal');
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 火力电台
|
||
const hr = num(t0.hp_ratio), prob = num(t0.prob), sc = num(t0.aoe_atk_scale, num(t0.damage_scale));
|
||
battle.on('ammoUsed', ({ unit: u }) => {
|
||
if (!live(unit) || !u || u.side !== 'ally') return;
|
||
if (hr > 0) battle.heal(unit, unit, unit.s.maxHp * hr, { self: true, silent: true });
|
||
if (!(prob > 0) || !(sc > 0) || battle.rng() >= prob) return;
|
||
const cands = battle.enemiesInKeys(u.rangeKeys || [], u, ANY);
|
||
if (!cands.length) return;
|
||
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, 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 });
|
||
} },
|
||
{ install(battle, unit) { // 铳弹协约
|
||
const atk = num(t1.atk), mult = num(t1.mult, 1);
|
||
if (atk) aura(battle, unit, 0.5, () => {
|
||
for (const a of battle.allyUnits) {
|
||
if (!live(a) || a.kind !== 'op' || !a.skill || a.skill.kind !== 'ammo') continue;
|
||
battle.addBuff(a, { key: 'angel2:covenant', mods: { atkPct: atk * (hasBond(a, 'lateranoShip') ? mult : 1) }, duration: 0.75, refresh: 'replace' });
|
||
}
|
||
});
|
||
} },
|
||
{ install(battle, unit) { // elite module: HP > 80 % ⇒ SP +0.25/s
|
||
const thr = bv(tb, 'hp_ratio', 0), spr = bv(tb, 'sp_recovery_per_sec', 0);
|
||
if (spr > 0 && tb['angel2_tr[e].hp_ratio'] != null) aura(battle, unit, 0.25, () => {
|
||
if (unit.hpRatio > num(tb['angel2_tr[e].hp_ratio'], thr)) battle.addBuff(unit, { key: 'angel2:calm', mods: { spRecoveryFlat: spr }, duration: 0.4, refresh: 'replace' });
|
||
});
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 流明 chess_char_6_14 (疗养师) — S3 灯火不灭; 凡人之愿; 应急处理; module 纯铜单筒望远镜
|
||
|
||
function lumen(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {};
|
||
const isDef = onDefaultSkill(chess);
|
||
const hs = num(bb.heal_scale, 1);
|
||
const abnormalAllies = (battle, unit) => battle.alliesInGrid(unit).filter((a) => hasAbnormal(a)).sort((a, b) => a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq);
|
||
const cleanse = (battle, t) => { cleanseAbnormal(battle, t); };
|
||
const skills = {
|
||
// S1 沐雨: the next heal also gives its target and the allies around it (8 tiles) aura.heal_scale × ATK healing
|
||
// per second for aura.projectile_life_time s
|
||
skchr_lumen_1: {
|
||
kind: 'instant',
|
||
heal: true,
|
||
attack: { healScale: 1 },
|
||
onHit({ battle, unit, target }) {
|
||
if (!target) return;
|
||
const sc = bv(bb, 'heal_scale'), life = bv(bb, 'projectile_life_time', 4), iv = Math.max(0.2, bv(bb, 'interval', 1));
|
||
if (!(sc > 0) || !(life > 0)) return;
|
||
battle.fx('healField', { x: target.x, y: target.y, id: unit.id });
|
||
for (const a of battle.alliesInRadius(target.x, target.y, 1.5, target.ownerId)) {
|
||
if (a.kind === 'device') continue;
|
||
battle.addBuff(a, {
|
||
key: `lumen:rain:${unit.id}`, duration: life, interval: iv, refresh: 'replace', visible: true, source: unit,
|
||
onTick: ({ unit: x }) => { if (x.hp < x.s.maxHp) battle.heal(unit, x, unit.s.atk * sc, { hot: true }); },
|
||
});
|
||
}
|
||
},
|
||
},
|
||
// S2 沛霖 (2 charges): heal up to max_target allies of her range for heal_scale × ATK; cast with full charges ⇒
|
||
// also cleanse their 异常状态 (蓄力额外效果)
|
||
skchr_lumen_2: {
|
||
kind: instantKind(def),
|
||
heal: true,
|
||
onStart({ battle, unit, skill }) {
|
||
const full = skill.maxCharges > 1 && skill.charges >= skill.maxCharges - 1; // (this cast already spent one)
|
||
const inj = battle.injuredAlliesInKeys(unit.rangeKeys, unit);
|
||
const abn = full ? abnormalAllies(battle, unit).filter((a) => !inj.includes(a)) : [];
|
||
const tgts = inj.concat(abn).slice(0, Math.max(1, Math.floor(num(bb.max_target, 2))));
|
||
for (const t of tgts) {
|
||
battle.heal(unit, t, unit.s.atk * num(bb.heal_scale, 1), { skillHeal: true });
|
||
if (full) cleanse(battle, t);
|
||
}
|
||
battle.fx('heal', { x: unit.x, y: unit.y, id: unit.id, n: tgts.length, charged: full });
|
||
},
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
skill: {
|
||
kind: 'ammo',
|
||
heal: true,
|
||
ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 4))),
|
||
mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) },
|
||
attack: { healScale: 1 },
|
||
onHit({ battle, unit, target }) { if (unit.mem.lumenAbn && target) cleanse(battle, target); },
|
||
onAttack(ctx) { // only heals on abnormal targets consume a bullet (engine: ctx.noAmmo — no bullet, no ammoUsed)
|
||
if (!ctx.unit.mem.lumenAbn) ctx.noAmmo = true;
|
||
ctx.unit.mem.lumenAbn = false;
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // S3 targeting / bullet bookkeeping
|
||
if (!isDef) return;
|
||
battle.on('beforeAttack', (ctx) => {
|
||
if (ctx.attacker !== unit || !unit.skill?.active) return;
|
||
const abn = abnormalAllies(battle, unit);
|
||
unit.mem.lumenAbn = abn.length > 0;
|
||
if (abn.length) ctx.targets = [abn[0]];
|
||
if (ctx.profile && ctx.profile !== unit.profile) ctx.profile.healScale = abn.length ? hs : 1;
|
||
}, { owner: unit });
|
||
battle.on('tick', () => { // abnormal allies at full HP are healed too
|
||
const sk = unit.skill;
|
||
if (!sk?.active || !unit.canAct || unit.atkCd > 0 || unit.s.flags.disarm) return;
|
||
if (battle.injuredAlliesInKeys(unit.rangeKeys, unit).length) return;
|
||
const abn = abnormalAllies(battle, unit);
|
||
if (!abn.length) return;
|
||
battle.forceAttack(unit, [abn[0]]);
|
||
unit.atkCd = unit.s.interval;
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 凡人之愿: healed targets gain 抵抗
|
||
const base = bv(t0, 'status_resistance[limit]', 0), special = num(t0['lumen_t_1[special].status_resistance[limit]'], base);
|
||
const res = -num(t0.one_minus_status_resistance, -0.5), thr = num(t0.hp_ratio, 0.75);
|
||
battle.on('heal', (ctx) => {
|
||
const t = ctx.target;
|
||
if (ctx.source !== unit || !t || !(res > 0) || !(base > 0)) return;
|
||
const after = Math.min(t.s.maxHp, t.hp + ctx.amount);
|
||
battle.applyStatus(t, 'resist', { duration: after / t.s.maxHp > thr ? special : base, value: Math.min(1, res), source: unit });
|
||
}, { owner: unit, priority: -10 });
|
||
} },
|
||
{ install(battle, unit) { // 应急处理
|
||
const sc = num(t1.heal_scale), cd = num(t1.duration, 12);
|
||
let ready = -Infinity;
|
||
battle.on('statusApplied', (ctx) => {
|
||
const t = ctx.target;
|
||
if (!live(unit) || !t || t.side !== 'ally' || !ABNORMAL.has(ctx.status) || battle.time < ready || !(sc > 0)) return;
|
||
if (!unit.rangeKeySet?.has(t.tileR * COLS + t.tileC)) return;
|
||
ready = battle.time + cd;
|
||
battle.heal(unit, t, unit.s.atk * sc);
|
||
battle.fx('emergency', { x: t.x, y: t.y, id: t.id, src: unit.id });
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // elite module: taunt −1 and 抵抗
|
||
const taunt = num(tb.taunt_level), res = -num(tb.one_minus_status_resistance);
|
||
if (taunt) battle.addBuff(unit, { key: 'lumen:scope', mods: { taunt }, persist: true, allowDead: true });
|
||
// a permanent 抵抗 (survives death/redeploy): any buff with status 'resist' counts (engine resistOf — the
|
||
// strongest one applies, never compounding with 凡人之愿 / 灵知)
|
||
if (res > 0) battle.addBuff(unit, { key: 'lumen:scopeResist', status: 'resist', visible: true, persist: true, allowDead: true, data: { value: Math.min(1, res) } });
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 仇白 chess_char_6_15 (领主) — S3 问雪; 入隙; 落英; module 雪浸过的斗笠
|
||
|
||
function qiubai(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {};
|
||
const extraTargets = parseN(def?.skill?.description, /额外攻击(\d+)个目标/, 0);
|
||
const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null;
|
||
const gapScale = t0.atk_scale_t != null ? num(t0.atk_scale_t) : num(t0.atk_scale), bothMul = t0.atk_scale_t != null ? num(t0.value, 1) : 1;
|
||
const modArts = num(tb.atk_scale_m);
|
||
// module 欲雪时: 落英 "首次命中敌人时提升至100%且束缚时间提升至3秒" (duration_advanced); trait "攻击范围内存在2名及以上敌人时
|
||
// 攻击速度+12" (talent-0 part cnt / attack_speed)
|
||
const firstBind = num(t1.duration_advanced), hitOnce = new WeakSet();
|
||
const crowdCnt = num(t0.cnt), crowdAs = num(t0.attack_speed);
|
||
const skillGridQ = skillGridOf(def);
|
||
const groundIn = (battle, unit) => enemiesIn(battle, unit).filter((e) => !e.isFlying);
|
||
const skills = {
|
||
// S1 留羽 (attack SP): the next attack binds its target `duration` s; when that bind ends the target and the enemies
|
||
// near it take aoe_scale × ATK arts
|
||
skchr_qiubai_1: {
|
||
kind: 'instant',
|
||
attack: {
|
||
onHit({ battle, unit, target }) {
|
||
if (!target || !target.alive) return;
|
||
const dur = num(bb.duration, 2);
|
||
battle.applyStatus(target, 'bind', { duration: dur, source: unit });
|
||
const seq = unit.deploySeq;
|
||
battle.after(dur, () => {
|
||
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, 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 });
|
||
},
|
||
},
|
||
},
|
||
// S2 承影 (attack SP, 5 s): sword_begin_atk_scale × ATK arts on the ground enemies of the front range at the start,
|
||
// skill range + ATK +atk, ground enemies in range 停顿, sword_end_atk_scale × ATK physical on them at the end
|
||
skchr_qiubai_2: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk) },
|
||
...(skillGridQ ? { targeting: { rangeGrid: skillGridQ } } : {}),
|
||
onStart({ battle, unit }) {
|
||
battle.fx('sword', { x: unit.x, y: unit.y, id: unit.id });
|
||
for (const e of groundIn(battle, unit)) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.sword_begin_atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] });
|
||
},
|
||
onTick({ battle, unit }) { for (const e of groundIn(battle, unit)) battle.applyStatus(e, 'sluggish', { duration: 0.25, source: unit }); },
|
||
onEnd({ battle, unit, reason }) {
|
||
if (reason === 'death' || !unit.alive) return;
|
||
for (const e of groundIn(battle, unit)) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.sword_end_atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
|
||
},
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
talents: crowdCnt > 0 && crowdAs ? [{ install(battle, unit) {
|
||
aura(battle, unit, 0.25, () => {
|
||
if (enemiesIn(battle, unit).length >= crowdCnt) battle.addBuff(unit, { key: 'qiubai:crowd', mods: { aspd: crowdAs }, duration: 0.4, refresh: 'replace' });
|
||
});
|
||
} }] : [],
|
||
trait: {
|
||
afterHit(battle, unit, target) {
|
||
if (!target || !target.alive) return;
|
||
if (modArts > 0) battle.dealDamage(unit, target, { amount: unit.s.atk * modArts, type: 'arts', tags: ['module'] });
|
||
const slug = !!target.findBuff('sluggish'), bind = !!(target.findBuff('bind') || target.s.flags.bind);
|
||
if ((slug || bind) && gapScale > 0 && target.alive) { // 入隙
|
||
battle.dealDamage(unit, target, { amount: unit.s.atk * gapScale * (slug && bind ? bothMul : 1), type: 'arts', tags: ['talent'] });
|
||
}
|
||
if (!target.alive) return;
|
||
if (firstBind > 0 && !hitOnce.has(target)) { // 落英 (module): first hit on an enemy
|
||
hitOnce.add(target);
|
||
battle.applyStatus(target, 'bind', { duration: firstBind, source: unit });
|
||
return;
|
||
}
|
||
if (num(t1.prob) > 0 && battle.rng() < num(t1.prob)) battle.applyStatus(target, 'bind', { duration: num(t1.duration), source: unit }); // 落英
|
||
},
|
||
},
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk) },
|
||
targeting: { maxTargets: 1 + extraTargets, ...(skillGrid ? { rangeGrid: skillGrid } : {}) },
|
||
attack: { dmgType: 'arts', dmgMul: () => 1 },
|
||
onStart({ unit }) { unit.mem.qbStack = 0; },
|
||
onAttack({ battle, unit }) {
|
||
const max = Math.floor(num(bb.max_stack_cnt));
|
||
if (unit.mem.qbStack >= max) return;
|
||
unit.mem.qbStack++;
|
||
battle.addBuff(unit, { key: 'qiubai:snow', mods: { aspd: num(bb.attack_speed) * unit.mem.qbStack } });
|
||
},
|
||
onEnd({ battle, unit }) { battle.removeBuff(unit, 'qiubai:snow'); unit.mem.qbStack = 0; },
|
||
},
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 溯光星源 chess_char_6_16 (凝滞师) — S3 并流连锁; 数据建模; 能源解析
|
||
|
||
function halo2(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), mod = moduleBb(chess);
|
||
const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def);
|
||
const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null;
|
||
const n = Math.max(1, Math.floor(num(bb['attack@max_target'], 1))), share = num(bb['attack@atk_share']);
|
||
const slug = (unit) => unit.profile?.onHitStatus?.key === 'sluggish' ? num(unit.profile.onHitStatus.duration) : 0;
|
||
const skills = {
|
||
// S1 星图闪烁: ATK +atk; every attack then bounces attack@chain.max_target times between enemies (back and forth
|
||
// allowed, never twice in a row on one; attack@projectile_range tiles), each bounce a full hit with her 停顿
|
||
skchr_halo2_1: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk) },
|
||
attack: {
|
||
onHit({ battle, unit, target }) {
|
||
if (!target) return;
|
||
const jumps = Math.max(0, Math.floor(num(bb['attack@chain.max_target'], 3))), R = num(bb['attack@projectile_range'], 1.7);
|
||
let prev = target, px = target.x, py = target.y;
|
||
for (let i = 0; i < jumps; i++) {
|
||
let best = null, bd = Infinity;
|
||
for (const e of battle.enemiesInRadius(px, py, R)) {
|
||
if (e === prev || !canTargetEnemy(unit, e, ANY)) continue;
|
||
const d = bodyDist(e, px, py);
|
||
if (d < bd - 1e-9) { bd = d; best = e; }
|
||
}
|
||
if (!best) break;
|
||
battle.dealDamage(unit, best, { amount: unit.s.atk, type: 'arts', isAttack: true, isSkill: true, tags: ['chain'] });
|
||
const sd = slug(unit);
|
||
if (sd > 0 && best.alive) battle.applyStatus(best, 'sluggish', { duration: sd, source: unit });
|
||
prev = best; px = best.x; py = best.y;
|
||
}
|
||
},
|
||
},
|
||
},
|
||
// S2 星束引力 (attack SP): the next attack targets the enemy of her range farthest from its goal (install), atk_scale
|
||
// × ATK arts with a `sluggish` s 停顿, and links up to max_target enemies within ability_range_radius of it: pulled
|
||
// toward it (force) and hit by atk_scale_link × ATK arts
|
||
skchr_halo2_2: {
|
||
kind: 'instant',
|
||
attack: {
|
||
atkScale: num(bb.atk_scale, 1),
|
||
onHitStatus: { key: 'sluggish', duration: num(bb.sluggish, 3) },
|
||
onHit({ battle, unit, target }) {
|
||
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) => 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] });
|
||
pullToward(battle, { x: target.x, y: target.y }, e, num(bb.force), 0.3);
|
||
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale_link, num(bb.atk_scale, 1)), type: 'arts', isSkill: true, tags: ['skill', 'link'] });
|
||
}
|
||
},
|
||
},
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
install(battle, unit) {
|
||
if (sid !== 'skchr_halo2_2') return;
|
||
battle.on('beforeAttack', (ctx) => { // "选择攻击范围内距离目标点最远的1个敌人为目标"
|
||
if (ctx.attacker !== unit || !unit.skill?.active || !unit.skill.pending) return;
|
||
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
|
||
if (!c.length) return;
|
||
let best = c[0];
|
||
for (const e of c) if (battle.remainingDistance(e) > battle.remainingDistance(best) + 1e-9) best = e;
|
||
ctx.targets = [best];
|
||
}, { owner: unit });
|
||
},
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk), batPct: batOf(bb.base_attack_time, def) },
|
||
targeting: { maxTargets: n, ...(skillGrid ? { rangeGrid: skillGrid } : {}) },
|
||
onStart({ unit }) { unit.mem.haloLocks = []; },
|
||
onEnd({ unit }) { unit.mem.haloLocks = []; },
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // S3: persistent locks + damage transfer between linked targets
|
||
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) && 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);
|
||
const fresh = ctx.targets.some((e) => !(unit.mem.haloLocks || []).includes(e));
|
||
unit.mem.haloLocks = ctx.targets.slice();
|
||
if (fresh && ctx.targets.length > 1) battle.fx('link', { x: ctx.targets[0].x, y: ctx.targets[0].y, id: unit.id, ids: ctx.targets.map((e) => e.id) });
|
||
}, { owner: unit });
|
||
battle.on('hit', (ctx) => {
|
||
const locks = unit.mem.haloLocks;
|
||
if (!(share > 0) || !unit.skill?.active || !locks || locks.length < 2 || ctx.dmg.type !== 'arts' || ctx.dmg.tags.includes('link')) return;
|
||
if (!locks.includes(ctx.target) || unit.mem.haloLinking) return;
|
||
// re-entrancy guard: a transferred hit (or anything it causes, e.g. enemy damage sharing) never re-links
|
||
unit.mem.haloLinking = true;
|
||
try {
|
||
for (const o of locks) if (o !== ctx.target && o.alive) {
|
||
battle.dealDamage(ctx.source ?? unit, o, { amount: ctx.dmg.amount * share, type: 'arts', canDodge: false, isSkill: true, tags: ['skill', 'link'] });
|
||
}
|
||
} finally { unit.mem.haloLinking = false; }
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 数据建模 (+ module ATK at max stacks)
|
||
const as = num(t0.attack_speed), max = Math.max(1, Math.floor(num(t0.max_stack_cnt, 18))), fullAtk = num(mod.atk);
|
||
battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.haloStack = 0; }, { owner: unit });
|
||
battle.on('statusApplied', (ctx) => {
|
||
if (ctx.source !== unit || ctx.status !== 'sluggish' || !live(unit) || (unit.mem.haloStack || 0) >= max) return;
|
||
unit.mem.haloStack = (unit.mem.haloStack || 0) + 1;
|
||
const k = unit.mem.haloStack;
|
||
battle.addBuff(unit, { key: 'halo2:model', mods: { aspd: as * k, atkPct: k >= max ? fullAtk : 0 } });
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 能源解析
|
||
const base = bv(t1, 'damage_scale', 1), maxS = bv(t1, 'damage_scale_max', base), iv = bv(t1, 'interval', 7);
|
||
const stay = new WeakMap();
|
||
aura(battle, unit, 0.25, () => {
|
||
const inR = enemiesIn(battle, unit);
|
||
for (const e of battle.enemies) if (!inR.includes(e)) stay.delete(e);
|
||
for (const e of inR) {
|
||
const t = (stay.get(e) ?? 0) + 0.25;
|
||
stay.set(e, t);
|
||
const v = t > iv ? maxS : base;
|
||
// 脆弱 (ba.fragile, 同名效果取最高): the catalogue status, refreshed while the enemy stays in range
|
||
if (v > 1) battle.applyStatus(e, 'fragile', { duration: 0.4, value: v - 1, source: unit });
|
||
}
|
||
});
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 耀骑士临光 chess_char_6_17 (无畏者) — S3 耀阳颔首; 不畏苦暗; 破晓; module 耀阳锋刃
|
||
|
||
function nearl2(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {};
|
||
const t0Grid = def?.talents?.[0]?.rangeGrid?.length ? def.talents[0].rangeGrid : N4;
|
||
const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null;
|
||
const tokId = (chess?.tokens || []).find((t) => /nearl2_sword/.test(String(t))) || 'token_10019_nearl2_sword';
|
||
const sunBurst = (battle, unit, r, c, scale, stun) => {
|
||
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 || !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 });
|
||
}
|
||
};
|
||
const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def);
|
||
const lastOps = { map: null };
|
||
const skills = {
|
||
// S1 灿焰长刃 (toggle, 持续时间无限): skill range, ATK / ASPD +
|
||
skchr_nearl2_1: {
|
||
kind: 'toggle',
|
||
mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) },
|
||
...(skillGrid ? { targeting: { rangeGrid: skillGrid } } : {}),
|
||
},
|
||
// S2 逐夜烁光 (passive, ON_DEPLOY): for the skill duration after each deployment ATK +atk and `times` 护盾 layers
|
||
// (whole hits negated); then she withdraws and this redeploy time is ×respawn_time (×1 when the operator deployed
|
||
// right before her is 【卡西米尔】)
|
||
skchr_nearl2_2: {
|
||
kind: 'passive',
|
||
onStart({ battle, unit, skill }) {
|
||
const dur = num(skill.duration, num(def?.skill?.duration, 22));
|
||
if (!(dur > 0)) return;
|
||
const prev = lastOps.map?.get(unit.ownerId);
|
||
const combo = !!prev && prev !== unit && hasBond(prev, 'kazimierzShip');
|
||
battle.addBuff(unit, { key: 'nearl2:night', mods: { atkPct: num(bb.atk) }, duration: dur, visible: true });
|
||
const hits = Math.floor(num(bb.times));
|
||
if (hits > 0) battle.addBuff(unit, { key: 'nearl2:shield', shieldHits: hits, duration: dur, visible: true });
|
||
const seq = unit.deploySeq;
|
||
battle.after(dur, () => {
|
||
if (!live(unit) || unit.deploySeq !== seq) return;
|
||
battle.retreat(unit, { reason: 'retreat' });
|
||
const mul = combo ? num(bb['nearl2_s_2[withdraw][combo].respawn_time'], 1) : num(bb.respawn_time, 1);
|
||
if (Number.isFinite(unit.respawnAt) && mul !== 1) unit.respawnAt = battle.time + (unit.respawnAt - battle.time) * mul;
|
||
battle.fx('disappear', { x: unit.x, y: unit.y, id: unit.id, combo });
|
||
}, { owner: unit });
|
||
},
|
||
},
|
||
};
|
||
// module “骑士家族”: "被击倒时不撤退且回复所有生命但生命上限-60%,攻击速度+30(单次部署只触发1次)" and 不畏苦暗
|
||
// "部署时及首次被击倒时…" (the burst again)
|
||
const standVal = num(tb.value), standAs = num(tb.attack_speed), standHp = num(tb.hp_ratio, 1);
|
||
return {
|
||
skills,
|
||
install(battle, unit) {
|
||
if (sid === 'skchr_nearl2_2') lastOps.map = ensureDeployTracker(battle);
|
||
if (standVal > 0) {
|
||
battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.nearlStood = false; }, { owner: unit });
|
||
battle.on('fatal', (ctx) => {
|
||
if (ctx.unit !== unit || ctx.prevented || unit.mem.nearlStood) return;
|
||
ctx.prevented = true;
|
||
unit.mem.nearlStood = true;
|
||
battle.addBuff(unit, { key: 'nearl2:stand', mods: { hpMul: Math.max(0.05, 1 - standVal), aspd: standAs }, visible: true });
|
||
unit.hp = Math.max(1, unit.s.maxHp * standHp);
|
||
battle.fx('undying', { x: unit.x, y: unit.y, id: unit.id });
|
||
battle.emit('nearl2:knockdown', { unit });
|
||
}, { owner: unit, priority: -60 });
|
||
}
|
||
},
|
||
trait: num(tb.atk_scale, 1) > 1 ? { dmgMul: (b, u, t) => (t.blockedBy ? num(tb.atk_scale, 1) : 1) } : null,
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk), defPct: num(bb.def) },
|
||
...(skillGrid ? { targeting: { rangeGrid: skillGrid } } : {}),
|
||
onStart({ battle, unit, skill }) {
|
||
const tile = bestTile(battle, freeTiles(battle, unit, N4));
|
||
const sword = tile ? battle.spawnToken(unit, tokId, tile[0], tile[1], { duration: skill.timeLeft }) : null;
|
||
unit.mem.sun = sword;
|
||
if (sword) battle.fx('summon', { x: sword.x, y: sword.y, id: sword.id, src: unit.id });
|
||
// the token kit (content/tokens.js) performs the appear burst; without it (or without a free tile) do it here
|
||
if (!sword || !sword.kit?.fromTokens) {
|
||
sunBurst(battle, unit, sword ? sword.tileR : unit.tileR, sword ? sword.tileC : unit.tileC, num(bb.value), num(bb.value2));
|
||
}
|
||
},
|
||
onEnd({ battle, unit }) {
|
||
const s = unit.mem.sun;
|
||
unit.mem.sun = null;
|
||
if (s && s.alive) battle.retreat(s, { reason: 'expired', permanent: true });
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // S3: attacks on units blocked by her or 耀阳 deal true damage
|
||
if (!isDef) return;
|
||
battle.on('hit', (ctx) => {
|
||
if (ctx.source !== unit || !unit.skill?.active || !ctx.dmg.isAttack) return;
|
||
const b = ctx.target.blockedBy;
|
||
if (b && (b === unit || b === unit.mem.sun)) ctx.dmg.type = 'true';
|
||
}, { owner: 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) => {
|
||
const foes = battle.unitsInGrid(unit, t0Grid, { side: 'enemy' });
|
||
if (foes.length) battle.fx('sunBurst', { x: unit.x, y: unit.y, id: unit.id });
|
||
for (const e of foes) {
|
||
for (let i = 0; i < times && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'true', tags: ['talent'] });
|
||
if (stun > 0 && e.alive) battle.applyStatus(e, 'stun', { duration: stun, source: unit });
|
||
}
|
||
};
|
||
const kazTimes = () => { const prev = last.get(unit.ownerId); return prev && prev !== unit && hasBond(prev, 'kazimierzShip') ? 2 : 1; };
|
||
battle.on('deploy', ({ unit: u }) => {
|
||
if (u !== unit || !(sc > 0)) return;
|
||
unit.mem.nearlDawnTimes = kazTimes();
|
||
dawn(unit.mem.nearlDawnTimes);
|
||
}, { owner: unit });
|
||
// module “骑士家族”: "…及首次被击倒时" — the burst again when the trait keeps her standing
|
||
battle.on('nearl2:knockdown', ({ unit: u }) => { if (u === unit && sc > 0) dawn(unit.mem.nearlDawnTimes ?? 1); }, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 破晓
|
||
const p = num(t1.def_penetrate);
|
||
if (p) battle.addBuff(unit, { key: 'nearl2:dawn', mods: { defIgnorePct: p }, persist: true, allowDead: true });
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 荒芜拉普兰德 chess_char_6_18 (驭械术师) — S3 终幕·浩劫; 头狼; 叙拉古的荣幸
|
||
|
||
function whitw2(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1);
|
||
const sid = selectedSkill(chess, def);
|
||
const speed = Math.max(0.1, num(bb['attack@projectile_move_speed'], 1)), R = num(bb['attack@range_radius'], 0.9);
|
||
const dmgScale = num(bb['attack@magic_atk_scale'], 1), fear = num(bb['attack@fear']), slow = num(bb['attack@move_speed']);
|
||
// "非移动敌人": blocked, or not walking (stunned, bound, asleep, waiting, speed 0 …)
|
||
const still = (e) => !!e.blockedBy || e.moving === false || !(e.s.moveSpeed > 0) || !!(e.s.flags.stun || e.s.flags.freeze || e.s.flags.bind || e.s.flags.sleep || e.s.flags.noMove);
|
||
const skillGridW = skillGridOf(def);
|
||
const skills = {
|
||
// S1 慵怠者悲鸣: passive 浮游单元+1 (trait: one more hit per attack); toggled on: ATK +atk and the drones lock a random
|
||
// non-moving enemy anywhere on the field (re-locking when it moves or falls; install), else her range
|
||
skchr_whitw2_1: {
|
||
kind: 'toggle',
|
||
mods: { atkPct: num(bb.atk) },
|
||
targeting: { rangeGrid: WHOLE_FIELD },
|
||
onStart({ unit }) { unit.mem.lazyLock = null; },
|
||
},
|
||
// S2 逐猎狂飙: 浮游单元+attack@cnt, skill range, ATK +atk: every drone locks a random enemy of the range until it falls
|
||
// (install); each drone hit ramps on its own target (trait init → max) and fears it attack@fear s with attack@prob
|
||
skchr_whitw2_2: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk) },
|
||
...(skillGridW ? { targeting: { rangeGrid: skillGridW } } : {}),
|
||
attack: {
|
||
hits: 1,
|
||
dmgMul(battle, unit, target) {
|
||
const f = unit.profile?.funnel || { init: 0.2, delta: 0.15, max: 1.1 };
|
||
const m = unit.mem.hunt;
|
||
if (!m) return f.init;
|
||
const v = m.ramp.has(target.id) ? Math.min(f.max, m.ramp.get(target.id) + f.delta) : f.init;
|
||
m.ramp.set(target.id, v);
|
||
return v;
|
||
},
|
||
onEachHit({ battle, unit, target, kind }) {
|
||
if (kind !== 'main' || !target || !target.alive || !(num(bb['attack@fear']) > 0)) return;
|
||
if (battle.rng() < num(bb['attack@prob'])) battle.applyStatus(target, 'fear', { duration: num(bb['attack@fear']), source: unit });
|
||
},
|
||
},
|
||
onStart({ unit }) { unit.mem.hunt = { locks: [], ramp: new Map() }; },
|
||
onEnd({ unit }) { unit.mem.hunt = null; },
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
// S1 "被动效果:浮游单元+1": one more drone hit with every normal attack
|
||
trait: sid === 'skchr_whitw2_1' ? { hits: 2 } : null,
|
||
install(battle, unit) {
|
||
if (sid === 'skchr_whitw2_1') {
|
||
battle.on('beforeAttack', (ctx) => {
|
||
if (ctx.attacker !== unit || !unit.skill?.active) return;
|
||
const ok = (e) => e && e.alive && !e.hidden && canTargetEnemy(unit, e, ANY);
|
||
let L = unit.mem.lazyLock;
|
||
if (!ok(L) || !still(L)) {
|
||
const c = battle.enemies.filter((e) => ok(e) && still(e));
|
||
L = unit.mem.lazyLock = c.length ? battle.rng.pick(c) : null;
|
||
}
|
||
if (L) { ctx.targets = [L]; return; }
|
||
// nothing stands still: her own (initial) range
|
||
const own = battle.enemiesInKeys(unit.baseRangeKeys || [], unit, ctx.profile);
|
||
sortEnemyTargets(battle, unit, own, ctx.profile?.priority ?? null);
|
||
ctx.targets = own.slice(0, 1);
|
||
}, { owner: unit });
|
||
}
|
||
if (sid === 'skchr_whitw2_2') {
|
||
battle.on('beforeAttack', (ctx) => {
|
||
const m = unit.mem.hunt;
|
||
if (ctx.attacker !== unit || !unit.skill?.active || !m) return;
|
||
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
|
||
if (!c.length) { ctx.targets = []; return; }
|
||
const n = 1 + Math.floor(num(bb['attack@cnt'])) + ((unit.mem.wolfStage || 0) >= 3 ? 1 : 0);
|
||
m.locks = m.locks.filter((e) => e.alive && c.includes(e)).slice(0, n);
|
||
while (m.locks.length < n) {
|
||
const free = c.filter((e) => !m.locks.includes(e));
|
||
m.locks.push(battle.rng.pick(free.length ? free : c));
|
||
}
|
||
ctx.targets = m.locks.slice();
|
||
}, { owner: unit });
|
||
}
|
||
},
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(bb.atk) },
|
||
attack: { noAttack: true },
|
||
onStart({ battle, unit }) {
|
||
const n = 1 + Math.floor(num(bb['attack@cnt'])) + ((unit.mem.wolfStage || 0) >= 3 ? 1 : 0);
|
||
unit.mem.drones = Array.from({ length: n }, () => ({ x: unit.x, y: unit.y, t: null, locked: null }));
|
||
unit.mem.droneAcc = 0;
|
||
battle.fx('drones', { x: unit.x, y: unit.y, id: unit.id, n });
|
||
},
|
||
onTick({ battle, unit, dt }) {
|
||
const D = unit.mem.drones;
|
||
if (!D) return;
|
||
const ok = (e) => e && e.alive && !e.hidden && canTargetEnemy(unit, e, ANY);
|
||
const taken = new Set(D.filter((d) => ok(d.t)).map((d) => d.t));
|
||
for (const d of D) {
|
||
if (!ok(d.t)) {
|
||
let best = null, bd = Infinity, bestFree = null, bfd = Infinity;
|
||
for (const e of battle.enemies) {
|
||
if (!ok(e)) continue;
|
||
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; }
|
||
}
|
||
d.t = bestFree ?? best;
|
||
d.locked = null;
|
||
if (d.t) taken.add(d.t);
|
||
}
|
||
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 && 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 });
|
||
}
|
||
}
|
||
const near = new Set();
|
||
for (const d of D) for (const e of battle.enemiesInRadius(d.x, d.y, R)) if (ok(e)) near.add(e);
|
||
if (slow) for (const e of near) battle.addBuff(e, { key: 'whitw2:slow', duration: 0.2, refresh: 'replace', mods: { moveMul: Math.max(0, 1 + slow) }, source: unit });
|
||
unit.mem.droneAcc += dt;
|
||
if (unit.mem.droneAcc + 1e-9 >= 1) {
|
||
unit.mem.droneAcc -= 1;
|
||
for (const e of near) if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * dmgScale, type: 'arts', isSkill: true, tags: ['skill', 'drone'] });
|
||
for (const d of D) battle.fx('drone', { x: d.x, y: d.y, id: unit.id });
|
||
}
|
||
},
|
||
onEnd({ unit }) { unit.mem.drones = null; },
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 头狼
|
||
const iv = Math.max(1, num(t0.interval, 20)), cap = num(t0.scale, 1), sil = num(t0['attack@silence_duration']);
|
||
const baseFunnel = unit.profile?.funnel ? { ...unit.profile.funnel } : null;
|
||
const baseHits = Math.max(1, Math.floor(num(unit.profile?.hits, 1)));
|
||
battle.on('deploy', ({ unit: u }) => {
|
||
if (u !== unit) return;
|
||
unit.mem.wolfStage = 0;
|
||
if (unit.profile) { unit.profile.hits = baseHits; if (baseFunnel) unit.profile.funnel = { ...baseFunnel }; }
|
||
}, { owner: unit });
|
||
aura(battle, unit, 1, () => {
|
||
const st = Math.min(3, Math.floor((battle.time - unit.deployedAt + 1e-6) / iv));
|
||
if (st <= (unit.mem.wolfStage || 0)) return;
|
||
unit.mem.wolfStage = st;
|
||
// stage 1: 伤害上限提高10% (funnel cap × scale)
|
||
if (st >= 1 && baseFunnel && unit.profile) unit.profile.funnel = { ...baseFunnel, max: baseFunnel.max * cap };
|
||
// stage 3: 数量+1 — one more drone hits her target with every normal attack (and S3 releases one more)
|
||
if (st >= 3 && unit.profile) unit.profile.hits = baseHits + 1;
|
||
battle.fx('talent', { x: unit.x, y: unit.y, id: unit.id, name: 'alpha', stage: st });
|
||
});
|
||
// stage 2: 造成伤害时使目标特殊能力失效2秒 (= silence)
|
||
if (sil > 0) battle.on('damaged', (ctx) => {
|
||
if (ctx.source !== unit || (unit.mem.wolfStage || 0) < 2 || !ctx.target.alive || ctx.target.side !== 'enemy' || ctx.type === 'element') return;
|
||
battle.applyStatus(ctx.target, 'silence', { duration: sil, source: unit });
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 叙拉古的荣幸: one team effect per player, the strongest copy's numbers
|
||
const S = bstate(battle);
|
||
S.siracusa ??= new Map();
|
||
const cur = S.siracusa.get(unit.ownerId);
|
||
const sp = num(t1.sp), as = num(t1.attack_speed);
|
||
if (cur) { cur.sp = Math.max(cur.sp, sp); cur.as = Math.max(cur.as, as); return; }
|
||
const cfg = { sp, as };
|
||
S.siracusa.set(unit.ownerId, cfg);
|
||
const sira = (u) => u && u.kind === 'op' && u.ownerId === unit.ownerId && hasBond(u, 'siracusaShip');
|
||
// 初始技力+5: every (re)deployment starts with +5 SP (not on top of the SP a unite helper carries over)
|
||
battle.on('deploy', ({ unit: u, initial }) => {
|
||
if (!(cfg.sp > 0) || !sira(u) || !u.skill) return;
|
||
if (initial && u.carry && Number.isFinite(u.carry.sp)) return;
|
||
u.skill.gainSp(cfg.sp, 'talent');
|
||
});
|
||
// elite: 首次触发技能后攻击速度+10
|
||
battle.on('skillStart', ({ unit: u }) => {
|
||
if (!cfg.as || !sira(u) || u.mem.siracusaHonor === u.deploySeq) return;
|
||
u.mem.siracusaHonor = u.deploySeq;
|
||
battle.addBuff(u, { key: 'whitw2:honor', mods: { aspd: cfg.as } });
|
||
});
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 锏 chess_char_6_19 (剑豪) — S3 归于宁静; “天生的武者”; 活着的传奇; module “过往的注脚”
|
||
|
||
function blkkgt(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {};
|
||
const slashes = Math.max(1, parseN(def?.skill?.description, /总计(\d+)次/, 10));
|
||
const iv = Math.max(0.05, num(bb.d_hit_interval, 0.3)), pullIv = Math.max(0.1, num(bb.p_hit_interval, 1));
|
||
const maxT = Math.max(1, Math.floor(num(bb.max_target, 1)));
|
||
const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null;
|
||
// the skill range (x-1): her range while S3 runs — also for the finisher, since onEnd runs before the engine
|
||
// removes the skill's range
|
||
const skillKeys = (unit) => unit.rangeKeys || [];
|
||
const victims = (battle, unit) => {
|
||
const c = battle.enemiesInKeys(skillKeys(unit), unit, unit.profile);
|
||
sortEnemyTargets(battle, unit, c, null);
|
||
return c.slice(0, maxT);
|
||
};
|
||
const slash = (battle, unit, scale) => {
|
||
const v = victims(battle, unit);
|
||
if (v.length) battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length });
|
||
for (const e of v) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'slash'] });
|
||
};
|
||
const pull = (battle, unit, force) => { for (const e of enemiesIn(battle, unit, skillKeys(unit))) if (!e.isFlying) pullToward(battle, unit, e, force); };
|
||
const skills = {
|
||
// S1 纯粹的武力 (attack SP): the next attack hits up to max_target ground enemies of the 3×3 around her, each twice
|
||
// (her trait) at atk_scale_s1 × ATK
|
||
skchr_blkkgt_1: {
|
||
kind: 'instant',
|
||
attack: { atkScale: num(bb.atk_scale_s1, 1) },
|
||
targeting: { maxTargets: Math.max(1, Math.floor(num(bb.max_target, 5))), ...(skillGrid ? { rangeGrid: skillGrid } : {}) },
|
||
},
|
||
// S2 无声的嘲笑 (attack SP, 2 charges): up to max_target ground enemies of the front skill range take 2 slashes
|
||
// (3 when blocked) of dot_scale × ATK; the talent procs at 100 % meanwhile (skill active + bb.prob)
|
||
skchr_blkkgt_2: {
|
||
kind: instantKind(def),
|
||
onStart({ battle, unit }) {
|
||
const grid = skillGrid || [[0, 0], [0, 1]];
|
||
const c = battle.unitsInGrid(unit, grid, { side: 'enemy' }).filter((e) => !e.isFlying && canTargetEnemy(unit, e, unit.profile));
|
||
sortEnemyTargets(battle, unit, c, null);
|
||
const v = c.slice(0, Math.max(1, Math.floor(num(bb.max_target, 5))));
|
||
const nFree = Math.floor(num(bb['blkkgt_s_2[not_blocked].trig_cnt'], 2)), nBlocked = Math.floor(num(bb['blkkgt_s_2[blocked].trig_cnt'], 3));
|
||
if (v.length) battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length });
|
||
for (const e of v) {
|
||
const n = e.blockedBy ? nBlocked : nFree;
|
||
for (let i = 0; i < n && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.dot_scale, 1), type: 'phys', isSkill: true, tags: ['skill', 'slash'] });
|
||
}
|
||
},
|
||
},
|
||
};
|
||
// module 新合同: trait "攻击时无视敌人70点的防御力"; talent 活着的传奇 "攻击力+8%…地面敌人首次进入自身攻击范围时,使其战栗6秒"
|
||
const penFlat = num(tb.def_penetrate_fixed), legendAtk = num(t1.atk), legendTremble = num(t1.not_combat);
|
||
return {
|
||
skills,
|
||
install(battle, unit) {
|
||
if (penFlat > 0 || legendAtk) battle.addBuff(unit, { key: 'blkkgt:contract', mods: { defIgnoreFlat: penFlat, atkPct: legendAtk }, persist: true, allowDead: true });
|
||
if (legendTremble > 0) {
|
||
const seen = new WeakSet();
|
||
battle.on('tick', () => {
|
||
if (!live(unit)) return;
|
||
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, ANY)) {
|
||
if (e.isFlying || seen.has(e)) continue;
|
||
seen.add(e);
|
||
battle.applyStatus(e, 'tremble', { duration: legendTremble, source: unit });
|
||
}
|
||
}, { owner: unit });
|
||
}
|
||
},
|
||
skill: {
|
||
kind: 'duration',
|
||
duration: slashes * iv,
|
||
...(skillGrid ? { targeting: { rangeGrid: skillGrid } } : {}),
|
||
attack: { noAttack: true },
|
||
onStart({ battle, unit }) {
|
||
unit.mem.dgb = { n: 1, acc: 0, pacc: 0 };
|
||
slash(battle, unit, num(bb.d_atk_scale, 1));
|
||
},
|
||
onTick({ battle, unit, dt }) {
|
||
const m = unit.mem.dgb;
|
||
if (!m) return;
|
||
m.acc += dt;
|
||
m.pacc += dt;
|
||
while (m.acc + 1e-9 >= iv && m.n < slashes) { m.acc -= iv; m.n++; slash(battle, unit, num(bb.d_atk_scale, 1)); }
|
||
if (m.pacc + 1e-9 >= pullIv) { m.pacc -= pullIv; pull(battle, unit, num(bb.p_force)); }
|
||
},
|
||
onEnd({ battle, unit, reason }) {
|
||
unit.mem.dgb = null;
|
||
if (reason === 'death' || !unit.alive) return;
|
||
battle.fx('finale', { x: unit.x, y: unit.y, id: unit.id });
|
||
unit.mem.dgbFinale = true; // the finisher is part of the skill (talent at 100 %, module +10 %)
|
||
try { slash(battle, unit, num(bb.e_atk_scale_end, 1)); } finally { unit.mem.dgbFinale = false; }
|
||
pull(battle, unit, num(bb.e_force));
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 活着的传奇 → “天生的武者” → module (skill damage +10 %)
|
||
const prob = num(t0.prob), sc = num(t0.atk_scale, 1), tr = num(t0.not_combat), pen = num(t1.def_penetrate), skillMul = num(tb.damage_scale, 1);
|
||
battle.on('hit', (ctx) => {
|
||
const t = ctx.target, d = ctx.dmg;
|
||
if (ctx.source !== unit || !t || t.side !== 'enemy' || d.type === 'element') return;
|
||
if (pen > 0 && t.findBuff('tremble')) d.defIgnorePct += pen;
|
||
const p = unit.skill?.active || unit.mem.dgbFinale ? num(bb.prob, prob) : prob;
|
||
if (p > 0 && (p >= 1 || battle.rng() < p)) {
|
||
d.amount *= sc;
|
||
if (tr > 0 && t.alive) battle.applyStatus(t, 'tremble', { duration: tr, source: unit });
|
||
}
|
||
if (skillMul > 1 && d.isSkill) d.mul *= skillMul;
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 纯烬艾雅法拉 chess_char_6_20 (行医) — S3 火山回响; 氤氲; 火山灰疗愈
|
||
|
||
function agoat2(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {};
|
||
const hs = num(bb['attack@heal_scale'], 1);
|
||
const shots = Math.max(1, parseN(def?.skill?.description, /(\d+)连发/, 1));
|
||
const elemLoad = (a) => (a.elem ? a.elem.burn + a.elem.neural + a.elem.necrosis + a.elem.apoptosis + a.elem.erosion : 0);
|
||
const skills = {
|
||
// S1 无声润物 (toggle, heal): ATK +atk, one extra heal target, every ally of her range recovers ep_heal_ratio × ATK
|
||
// 元素损伤 per second
|
||
skchr_agoat2_1: {
|
||
kind: 'toggle',
|
||
heal: true,
|
||
mods: { atkPct: num(bb.atk) },
|
||
targeting: { maxTargets: 2 },
|
||
onStart({ unit }) { unit.mem.agoatAcc = 0; },
|
||
onTick({ battle, unit, dt }) {
|
||
unit.mem.agoatAcc = (unit.mem.agoatAcc || 0) + dt;
|
||
if (unit.mem.agoatAcc + 1e-9 < 1) return;
|
||
unit.mem.agoatAcc -= 1;
|
||
const v = unit.s.atk * bv(bb, 'ep_heal_ratio');
|
||
if (v > 0) for (const a of battle.alliesInGrid(unit)) battle.reduceElement(a, v);
|
||
},
|
||
},
|
||
// S2 云霭荫佑 (heal): one heal (her normal amount + element recovery) on every ally of her range, then a barrier
|
||
// over that range for `duration` s absorbing agoat2_s_2[shield].atk_scale × ATK of 元素损伤 in all (install)
|
||
skchr_agoat2_2: {
|
||
kind: 'instant',
|
||
heal: true,
|
||
onStart({ battle, unit }) {
|
||
const er = num(unit.profile?.heal?.elementHealRatio, num(tb.ep_heal_ratio, 0.5));
|
||
const allies = battle.alliesInGrid(unit);
|
||
for (const a of allies) {
|
||
if (er > 0) battle.reduceElement(a, unit.s.atk * er);
|
||
if (a.hp < a.s.maxHp) battle.heal(unit, a, unit.s.atk, { skillHeal: true });
|
||
}
|
||
const S = bstate(battle);
|
||
S.agoatVeils ??= [];
|
||
S.agoatVeils.push({ keys: new Set(unit.rangeKeys || []), pool: unit.s.atk * bv(bb, 'atk_scale', 5), until: battle.time + num(bb.duration, 12), src: unit });
|
||
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, duration: num(bb.duration, 12) });
|
||
if (!S.agoatVeilHook) {
|
||
S.agoatVeilHook = true;
|
||
battle.on('elementHit', (ctx) => { // the barrier absorbs 元素损伤 of the allies inside it
|
||
const t = ctx.target, d = ctx.dmg;
|
||
if (!t || t.side !== 'ally' || !d || d.type !== 'element' || d.cancel) return;
|
||
S.agoatVeils = S.agoatVeils.filter((v) => v.pool > 1e-9 && battle.time < v.until);
|
||
const k = t.tileR * COLS + t.tileC;
|
||
for (const v of S.agoatVeils) {
|
||
if (!v.keys.has(k)) continue;
|
||
const eff = d.amount * d.mul;
|
||
if (!(eff > 0)) return;
|
||
const take = Math.min(v.pool, eff);
|
||
v.pool -= take;
|
||
if (take >= eff - 1e-9) { d.cancel = true; return; }
|
||
d.mul *= (eff - take) / eff;
|
||
}
|
||
}, { priority: -50 });
|
||
}
|
||
},
|
||
},
|
||
};
|
||
return {
|
||
skills,
|
||
install(battle, unit) {
|
||
// module 想要留下的生命: "攻击范围内存在受到元素损伤的友方单位时,攻击速度+8"
|
||
const as = num(tb.attack_speed);
|
||
if (as) aura(battle, unit, 0.25, () => {
|
||
if (battle.alliesInGrid(unit).some((a) => elemLoad(a) > 0)) battle.addBuff(unit, { key: 'agoat2:linger', mods: { aspd: as }, duration: 0.4, refresh: 'replace' });
|
||
});
|
||
},
|
||
skill: {
|
||
kind: 'duration',
|
||
heal: true,
|
||
targeting: { rangeGrid: WHOLE_FIELD, maxTargets: shots },
|
||
attack: { healScale: hs },
|
||
onStart({ skill, unit }) {
|
||
const h = unit.profile?.heal || { mode: 'single' };
|
||
skill.spec.attack.heal = { ...h, elementHealRatio: num(h.elementHealRatio) * hs };
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // S3: 5 shots, different targets first
|
||
battle.on('beforeAttack', (ctx) => {
|
||
if (ctx.attacker !== unit || !unit.skill?.active || !ctx.targets.length || ctx.targets.length >= shots) return;
|
||
const base = ctx.targets.slice();
|
||
const out = [];
|
||
for (let i = 0; i < shots; i++) out.push(base[i % base.length]);
|
||
ctx.targets = out;
|
||
}, { 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;
|
||
// 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 }, 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 });
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
|
||
export default {
|
||
chess_char_1_15_a: pithst,
|
||
chess_char_6_01_a: lemuen,
|
||
chess_char_6_02_a: sbell2,
|
||
chess_char_6_03_a: yu,
|
||
chess_char_6_04_a: skadi2,
|
||
chess_char_6_05_a: pasngr,
|
||
chess_char_6_06_a: pepe,
|
||
chess_char_6_07_a: siege2,
|
||
chess_char_6_08_a: reed2,
|
||
chess_char_6_09_a: cello,
|
||
chess_char_6_10_a: nymph,
|
||
chess_char_6_11_a: mlyss,
|
||
chess_char_6_12_a: rosmon,
|
||
chess_char_6_13_a: angel2,
|
||
chess_char_6_14_a: lumen,
|
||
chess_char_6_15_a: qiubai,
|
||
chess_char_6_16_a: halo2,
|
||
chess_char_6_17_a: nearl2,
|
||
chess_char_6_18_a: whitw2,
|
||
chess_char_6_19_a: blkkgt,
|
||
chess_char_6_20_a: agoat2,
|
||
};
|