feedback5 O12: 自选 operator kits W (with her 此面向敌), 菲亚梅塔 and 维什戴尔 (with her 魂灵之影) — every skill, talent, trait and module (stage 1 / 3) at every form
- kits/ops/op-cqbw.js (W, 炮手; ART-X / ART-Y), op-phenxi.js (菲亚梅塔, 炮手; ART-Y / ART-X / ISW-A attributes only), op-wisdel.js (维什戴尔, 投掷手; BOM-X), appended to OPERATOR_KIT_FILES; numbers from data/backups.json, mechanics from PRTS (备注) and the client's skill prefabs / buff templates. The 炮手 kits splash 1.0 (PRTS 溅射半径一览; the aoesniper profile keeps 1.1): docs/SIM.md §8 says so. - W: 红桃K / D12 cast like an attack (that attack throws the grenade / bombs), 惊吓盒子 plants 此面向敌 by PRTS's tile priority and is refused with no free deployable tile (activate guard); the mine arms within 1.35, blows 1.5 s later with W's ATK read at arming, ground only; D12 is 无来源 with the cast's cached ATK, early on a fallen target; 设伏 / ART-Y ramp, 落井下石 (any physical damage on a stunned enemy of her range, strongest of two W), ART-X SP on her projectiles' / mines' knock-outs. - 菲亚梅塔: 陈述苦难 流失 every 0.1 s and 精力充沛 (ART-X stage 3 floor 50.3 %), 宣告终局 outside / inside a skill, S1 per the 2026-08-01 client (PRTS 备注: ground targets only, the splash still hits air, no range growth — one constant switches to the skill text), S2 灼痕弹 and its 灼痕, S3 持续攻击 shells; ISW-A: base talents. - 维什戴尔: the aftershocks are the kit's (好礼 ×main, 残影 and its 15 % / 100 % explosion), S1 / S2 过载 (the second half, 4 random rounds) / S3 (air targets and splash, 2.5), 魂灵之影 (talent and S3, cap 3, 迷彩 on its x-5, 禁疗, its skill on her range, unmarked first, 0–2 SP back). - Tests: test/content/op_cqbw.test.js (10), op_phenxi.test.js (8), op_wisdel.test.js (8). - Golden: only diy moved — diy-049 … diy-054 now field the three new kits in every form × module × skill (W: 049 / 050, 菲亚梅塔: 051 – 053, 维什戴尔: 053 / 054), the prototypes moved to diy-055 … diy-058 (same packing, new ids); roster, bonds, fields, matches and standins unchanged.
This commit is contained in:
1 parent
fef4ea3d4b
commit
fc18242be1
9 files changed
+2407
-104
No files matched your search
@@ -103,6 +103,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
|
||||
'op-ifrit.js',
|
||||
'op-aphris.js',
|
||||
'op-lin.js',
|
||||
'op-cqbw.js',
|
||||
'op-phenxi.js',
|
||||
'op-wisdel.js',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
// server/sim/content/kits/ops/op-cqbw.js — W (char_113_cqbw) 自选 operator kit: 6★ 炮手 (狙击), an owned-6★ pick of the
|
||||
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules (ART-X “佣兵的行囊”, ART-Y “刺棱钝刃”) at
|
||||
// every form, and the kit of her summon “此面向敌” (token_10008_cqbw_box). Kit contract and the 自选 rules: ../README.md
|
||||
// ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_113_cqbw, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
|
||||
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05.
|
||||
// Potential 0 [ASSUMED: no account]. Sources: character_table / skill_table / battle_equip_table and the token record
|
||||
// (zh_CN, as built into backups.json); PRTS W, 此面向敌 and 溅射半径一览 (the 备注 quoted below); the client's skill prefabs
|
||||
// ([uc]skills skchr_cqbw_1 / 2 / 3, sktok_cqbw_token) and buff templates (cqbw_t_1, cqbw_t_2, cqbw_e_dmg,
|
||||
// cqbw_e_sp[kill_with_projectile] / [kill_with_token], cqbw_e_003_t).
|
||||
// - Trait (炮手) "攻击造成群体物理伤害": ranged physical, every enemy within SPLASH of the struck one takes the hit in full
|
||||
// (the aoesniper profile), air units too (data canHitFly); her 3-10, blocks 1, ground enemies target her (no flag).
|
||||
// PRTS 溅射半径一览 gives the 炮手 1.0 — the profile keeps the 扩散术师's 1.1 — so the kit's trait sets 1.0.
|
||||
// - Module ART-X “佣兵的行囊” "攻击被阻挡的敌人时攻击力提升至110%" (trait bb atk_scale; cqbw_e_dmg: AtkScaleUp on every damage
|
||||
// she calculates, the cached D12 bombs included, when the target is blocked): ×atk_scale on her damage to a blocked
|
||||
// enemy — D12 only while she is on the field (PRTS "撤退W…炸弹无法享受W的天赋、模组的增益"); the module's token part gives
|
||||
// 此面向敌 the same (its trait atk_scale, below). Stage 2+ changes 落井下石 (below).
|
||||
// - Module ART-Y “刺棱钝刃” "攻击时无视敌人100点的防御力" (def_penetrate_fixed): defIgnoreFlat on her damage (D12 is 无来源, so
|
||||
// no source penetration reaches it — the engine's sourceless rule, PRTS 伤害分类). Stage 2+ changes 设伏 (below).
|
||||
// - T1 设伏 "在战场停留10秒后获得60%的物理和法术闪避,且不容易成为敌人的攻击目标" (cqbw_t_1: the evade buff with taunt level −1,
|
||||
// lifetime ∞): `interval` s after each deployment, dodgePhys / dodgeArts `prob` and taunt taunt_level until she leaves.
|
||||
// ART-Y stage 3: 8 s, plus the hidden talent 2 (cqbw_e_003_t; PRTS 备注 "部署后每秒获得一层永久的攻击力提升效果(…3级时每层
|
||||
// 1.25%攻击力,上限16层),受到伤害时…清空攻击力提升效果"): one stack of ATK +atk (直接乘算) every second from her deployment,
|
||||
// at most max_stack_cnt, all cleared by any damage she takes (a 流失, an element 损伤 or a dodged hit is none; a hit a
|
||||
// shield absorbs is one [ASSUMED]).
|
||||
// - T2 落井下石 "攻击范围内的敌人在被晕眩时受到的物理伤害+18%" (cqbw_t_2 on the enemies of her range: ON_TAKE_DAMAGE, STUNNED,
|
||||
// DamageScale PHYSICAL): any physical damage, whoever deals it, on an enemy standing in her attack range while it is
|
||||
// 晕眩 (the catalogue stun — not 冻结 / 浮空) ×damage_scale; two W keep the strongest (同名 buff). ART-X stage 2+ (+24 % at
|
||||
// stage 3) adds "击倒敌人时获得1点技力" — PRTS 修正 "被自身的远程攻击击倒时" and 备注 "当受天赋影响的单位被W的弹道(包括
|
||||
// D12的炸弹)击倒,W将恢复1点技力(无视阻回)…当此面向敌击倒敌人时,不论该敌人是否受天赋效果影响,也会令W获得1点技力(无视
|
||||
// 阻回)": +sp SP (forced) when her projectile — a normal attack, 红桃K, a D12 bomb — knocks out an enemy of her range,
|
||||
// or one of her mines knocks out any enemy.
|
||||
// - S1 红桃K (MANUAL, data DEFAULT): "立即发射一枚榴弹…并使命中目标晕眩" — cast like an attack (prefab
|
||||
// `_shouldCastLikeAttack`): the attack it is cast for throws the grenade at her target instead (an instant skill with
|
||||
// an attack override): atk_scale × ATK physical to every enemy within GRENADE_RADIUS (PRTS 备注 "榴弹爆炸范围半径为1.2"),
|
||||
// air units too (selector `_targetMotion` 3), each stunned `stun` s after its hit.
|
||||
// - S2 惊吓盒子 (AUTO, data DEFAULT — "下次攻击变为…埋下地雷"): the attack it is cast for plants a 此面向敌 on a free deployable
|
||||
// tile of her range instead (melee or ranged: 此面向敌 "部署位置=全部位"). PRTS 备注: "攻击范围内不存在可部署位时,技能不会
|
||||
// 发动" (the cast is refused and keeps its SP — prefab `_cancelIfSearchTargetFailed`), "召唤物部署优先级:可选敌人中点所在
|
||||
// 地块(按地块上所有可选敌人中最高仇恨值降序)>距离其它可选敌人最近地块>其它地块(随机排序)" — targetable ground enemies
|
||||
// (the selector's `_targetMotion` 1), 仇恨值 read as her target order [ASSUMED], the nearest tile measured from its centre
|
||||
// to any such enemy of the field [ASSUMED], ties and the rest drawn with battle.rng — and "W撤退时撤退全部此面向敌" (her
|
||||
// knock-out too [ASSUMED]). No cap on the mines [ASSUMED: the token's maxDeployCount 1 is its deck limit; no source
|
||||
// caps what the skill lays].
|
||||
// 此面向敌 (mineKit): untargetable [ASSUMED: a trap no enemy selects, as 香槟炸弹 — PRTS 实体类型 装置], blocks nothing, no
|
||||
// attack, lasts its talent `duration` (120 s). PRTS 此面向敌 备注 "受害者启动模式的触发及爆炸的半径均为1.35,触发后延迟1.5s
|
||||
// 爆炸,不可对空", W 备注 "触发瞬间实时读取W的攻击力", 技能 备注 "该技能伤害会借用持有者的攻击力计算": a targetable ground
|
||||
// enemy within 1.35 arms it (W's ATK read then); 1.5 s later every targetable ground enemy within 1.35 takes atk_scale ×
|
||||
// that ATK physical (×its trait atk_scale on a blocked one: ART-X) and is stunned `stun` s; the mine is used up
|
||||
// (sktok_cqbw_token's suicide buff). The token skill's blackboard is the S2 blackboard of the form.
|
||||
// - S3 D12 (MANUAL, data DEFAULT): cast like an attack — the attack it is cast for throws instead one bomb (a shell at
|
||||
// BOMB_SPEED [ASSUMED]) at each of the max_target enemies of her range with the most HP (current HP; air units too:
|
||||
// selector `_targetMotion` 3). PRTS 备注 "炸弹使用技能开启瞬间W的缓存攻击力,爆炸半径为1.2,延迟3s引爆,造成无来源的物理普通
|
||||
// 伤害…被安装炸弹的目标被击倒/离场,或进入重生/消失状态时,炸弹会立刻提前引爆": BOMB_DELAY s after it lands — at once when
|
||||
// its target falls, leaves, hides or turns 无敌 + 无法选中 (重生), or when it had fallen before the bomb landed
|
||||
// [ASSUMED] — every targetable enemy within 1.2 of the bomb takes atk_scale × the cached ATK physical, 无来源 (credited to
|
||||
// her), and is stunned `stun` s.
|
||||
|
||||
import { num, traitBb, skillRec, up, statBuff } from '../shared/tier1.js';
|
||||
import { sortEnemyTargets, canTargetEnemy } from '../../../targeting.js';
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
import { hasHp, isHpLoss } from '../../../damage.js';
|
||||
import { COLS, PROJECTILE_SPEEDS } from '../../../constants.js';
|
||||
|
||||
const S1 = 'skchr_cqbw_1';
|
||||
const S2 = 'skchr_cqbw_2';
|
||||
const S3 = 'skchr_cqbw_3';
|
||||
/** Her summon “此面向敌” (S2 惊吓盒子). */
|
||||
export const MINE = 'token_10008_cqbw_box';
|
||||
/** PRTS 溅射半径一览: 炮手 1.0. */
|
||||
const SPLASH = 1.0;
|
||||
/** PRTS S1 备注 "榴弹爆炸范围半径为1.2". */
|
||||
const GRENADE_RADIUS = 1.2;
|
||||
/** PRTS 此面向敌 备注 "触发及爆炸的半径均为1.35,触发后延迟1.5s爆炸". */
|
||||
const MINE_RADIUS = 1.35;
|
||||
const MINE_DELAY = 1.5;
|
||||
/** 此面向敌's lifetime when its token record carries none (token talent `duration`: 120 s). */
|
||||
const MINE_LIFE = 120;
|
||||
/** PRTS S3 备注 "爆炸半径为1.2,延迟3s引爆". */
|
||||
const BOMB_RADIUS = 1.2;
|
||||
const BOMB_DELAY = 3;
|
||||
/** D12's bombs fly as shells [ASSUMED: the engine's bomb projectile speed]. */
|
||||
const BOMB_SPEED = PROJECTILE_SPEEDS.bomb;
|
||||
/** 设伏 ART-Y: one ATK stack every second from the deployment (PRTS 备注 "部署后每秒获得一层"). */
|
||||
const RAMP_IV = 1;
|
||||
const AMBUSH_KEY = 'talent:cqbw:ambush';
|
||||
const RAMP_KEY = 'talent:cqbw:ramp';
|
||||
const MINE_TAG = 'cqbw:mine';
|
||||
const D12_TAG = 'cqbw:d12';
|
||||
const GROUND = Object.freeze({ canHitFly: false });
|
||||
const AIR = Object.freeze({ canHitFly: true });
|
||||
/** 落井下石's factor already applied to a damage instance (two W on one field keep the strongest). */
|
||||
const STUN_SCALED = new WeakMap();
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
/** Talent of data index `i` of the composed record (ART-Y's hidden talent 2 has no name). */
|
||||
const talentAt = (chess, i) => (chess?.talents ?? []).find((t) => t && t.index === i) ?? null;
|
||||
/** First talent of a normalised token def holding `key` (token talents keep no index). */
|
||||
const tokTalent = (def, key) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null) ?? null;
|
||||
/** A mine of `unit`. */
|
||||
const isMineOf = (t, unit) => !!t && t.kind === 'token' && t.defId === MINE && t.ownerUnit === unit;
|
||||
/** 晕眩 (cqbw_t_2 checks STUNNED: the catalogue stun, not 冻结 / 浮空). */
|
||||
const stunned = (e) => e.hasBuff('stun');
|
||||
/** D12's early burst: the target fell, left, hides (消失) or is 无敌 + 无法选中 (重生). */
|
||||
const gone = (e) => !e || !e.alive || e.hidden || !!(e.s.flags.invulnerable && e.s.flags.untargetable);
|
||||
|
||||
/** Free deployable tiles of her current range (melee or ranged: 此面向敌 "部署位置=全部位"). */
|
||||
function mineTiles(battle, unit) {
|
||||
const out = [];
|
||||
for (const k of unit.rangeKeys ?? []) {
|
||||
const r = Math.floor(k / COLS), c = k % COLS;
|
||||
if (!battle.grid.inRect(r, c) || !battle.grid.canStand(r, c, { ranged: true }) || battle.isReservedTile(r, c)) continue;
|
||||
out.push([r, c]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The tile 惊吓盒子 plants on (PRTS's priority, header), or null when her range has none free. */
|
||||
function mineTile(battle, unit) {
|
||||
const tiles = mineTiles(battle, unit);
|
||||
if (!tiles.length) return null;
|
||||
const foes = battle.enemies.filter((e) => canTargetEnemy(unit, e, GROUND));
|
||||
if (!foes.length) return battle.rng.pick(tiles);
|
||||
// 可选敌人中点所在地块, by the enemies' order
|
||||
sortEnemyTargets(battle, unit, foes, null);
|
||||
const byKey = new Map(tiles.map((t) => [t[0] * COLS + t[1], t]));
|
||||
for (const e of foes) {
|
||||
const t = byKey.get(Math.round(e.y) * COLS + Math.round(e.x));
|
||||
if (t) return t;
|
||||
}
|
||||
// 距离其它可选敌人最近地块
|
||||
let best = [], bd = Infinity;
|
||||
for (const t of tiles) {
|
||||
let d = Infinity;
|
||||
for (const e of foes) d = Math.min(d, Math.hypot(e.x - t[1], e.y - t[0]));
|
||||
if (d < bd - 1e-9) { bd = d; best = [t]; } else if (Math.abs(d - bd) <= 1e-9) best.push(t);
|
||||
}
|
||||
return best.length === 1 ? best[0] : battle.rng.pick(best);
|
||||
}
|
||||
|
||||
/**
|
||||
* 此面向敌's kit (header). `owner` = W, `def` = the token def of her pick (the S2 blackboard of the form; ART-X: the
|
||||
* token trait atk_scale).
|
||||
*/
|
||||
function mineKit(owner, def) {
|
||||
const sk = def?.skill?.bb ?? {};
|
||||
const scale = num(sk.atk_scale), stun = num(sk.stun);
|
||||
const blocked = num(def?.traitBb?.atk_scale, 1);
|
||||
return {
|
||||
skill: {
|
||||
kind: 'passive',
|
||||
onTick({ battle, unit }) {
|
||||
if (!unit.alive || unit.mem.armed) return;
|
||||
if (!battle.foesInRadius(unit.x, unit.y, MINE_RADIUS, true).some((e) => !e.isFlying)) return;
|
||||
unit.mem.armed = true;
|
||||
const atk = up(owner) ? owner.s.atk : 0; // 触发瞬间实时读取W的攻击力
|
||||
battle.fx('telegraph', { x: unit.x, y: unit.y, id: unit.id, r: MINE_RADIUS, dur: MINE_DELAY });
|
||||
battle.after(MINE_DELAY, () => {
|
||||
if (!unit.alive) return; // withdrawn meanwhile (W left the field, its time ran out)
|
||||
for (const e of battle.foesInRadius(unit.x, unit.y, MINE_RADIUS, true)) {
|
||||
if (e.isFlying) continue; // 不可对空
|
||||
const mul = blocked !== 1 && e.blockedBy ? blocked : 1;
|
||||
battle.dealDamage(unit, e, { amount: atk * scale * mul, type: 'phys', isSkill: true, tags: ['summon', MINE_TAG] });
|
||||
if (stun > 0 && hasHp(e)) battle.applyStatus(e, 'stun', { duration: stun, source: unit });
|
||||
}
|
||||
battle.fx('aoe', { x: unit.x, y: unit.y, radius: MINE_RADIUS, id: unit.id, skill: MINE_TAG, consumed: true });
|
||||
battle.retreat(unit, { reason: 'expired', permanent: true });
|
||||
}, { owner: unit });
|
||||
},
|
||||
},
|
||||
trait: { noAttack: true },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* D12: a bomb thrown at `target` (its cached ATK × scale, `mul` = ART-X's blocked factor while she stays on the field).
|
||||
* It sticks to the target and goes off BOMB_DELAY s after it landed, or at once when the target is gone.
|
||||
*/
|
||||
function throwBomb(battle, unit, target, cached, b3, blocked) {
|
||||
const scale = num(b3.atk_scale), stun = num(b3.stun);
|
||||
const burst = (x, y) => {
|
||||
const mul = up(unit) ? blocked : 1; // a withdrawn W lends her bombs no module
|
||||
battle.fx('aoe', { x, y, radius: BOMB_RADIUS, id: unit.id, skill: D12_TAG });
|
||||
for (const v of battle.foesInRadius(x, y, BOMB_RADIUS, true)) {
|
||||
const m = mul !== 1 && v.blockedBy ? mul : 1;
|
||||
battle.dealDamage(unit, v, { amount: cached * scale * m, type: 'phys', sourceless: true, isSkill: true, tags: ['skill', D12_TAG] });
|
||||
if (stun > 0 && hasHp(v)) battle.applyStatus(v, 'stun', { duration: stun, source: unit });
|
||||
}
|
||||
};
|
||||
battle.addProjectile({
|
||||
from: unit, target, speed: BOMB_SPEED, visual: 'bomb', source: unit, hitDead: true,
|
||||
onHit: (c) => {
|
||||
const e = c.target;
|
||||
if (gone(e)) { burst(c.x, c.y); return; } // its target fell before it landed: it goes off there [ASSUMED]
|
||||
const spot = { x: e.x, y: e.y };
|
||||
let done = false, watch = null, timer = null;
|
||||
const fire = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (watch) battle.off(watch);
|
||||
if (timer) timer.cancel();
|
||||
burst(spot.x, spot.y);
|
||||
};
|
||||
watch = battle.on('tick', () => {
|
||||
if (gone(e)) { fire(); return; }
|
||||
spot.x = e.x; spot.y = e.y;
|
||||
});
|
||||
timer = battle.after(BOMB_DELAY, () => { if (!gone(e)) { spot.x = e.x; spot.y = e.y; } fire(); });
|
||||
battle.fx('lock', { x: e.x, y: e.y, id: e.id, skill: D12_TAG, dur: BOMB_DELAY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
char_113_cqbw: (bb, chess) => {
|
||||
const t0 = talentAt(chess, 0)?.bb ?? {}; // 设伏 (ART-Y stage 3: interval 8)
|
||||
const ramp = talentAt(chess, 2)?.bb ?? {}; // ART-Y stage 3 hidden talent: atk / max_stack_cnt
|
||||
const t1 = talentAt(chess, 1)?.bb ?? {}; // 落井下石 (ART-X stage 2+: damage_scale, sp)
|
||||
const tb = traitBb(chess); // ART-X atk_scale · ART-Y def_penetrate_fixed
|
||||
const blocked = num(tb.atk_scale, 1);
|
||||
const b1 = bbOf(chess, S1), b3 = bbOf(chess, S3);
|
||||
return {
|
||||
trait: { splashRadius: SPLASH },
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'instant',
|
||||
attack: {
|
||||
atkScale: num(b1.atk_scale, 1),
|
||||
splashRadius: GRENADE_RADIUS,
|
||||
onEachHit({ battle, unit, target }) {
|
||||
const d = num(b1.stun);
|
||||
if (d > 0 && target && target.side === 'enemy' && hasHp(target)) battle.applyStatus(target, 'stun', { duration: d, source: unit });
|
||||
},
|
||||
},
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'instant',
|
||||
onStart({ battle, unit }) {
|
||||
const tile = mineTile(battle, unit);
|
||||
if (!tile) return; // (the activate guard below refuses such a cast)
|
||||
unit.mem.cqbwCastAt = battle.time; // this attack plants the mine instead
|
||||
const def = battle.tokenDef(MINE, unit);
|
||||
const life = num(tokTalent(def, 'duration')?.bb?.duration, MINE_LIFE);
|
||||
const m = battle.spawnToken(unit, MINE, tile[0], tile[1], { kit: mineKit(unit, def), untargetable: true, duration: life });
|
||||
if (m) battle.fx('summon', { x: m.x, y: m.y, id: unit.id, token: MINE });
|
||||
},
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'instant',
|
||||
onStart({ battle, unit }) {
|
||||
unit.mem.cqbwCastAt = battle.time; // this attack throws the bombs instead
|
||||
const cached = unit.s.atk * unit.s.atkScaleMul; // 技能开启瞬间的缓存攻击力
|
||||
const n = Math.max(1, Math.floor(num(b3.max_target, 3)));
|
||||
const pool = battle.enemiesInKeys(unit.rangeKeys, unit, AIR);
|
||||
for (const e of battle.blockedTargets(unit, AIR)) if (!pool.includes(e)) pool.push(e);
|
||||
sortEnemyTargets(battle, unit, pool, null);
|
||||
pool.sort((a, b) => b.hp - a.hp); // 生命值最多 (stable: her order breaks ties)
|
||||
for (const e of pool.slice(0, n)) throwBomb(battle, unit, e, cached, b3, blocked);
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 设伏 (+ ART-Y stage 3: ATK ramp, reset by damage)
|
||||
const iv = Math.max(0, num(t0.interval)), prob = num(t0.prob), taunt = num(t0.taunt_level);
|
||||
const per = num(ramp.atk), cap = Math.floor(num(ramp.max_stack_cnt));
|
||||
battle.on('deploy', (ctx) => {
|
||||
if (ctx.unit !== unit) return;
|
||||
const seq = unit.deploySeq;
|
||||
if (prob > 0 || taunt) {
|
||||
battle.after(iv, () => {
|
||||
if (!up(unit) || unit.deploySeq !== seq) return;
|
||||
battle.addBuff(unit, { key: AMBUSH_KEY, mods: { dodgePhys: prob, dodgeArts: prob, taunt }, tags: ['talent'], visible: true });
|
||||
battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'cqbw:ambush' });
|
||||
}, { owner: unit });
|
||||
}
|
||||
if (per > 0 && cap > 0) {
|
||||
battle.every(RAMP_IV, (b, sched) => {
|
||||
if (!up(unit) || unit.deploySeq !== seq) { sched.cancel(); return; }
|
||||
battle.addBuff(unit, { key: RAMP_KEY, refresh: 'stack', maxStacks: cap, mods: { atkPct: per }, tags: ['talent'] });
|
||||
}, { owner: unit });
|
||||
}
|
||||
}, { owner: unit });
|
||||
if (per > 0 && cap > 0) {
|
||||
battle.on('damaged', (ctx) => {
|
||||
if (ctx.target !== unit || ctx.type === 'element' || isHpLoss(ctx.dmg)) return;
|
||||
if (unit.findBuff(RAMP_KEY)) battle.removeBuff(unit, RAMP_KEY);
|
||||
}, { owner: unit });
|
||||
}
|
||||
} },
|
||||
{ install(battle, unit) { // 落井下石 (+ ART-X stage 2+: SP on her projectiles' / mines' knock-outs)
|
||||
const scale = num(t1.damage_scale, 1), sp = num(t1.sp);
|
||||
if (scale !== 1) {
|
||||
battle.on('hit', (ctx) => {
|
||||
const e = ctx.target, d = ctx.dmg;
|
||||
if (!e || e.side !== 'enemy' || d.type !== 'phys' || !up(unit) || !stunned(e) || !bodyInKeys(e, unit.rangeKeySet)) return;
|
||||
const prev = STUN_SCALED.get(d) ?? 1;
|
||||
if (!(scale > prev)) return;
|
||||
d.mul *= scale / prev;
|
||||
STUN_SCALED.set(d, scale);
|
||||
}, { owner: unit });
|
||||
}
|
||||
if (sp > 0) {
|
||||
battle.on('damaged', (ctx) => {
|
||||
const e = ctx.target, d = ctx.dmg;
|
||||
if (!e || e.side !== 'enemy' || !d || d.type === 'element' || hasHp(e) || !up(unit) || !unit.skill) return;
|
||||
const src = ctx.source;
|
||||
const shot = (src === unit && d.isAttack) || ((ctx.credit ?? src) === unit && d.tags?.includes(D12_TAG));
|
||||
if (isMineOf(src, unit) || (shot && bodyInKeys(e, unit.rangeKeySet))) unit.skill.gainSp(sp, 'init'); // 无视阻回
|
||||
}, { owner: unit });
|
||||
}
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
const sid = unit.skill?.id;
|
||||
// ART-X: 攻击被阻挡的敌人时攻击力提升至110% (her own damage; D12 and the mines apply it themselves)
|
||||
if (blocked !== 1) {
|
||||
battle.on('hit', (ctx) => {
|
||||
const t = ctx.target;
|
||||
if (ctx.source !== unit || !t || t.side !== 'enemy' || !t.blockedBy || ctx.dmg.type === 'element') return;
|
||||
ctx.dmg.amount *= blocked;
|
||||
}, { owner: unit });
|
||||
}
|
||||
// ART-Y: 攻击时无视敌人100点的防御力
|
||||
statBuff(battle, unit, 'trait:cqbw:pierce', { defIgnoreFlat: num(tb.def_penetrate_fixed) });
|
||||
// 惊吓盒子 / D12 are cast like an attack: the attack they are cast for makes no hit of its own
|
||||
if (sid === S2 || sid === S3) {
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || unit.mem.cqbwCastAt !== battle.time) return;
|
||||
unit.mem.cqbwCastAt = null;
|
||||
ctx.targets = [];
|
||||
}, { owner: unit, priority: 100 });
|
||||
}
|
||||
if (sid === S2) {
|
||||
// "攻击范围内不存在可部署位时,技能不会发动": every activation is refused while her range has no free deployable
|
||||
// tile — the SP stays (non-enumerable: never serialised; the pattern of 6_06 莎草)
|
||||
const sk = unit.skill;
|
||||
if (sk && !Object.prototype.hasOwnProperty.call(sk, 'activate')) {
|
||||
const activate = sk.activate;
|
||||
Object.defineProperty(sk, 'activate', {
|
||||
configurable: true, writable: true, enumerable: false,
|
||||
value(reason, opts) { return mineTiles(battle, unit).length ? activate.call(this, reason, opts) : false; },
|
||||
});
|
||||
}
|
||||
// W撤退时撤退全部此面向敌
|
||||
battle.on('death', (ctx) => {
|
||||
if (ctx.unit !== unit) return;
|
||||
for (const t of battle.allyUnits) if (t.alive && isMineOf(t, unit)) battle.retreat(t, { reason: 'expired', permanent: true });
|
||||
}, { owner: unit });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
// server/sim/content/kits/ops/op-phenxi.js — 菲亚梅塔 (char_300_phenxi) 自选 operator kit: 6★ 炮手 (狙击), an owned-6★ pick of
|
||||
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and all three modules (ART-Y “‘律外’特种弹药配给组”,
|
||||
// ART-X “漫长的旅途”, ISW-A “菲亚梅塔特限证章”) at every form. Kit contract and the 自选 rules: ../README.md ("How to add an
|
||||
// operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_300_phenxi, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
|
||||
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05.
|
||||
// Potential 0 [ASSUMED: no account]. Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into
|
||||
// backups.json; battle_equip_table marks every ISW-A part but its attributes `validInGameTag` roguelike); PRTS 菲亚梅塔
|
||||
// and 溅射半径一览 (the 备注 quoted below); the client's charpack char_300_phenxi (the S1 mode's attack selector), skill
|
||||
// prefabs ([uc]skills skchr_phenxi_1 / 2 / 3) and buff templates (phenxi_t_1 / [bleeding] / [peak], phenxi_t_2,
|
||||
// phenxi_e_t_2, phenxi_e_003_t / [bleeding], phenxi_e_003_tr).
|
||||
// - Trait (炮手) "攻击造成群体物理伤害": ranged physical, every enemy within SPLASH of the struck one takes the hit in full
|
||||
// (the aoesniper profile), air units too; her 3-10, blocks 1, ground enemies target her (no flag). PRTS 溅射半径一览 gives
|
||||
// the 炮手 1.0 (the profile keeps the 扩散术师's 1.1): the kit's trait sets 1.0. Bonds (拉特兰) are the slot's business.
|
||||
// - Module ART-Y “‘律外’特种弹药配给组” "攻击时无视敌人100点的防御力" (def_penetrate_fixed): defIgnoreFlat on all her damage.
|
||||
// Stage 3 changes 宣告终局 (below).
|
||||
// - Module ART-X “漫长的旅途” "攻击被阻挡的敌人时攻击力提升至110%" (trait bb atk_scale; phenxi_e_003_tr: AtkScaleUp on every
|
||||
// damage she calculates when the target is blocked): ×atk_scale on her damage to a blocked enemy — normal attacks and
|
||||
// their splash, both skills' blasts. Stage 3 changes 陈述苦难 (below).
|
||||
// - Module ISW-A “菲亚梅塔特限证章”: its trait and every talent part are 集成战略-only (battle_equip_table validInGameTag
|
||||
// roguelike: 首次部署费用 −50 %, 倒下时立即重新部署, the slower 生命流失, the doubled 精力充沛, the 再度施放 echo, +30 攻速) ⇒
|
||||
// N/A here; only its attributes (HP / ATK) count, and both talents are the base ones (the record's talentsBase — the
|
||||
// composed talents would carry the 集成战略 blackboard).
|
||||
// - T1 陈述苦难 "自身生命会不断流失(该效果不会使生命降至0);生命值高于50%时获得+25%攻击力的精力充沛;高于80%效果翻倍"
|
||||
// (phenxi_t_1: from each deployment phenxi_t_1[bleeding] every `interval` s; PRTS 备注 "每0.1s结算一次,每次结算时菲亚梅塔
|
||||
// 流失当前生命值的0.5%(向上取整),且始终不会致命(至多令菲亚梅塔生命值降至1点)"): a 流失 (battle.loseHp — no hit, no SP,
|
||||
// no shield; PRTS calls it 生命流失) of ⌈hp_ratio × current HP⌉ every `interval` s while she has more than 1 HP, never
|
||||
// below 1. 精力充沛 (peak_performance; ba.strong "生命值高于一定比例时获得一定属性加成"): ATK +peak_1 atk while HP > peak_1
|
||||
// hp_ratio (50 %), +peak_2 atk (twice that) while HP > peak_2 hp_ratio (80 %) — the higher replaces the lower.
|
||||
// ART-X stage 3 (phenxi_e_003_t[bleeding]: FilterByTargetHpRatio min_hp_ratio 0.503): no loss unless HP > 50.3 % ("不会
|
||||
// 使生命降至50%以下"), +30 % / +60 %.
|
||||
// - T2 宣告终局 "技能持续期间外,攻击速度+27" (phenxi_t_2: an ASPD buff, overridden while a skill runs): ASPD +attack_speed
|
||||
// except while a timed skill runs (S1's 30 s, S3 for good; S2 is instant). ART-Y stage 3 (phenxi_e_t_2): +30 outside,
|
||||
// and ASPD +phenxi_e_t_2[in_skill] (10) while one runs.
|
||||
// - S1 “你须直面” (MANUAL, attack SP, 30 s, data ACTIVE_RANGE on her 3-10 grown by ability_range_forward_extend): ATK +atk.
|
||||
// PRTS 备注 "自2026年8月1日(客户端版本 2.7.61)版本更新后:技能期间的普通攻击不再以飞行单位为目标(但弹道依旧能击中飞行
|
||||
// 单位),技能期间的攻击范围不再受攻击距离属性影响,因此技能提供的攻击距离加成不会产生攻击范围扩展效果" — the client agrees
|
||||
// (charpack: the S1 mode's attack selector `_targetMotion` 1, owner range): while it runs she selects ground enemies
|
||||
// only, her shells still splash air units (attack `splashHitsFly`), and her range stays 3-10 (S1_SINCE_2_7_61; false =
|
||||
// the skill text: range +1 forward, air targets). The data trigger still reads the +1 grid — reported as an open
|
||||
// question (by the owner's rule the trigger would be DEFAULT once the range does not grow).
|
||||
// - S2 “你须愧悔” (MANUAL, attack SP, data DEFAULT): the attack it is cast for fires the 灼痕弹 instead (its cast animation;
|
||||
// no hit of its own [ASSUMED]). PRTS 备注 "灼痕弹飞行终点为干员正前方第4格中点,每飞行0.66格留下一道灼痕,飞行速度8格/秒。
|
||||
// 灼痕弹爆炸半径1.5;灼痕生成后0.8s爆炸,爆炸半径1.1": from her tile along her facing to the 4th tile ahead at 8 tiles/s,
|
||||
// a 灼痕 every `dist` tiles of its flight (none on the end point [ASSUMED]); at the end atk_scale × ATK physical to every
|
||||
// targetable enemy within 1.5, each 灼痕 0.8 s after it was left atk_scale_2 × ATK within 1.1 (中点判定; air units too
|
||||
// [ASSUMED: no 不可对空 note]). Her ATK when each blast goes off [ASSUMED].
|
||||
// - S3 “你须偿还” (MANUAL, attack SP, data DEFAULT, 持续时间无限 — a toggle; ba.permanentatk 持续攻击 "无论攻击范围内是否有攻击
|
||||
// 目标,都会持续进行攻击"): every attack interval, enemy or not, a shell (S3_SPEED [ASSUMED]) to the centre of the farthest
|
||||
// tile of her range on her own line ahead; PRTS 备注 "爆炸范围扩大至半径2.0…爆炸中心为半径1.1的圆(中点判定);爆炸中心攻击倍率
|
||||
// 提升效果不叠加,取最高": every targetable enemy within 2.0 takes attack@atk_scale_2 × ATK, within attack@dist (1.1) of
|
||||
// the centre attack@atk_scale instead, physical, air units too [ASSUMED: no 不可对空 note]. These shells are her attacks
|
||||
// (isAttack, every victim a splash victim: the shot aims at a tile); a stun / freeze holds the cycle, disarm stops it.
|
||||
|
||||
import { num, talentBb, traitBb, moduleOn, skillRec, up, statBuff } from '../shared/tier1.js';
|
||||
import { toLocal } from '../../../dir.js';
|
||||
import { COLS, PROJECTILE_SPEEDS } from '../../../constants.js';
|
||||
|
||||
const S1 = 'skchr_phenxi_1';
|
||||
const S2 = 'skchr_phenxi_2';
|
||||
const S3 = 'skchr_phenxi_3';
|
||||
/** PRTS 溅射半径一览: 炮手 1.0. */
|
||||
const SPLASH = 1.0;
|
||||
/**
|
||||
* “你须直面” since the 2026-08-01 client (PRTS S1 备注, 客户端版本 2.7.61): her skill attacks select ground enemies only (the
|
||||
* shell still hits air units) and the skill's 攻击距离+1 no longer grows her range. false = the skill text.
|
||||
*/
|
||||
const S1_SINCE_2_7_61 = true;
|
||||
/** PRTS S2 备注: the 灼痕弹 ends at the centre of the 4th tile ahead, flies 8 tiles/s, bursts 1.5; each 灼痕 0.8 s / 1.1. */
|
||||
const SHELL_REACH = 4;
|
||||
const SHELL_SPEED = 8;
|
||||
const SHELL_RADIUS = 1.5;
|
||||
const MARK_DELAY = 0.8;
|
||||
const MARK_RADIUS = 1.1;
|
||||
/** PRTS S3 备注 "爆炸范围扩大至半径2.0". */
|
||||
const S3_RADIUS = 2.0;
|
||||
/** S3's shells fly as bombs [ASSUMED: the engine's bomb projectile speed]. */
|
||||
const S3_SPEED = PROJECTILE_SPEEDS.bomb;
|
||||
const PEAK_KEY = 'talent:phenxi:peak';
|
||||
const ASPD_KEY = 'talent:phenxi:aspd';
|
||||
const BLEED_TAG = 'phenxi:bleed';
|
||||
const P1 = 'phenxi_t_1[peak_1].peak_performance';
|
||||
const P2 = 'phenxi_t_1[peak_2].peak_performance';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
/** A 集成战略 module (ISW-*) is picked: none of its talent parts applies outside 集成战略. */
|
||||
const isRoguelikeModule = (chess) => moduleOn(chess) && /^ISW/i.test(String(chess?.module?.type ?? ''));
|
||||
|
||||
/** Every targetable enemy within `r` of (x, y) (中点判定) takes scale × her ATK physical. */
|
||||
function blast(battle, unit, x, y, r, scale, skill) {
|
||||
battle.fx('aoe', { x, y, radius: r, id: unit.id, skill });
|
||||
for (const e of battle.foesInRadius(x, y, r, true)) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * scale * unit.s.atkScaleMul, type: 'phys', isSkill: true, tags: ['skill', skill] });
|
||||
}
|
||||
}
|
||||
|
||||
/** “你须愧悔”: the 灼痕弹 along her facing and the 灼痕 it leaves (header). */
|
||||
function fireShell(battle, unit, b2) {
|
||||
const [fr, fc] = unit.fwd;
|
||||
const x0 = unit.x, y0 = unit.y;
|
||||
const at = (d) => ({ x: x0 + fc * d, y: y0 + fr * d });
|
||||
const end = at(SHELL_REACH);
|
||||
const scale = num(b2.atk_scale, 1), scale2 = num(b2.atk_scale_2, scale), step = num(b2.dist);
|
||||
battle.addProjectile({ from: unit, to: end, speed: SHELL_SPEED, visual: 'bomb', source: unit,
|
||||
onHit: () => blast(battle, unit, end.x, end.y, SHELL_RADIUS, scale, 'phenxi:s2') });
|
||||
if (!(step > 0)) return;
|
||||
for (let k = 1; k * step < SHELL_REACH - 1e-9; k++) {
|
||||
const p = at(k * step);
|
||||
battle.after((k * step) / SHELL_SPEED, () => {
|
||||
battle.fx('zone', { x: p.x, y: p.y, radius: MARK_RADIUS, dur: MARK_DELAY, id: unit.id, skill: 'phenxi:mark' });
|
||||
battle.after(MARK_DELAY, () => blast(battle, unit, p.x, p.y, MARK_RADIUS, scale2, 'phenxi:mark'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** “你须偿还”'s aim: the farthest tile of her current range on her own line ahead ([row, col]), or null. */
|
||||
function farthestAhead(unit) {
|
||||
let best = null, bc = -1;
|
||||
for (const k of unit.rangeKeys ?? []) {
|
||||
const r = Math.floor(k / COLS), c = k % COLS;
|
||||
const [lr, lc] = toLocal(r - unit.tileR, c - unit.tileC, unit.dir);
|
||||
if (lr === 0 && lc > bc) { bc = lc; best = [r, c]; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** One “你须偿还” shell (header). */
|
||||
function s3Shot(battle, unit, b3) {
|
||||
const tile = farthestAhead(unit);
|
||||
if (!tile) return;
|
||||
const x = tile[1], y = tile[0];
|
||||
const lo = num(b3['attack@atk_scale_2'], 1), hi = Math.max(lo, num(b3['attack@atk_scale'], lo)), core = num(b3['attack@dist'], 1.1);
|
||||
unit.stats.attacks++;
|
||||
unit.lastAttackAt = battle.time;
|
||||
battle.addProjectile({ from: unit, to: { x, y }, speed: S3_SPEED, visual: 'bomb', source: unit, onHit: () => {
|
||||
battle.fx('aoe', { x, y, radius: S3_RADIUS, id: unit.id, skill: 'phenxi:s3' });
|
||||
for (const e of battle.foesInRadius(x, y, S3_RADIUS, true)) {
|
||||
const sc = Math.hypot(e.x - x, e.y - y) <= core + 1e-9 ? hi : lo;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * sc * unit.s.atkScaleMul, type: 'phys', isAttack: true, isSkill: true, isSplash: true, tags: ['skill', 'phenxi:s3'] });
|
||||
}
|
||||
} });
|
||||
}
|
||||
|
||||
export default {
|
||||
char_300_phenxi: (bb, chess) => {
|
||||
const src = isRoguelikeModule(chess) ? { talents: chess.talentsBase ?? chess.talents } : chess;
|
||||
const t0 = talentBb(src, 0); // 陈述苦难 (ART-X stage 3: min_hp_ratio, +30 % / +60 %)
|
||||
const t1 = talentBb(src, 1); // 宣告终局 (ART-Y stage 3: 30, in-skill 10)
|
||||
const tb = traitBb(chess); // ART-Y def_penetrate_fixed · ART-X atk_scale
|
||||
const blocked = num(tb.atk_scale, 1);
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
return {
|
||||
trait: { splashRadius: SPLASH },
|
||||
skills: {
|
||||
[S1]: S1_SINCE_2_7_61
|
||||
? { kind: 'duration', mods: { atkPct: num(b1.atk) }, targeting: { canHitFly: false }, attack: { splashHitsFly: true } }
|
||||
: { kind: 'duration', mods: { atkPct: num(b1.atk) }, targeting: { rangeExtend: num(b1.ability_range_forward_extend) } },
|
||||
[S2]: {
|
||||
kind: 'instant',
|
||||
onStart({ battle, unit }) {
|
||||
unit.mem.phenxiCastAt = battle.time; // the cast takes this attack's place
|
||||
fireShell(battle, unit, b2);
|
||||
},
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'toggle',
|
||||
attack: { noAttack: true }, // 持续攻击: her shells below are her attacks
|
||||
onStart({ unit }) { unit.mem.phenxiCd = 0; },
|
||||
onTick({ battle, unit, dt }) {
|
||||
if (!unit.canAct) return; // 晕眩 / 冻结 hold the cycle, as her attack cooldown
|
||||
unit.mem.phenxiCd = num(unit.mem.phenxiCd) - dt;
|
||||
if (unit.mem.phenxiCd > 1e-9 || unit.s.flags.disarm) return;
|
||||
unit.mem.phenxiCd = unit.s.interval;
|
||||
s3Shot(battle, unit, b3);
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 陈述苦难: the 流失 and 精力充沛
|
||||
const iv = Math.max(0.05, num(t0.interval, 0.1)), ratio = num(t0.hp_ratio), floor = num(t0.min_hp_ratio);
|
||||
const a1 = num(t0[`${P1}.atk`]), h1 = num(t0[`${P1}.hp_ratio`], 0.5);
|
||||
const a2 = num(t0[`${P2}.atk`]), h2 = num(t0[`${P2}.hp_ratio`], 0.8);
|
||||
if (ratio > 0) {
|
||||
battle.on('deploy', (ctx) => {
|
||||
if (ctx.unit !== unit) return;
|
||||
const seq = unit.deploySeq;
|
||||
battle.every(iv, (b, sched) => {
|
||||
if (!unit.alive || !unit.deployed || unit.deploySeq !== seq) { sched.cancel(); return; }
|
||||
if (!(unit.hp > 1) || (floor > 0 && !(unit.hpRatio > floor))) return;
|
||||
const n = Math.min(Math.ceil(unit.hp * ratio - 1e-9), unit.hp - 1); // 向上取整, never lethal
|
||||
if (n > 0) battle.loseHp(unit, n, { source: unit, silent: true, tags: [BLEED_TAG] });
|
||||
}, { owner: unit });
|
||||
}, { owner: unit });
|
||||
}
|
||||
if (!(a1 > 0) && !(a2 > 0)) return;
|
||||
const sync = () => {
|
||||
const r = unit.hpRatio;
|
||||
const v = !up(unit) ? 0 : r > h2 ? a2 : r > h1 ? a1 : 0;
|
||||
const cur = unit.findBuff(PEAK_KEY);
|
||||
if (v > 0 && (!cur || cur.mods?.atkPct !== v)) battle.addBuff(unit, { key: PEAK_KEY, mods: { atkPct: v }, tags: ['talent'], visible: true });
|
||||
else if (!(v > 0) && cur) battle.removeBuff(unit, PEAK_KEY);
|
||||
};
|
||||
battle.on('tick', sync, { owner: unit });
|
||||
battle.on('battleStart', sync, { owner: unit });
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === unit && battle.started) sync(); }, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 宣告终局: ASPD outside a running skill (ART-Y stage 3: and a smaller one inside)
|
||||
const out = num(t1.attack_speed), inside = num(t1['phenxi_e_t_2[in_skill].attack_speed']);
|
||||
if (!out && !inside) return;
|
||||
const sync = () => {
|
||||
const running = !!(unit.skill && unit.skill.active && unit.skill.isTimed);
|
||||
const v = up(unit) ? (running ? inside : out) : 0;
|
||||
const cur = unit.findBuff(ASPD_KEY);
|
||||
if (v && (!cur || cur.mods?.aspd !== v)) battle.addBuff(unit, { key: ASPD_KEY, mods: { aspd: v }, tags: ['talent'] });
|
||||
else if (!v && cur) battle.removeBuff(unit, ASPD_KEY);
|
||||
};
|
||||
battle.on('tick', sync, { owner: unit });
|
||||
battle.on('battleStart', sync, { owner: unit });
|
||||
for (const ev of ['deploy', 'skillStart', 'skillEnd']) battle.on(ev, (ctx) => { if (ctx.unit === unit && battle.started) sync(); }, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// ART-X: 攻击被阻挡的敌人时攻击力提升至110% (every damage she calculates)
|
||||
if (blocked !== 1) {
|
||||
battle.on('hit', (ctx) => {
|
||||
const t = ctx.target;
|
||||
if (ctx.source !== unit || !t || t.side !== 'enemy' || !t.blockedBy || ctx.dmg.type === 'element') return;
|
||||
ctx.dmg.amount *= blocked;
|
||||
}, { owner: unit });
|
||||
}
|
||||
// ART-Y: 攻击时无视敌人100点的防御力
|
||||
statBuff(battle, unit, 'trait:phenxi:pierce', { defIgnoreFlat: num(tb.def_penetrate_fixed) });
|
||||
// “你须愧悔”: the attack it is cast for makes no hit of its own
|
||||
if (unit.skill?.id === S2) {
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || unit.mem.phenxiCastAt !== battle.time) return;
|
||||
unit.mem.phenxiCastAt = null;
|
||||
ctx.targets = [];
|
||||
}, { owner: unit, priority: 100 });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
// server/sim/content/kits/ops/op-wisdel.js — 维什戴尔 (char_1035_wisdel) 自选 operator kit: 6★ 投掷手 (狙击), an owned-6★ pick
|
||||
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, her module BOM-X “‘祖宗发射器’” at every form, and
|
||||
// the kit of her summon “魂灵之影” (token_10035_wisdel_wward). Kit contract and the 自选 rules: ../README.md ("How to add an
|
||||
// operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_1035_wisdel, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
|
||||
// elite = E2 Lv60, rank 7, the module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0
|
||||
// [ASSUMED: no account]. Sources: character_table / skill_table / battle_equip_table and the token record (zh_CN, as
|
||||
// built into backups.json); PRTS 维什戴尔, 魂灵之影 and 溅射半径一览 (the 备注 quoted below); the client's skill prefabs
|
||||
// ([uc]skills skchr_wisdel_1 / 2 / 3, sktok_wisdel_wward) and buff templates (wisdel_t_1[bomb] / [projectile_shock],
|
||||
// wisdel_token_t_1[bomb], token_wisdel_passive[to_host], token_wisdel_host[Camouflage], token_wisdel_skill_end,
|
||||
// wisdel_s_2[switch] / [overload_start], wisdel_s_3[trigger_spawn], wisdel_s_3_token[add_sp]).
|
||||
// - Trait (投掷手) "攻击对小范围的地面敌人造成两次物理伤害(第二次为余震,伤害降低至攻击力的一半)": the bombarder profile — a
|
||||
// ground-only splash of SPLASH (PRTS 溅射半径一览 投掷手 0.9) at full damage, then 余震 (aftershocks) on the same spot
|
||||
// SHOCK_GAP s apart (the engine's spacing [ASSUMED there]) at attack@append_atk_scale × the attack's ATK — her 3-9,
|
||||
// blocks 1, never targets air units (data canHitFly false), ground enemies target her. The kit replaces the profile's
|
||||
// aftershock (`afterHit`) to carry 好礼 and the skills' changes. Module BOM-X “‘祖宗发射器’” "三次物理伤害(后两次为余震…)"
|
||||
// (trait bb attack@enable_third_attack 1): two aftershocks; ATK / 攻击速度 in the stats; stage 3 changes 好礼 (below).
|
||||
// - T1 好礼 "攻击时对主目标的攻击力提升至115%并为其附着残影,残影受到维什戴尔的余震影响时有15%概率爆炸,对周围所有敌人造成
|
||||
// 150%攻击力的物理伤害并使其晕眩1秒" (BOM-X stage 3: 125 % / 175 %). PRTS 备注 "攻击倍率提升效果仅对普通攻击(含余震)生效",
|
||||
// "残影持续时间无限,不可叠加,维什戴尔退场后消失;每次余震独立判定残影爆炸,爆炸半径1.1(中点判定),爆炸效果可对空": every
|
||||
// main target of her attacks (each of S2's targets, each overload round) takes ×main_atk_scale on that hit and on her
|
||||
// aftershocks, and carries her 残影 (one per enemy, removed when she leaves the field — a knock-out too); each
|
||||
// aftershock hit on a marked enemy that survives it rolls `prob` (wisdel_t_1[projectile_shock]: Dice, then the AOE) —
|
||||
// on success the mark is spent and every targetable enemy within attack@range_radius of it, air units too, takes
|
||||
// bomb_atk_scale × ATK physical and is stunned attack@stun s.
|
||||
// - T2 死魂灵的余息 "部署后立刻在攻击范围内召唤一个魂灵之影,在魂灵之影周围时获得迷彩": each deployment summons one 魂灵之影 on a
|
||||
// free deployable tile of her range — PRTS 备注 "部署时优先在距离自身最近的>地图下方行>地图左侧列可部署位部署" (nearest to
|
||||
// her, then the lower row, then the left column; at once — the deploy animation is not modelled [ASSUMED]) — while fewer
|
||||
// than SHADOW_CAP stand ("最多存在3个"; beyond it nothing is summoned — S3_Spawn `_checkTokenMaxDeployCnt`, the talent's
|
||||
// summon read alike [ASSUMED]). A shadow coming in gives her 迷彩 while it
|
||||
// stays when she stands on its x-5 (token_wisdel_passive[to_host] + token_wisdel_host[Camouflage]: only the host keeps
|
||||
// it; PRTS "仅在登场时尝试给予维什戴尔迷彩…可受孤立干扰", "本天赋的迷彩不会因维什戴尔阻挡敌人而解除" — the engine's 迷彩
|
||||
// never lifts by blocking). Shadows stay when she leaves [ASSUMED: no source withdraws them; their skill needs her range].
|
||||
// 魂灵之影 (shadowKit): its stats are the form's (token variant), 禁疗, no normal attack, no range of its own (PRTS 备注 "持有
|
||||
// 禁疗 / 无视野范围 / 不进行普通攻击"); its skill (AUTO, 5 SP over time) "对一名在维什戴尔攻击范围内的敌人造成法术伤害,对其造成
|
||||
// 1秒停顿并附着残影,之后获得0-2点技力" — PRTS "技能仅攻击维什戴尔攻击范围内的敌人,优先攻击未携带残影的敌人,不可对空" (prefab
|
||||
// SkillTrigger: `_fetchHost`, `_targetMotion` 1, `_withoutThisBuff` wisdel_t_1[bomb]): cast by this kit once ready with a
|
||||
// targetable ground enemy in her current range (the data's DEFAULT read on her range, which the engine's rule cannot see),
|
||||
// unmarked ones first, then the usual order: its ATK × 1 arts (`_atkScale` 1, `_damageType` magical), 停顿 `sluggish` s,
|
||||
// her 残影 (while she is on the field — wisdel_token_t_1[bomb] hangs it on her talent), then
|
||||
// sp_min … sp_max − 1 SP (token_wisdel_skill_end: RandomSetter, forced). It can be attacked (实体类型 默认).
|
||||
// - S1 定点清算 (AUTO, attack SP, data DEFAULT — the next attack): that attack splashes S1_SPLASH (PRTS 备注 "溅射半径1.1";
|
||||
// its aftershocks too [ASSUMED]), makes 2 more aftershocks, each at append_atk_scale × ATK, and stuns every enemy it hits
|
||||
// (main, splash, aftershocks) stun_duration s.
|
||||
// - S2 饱和复仇 (MANUAL, 25 s, data DEFAULT): ATK +atk, attack interval +base_attack_time s (−0.5 / −0.7 on 2.1 s), 3 targets
|
||||
// at once. 过载 (ba.overdrive "技能持续拥有两段计量槽,技能进行到一半时触发额外效果": the second half, as 号角 S3 —
|
||||
// OVERLOAD_AT): every attack becomes OVERLOAD_SHOTS rounds of attack@atk_scale_ol × ATK, each on a random enemy of her
|
||||
// range (PRTS "过载模式下不再同时攻击3个目标,4次攻击分别随机索敌"; one draw per round, repeats allowed), the rounds of one
|
||||
// attack at once [ASSUMED], their aftershocks at append_atk_scale × that ATK (PRTS S3 备注 "攻击倍率提升效果仅对普通攻击
|
||||
// 与余震生效" read for the overload rounds too [ASSUMED]). Manual close: N/A in the auto battle.
|
||||
// - S3 爆裂黎明 (MANUAL, 6 发弹药 — attack@trigger_time, data SP_FULL from the official ALWAYS row): at once max_cnt shadows
|
||||
// (the first one with `sp` SP: wisdel_s_3_token[add_sp] — PRTS "初始技力分别为3sp(优先召唤)与0sp"), then ATK +atk, attack
|
||||
// interval +base_attack_time s, each attack attack@atk_scale_3 × ATK with a splash of S3_SPLASH (PRTS 备注 "攻击溅射半径
|
||||
// 2.5"), 好礼 at attack@prob (100 %). PRTS "施加锁定、普通攻击、溅射均可对空": she targets and splashes air units meanwhile;
|
||||
// the aftershocks stay on ground enemies [ASSUMED: PRTS names only the lock, the attack and its splash] on the 2.5 spot
|
||||
// [ASSUMED], at append_atk_scale × the boosted ATK (the same 备注). Its <爆裂锁定> picks the target her own selection
|
||||
// picks [ASSUMED: one 维什戴尔, no shared lock].
|
||||
|
||||
import { num, talentBb, traitBb, skillRec, up, batMod } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
|
||||
const S1 = 'skchr_wisdel_1';
|
||||
const S2 = 'skchr_wisdel_2';
|
||||
const S3 = 'skchr_wisdel_3';
|
||||
/** Her summon “魂灵之影” (T2 死魂灵的余息, S3 爆裂黎明). */
|
||||
export const SHADOW = 'token_10035_wisdel_wward';
|
||||
/** PRTS 溅射半径一览: 投掷手 0.9 (the bombarder profile's). */
|
||||
const SPLASH = 0.9;
|
||||
/** PRTS S1 备注 "溅射半径1.1" / S3 备注 "攻击溅射半径2.5". */
|
||||
const S1_SPLASH = 1.1;
|
||||
const S3_SPLASH = 2.5;
|
||||
/** Seconds between two aftershocks (professions.js bombarder's spacing) [ASSUMED there]. */
|
||||
const SHOCK_GAP = 0.3;
|
||||
/** "最多存在3个魂灵之影" (the token's maxDeployCount 3). */
|
||||
const SHADOW_CAP = 3;
|
||||
/** 过载 from the second half of the skill (ba.overdrive). */
|
||||
const OVERLOAD_AT = 0.5;
|
||||
/** "4连发". */
|
||||
const OVERLOAD_SHOTS = 4;
|
||||
/** The shadow's x-5 (PRTS 魂灵之影 备注 "登场时给予自身及周围4格的其他我方单位迷彩"). */
|
||||
const X5 = Object.freeze([[1, 0], [0, -1], [0, 0], [0, 1], [-1, 0]]);
|
||||
const GROUND = Object.freeze({ canHitFly: false });
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
/** Buff key of 维什戴尔 `host`'s 残影 on an enemy (one per enemy: "不可叠加"). */
|
||||
const markKey = (host) => `wisdel:mark:${host.id}`;
|
||||
/** Buff key of the 迷彩 a shadow gave its host. */
|
||||
const camoKey = (t) => `wisdel:camou:${t.id}`;
|
||||
const isShadowOf = (t, host) => !!t && t.kind === 'token' && t.defId === SHADOW && t.ownerUnit === host;
|
||||
const shadowsOf = (battle, host) => battle.allyUnits.filter((t) => t.alive && isShadowOf(t, host));
|
||||
|
||||
/** Hang `host`'s 残影 on enemy `e` (while she is on the field). */
|
||||
function markOf(battle, host, e) {
|
||||
if (!e || !e.alive || e.side !== 'enemy' || !up(host)) return;
|
||||
const key = markKey(host);
|
||||
if (!e.findBuff(key)) battle.addBuff(e, { key, source: host, tags: ['wisdel:mark'] });
|
||||
}
|
||||
|
||||
/** 好礼's explosion on marked enemy `e` (the mark is spent). */
|
||||
function detonate(battle, unit, e, bomb) {
|
||||
battle.removeBuff(e, markKey(unit));
|
||||
const x = e.x, y = e.y;
|
||||
battle.fx('aoe', { x, y, radius: bomb.radius, id: unit.id, skill: 'wisdel:bomb' });
|
||||
for (const v of battle.foesInRadius(x, y, bomb.radius, true)) { // 爆炸效果可对空
|
||||
battle.dealDamage(unit, v, { amount: unit.s.atk * bomb.scale * unit.s.atkScaleMul, type: 'phys', isSplash: true, tags: ['wisdel:bomb'] });
|
||||
if (bomb.stun > 0 && hasHp(v)) battle.applyStatus(v, 'stun', { duration: bomb.stun, source: unit });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The aftershocks of one hit (the profile's `afterHit`): `count` shocks SHOCK_GAP s apart on the hit's spot, each
|
||||
* hitting every targetable ground enemy within `radius` for `atkScale` × `shockScale` × ATK (×`mainScale` on the main
|
||||
* target), stunning them `stun` s, and rolling 好礼 on each marked survivor (`prob`).
|
||||
*/
|
||||
function shocks({ count, atkScale, shockScale, radius, prob, stun, mainScale, bomb }) {
|
||||
return (battle, unit, target, hctx) => {
|
||||
for (let i = 1; i <= count; i++) {
|
||||
battle.after(SHOCK_GAP * i, () => {
|
||||
for (const e of battle.foesInRadius(hctx.x, hctx.y, radius, true)) {
|
||||
if (e.isFlying) continue; // 余震: 地面敌人
|
||||
const boost = e === target ? mainScale : 1;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * atkScale * shockScale * boost * unit.s.atkScaleMul, type: 'phys', isSplash: true, isSkill: !!hctx.isSkill, tags: ['aftershock', 'wisdel:shock'] });
|
||||
if (!hasHp(e)) continue;
|
||||
if (stun > 0) battle.applyStatus(e, 'stun', { duration: stun, source: unit });
|
||||
if (prob > 0 && e.findBuff(markKey(unit)) && (prob >= 1 || battle.rng.chance(prob))) detonate(battle, unit, e, bomb);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** The free deployable tile of her range a shadow takes (PRTS: nearest to her > lower row > left column), or null. */
|
||||
function shadowTile(battle, host) {
|
||||
let best = null, bd = Infinity;
|
||||
for (const k of host.rangeKeys ?? []) {
|
||||
const r = Math.floor(k / COLS), c = k % COLS;
|
||||
if (!battle.grid.inRect(r, c) || !battle.grid.canStand(r, c, { ranged: true }) || battle.isReservedTile(r, c)) continue;
|
||||
const d = Math.hypot(r - host.tileR, c - host.tileC);
|
||||
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && (r < best[0] || (r === best[0] && c < best[1])))) { bd = d; best = [r, c]; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The enemy a shadow strikes: a targetable ground enemy of `host`'s range, unmarked first, then the usual order. */
|
||||
function shadowTarget(battle, host, t) {
|
||||
const pool = battle.enemiesInKeys(host.rangeKeys, t, GROUND);
|
||||
if (!pool.length) return null;
|
||||
const key = markKey(host);
|
||||
const fresh = pool.filter((e) => !e.findBuff(key));
|
||||
const list = fresh.length ? fresh : pool;
|
||||
sortEnemyTargets(battle, t, list, null);
|
||||
return list[0];
|
||||
}
|
||||
|
||||
/** 魂灵之影's kit (header); `host` = 维什戴尔. */
|
||||
function shadowKit(host) {
|
||||
return {
|
||||
skill: {
|
||||
kind: 'instant',
|
||||
trigger: 'NEVER', // cast by the install below on her range
|
||||
onStart({ battle, unit, bb }) {
|
||||
const e = unit.mem.wisdelTarget;
|
||||
unit.mem.wisdelTarget = null;
|
||||
if (e && e.alive) {
|
||||
battle.fx('strike', { x: e.x, y: e.y, id: unit.id, target: e.id });
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * unit.s.atkScaleMul, type: 'arts', isSkill: true, tags: ['skill', 'wisdel:shadow'] });
|
||||
if (hasHp(e)) {
|
||||
const slug = num(bb.sluggish);
|
||||
if (slug > 0) battle.applyStatus(e, 'sluggish', { duration: slug, source: unit });
|
||||
markOf(battle, host, e);
|
||||
}
|
||||
}
|
||||
// token_wisdel_skill_end: 之后获得0-2点技力 (an integer of [sp_min, sp_max), forced)
|
||||
const lo = Math.floor(num(bb.sp_min)), hi = Math.floor(num(bb.sp_max, lo + 1));
|
||||
const add = lo + battle.rng.int(Math.max(1, hi - lo));
|
||||
if (add > 0) unit.skill.gainSp(add, 'init');
|
||||
},
|
||||
},
|
||||
trait: { noAttack: true },
|
||||
install(battle, t) {
|
||||
battle.addBuff(t, { key: 'trait:abnormal', flags: { noHeal: true }, persist: true, allowDead: true }); // 持有禁疗
|
||||
battle.on('tick', () => {
|
||||
const sk = t.skill;
|
||||
if (!t.alive || !t.deployed || !sk || !sk.ready || sk.active || !t.canAct || t.s.flags.silence || !up(host)) return;
|
||||
const e = shadowTarget(battle, host, t);
|
||||
if (!e) return;
|
||||
t.mem.wisdelTarget = e;
|
||||
sk.activate('DEFAULT');
|
||||
}, { owner: t });
|
||||
battle.on('death', (ctx) => {
|
||||
if (ctx.unit === t && host.findBuff(camoKey(t))) battle.removeBuff(host, camoKey(t));
|
||||
}, { owner: t });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A shadow coming in: 迷彩 for its host while it stays, when she stands on its x-5 and may be selected by it. */
|
||||
function grantCamo(battle, host, t) {
|
||||
if (!up(host) || !battle.allySelectable(host, t)) return;
|
||||
if (!absoluteRangeKeys(X5, t.tileR, t.tileC, t.dir, 0).includes(host.tileR * COLS + host.tileC)) return;
|
||||
battle.addBuff(host, { key: camoKey(t), flags: { camou: true }, source: t, tags: ['talent'], visible: true });
|
||||
battle.fx('camouflage', { x: host.x, y: host.y, id: host.id });
|
||||
}
|
||||
|
||||
/** Summon up to `n` shadows for `host` (never beyond SHADOW_CAP); the first one starts with `firstSp` SP. */
|
||||
function summonShadows(battle, host, n, firstSp = 0) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (shadowsOf(battle, host).length >= SHADOW_CAP) return;
|
||||
const tile = shadowTile(battle, host);
|
||||
if (!tile) return;
|
||||
const t = battle.spawnToken(host, SHADOW, tile[0], tile[1], { kit: shadowKit(host) });
|
||||
if (!t) return;
|
||||
if (i === 0 && firstSp > 0 && t.skill) t.skill.gainSp(firstSp, 'init');
|
||||
battle.fx('summon', { x: t.x, y: t.y, id: host.id, token: SHADOW });
|
||||
grantCamo(battle, host, t);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
char_1035_wisdel: (bb, chess) => {
|
||||
const tb = traitBb(chess);
|
||||
const shockScale = num(tb['attack@append_atk_scale'], 0.5);
|
||||
const count = num(tb['attack@enable_third_attack']) > 0 ? 2 : Math.max(1, Math.floor(num(tb['attack@times'], 2)) - 1);
|
||||
const t0 = talentBb(chess, 0);
|
||||
const mainScale = num(t0['attack@main_atk_scale'], 1);
|
||||
const bomb = { scale: num(t0['attack@bomb_atk_scale']), stun: num(t0['attack@stun']), radius: num(t0['attack@range_radius'], 1.1) };
|
||||
const base = { count, atkScale: 1, shockScale, radius: SPLASH, prob: num(t0['attack@prob']), stun: 0, mainScale, bomb };
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s1 = skillRec(chess, S1);
|
||||
const s3Scale = num(b3['attack@atk_scale_3'], 1);
|
||||
return {
|
||||
trait: {
|
||||
splashRadius: SPLASH,
|
||||
...(mainScale !== 1 ? { dmgMul: () => mainScale } : null), // 好礼: 主目标 ×main_atk_scale (ai.js: the main target only)
|
||||
onEachHit(battle, unit, victim, hc) { if (hc.kind === 'main') markOf(battle, unit, victim); }, // 附着残影
|
||||
afterHit: shocks(base),
|
||||
},
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
attack: {
|
||||
splashRadius: S1_SPLASH,
|
||||
onEachHit({ battle, unit, target }) {
|
||||
const d = num(b1.stun_duration);
|
||||
if (d > 0 && target && target.side === 'enemy' && hasHp(target)) battle.applyStatus(target, 'stun', { duration: d, source: unit });
|
||||
},
|
||||
afterHit: shocks({ ...base, count: count + 2, shockScale: num(b1.append_atk_scale, shockScale), radius: S1_SPLASH, stun: num(b1.stun_duration) }),
|
||||
},
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b2.atk), batPct: batMod(b2.base_attack_time, chess) },
|
||||
targeting: { maxTargets: 3 },
|
||||
onStart({ unit }) { unit.mem.wisdelOverload = false; },
|
||||
onTick({ battle, unit, skill }) {
|
||||
if (unit.mem.wisdelOverload || skill.timeLeft > skill.duration * (1 - OVERLOAD_AT) + 1e-9) return;
|
||||
unit.mem.wisdelOverload = true;
|
||||
battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id });
|
||||
},
|
||||
onEnd({ unit }) { unit.mem.wisdelOverload = false; },
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'ammo',
|
||||
ammo: Math.max(1, Math.floor(num(b3['attack@trigger_time'], 6))),
|
||||
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
|
||||
targeting: { canHitFly: true },
|
||||
attack: {
|
||||
atkScale: s3Scale,
|
||||
splashRadius: S3_SPLASH,
|
||||
groundOnly: false,
|
||||
afterHit: shocks({ ...base, atkScale: s3Scale, radius: S3_SPLASH, prob: num(b3['attack@prob'], 1) }),
|
||||
},
|
||||
onStart({ battle, unit }) { summonShadows(battle, unit, Math.max(0, Math.floor(num(b3.max_cnt, 1))), num(b3.sp)); },
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 好礼: her 残影 leave with her
|
||||
battle.on('death', (ctx) => {
|
||||
if (ctx.unit !== unit) return;
|
||||
const key = markKey(unit);
|
||||
for (const e of battle.enemies) if (e.findBuff(key)) battle.removeBuff(e, key);
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 死魂灵的余息: a shadow at each deployment
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === unit) summonShadows(battle, unit, 1); }, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// 过载: every attack becomes 4 rounds at attack@atk_scale_ol, each on a random enemy of her range
|
||||
if (unit.skill?.id !== S2) return;
|
||||
const ol = num(b2['attack@atk_scale_ol'], 1);
|
||||
const olShock = shocks({ ...base, atkScale: ol });
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !unit.mem.wisdelOverload || !unit.skill?.active || !ctx.profile) return;
|
||||
const pool = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
|
||||
for (const e of battle.blockedTargets(unit, ctx.profile)) if (!pool.includes(e)) pool.push(e);
|
||||
if (!pool.length) return;
|
||||
const picks = [];
|
||||
for (let i = 0; i < OVERLOAD_SHOTS; i++) picks.push(battle.rng.pick(pool));
|
||||
ctx.targets = picks;
|
||||
// (a per-attack copy while the skill runs: its targeting override makes effectiveProfile copy the profile)
|
||||
ctx.profile.atkScale = ol;
|
||||
ctx.profile.afterHit = olShock;
|
||||
}, { owner: unit, priority: -100 });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user