feedback5 WK: merge feedback5 (O2–O8 自选 operator kits and their engine hooks; 焰狐龙梓兰 out of the 自选 pool) — no conflict; golden unchanged (the 自选 match and the 52 diy scenarios match)
This commit is contained in:
commit
335f3f6045
68 files changed
+14722
-148
No files matched your search
+32
-5
@@ -185,19 +185,39 @@ export function performAttack(b, u, prof, targets, opts = null) {
|
||||
* current position at BOOMERANG_RETURN_SPEED, dealing nothing on the way back, and is caught (u.trait.boomerangsOut −1:
|
||||
* the thrower attacks again once every boomerang is back). A thrower knocked out / withdrawn meanwhile loses it — nothing
|
||||
* returns to a unit off the field or to a later deployment of it (the deploy hook hands it a fresh one).
|
||||
* Content: a catch fires the hook `boomerangCaught` { unit, attackId, isSkill, x, y } (娜仁图亚 LPS-Y "每回收5次回旋投射物",
|
||||
* S3 "投射物全部回收时"); an attack profile with `boomerangOnward(ctx)` (a skill's attack override: 娜仁图亚 S1's bounces,
|
||||
* S2's dash) takes the flight over after the first hit — ctx { battle, unit, profile, target, x, y, attackId, isSkill,
|
||||
* home(), hit(target, x, y) (resolveHit with this attack's profile), comeBack(x, y) (sends it back from there, once;
|
||||
* returns the return projectile or null) } — and a content error there sends it back from the hit point.
|
||||
*/
|
||||
function throwBoomerang(b, u, prof, t, info) {
|
||||
const seq = u.deploySeq;
|
||||
u.trait.boomerangsOut = (u.trait.boomerangsOut || 0) + 1;
|
||||
const home = () => u.alive && u.deployed && u.deploySeq === seq;
|
||||
let sent = false;
|
||||
const comeBack = (x, y) => {
|
||||
if (sent || !home()) return null;
|
||||
sent = true;
|
||||
// hitDead: flies on to the thrower's last position even while it is hidden, caught there when it is still home
|
||||
return b.addProjectile({ from: { x, y }, target: u, speed: BOOMERANG_RETURN_SPEED, visual: 'boomerangReturn', source: u, hitDead: true,
|
||||
onHit: (r) => {
|
||||
if (!home() || !(u.trait.boomerangsOut > 0)) return;
|
||||
u.trait.boomerangsOut--;
|
||||
if (b._hooks.boomerangCaught) b.emit('boomerangCaught', { unit: u, attackId: info.attackId ?? 0, isSkill: !!info.isSkill, x: r.x, y: r.y });
|
||||
} });
|
||||
};
|
||||
b.addProjectile({ from: u, target: t, speed: PROJECTILE_SPEEDS.boomerang, visual: 'boomerang', source: u, hitDead: true,
|
||||
onHit: (c) => {
|
||||
// (guarded on its own: a content error in the hit must not cost the thrower its boomerang for the battle)
|
||||
if (c.target || prof.splashRadius > 0) b._safe(() => resolveHit(b, u, prof, c.target, info, c.x, c.y), 'boomerang.hit', u);
|
||||
if (!home()) return;
|
||||
// hitDead: flies on to the thrower's last position even while it is hidden, caught there when it is still home
|
||||
b.addProjectile({ from: { x: c.x, y: c.y }, target: u, speed: BOOMERANG_RETURN_SPEED, visual: 'boomerangReturn', source: u, hitDead: true,
|
||||
onHit: () => { if (home() && u.trait.boomerangsOut > 0) u.trait.boomerangsOut--; } });
|
||||
if (typeof prof.boomerangOnward === 'function') {
|
||||
const ctx = { battle: b, unit: u, profile: prof, target: c.target, x: c.x, y: c.y, attackId: info.attackId ?? 0, isSkill: !!info.isSkill,
|
||||
home, comeBack, hit: (tgt, x, y) => b._safe(() => resolveHit(b, u, prof, tgt, info, x, y), 'boomerang.hit', u) };
|
||||
if (b._safe(() => { prof.boomerangOnward(ctx); return true; }, 'boomerang.onward', u) === true) return;
|
||||
}
|
||||
comeBack(c.x, c.y);
|
||||
} });
|
||||
}
|
||||
|
||||
@@ -656,10 +676,17 @@ function enemyAttack(b, e) {
|
||||
if (own && targets.length) targets = targets.filter((a) => own(a));
|
||||
if (targets.length > 1) sortAllyTargets(e, targets);
|
||||
if (!targets.length) return false;
|
||||
// 麻痹 (ba.palsy): each stack interrupts one normal attack
|
||||
// 麻痹 (ba.palsy): each stack interrupts one normal attack. The `palsyTrigger` hook { enemy, buff, keep } fires as it
|
||||
// does (PRTS 真言 备注: "触发麻痹时" = the attack interrupted by 麻痹, not a stack gained): a handler may set `keep` — the
|
||||
// stack is not consumed (真言 噤声限域 "触发麻痹时有N%概率不消耗麻痹层数") — or deal damage (the enemy may die there)
|
||||
const palsy = e.buffs.length ? e.findBuff('palsy') : null;
|
||||
if (palsy) {
|
||||
if (--palsy.stacks <= 0) b.removeBuff(e, palsy); else e.markDirty();
|
||||
let keep = false;
|
||||
if (b._hooks.palsyTrigger) {
|
||||
keep = !!b.emit('palsyTrigger', { enemy: e, buff: palsy, keep: false }).keep;
|
||||
if (!e.alive) return false;
|
||||
}
|
||||
if (!keep) { if (--palsy.stacks <= 0) b.removeBuff(e, palsy); else e.markDirty(); }
|
||||
e.atkCd = e.s.interval;
|
||||
// the interrupted attack ends its clip: the old short stand after it (PRTS 异常效果 麻痹: 0.5 s 麻痹震颤 — not modelled)
|
||||
if (!e.blockedBy && radius > 0 && !(e.profile?.attackMoves ?? def.attackMoves)) e.atkStandUntil = b.time + ATTACK_PAUSE;
|
||||
|
||||
@@ -155,6 +155,9 @@ export function makeBuff(b) {
|
||||
tags: b.tags ?? [],
|
||||
shield: b.shield ?? (b.mods && b.mods.shield) ?? 0,
|
||||
shieldHits: b.shieldHits ?? 0,
|
||||
// a 屏障 that absorbs one damage type only (damage.js absorbShields): 'phys' | 'arts' | 'true' | 'elemental'; null =
|
||||
// every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害") — 夜莺 S2 法术护盾 "能吸收…法术伤害"
|
||||
shieldType: b.shieldType ?? null,
|
||||
persist: !!b.persist,
|
||||
status: b.status ?? null,
|
||||
visible: b.visible ?? false,
|
||||
|
||||
@@ -143,7 +143,7 @@ or a prototype (DATA.md §18, `shared/diy.js`). The prototypes run their stand-i
|
||||
an owned 6★ can be picked only once it has a kit of its own: one file per operator, the same rules as every kit above.
|
||||
Contributions are welcome — one operator per pull request is easiest to review.
|
||||
|
||||
**Who.** The owned-6★ picks are `data/backups.json diy.ownedPool` (72 operators; the collab operators are not included,
|
||||
**Who.** The owned-6★ picks are `data/backups.json diy.ownedPool` (71 operators; the collab operators are not included,
|
||||
the owner's decision of 2026-10-05). The ones still without a kit:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -78,7 +78,32 @@ export const STANDIN_KIT_FILES = Object.freeze(['standin-acguad.js', 'standin-sh
|
||||
* The 自选 operator kit files (`ops/op-<codename>.js`, registry key = the operator's charId, an owned-6★ pick of
|
||||
* data/backups.json; a new file is appended). An operator without a file is not offered as a 自选 pick (KITTED_CHARS).
|
||||
*/
|
||||
export const OPERATOR_KIT_FILES = Object.freeze(['op-siege.js']);
|
||||
export const OPERATOR_KIT_FILES = Object.freeze([
|
||||
'op-siege.js',
|
||||
'op-chen.js',
|
||||
'op-irene.js',
|
||||
'op-helage.js',
|
||||
'op-shining.js',
|
||||
'op-cgbird.js',
|
||||
'op-saga.js',
|
||||
'op-amgoat.js',
|
||||
'op-cerber.js',
|
||||
'op-heyak.js',
|
||||
'op-poca.js',
|
||||
'op-typhon.js',
|
||||
'op-narant.js',
|
||||
'op-logos.js',
|
||||
'op-mantra.js',
|
||||
'op-veen.js',
|
||||
'op-zuole.js',
|
||||
'op-chyue.js',
|
||||
'op-huang.js',
|
||||
'op-shwaz.js',
|
||||
'op-bgsnow.js',
|
||||
'op-ifrit.js',
|
||||
'op-aphris.js',
|
||||
'op-lin.js',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The 预备干员 whose every skill the generic kit covers exactly (冲锋号令, 攻击力 / 防御力 / 治疗强化, 战术咏唱, 一击即退 —
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// server/sim/content/kits/ops/op-amgoat.js — 艾雅法拉 (char_180_amgoat) 自选 operator kit: 6★ 中坚术师 (术师), an owned-6★
|
||||
// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_180_amgoat, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json; every module part is
|
||||
// valid outside 集成战略 — no `validInGameTag`) and PRTS 艾雅法拉 (S1 备注 "每次部署艾雅法拉时,重新计算该技能的使用次数";
|
||||
// S2 备注 "爆炸范围为半径1.5", "技能实际效果为先削减主攻击目标的法术抗性,再对爆炸范围内所有敌人造成½倍率的伤害(包括主攻击目标),
|
||||
// 最后对主攻击目标额外造成一次½倍率的伤害", 法术抗性 "{{**|80%|-20%}}" = a multiplier; S3 "攻击间隔大幅度缩短(-1.1)";
|
||||
// 乱火's hidden 备注 "在最小值与最大值之间随机生成一个浮点数后向下取整").
|
||||
// - Trait (中坚术师) "攻击造成法术伤害": the profession default (ranged arts, hits air, block 1). Module CCR-X “错过的声音”
|
||||
// adds "无视目标10点法术抗性" (trait bb magic_resist_penetrate_fixed: a permanent resIgnoreFlat), CCR-Y “宠物大赛第一名”
|
||||
// "普通攻击命中精英或领袖敌人时获得1点技力" (trait bb sp: per hit of one of her attacks on an elite / leader — the S2
|
||||
// explosion's splash and its second ½ are not further 命中 [ASSUMED]; a running timed skill takes no SP, as always).
|
||||
// - T1 炎息 "在场时,所有友方【术师】职业干员的攻击力+14%": an aura on every 【术师】 operator of the field (her included) while
|
||||
// she is deployed (installAura, the kits' convention for field-wide talents; several sources keep the strongest). CCR-X
|
||||
// stage 2+ turns it into "携带时…+22%" (the module talent's bb): the same aura whether she is on the field or not — she
|
||||
// is carried while she belongs to the battle, knocked out or waiting to redeploy included.
|
||||
// - T2 乱火 "部署后立即随机获得7~15点技力" (bb sp_min 7, sp_max 16): floor(uniform [sp_min, sp_max)) SP at every deployment
|
||||
// (PRTS hidden 备注). CCR-Y stage 3 adds "并随机提升6~15的攻击速度" (attack_speed_min / _max, the same draw) for that
|
||||
// deployment [ASSUMED: no duration given — until she leaves the field], and with `factor` "部署后范围内存在精英或领袖敌人时,
|
||||
// 获得的技力和提升的攻击速度取最大值": an elite or leader enemy in her attack range at that deployment gives the text's
|
||||
// maxima, 15 SP / 15 ASPD — the blackboard maxima are the exclusive bounds of the draw (stage 2's attack_speed_max 1
|
||||
// with no ASPD in its text says so) [ASSUMED: 取最大值 = the largest value the draw yields; checked at the deployment
|
||||
// instant — at the battle start no enemy is on the field yet].
|
||||
// - S1 二重咏唱 (MANUAL, 25 s, data DEFAULT): ASPD +[a].attack_speed; from the second cast of a deployment on, ASPD
|
||||
// +[b].attack_speed and ATK +[b].atk (PRTS 备注: the count restarts at every deployment).
|
||||
// - S2 点燃 (AUTO, 可充能 2, data DEFAULT): the next attack, on impact — the main target's RES ×(1 + magic_resistance) for
|
||||
// `duration` s first, then an explosion of radius 1.5 around it (a 中点判定 splash) dealing atk_scale × ATK arts to
|
||||
// every enemy in it, the main target included, each of the others then getting the same RES cut, and last
|
||||
// atk_scale_2 × ATK arts to the main target again (the PRTS 备注's order; it names the main target's cut only as
|
||||
// coming first, so the others' cut follows their hit [ASSUMED]; the cut is "同名效果取最高" through applyStrongest).
|
||||
// The explosion reaches air units and skips enemies an area effect cannot select (foesInRadius); like the engine's
|
||||
// profession splash it can be dodged.
|
||||
// - S3 火山 (MANUAL, 15 s): ATK +atk, attack interval base_attack_time (−1.1 s flat on her 1.6 s), the attack range x-3
|
||||
// while it runs, each attack throws lava at attack@max_target enemies of that range drawn at random (all of them when
|
||||
// fewer) — one projectile each, no splash [ASSUMED: the text names none]. Trigger: the data's SKILL_RANGE on
|
||||
// x-3 — its text says "攻击范围增大", which tools/build-data.mjs ATTACK_RANGE_CHANGE does not list, so the owner's
|
||||
// ACTIVE_RANGE rule did not reach it (reported; the kit keeps the data rule).
|
||||
// - No summon (the forms list no token).
|
||||
|
||||
import { num, talentBb, traitBb, skillRec, statBuff, installAura, batMod, giveSp, onHitBy } from '../shared/tier1.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
import { canTargetEnemy } from '../../../targeting.js';
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
|
||||
const S1 = 'skchr_amgoat_1';
|
||||
const S2 = 'skchr_amgoat_2';
|
||||
const S3 = 'skchr_amgoat_3';
|
||||
/** S2 点燃: the explosion radius (PRTS 备注 "爆炸范围为半径1.5"; no blackboard key). */
|
||||
const IGNITE_RADIUS = 1.5;
|
||||
/** S3 火山's 技能范围 x-3 when the data carries no grid (range_table x-3: the tiles within 3 steps). */
|
||||
const X3 = Object.freeze([[3, 0], [2, -1], [2, 0], [2, 1], [1, -2], [1, -1], [1, 0], [1, 1], [1, 2], [0, -3], [0, -2], [0, -1],
|
||||
[0, 0], [0, 1], [0, 2], [0, 3], [-1, -2], [-1, -1], [-1, 0], [-1, 1], [-1, 2], [-2, -1], [-2, 0], [-2, 1], [-3, 0]]);
|
||||
/** Damage of the S2 explosion and its second ½ (not a further 命中 of the attack: CCR-Y counts the first). */
|
||||
const IGNITE_TAG = 'amgoat:ignite';
|
||||
const AURA_KEY = 'talent:amgoat:casters';
|
||||
const AURA_EVERY = 0.5;
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
/** A 【术师】 operator (summons are never operators). */
|
||||
const isCaster = (a) => !!a && a.kind === 'op' && a.def?.profession === 'CASTER';
|
||||
/** An elite or leader enemy (精英或领袖敌人). */
|
||||
const isEliteEnemy = (e) => !!e && e.side === 'enemy' && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS');
|
||||
const tagged = (d, tag) => !!(d && d.tags && d.tags.includes(tag));
|
||||
/** A module trait that works 在集成战略中 only (ISW modules): nothing of it applies in this mode. */
|
||||
const roguelikeOnly = (chess) => /集成战略/.test(String(chess?.trait?.moduleDesc ?? ''));
|
||||
/** Named talent `i` of the record (its desc tells the module stage's wording). */
|
||||
const talentRec = (chess, i) => (chess?.talents ?? []).filter((t) => t && t.index !== -1)[i] ?? null;
|
||||
|
||||
/** "携带时": the aura of installAura, held while she belongs to the battle — on the field or not. */
|
||||
function carriedAura(battle, unit, { key, select, mods, value }) {
|
||||
battle.every(AURA_EVERY, () => {
|
||||
for (const a of battle.alliesFor(unit)) {
|
||||
if (!select(a)) continue;
|
||||
const cur = a.findBuff(key);
|
||||
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > value && cur.timeLeft > 0.05) continue;
|
||||
battle.addBuff(a, { key, duration: AURA_EVERY + 0.1, mods, source: unit, data: { v: value }, tags: ['aura'] });
|
||||
}
|
||||
}, { owner: unit, immediate: true });
|
||||
}
|
||||
|
||||
export default {
|
||||
char_180_amgoat: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const carried = /携带时/.test(String(talentRec(chess, 0)?.desc ?? ''));
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
|
||||
const mr = num(b2.magic_resistance), resDur = num(b2.duration);
|
||||
const resCut = (battle, unit, e) => {
|
||||
if (e && e.alive && mr) battle.applyStrongest(e, 'amgoat:ignite', { duration: resDur, value: mr, mods: (v) => ({ resMul: Math.max(0, 1 + v) }), source: unit });
|
||||
};
|
||||
const maxLava = Math.max(1, Math.floor(num(b3['attack@max_target'], 1)));
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'duration',
|
||||
onStart({ battle, unit }) {
|
||||
// the casts of this deployment (PRTS 备注: "每次部署…重新计算该技能的使用次数")
|
||||
const n = unit.mem.amgoatDuetSeq === unit.deploySeq ? num(unit.mem.amgoatDuetN) + 1 : 1;
|
||||
unit.mem.amgoatDuetSeq = unit.deploySeq;
|
||||
unit.mem.amgoatDuetN = n;
|
||||
const mods = n >= 2
|
||||
? { aspd: num(b1['amgoat_s_1[b].attack_speed']), atkPct: num(b1['amgoat_s_1[b].atk']) }
|
||||
: { aspd: num(b1['amgoat_s_1[a].attack_speed']) };
|
||||
battle.addBuff(unit, { key: 'skill:amgoat:duet', mods, tags: ['skill'] });
|
||||
},
|
||||
onEnd({ battle, unit }) { battle.removeBuff(unit, 'skill:amgoat:duet'); },
|
||||
},
|
||||
[S2]: {
|
||||
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
attack: {
|
||||
// the engine's hit on the main target is its share of the explosion (½); the RES cut came first (install)
|
||||
atkScale: num(b2.atk_scale, 1),
|
||||
onEachHit({ battle, unit, target, kind }) {
|
||||
if (kind !== 'main' || !target) return;
|
||||
const atk = unit.s.atk, x = target.x, y = target.y;
|
||||
for (const e of battle.foesInRadius(x, y, IGNITE_RADIUS, true)) {
|
||||
if (e === target) continue;
|
||||
battle.dealDamage(unit, e, { amount: atk * num(b2.atk_scale, 1), type: 'arts', isAttack: true, isSplash: true, isSkill: true, tags: [IGNITE_TAG] });
|
||||
resCut(battle, unit, e);
|
||||
}
|
||||
if (hasHp(target)) battle.dealDamage(unit, target, { amount: atk * num(b2.atk_scale_2, num(b2.atk_scale, 1)), type: 'arts', isAttack: true, isSkill: true, tags: [IGNITE_TAG] });
|
||||
battle.fx('explosion', { x, y, id: unit.id, r: IGNITE_RADIUS, dmgType: 'arts' });
|
||||
},
|
||||
},
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
|
||||
// every enemy of x-3 is a candidate; install draws the lava targets among them
|
||||
targeting: { rangeGrid: s3?.rangeGrid ?? X3, allInRange: true },
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 炎息: the field's 【术师】 ATK +atk — while she is deployed, or (CCR-X stage 2+) carried
|
||||
const a = num(t0.atk);
|
||||
if (!(a > 0)) return;
|
||||
const opts = { key: AURA_KEY, value: a, select: isCaster, mods: { atkPct: a } };
|
||||
if (carried) carriedAura(battle, unit, opts);
|
||||
else installAura(battle, unit, { ...opts, interval: AURA_EVERY });
|
||||
} },
|
||||
{ install(battle, unit) { // 乱火: SP (and with CCR-Y stage 3 ASPD) at every deployment; the maxima with an elite in range
|
||||
const spLo = num(t1.sp_min), spHi = num(t1.sp_max), asLo = num(t1.attack_speed_min), asHi = num(t1.attack_speed_max);
|
||||
const maxRule = num(t1.factor) > 0;
|
||||
battle.on('deploy', (ctx) => {
|
||||
if (ctx.unit !== unit || !unit.alive) return;
|
||||
// (a scan of the enemies: the tile index of enemiesInKeys is the last tick's)
|
||||
const top = maxRule && battle.enemies.some((e) => isEliteEnemy(e) && canTargetEnemy(unit, e, unit.profile) && bodyInKeys(e, unit.rangeKeySet));
|
||||
// floor of a uniform draw in [lo, hi); "取最大值" = the largest value the draw yields (hi − 1 for integer bounds)
|
||||
const draw = (lo, hi) => (hi > lo ? (top ? Math.ceil(hi) - 1 : Math.floor(battle.rng.range(lo, hi))) : 0);
|
||||
const sp = draw(spLo, spHi);
|
||||
if (sp > 0) giveSp(unit, sp);
|
||||
const as = draw(asLo, asHi);
|
||||
if (as > 0) battle.addBuff(unit, { key: 'talent:amgoat:wildfire', mods: { aspd: as }, tags: ['talent'] });
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// module traits (none on the normal form): CCR-X 无视目标10点法术抗性, CCR-Y SP per hit on an elite / leader
|
||||
if (!roguelikeOnly(chess)) {
|
||||
const tb = traitBb(chess);
|
||||
const pen = num(tb.magic_resist_penetrate_fixed);
|
||||
if (pen > 0) statBuff(battle, unit, 'trait:amgoat:resIgnore', { resIgnoreFlat: pen });
|
||||
const sp = num(tb.sp);
|
||||
if (sp > 0) {
|
||||
battle.on('damaged', (ctx) => {
|
||||
const d = ctx.dmg;
|
||||
if (ctx.source !== unit || !d || !d.isAttack || d.isSplash || tagged(d, IGNITE_TAG) || !isEliteEnemy(ctx.target)) return;
|
||||
giveSp(unit, sp, 'trait');
|
||||
}, { owner: unit });
|
||||
}
|
||||
}
|
||||
const sid = unit.skill?.id;
|
||||
if (sid === S2) {
|
||||
// 点燃: "先削减主攻击目标的法术抗性" — the main target's RES cut lands before the first damage of the attack
|
||||
onHitBy(battle, unit, (ctx) => {
|
||||
const d = ctx.dmg;
|
||||
if (d.isAttack && d.isSkill && !d.isSplash && !tagged(d, IGNITE_TAG)) resCut(battle, unit, ctx.target);
|
||||
}, 50);
|
||||
} else if (sid === S3) {
|
||||
// 火山: "随机对攻击范围内至多N个敌人发射熔岩" — a random draw among every enemy of the range
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !unit.skill?.active || ctx.targets.length <= maxLava) return;
|
||||
ctx.targets = battle.rng.shuffle(ctx.targets.slice()).slice(0, maxLava);
|
||||
}, { owner: unit });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
// server/sim/content/kits/ops/op-aphris.js — 谬因 (char_4229_aphris) 自选 operator kit: 6★ 轰击术师 (术师), an owned-6★ pick
|
||||
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, her module and her summon “中继器” at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_4229_aphris, 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). Potential 0 [ASSUMED: no account].
|
||||
// Summon: tokens.token_10070_aphris_pc “中继器” (talent 链路协议; a hand piece the player places with a facing — data
|
||||
// `placeable`, a talent summon: it deploys with the board, docs/SIM.md §1.1 token pieces).
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 谬因 (链路协议 备注:
|
||||
// her range "以自己所在格为起点向部署方向延伸,直至达到延伸距离上限" — 5 tiles from E1 —, "若延伸到了自身的中继器所在的地块,会令延伸
|
||||
// 距离上限增加(并非增加谬因的攻击距离属性)并改变接下来的延伸方向", "尝试延伸到已延伸到的地块或地图边界外时延伸会提前终止", "索敌与攻击
|
||||
// 始终采用格子判定", "攻击力增加效果不可叠加"; 取样优化 备注 "无视法抗效果为增加受益者的法术穿透属性(直接加算)"; 连续映射 备注
|
||||
// "中继器持续时间结束时不再进行自动撤退"; 临界瞬爆 备注 "技能持续期间,自身的攻击范围可无限延伸直至无法继续延伸", "持有孤立的我方干员
|
||||
// 也可令谬因攻击范围延伸效果转向"; 混沌的本质 备注 "技能开启后3秒内持有强制缴械", "“停顿时间延长”指的是增加本次攻击对该敌人施加的
|
||||
// 停顿的时间", "技能开始/结束时强制撤回所有中继器", 修正 "清空再部署"); PRTS 中继器 (备注: 无敌, 禁疗, 孤立, 阻挡数 0, 再部署策略
|
||||
// 队列); PRTS 卫戍协议/帮助 §作战阶段 ("若战场区初始部署有召唤物,若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个"); the client's
|
||||
// battle data (charpack char_4229_aphris — the range selectors, the talent abilities; battle/prefabs [uc]skills skchr_aphris_1/2/3;
|
||||
// buff_template_data aphris_token_add_atk_tohost, aphris_pc_t[with-draw], aphris_sk1(_token), aphris_sk2_switch_mode4,
|
||||
// aphris_sk3 / [token-withdraw] / [on_finish], die_to_kill_token, charge_token[born]).
|
||||
// - Range (链路协议; her selectors `_extendable`, `_extendValidator` = the 中继器, `_extendLengthEveryValidateChar` 2): a path,
|
||||
// not her data grid — from her tile along her facing, one tile at a time, up to her 5-1 length; reaching her own 中继器
|
||||
// adds 2 to the length still allowed and turns the path to that piece's facing; it stops at a tile it already holds or at
|
||||
// the map's edge. The path is her range (`unit.rangeGrid`, rebuilt when a piece or an operator comes or goes, at her
|
||||
// skill's start / end, and checked every tick): her attacks, the DEFAULT trigger, 取样优化 and the detail card all read it.
|
||||
// [ASSUMED] a huge enemy counts on every tile it covers (the engine's selection; PRTS: 格子判定 by its position), her hits
|
||||
// land at once (the client spaces them `_hitInterval` 0.05 s along the beam) and a 攻击距离 bonus (none exists for her in
|
||||
// this mode) would extend it as the engine extends any range.
|
||||
// - Trait (轰击术师) "攻击造成超远距离的群体法术伤害": the profession's `rangeAoe` — every selectable enemy of her path at once,
|
||||
// air units too. Module BLA-Y “第三相态”: 部署费用 −8 (the attribute; stats 34 → 26), HP / ATK in the stats, no trait change;
|
||||
// stage 3 changes T1 (35 s piece, 再部署时间变短 — the piece's byModule respawnTime 30 → 20 —, ATK +35 %).
|
||||
// - T1 链路协议: one 中继器 lasting the token talent's duration (25 s; BLA-Y stage 3: its hidden module talent, 35 s) from each
|
||||
// deployment (aphris_pc_t[with-draw]: it withdraws when that time is up unless it holds aphris_sk1_token — given to every
|
||||
// piece of hers when 连续映射 starts, and to a piece taking the field while 连续映射 or 混沌的本质 runs). Back on its tile its
|
||||
// respawnTime (30 / 20 s) after it left, paying its 3 DP — the 卫戍 auto redeploy of a placed summon, as 斯卡蒂的海嗣 —, and
|
||||
// only while 谬因 stands: her leaving the field withdraws it (die_to_kill_token), her (re)deployment makes it ready at once
|
||||
// (charge_token[born]). ATK +atk while one of hers stands (aphris_token_add_atk_tohost: one buff aphris_t1[atk], "不可叠加").
|
||||
// The piece "不会受到攻击" (data: untargetable) and holds 无敌, 禁疗 and 孤立 (PRTS 中继器; [ASSUMED] given by this kit — the
|
||||
// data's `abnormal` lacks them).
|
||||
// - T2 取样优化 "攻击范围内的友方干员攻击时无视敌方10点法术抗性" (aphris[t2]: attribute 27, 法术穿透, on allied operators — profession
|
||||
// mask 639 — of her attack range): resIgnoreFlat +magic_resist_penetrate_fixed on every allied operator standing on her path,
|
||||
// her own tile included [ASSUMED: the selector does not drop its owner], refreshed every AURA_IV s; several sources keep the
|
||||
// strongest.
|
||||
// - S1 连续映射 (AUTO, 持续时间无限): ATK +atk (aphris_sk1) and the 中继器 marks above. An AUTO skill acting on herself fires at
|
||||
// full SP (`trigger: 'SP_FULL'`, the owner's AUTO rule, kits/README.md checklist 5); `toggle`.
|
||||
// - S2 临界瞬爆 (MANUAL, data DEFAULT, 8 s): mode 4 (aphris_sk2_switch_mode4) — her normal attack is replaced by the beam
|
||||
// ability (`_preDelay` 0.167 = the Skill_2_Begin clip, then `_triggerDelta` 0.5; MAGICAL atk_scale): from BEAM_FIRST on,
|
||||
// every `interval` (0.5) s, every selectable enemy on her path takes atk_scale × ATK arts (16 hits in the 8 s). The path
|
||||
// meanwhile has no length limit (`_infinite`) and turns at her 中继器 and at every allied
|
||||
// operator it reaches (validators: the piece, allies of profession mask 639 — every operator profession; a 孤立 one too),
|
||||
// to that unit's facing. The data resolves DEFAULT: the unbounded range is no data range (no rangeId), so build-data's
|
||||
// ACTIVE_RANGE rule cannot see it (reported, not hand-coded).
|
||||
// - S3 混沌的本质 (MANUAL, data DEFAULT, 20 发弹药 — `ammo`, attack@trigger_time): for charge_time (3) s from the cast 强制缴械
|
||||
// (aphris_sk3[sheltering], abnormal flag disarmed) and aphris_sk3[token-withdraw]: every 中继器 of hers withdrawn and its
|
||||
// redeploy time cleared (ResetTokenRespawnTime — the placed piece is back on its tile at once; the two extra pieces the
|
||||
// skill allows have no piece to place in the auto battle [ASSUMED: N/A]); attack interval +base_attack_time (−1.1 s on
|
||||
// 2.9 s), ATK +atk, each attack attack@atk_scale × ATK on her path, and on every enemy it hits the buff aphris_sk3: 停顿
|
||||
// origin_sluggish + extra_sluggish × n s and n hits of extra_atk_scale × ATK arts, n = the 中继器 the path passed before the
|
||||
// enemy, at most max_times (1). It ends with its bullets; aphris_sk3[on_finish] withdraws every 中继器 and clears their
|
||||
// redeploy time again (so the piece is back at once, now with its normal lifetime).
|
||||
|
||||
import { num, talentBb, skillRec, up, batMod, toggleBuff } from '../shared/tier1.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { dirVec, toLocal } from '../../../dir.js';
|
||||
|
||||
const S1 = 'skchr_aphris_1';
|
||||
const S2 = 'skchr_aphris_2';
|
||||
const S3 = 'skchr_aphris_3';
|
||||
/** Her summon “中继器” (talent 链路协议). */
|
||||
const RELAY = 'token_10070_aphris_pc';
|
||||
/** Extra path length per 中继器 (the selectors' `_extendLengthEveryValidateChar`; "使该次攻击距离+2"). */
|
||||
const RELAY_REACH = 2;
|
||||
/** Talent-2 aura refresh period and buff length (the non-stacking aura convention of the kits). */
|
||||
const AURA_IV = 0.25;
|
||||
const AURA_DUR = 0.4;
|
||||
/** S2 临界瞬爆: the first beam hit (skchr_aphris_2 `_preDelay`, the Skill_2_Begin clip's length), then every `interval` s. */
|
||||
const BEAM_FIRST = 0.167;
|
||||
/** How often a withdrawn 中继器 tries to come back once ready (her on the field, its tile free, DP paid). */
|
||||
const RELAY_RETRY = 0.1;
|
||||
const LINK_KEY = 'aphris:link';
|
||||
const SAMPLING_KEY = 'aphris:sampling';
|
||||
const CHARGE_KEY = 'aphris:charge';
|
||||
const RELAY_ABNORMAL_KEY = 'aphris:relayAbnormal';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
/** A 中继器 of `unit`. */
|
||||
const isRelayOf = (t, unit) => !!t && t.kind === 'token' && t.defId === RELAY && t.ownerUnit === unit;
|
||||
const relaysOf = (battle, unit) => battle.allyUnits.filter((t) => isRelayOf(t, unit));
|
||||
/** 连续映射 / 混沌的本质 running: a 中继器 taking the field now gets the no-withdraw mark (aphris_pc_t[with-draw]). */
|
||||
const marksRelays = (unit) => !!(unit.skill && unit.skill.active && (unit.skill.id === S1 || unit.skill.id === S3));
|
||||
/** Her own path length: the farthest forward tile of her data grid (5 on her E2 5-1). */
|
||||
function baseReach(unit) {
|
||||
let n = 0;
|
||||
for (const p of unit.def?.rangeGrid ?? []) if (Array.isArray(p) && p[0] === 0 && p[1] > n) n = p[1];
|
||||
return n;
|
||||
}
|
||||
/** A 中继器's lifetime: the last `duration` of its talents (the BLA-Y stage-3 hidden talent after the base one). */
|
||||
function relayLife(t) {
|
||||
let life = 0;
|
||||
for (const x of t.def?.talents ?? []) if (x && x.bb && x.bb.duration != null) life = num(x.bb.duration, life);
|
||||
return life;
|
||||
}
|
||||
|
||||
/**
|
||||
* Her 延伸 path (header): absolute tiles in order from her own, with `index` (tile key → position) and `relays` (the
|
||||
* positions of the 中继器 tiles it passed). `beam` = S2: no length limit, every allied operator turns it too.
|
||||
*/
|
||||
function aphrisPath(battle, unit, beam = false) {
|
||||
const tiles = [[unit.tileR, unit.tileC]];
|
||||
const index = new Map([[unit.tileR * COLS + unit.tileC, 0]]);
|
||||
const relays = [];
|
||||
let [dr, dc] = dirVec(unit.dir);
|
||||
let reach = beam ? Infinity : baseReach(unit);
|
||||
let r = unit.tileR, c = unit.tileC, steps = 0;
|
||||
while (steps < reach) {
|
||||
const nr = r + dr, nc = c + dc;
|
||||
const k = nr * COLS + nc;
|
||||
if (!battle.grid.inBounds(nr, nc) || index.has(k)) break;
|
||||
index.set(k, tiles.length);
|
||||
tiles.push([nr, nc]);
|
||||
r = nr; c = nc; steps++;
|
||||
const o = battle.unitAt(nr, nc);
|
||||
if (o && isRelayOf(o, unit)) {
|
||||
reach += RELAY_REACH;
|
||||
[dr, dc] = dirVec(o.dir);
|
||||
relays.push(tiles.length - 1);
|
||||
} else if (beam && o && o !== unit && o.kind === 'op' && o.side === 'ally') [dr, dc] = dirVec(o.dir);
|
||||
}
|
||||
return { tiles, index, relays };
|
||||
}
|
||||
|
||||
/** Rebuild her range from the path when it changed (the relative grid of the path in her facing-RIGHT frame). */
|
||||
function refreshPath(battle, unit) {
|
||||
if (!up(unit)) return;
|
||||
const beam = !!(unit.skill && unit.skill.active && unit.skill.id === S2);
|
||||
const p = aphrisPath(battle, unit, beam);
|
||||
const sig = p.tiles.map(([r, c]) => r * COLS + c).join(',');
|
||||
unit.mem.aphrisPath = p;
|
||||
if (unit.mem.aphrisSig === sig) return;
|
||||
unit.mem.aphrisSig = sig;
|
||||
unit.rangeGrid = p.tiles.map(([r, c]) => toLocal(r - unit.tileR, c - unit.tileC, unit.dir));
|
||||
battle.refreshRange(unit);
|
||||
}
|
||||
|
||||
/** How many 中继器 her path passed before `e`'s tile (S3 aphris_sk3 "extendTimes"). */
|
||||
function relaysBefore(unit, e) {
|
||||
const p = unit.mem.aphrisPath;
|
||||
if (!p || !p.relays.length) return 0;
|
||||
const i = p.index.get(Math.round(e.y) * COLS + Math.round(e.x));
|
||||
return i == null ? 0 : p.relays.filter((j) => j < i).length;
|
||||
}
|
||||
|
||||
/** Withdraw every 中继器 of hers that stands; `clear` = their redeploy time cleared (ResetTokenRespawnTime). */
|
||||
function withdrawRelays(battle, unit, clear) {
|
||||
for (const t of relaysOf(battle, unit)) {
|
||||
if (t.alive) {
|
||||
t.mem.relayCleared = clear;
|
||||
battle.retreat(t, { reason: 'expired', permanent: true });
|
||||
} else if (clear && !t.removed) t.mem.relayReadyAt = battle.time;
|
||||
}
|
||||
}
|
||||
|
||||
/** A 中继器 of hers took the field: its abnormal flags and redeploy rule once, its lifetime and mark every time. */
|
||||
function relayDeployed(battle, unit, t) {
|
||||
if (!t.mem.aphrisRelay) {
|
||||
t.mem.aphrisRelay = true;
|
||||
battle.addBuff(t, { key: RELAY_ABNORMAL_KEY, flags: { invulnerable: true, isolated: true, noHeal: true }, persist: true, allowDead: true });
|
||||
// back on its tile its respawnTime after it left (at once when its redeploy time was cleared), while she stands,
|
||||
// paying its DP — a token is otherwise removed for good: the piece is kept (`removed = false`) so its hooks survive
|
||||
// and `battle.redeploy` accepts it
|
||||
battle.on('death', (c) => {
|
||||
if (c.unit !== t || battle.finished) return;
|
||||
t.removed = false;
|
||||
t.mem.relayReadyAt = battle.time + (t.mem.relayCleared ? 0 : Math.max(0, num(t.base.respawnTime)));
|
||||
t.mem.relayCleared = false;
|
||||
if (t.mem.relayRetry) return;
|
||||
t.mem.relayRetry = battle.every(RELAY_RETRY, (b, sched) => {
|
||||
const stop = () => { sched.cancel(); t.mem.relayRetry = null; };
|
||||
if (t.alive || t.removed || b.finished) { stop(); return; }
|
||||
if (!up(unit) || b.time + 1e-9 < t.mem.relayReadyAt) return;
|
||||
if (b.redeploy(t, { free: false })) stop();
|
||||
}, { owner: t });
|
||||
}, { owner: t, priority: -10 });
|
||||
}
|
||||
t.mem.relayKept = marksRelays(unit); // aphris_pc_t[with-draw] ON_BUFF_START: aphris_sk1_token
|
||||
const life = relayLife(t);
|
||||
if (!(life > 0)) return;
|
||||
const seq = t.deploySeq;
|
||||
battle.after(life, () => {
|
||||
if (!t.alive || t.deploySeq !== seq || t.mem.relayKept) return; // 连续映射's mark: "不再进行自动撤退"
|
||||
battle.retreat(t, { reason: 'expired', permanent: true });
|
||||
}, { owner: t });
|
||||
}
|
||||
|
||||
export default {
|
||||
char_4229_aphris: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'toggle', trigger: 'SP_FULL', mods: { atkPct: num(b1.atk) },
|
||||
onStart({ battle, unit }) { for (const t of relaysOf(battle, unit)) if (t.alive) t.mem.relayKept = true; }, // aphris_sk1 → aphris_sk1_token
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
attack: { noAttack: true },
|
||||
onStart({ battle, unit }) {
|
||||
unit.mem.beamAt = 0;
|
||||
unit.mem.beamNext = BEAM_FIRST;
|
||||
battle.fx('beam', { x: unit.x, y: unit.y, id: unit.id, skill: 'aphris:beam' });
|
||||
},
|
||||
onTick({ battle, unit, skill, dt }) {
|
||||
const iv = num(b2.interval, 0.5);
|
||||
if (!(iv > 0)) return;
|
||||
const t = (unit.mem.beamAt = (unit.mem.beamAt ?? 0) + dt);
|
||||
while (t >= unit.mem.beamNext - 1e-6 && skill.active && unit.alive) {
|
||||
unit.mem.beamNext += iv;
|
||||
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile)) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'aphris:beam'] });
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'ammo', ammo: num(b3['attack@trigger_time'], 20),
|
||||
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
|
||||
attack: {
|
||||
atkScale: num(b3['attack@atk_scale'], 1),
|
||||
onEachHit({ battle, unit, target }) { // aphris_sk3 on every enemy the attack hits
|
||||
if (!target || target.side !== 'enemy' || !target.alive) return;
|
||||
const max = num(b3['attack@max_times'], 1);
|
||||
const n = max >= 0 ? Math.min(max, relaysBefore(unit, target)) : relaysBefore(unit, target);
|
||||
const slow = num(b3['attack@origin_sluggish']) + num(b3['attack@extra_sluggish']) * n;
|
||||
if (slow > 0) battle.applyStatus(target, 'sluggish', { duration: slow, source: unit });
|
||||
const ex = num(b3['attack@extra_atk_scale']);
|
||||
for (let i = 0; i < n && target.alive && ex > 0; i++) battle.dealDamage(unit, target, { amount: unit.s.atk * ex, type: 'arts', isSkill: true, tags: ['skill', 'aphris:relay'] });
|
||||
},
|
||||
},
|
||||
onStart({ battle, unit }) {
|
||||
const ct = num(b3.charge_time);
|
||||
if (ct > 0) battle.addBuff(unit, { key: CHARGE_KEY, duration: ct, flags: { disarm: true }, tags: ['skill'] }); // 强制缴械
|
||||
withdrawRelays(battle, unit, true); // aphris_sk3[token-withdraw]
|
||||
},
|
||||
onEnd({ battle, unit }) {
|
||||
battle.removeBuff(unit, CHARGE_KEY);
|
||||
withdrawRelays(battle, unit, true); // aphris_sk3[on_finish]
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 链路协议: ATK +atk while one of her 中继器 stands (one buff however many)
|
||||
const a = num(t0.atk);
|
||||
if (a) toggleBuff(battle, unit, LINK_KEY, () => relaysOf(battle, unit).some((t) => t.alive && t.deployed), { atkPct: a });
|
||||
// die_to_kill_token: her pieces leave with her; charge_token[born]: her (re)deployment makes a waiting one ready
|
||||
battle.on('death', (c) => { if (c.unit === unit) withdrawRelays(battle, unit, false); }, { owner: unit });
|
||||
battle.on('deploy', (c) => {
|
||||
if (c.unit !== unit) return;
|
||||
for (const t of relaysOf(battle, unit)) if (!t.alive && !t.removed) t.mem.relayReadyAt = battle.time;
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 取样优化: 法术穿透 +v on the allied operators of her path
|
||||
const v = num(t1.magic_resist_penetrate_fixed);
|
||||
if (!(v > 0)) return;
|
||||
battle.every(AURA_IV, () => {
|
||||
if (!up(unit) || !unit.rangeKeySet) return;
|
||||
for (const a of battle.alliesFor(unit)) {
|
||||
if (a.kind !== 'op' || !unit.rangeKeySet.has(a.tileR * COLS + a.tileC)) continue;
|
||||
const cur = a.findBuff(SAMPLING_KEY);
|
||||
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > v && cur.timeLeft > 0.05) continue;
|
||||
battle.addBuff(a, { key: SAMPLING_KEY, duration: AURA_DUR, mods: { resIgnoreFlat: v }, source: unit, data: { v }, tags: ['aura'] });
|
||||
}
|
||||
}, { owner: unit, immediate: true });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// the 延伸 path: rebuilt on any ally's arrival / departure (her 中继器, an operator S2 bends on), her skill's
|
||||
// start / end, and checked every tick
|
||||
battle.on('deploy', (c) => {
|
||||
const t = c.unit;
|
||||
if (t === unit) unit.mem.aphrisSig = null;
|
||||
else if (isRelayOf(t, unit)) relayDeployed(battle, unit, t);
|
||||
if (t && t.side === 'ally') refreshPath(battle, unit);
|
||||
}, { owner: unit });
|
||||
battle.on('death', (c) => { if (c.unit && c.unit.side === 'ally' && c.unit !== unit) refreshPath(battle, unit); }, { owner: unit });
|
||||
battle.on('skillStart', (c) => { if (c.unit === unit) refreshPath(battle, unit); }, { owner: unit });
|
||||
battle.on('skillEnd', (c) => { if (c.unit === unit) refreshPath(battle, unit); }, { owner: unit });
|
||||
battle.on('tick', () => refreshPath(battle, unit), { owner: unit });
|
||||
// S3's 强制缴械 from the cast on: the attack loop goes on to the attack the cast was made for (ai.js updateAlly
|
||||
// checks `disarm` before the cast only) — no target, no attack, no bullet spent
|
||||
battle.on('beforeAttack', (c) => { if (c.attacker === unit && unit.findBuff(CHARGE_KEY)) c.targets = []; }, { owner: unit });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
// server/sim/content/kits/ops/op-bgsnow.js — 鸿雪 (char_4055_bgsnow) 自选 operator kit: 6★ 重射手 (狙击), an owned-6★ pick of
|
||||
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules (ARC-Y 打字机色带, ARC-X 仪式感) at
|
||||
// every form, and the kit of her summon “打字机” (token_10026_bgsnow_subbow). Kit contract and the 自选 rules: ../README.md
|
||||
// ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_4055_bgsnow): 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 / token_table (zh_CN, as built into backups.json)
|
||||
// and PRTS 鸿雪 + “打字机” (点题 备注; the {{**|82%|下降18%}} marks = a final ×0.82; PRTS 伤害分类: "鸿雪2技能三段伤害的后2段"
|
||||
// are 持续伤害).
|
||||
// - Trait (重射手) "高精度的近距离射击": ranged physical arrows, 3-6, can hit air units (PRTS 分支特性信息 重射手 "可对空"),
|
||||
// blocks 1, ground enemies target her (no flag).
|
||||
// - Module ARC-Y “打字机色带” trait "攻击正前方的敌人时攻击力提升至105%且无视其物理闪避" (trait bb atk_scale): her damage on
|
||||
// an enemy whose body is on her straight line ahead ×atk_scale, not dodgeable — 点题's two 持续伤害 hits included
|
||||
// [ASSUMED: they are two of its "3次攻击"]; 正前方 = local row 0, column ≥ 0 (her own tile included, as the 3-2 "前方3格"
|
||||
// lines count it) [ASSUMED]. Stage 2+ "属性更强的打字机": the typewriter's stats of the data (token module attributes).
|
||||
// - Module ARC-X “仪式感”: "再部署时间减少" (attribute respawn_time −25, in the stats); stage 2+ upgrades 弱点速记 (below).
|
||||
// - T1 自动打字机 "可使用一个持续25秒的“打字机”,“打字机”拥有和自己一样的技能,拥有独立的再部署时间": the typewriter is a
|
||||
// hand piece the player places (the mode's talent summons, content/tokens.js header): it deploys with the board, leaves
|
||||
// after 25 s (the token's hidden talent `interval`), and comes back on its tile after its own redeploy time (data 40 s; ×
|
||||
// respawn_time while she carries S2), paying its cost (5 DP), while she is on the field [ASSUMED: as the 海嗣]. Its skill
|
||||
// is the copy of hers the data gives (bySkill: sktok_bgsnow_1 / 2 / 3 — the 卫戍 strategy force-opens a summon's manual
|
||||
// skill, PRTS 卫戍协议/帮助): free (spCost 0), once per deployment, by the data's DEFAULT. It is untargetable ("不会受到
|
||||
// 攻击"), blocks nothing, hits air units. content/tokens.js has no kit for it: her install gives her pieces this one.
|
||||
// - T2 弱点速记 "“打字机”的攻击会使命中目标的防御力下降18%,持续4秒;若“打字机”放在鸿雪周围四格则效果提升至23%": every
|
||||
// damage the typewriter's attacks land cuts the target's DEF by bgsnow_token[def_down]_1 (_2 while the typewriter stands
|
||||
// on one of the 4 tiles next to her — while she is on the field [ASSUMED]) for `duration` s: the catalogue `defDown`
|
||||
// (final ×, 同名 keeps the strongest). ARC-X stage 2+: 28 % / 33 % and "周围八格" (the talent text names the ring).
|
||||
// - S1 抑扬格 (AUTO, attack SP, data DEFAULT — an AUTO attack buff waits for her next attack): ATK +atk until she leaves the
|
||||
// field ("持续时间无限": a toggle), and every attack meanwhile `prob` to deal ×atk_scale (one roll per attack).
|
||||
// - S2 点题 (MANUAL, 2 charges, data SKILL_RANGE on its 3-1): passive — the typewriter's redeploy time ×respawn_time;
|
||||
// active — 3 attacks at once ("立即": the cast takes the next attack now) on one target of the 3-1, atk_scale × ATK each:
|
||||
// the first a physical 普通伤害 attack, the other two physical 持续伤害 (PRTS 伤害分类: tags 'dot', not dodgeable).
|
||||
// PRTS 备注 "仅在干员自身范围内有可攻击目标时可释放": the data's SKILL_RANGE fires for any enemy of the 3-1, untargetable
|
||||
// ones too; the cast then waits (pending) for an attackable target — reported as an open question (the 备注 reading
|
||||
// of the strategy would be the basic one on her own range).
|
||||
// - S3 锐笔速写 (MANUAL, data ACTIVE_RANGE on its 3-1): range 3-1 while it runs, attack interval base_attack_time s (−0.6 on
|
||||
// 1.6 s), every attack atk_scale × ATK, bgsnow_s_3[atk_up].atk_scale × ATK on the 正前方3格 (the 3-2 line, her tile
|
||||
// included [ASSUMED]).
|
||||
|
||||
import { num, up, traitBb, skillRec, batMod } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys } from '../../../targeting.js';
|
||||
import { bodyKeys, bodyInKeys } from '../../../body.js';
|
||||
import { toLocal } from '../../../dir.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
|
||||
const S1 = 'skchr_bgsnow_1';
|
||||
const S2 = 'skchr_bgsnow_2';
|
||||
const S3 = 'skchr_bgsnow_3';
|
||||
export const TYPEWRITER = 'token_10026_bgsnow_subbow';
|
||||
const TS1 = 'sktok_bgsnow_1';
|
||||
const TS2 = 'sktok_bgsnow_2';
|
||||
const TS3 = 'sktok_bgsnow_3';
|
||||
/** 锐笔速写's 正前方3格: the 3-2 line (range_table) — her tile and the three ahead. */
|
||||
const FRONT_3 = Object.freeze([[0, 0], [0, 1], [0, 2], [0, 3]]);
|
||||
/** 点题's two 持续伤害 hits (PRTS 伤害分类 attackType BUFF). */
|
||||
const TAG_PRECIS = 'bgsnow:precis';
|
||||
/** 自动打字机: the typewriter's life when neither the token nor the talent carries it (PRTS: 持续25秒 at E2). */
|
||||
const LIFE_FALLBACK = 25;
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
/** 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;
|
||||
|
||||
/** ARC-Y 正前方: the enemy's body touches the unit's straight line ahead (local row 0, local column ≥ 0). */
|
||||
function inFrontLine(unit, e) {
|
||||
for (const k of bodyKeys(e)) {
|
||||
const [lr, lc] = toLocal(Math.floor(k / COLS) - unit.tileR, (k % COLS) - unit.tileC, unit.dir);
|
||||
if (lr === 0 && lc >= 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 点题 / its copy: the two 持续伤害 hits after the attack's first (普通伤害) hit, on the same target. */
|
||||
function precisFollowUps(battle, unit, target, scale) {
|
||||
for (let i = 0; i < 2 && target && target.alive; i++) {
|
||||
battle.dealDamage(unit, target, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, canDodge: false, tags: ['skill', 'dot', TAG_PRECIS] });
|
||||
}
|
||||
}
|
||||
|
||||
/** 抑扬格 / its copy: while `on(unit)`, every attack `prob` to deal ×scale (one roll per attack). */
|
||||
function installIamb(battle, unit, prob, scale, on) {
|
||||
if (!(prob > 0) || !(scale > 0) || scale === 1) return;
|
||||
battle.on('hit', (ctx) => {
|
||||
const d = ctx.dmg, t = ctx.target;
|
||||
if (ctx.source !== unit || !t || t.side !== 'enemy' || !d.isAttack || d.isSplash || d.type === 'element' || !on(unit)) return;
|
||||
const m = unit.mem;
|
||||
if (!d.attackId || m.iambRollId !== d.attackId) {
|
||||
m.iambRollId = d.attackId;
|
||||
m.iambProc = battle.rng.chance(prob);
|
||||
if (m.iambProc) battle.fx('crit', { x: t.x, y: t.y, id: unit.id });
|
||||
}
|
||||
if (m.iambProc) d.amount *= scale;
|
||||
}, { owner: unit });
|
||||
}
|
||||
|
||||
/**
|
||||
* The typewriter's kit: its skill (the copy of her skill the data gives), 弱点速记, its life and its redeploys.
|
||||
* `owner` = 鸿雪, `chess` = her composed record.
|
||||
*/
|
||||
function typewriterKit(t, owner, chess) {
|
||||
const def = t.def;
|
||||
const sk = def.skill ?? null;
|
||||
const tb = sk?.bb ?? {};
|
||||
const weak = tokTalent(def, 'duration');
|
||||
const wb = weak?.bb ?? {};
|
||||
const cut1 = -num(wb['bgsnow_token[def_down]_1.def']), cut2 = -num(wb['bgsnow_token[def_down]_2.def'], -cut1);
|
||||
const ring8 = /八格/.test(String(weak?.description ?? ''));
|
||||
const ownTalent = (chess?.talents ?? []).find((x) => x && x.index === 0)?.bb ?? {};
|
||||
const life = num(tokTalent(def, 'interval')?.bb?.interval, num(ownTalent.duration, LIFE_FALLBACK));
|
||||
const respawnMul = owner.skill?.id === S2 ? num(bbOf(chess, S2).respawn_time, 1) : 1;
|
||||
const near = () => {
|
||||
if (!up(owner)) return false;
|
||||
const dr = Math.abs(t.tileR - owner.tileR), dc = Math.abs(t.tileC - owner.tileC);
|
||||
return ring8 ? Math.max(dr, dc) === 1 : dr + dc === 1;
|
||||
};
|
||||
const skillOn = (u) => !!(u.skill && u.skill.active);
|
||||
let skill = null;
|
||||
if (sk?.id === TS1) skill = { kind: 'toggle', mods: { atkPct: num(tb.atk) } };
|
||||
else if (sk?.id === TS2) {
|
||||
const scale = num(tb.atk_scale, 1);
|
||||
skill = { kind: 'instant', attack: { atkScale: scale, onHit({ battle, unit, target }) { precisFollowUps(battle, unit, target, scale); } } };
|
||||
} else if (sk?.id === TS3) {
|
||||
skill = {
|
||||
kind: 'duration',
|
||||
targeting: { rangeGrid: sk.rangeGrid ?? [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]] },
|
||||
mods: { batPct: batMod(tb.base_attack_time, { stats: def.stats }) },
|
||||
attack: { atkScale: num(tb['attack@atk_scale'], 1) },
|
||||
};
|
||||
}
|
||||
return {
|
||||
skill,
|
||||
talents: [{ install(battle, unit) { // 弱点速记: every damage of its attacks cuts the target's DEF (near her: more)
|
||||
const dur = num(wb.duration, 4);
|
||||
if (!(cut1 > 0) || !(dur > 0)) return;
|
||||
battle.on('damaged', (ctx) => {
|
||||
if (ctx.source !== unit || !ctx.target || ctx.target.side !== 'enemy' || !hasHp(ctx.target) || ctx.dmg?.type === 'element') return;
|
||||
battle.applyStatus(ctx.target, 'defDown', { duration: dur, value: near() ? cut2 : cut1, source: unit });
|
||||
}, { owner: unit });
|
||||
} }],
|
||||
install(battle, unit) {
|
||||
if (sk?.id === TS1) installIamb(battle, unit, num(tb.prob), num(tb.atk_scale, 1), skillOn);
|
||||
// 持续25秒: leaves `life` s after each deployment
|
||||
battle.on('deploy', (ctx) => {
|
||||
if (ctx.unit !== unit || !(life > 0)) return;
|
||||
const seq = unit.deploySeq;
|
||||
battle.after(life, () => { if (unit.alive && unit.deploySeq === seq) battle.retreat(unit, { reason: 'expired', permanent: true }); }, { owner: unit });
|
||||
}, { owner: unit });
|
||||
// 拥有独立的再部署时间: back on its tile after its redeploy time, paying its cost, while 鸿雪 stands (the piece is kept:
|
||||
// `removed` false, as content/tokens.js enableRespawn)
|
||||
battle.on('death', (ctx) => {
|
||||
if (ctx.unit !== unit || battle.finished) return;
|
||||
unit.removed = false;
|
||||
const at = battle.time + Math.max(0, num(unit.base.respawnTime) * respawnMul);
|
||||
unit.mem.respawnAt = at;
|
||||
battle.every(0.25, (b, sched) => {
|
||||
if (unit.alive || unit.removed) { sched.cancel(); return; }
|
||||
if (b.time + 1e-9 < at || !up(owner)) return;
|
||||
if (b.redeploy(unit, { free: false })) sched.cancel();
|
||||
}, { owner: unit });
|
||||
}, { owner: unit, priority: -10 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
char_4055_bgsnow: (bb, chess) => {
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
|
||||
const frontScale = num(traitBb(chess).atk_scale, 1);
|
||||
const s2Scale = num(b2.atk_scale, 1);
|
||||
const s3Scale = num(b3.atk_scale, 1), s3Front = num(b3['bgsnow_s_3[atk_up].atk_scale'], s3Scale);
|
||||
return {
|
||||
skills: {
|
||||
[S1]: { kind: 'toggle', mods: { atkPct: num(b1.atk) } },
|
||||
[S2]: {
|
||||
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
// the 3-1 selects the target; her attack range stays her own (a 技能范围, not a range change)
|
||||
targeting: { rangeGrid: s2?.rangeGrid ?? null, showOwnRange: true },
|
||||
attack: { atkScale: s2Scale, onHit({ battle, unit, target }) { precisFollowUps(battle, unit, target, s2Scale); } },
|
||||
onStart({ unit }) { unit.atkCd = 0; }, // "立即": the pending attack goes off now when a target is there
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
targeting: { rangeGrid: s3?.rangeGrid ?? null },
|
||||
mods: { batPct: batMod(b3.base_attack_time, chess) },
|
||||
attack: { atkScale: s3Scale },
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
// 自动打字机 (and 弱点速记, the typewriter's own talent): her typewriter pieces run the typewriter's kit — set up
|
||||
// here, before the battle starts (Battle._setupUnit with a kit, as spawnToken's `kit` option)
|
||||
{ install(battle, unit) {
|
||||
for (const t of battle.allyUnits) {
|
||||
if (t.kind !== 'token' || t.defId !== TYPEWRITER || t.ownerUnit !== unit || t.alive || t.deployed) continue;
|
||||
if (t.kit) battle.offOwner(t); // a piece set up before her: drop its generic kit's hooks
|
||||
battle._setupUnit(t, typewriterKit(t, unit, chess));
|
||||
}
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
const sid = unit.skill?.id;
|
||||
if (sid === S1) installIamb(battle, unit, num(b1.prob), num(b1.atk_scale, 1), (u) => !!u.skill?.active);
|
||||
// 锐笔速写: 正前方3格 ⇒ atk_up's scale instead of the skill's
|
||||
if (sid === S3 && s3Front !== s3Scale && s3Scale > 0) {
|
||||
battle.on('hit', (ctx) => {
|
||||
const d = ctx.dmg, t = ctx.target;
|
||||
if (ctx.source !== unit || !t || t.side !== 'enemy' || !d.isAttack || !unit.skill?.active) return;
|
||||
if (bodyInKeys(t, absoluteRangeKeys(FRONT_3, unit.tileR, unit.tileC, unit.dir, 0))) d.amount *= s3Front / s3Scale;
|
||||
}, { owner: unit });
|
||||
}
|
||||
// ARC-Y 打字机色带: 攻击正前方的敌人时攻击力提升至105%且无视其物理闪避
|
||||
if (frontScale > 0 && frontScale !== 1) {
|
||||
battle.on('hit', (ctx) => {
|
||||
const d = ctx.dmg, t = ctx.target;
|
||||
if (ctx.source !== unit || !t || t.side !== 'enemy' || d.type === 'element') return;
|
||||
if (!(d.isAttack || d.tags?.includes(TAG_PRECIS)) || !inFrontLine(unit, t)) return;
|
||||
d.amount *= frontScale;
|
||||
d.canDodge = false;
|
||||
}, { owner: unit });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
// server/sim/content/kits/ops/op-cerber.js — 刻俄柏 (char_2013_cerber) 自选 operator kit: 6★ 中坚术师 (术师), an owned-6★
|
||||
// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_2013_cerber, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json; every module part is
|
||||
// valid outside 集成战略 — no `validInGameTag`) and PRTS 刻俄柏 (S2 "攻击间隔缩短(*0.6)" / "较大幅度缩短(*0.4)": a ratio of the
|
||||
// interval; 剥壳 备注 "处理敌人防御力时不考虑物理穿透", "<X模组>每次攻击与上一次攻击相同的目标时,将提升5%的比例值(第五次后达到
|
||||
// 最大值,于剥壳伤害前处理);攻击不同目标时比例值立刻重置"; 独行长路 能力生效范围 x-5 and 备注 "天赋判定范围包括刻俄柏所在格,不判定
|
||||
// 目标的可选性、实体类型等;天赋生效期间检测到敌人类我方单位时不会主动失效,但失效期间检测到敌人类我方单位后,直到该敌人类我方单位退场前
|
||||
// 无法重新生效", "<Y模组>本天赋不再通过原本的判定方式生效/失效…部署时检测到敌人类我方单位也会失效").
|
||||
// - Trait (中坚术师) "攻击造成法术伤害": the profession default (ranged arts, hits air, block 1). Module CCR-X “很干的面包”
|
||||
// adds "无视目标10点法术抗性" (trait bb magic_resist_penetrate_fixed: a permanent resIgnoreFlat), CCR-Y “我打的刀”
|
||||
// "普通攻击命中精英或领袖敌人时获得1点技力" (trait bb sp: per hit of one of her attacks on an elite / leader; a running timed
|
||||
// skill takes no SP, as always).
|
||||
// - T1 剥壳 "攻击时对目标额外造成相当于其防御力40%的法术伤害": each hit of her attacks (S3's physical spear too) is followed by
|
||||
// atk_scale × the target's DEF (its current DEF, her own penetration ignored — she has none) as arts damage, a damage
|
||||
// instance of the talent (not an attack: no further on-hit effects). A target with no DEF takes none (PRTS: she "tries"
|
||||
// and deals nothing; no flat arts bonus exists in this mode that a zero hit could carry). CCR-X stage 2+ (bb
|
||||
// basic_atk_scale / delta_atk_scale / max_atk_scale): 50 % +5 % per consecutive hit on the same target, ≤ 75 %, back to
|
||||
// 50 % on another target (PRTS 备注), counted from the start of each deployment [ASSUMED].
|
||||
// - T2 独行长路 "当周围四格内没有其他友方单位时,攻击力+8%,攻击速度+8": while no other ally unit (operator, summon or device —
|
||||
// PRTS: no selectability / entity check) stands on her tile or the four next to it (x-5), with the 敌人类我方单位 rule
|
||||
// of the 备注 (炎佑: an ally built from an enemy record; it does not switch the talent off, but one seen while it is off
|
||||
// keeps it off until that unit leaves the field). CCR-Y stage 2+ ("部署时若周围四格内没有其他友方单位,攻击力+15%,攻击速度
|
||||
// +15"): checked once per deployment — an ally of either kind there switches it off for that deployment; the battle's
|
||||
// initial deployment counts as one moment, every board piece already placed [ASSUMED: the pieces of the mode are on the
|
||||
// board together before the battle starts; the official deploys one at a time].
|
||||
// - S1 “很冰的斧” (AUTO, 可充能 1 / 2, data DEFAULT): the next attack at atk_scale × ATK, on an enemy that nobody blocks first
|
||||
// (the usual order among those, then the blocked ones), binding (束缚) it for `duration` s.
|
||||
// - S2 “很热的刀” (MANUAL, 33 / 36 s, data DEFAULT): attack interval × base_attack_time (0.6 / 0.4: PRTS "(*0.4)"), the
|
||||
// highest-DEF enemy first.
|
||||
// - S3 “很重的枪” (MANUAL, 56 / 57 s, data ACTIVE_RANGE on 3-3): attack range 3-3 while it runs, ATK +atk, physical damage,
|
||||
// the lowest-DEF enemy first, each hit silences (失去特殊能力) for attack@silence s.
|
||||
// - No summon (the forms list no token).
|
||||
|
||||
import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff, batMod, giveSp, up } from '../shared/tier1.js';
|
||||
import { sortEnemyTargets } from '../../../targeting.js';
|
||||
import { offsetTile } from '../../../dir.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
|
||||
const S1 = 'skchr_cerber_1';
|
||||
const S2 = 'skchr_cerber_2';
|
||||
const S3 = 'skchr_cerber_3';
|
||||
/** 独行长路's area: her tile and the four next to it (PRTS 能力生效范围 x-5; the data carries no talent grid). */
|
||||
const X5 = Object.freeze([[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]);
|
||||
/** S3's 3-3 when the data carries no grid. */
|
||||
const R33 = Object.freeze([[1, 0], [1, 1], [1, 2], [1, 3], [0, 0], [0, 1], [0, 2], [0, 3], [-1, 0], [-1, 1], [-1, 2], [-1, 3]]);
|
||||
/** 剥壳's extra damage (a talent instance: never itself followed by 剥壳). */
|
||||
const SHELL_TAG = 'cerber:shell';
|
||||
const LONE_KEY = 'talent:cerber:lone';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
/** An elite or leader enemy (精英或领袖敌人). */
|
||||
const isEliteEnemy = (e) => !!e && e.side === 'enemy' && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS');
|
||||
const tagged = (d, tag) => !!(d && d.tags && d.tags.includes(tag));
|
||||
/** A module trait that works 在集成战略中 only (ISW modules): nothing of it applies in this mode. */
|
||||
const roguelikeOnly = (chess) => /集成战略/.test(String(chess?.trait?.moduleDesc ?? ''));
|
||||
/** Named talent `i` of the record (its desc tells the module stage's wording). */
|
||||
const talentRec = (chess, i) => (chess?.talents ?? []).filter((t) => t && t.index !== -1)[i] ?? null;
|
||||
/** PRTS's 敌人类我方单位: an ally built from an enemy record (炎佑 enemy_9012_acloon, content/items/battle.js). */
|
||||
const enemyLike = (a) => /^enemy_/.test(String(a?.defId ?? a?.def?.id ?? ''));
|
||||
|
||||
/** The other ally units on her x-5 (any kind — PRTS: "不判定目标的可选性、实体类型等"). */
|
||||
function othersAround(battle, unit) {
|
||||
const out = [];
|
||||
for (const [dr, dc] of X5) {
|
||||
const [r, c] = offsetTile(unit.tileR, unit.tileC, dr, dc, unit.dir);
|
||||
const o = battle.grid.inBounds(r, c) ? battle.unitAt(r, c) : null;
|
||||
if (o && o !== unit && o.side === 'ally' && !out.includes(o)) out.push(o);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default {
|
||||
char_2013_cerber: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const onDeploy = /部署时/.test(String(talentRec(chess, 1)?.desc ?? '')); // CCR-Y stage 2+
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s1 = skillRec(chess, S1), s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
attack: { atkScale: num(b1.atk_scale, 1), onHitStatus: { key: 'bind', duration: num(b1.duration) } },
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
mods: { batPct: batMod(b2.base_attack_time, chess, s2?.desc) },
|
||||
targeting: { priority: 'highDef' },
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b3.atk) },
|
||||
targeting: { rangeGrid: s3?.rangeGrid ?? R33, priority: 'lowDef' },
|
||||
attack: { dmgType: 'phys', onHitStatus: { key: 'silence', duration: num(b3['attack@silence']) } },
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 剥壳: DEF × ratio arts after each hit; CCR-X stage 2+: the ratio climbs on the same target
|
||||
const base = num(t0.basic_atk_scale, num(t0.atk_scale)), delta = num(t0.delta_atk_scale), top = num(t0.max_atk_scale, base);
|
||||
if (!(base > 0)) return;
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === unit) { unit.mem.cerberShellTarget = null; unit.mem.cerberShellScale = base; } }, { owner: unit });
|
||||
battle.on('damaged', (ctx) => {
|
||||
const t = ctx.target, d = ctx.dmg;
|
||||
if (ctx.source !== unit || !t || t.side !== 'enemy' || !d || !d.isAttack || d.isSplash || tagged(d, SHELL_TAG)) return;
|
||||
let sc = base;
|
||||
if (delta > 0) {
|
||||
// "于剥壳伤害前处理": the same target as her previous hit +delta (≤ max), another one back to the base
|
||||
sc = unit.mem.cerberShellTarget === t ? Math.min(top, num(unit.mem.cerberShellScale, base) + delta) : base;
|
||||
unit.mem.cerberShellTarget = t;
|
||||
unit.mem.cerberShellScale = sc;
|
||||
}
|
||||
const def = t.s.def;
|
||||
if (def > 0 && hasHp(t)) battle.dealDamage(unit, t, { amount: def * sc, type: 'arts', tags: [SHELL_TAG] });
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 独行长路
|
||||
const mods = { atkPct: num(t1.atk), aspd: num(t1.attack_speed) };
|
||||
if (!mods.atkPct && !mods.aspd) return;
|
||||
if (onDeploy) {
|
||||
// CCR-Y stage 2+: the deployment's check, for the whole deployment
|
||||
const check = () => {
|
||||
if (!up(unit)) return;
|
||||
if (othersAround(battle, unit).length) battle.removeBuff(unit, LONE_KEY);
|
||||
else battle.addBuff(unit, { key: LONE_KEY, mods, tags: ['talent'] });
|
||||
};
|
||||
battle.on('battleStart', check, { owner: unit });
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === unit && battle.started) check(); }, { owner: unit });
|
||||
return;
|
||||
}
|
||||
// the base talent, checked every tick: an ordinary ally switches it off; an enemy-like one seen while it is off
|
||||
// keeps it off until that unit leaves the field
|
||||
let on = false;
|
||||
const locks = new Set();
|
||||
battle.on('tick', () => {
|
||||
if (!up(unit)) { on = false; locks.clear(); return; }
|
||||
const near = othersAround(battle, unit);
|
||||
if (on && near.some((a) => !enemyLike(a))) on = false;
|
||||
if (!on) {
|
||||
for (const a of near) if (enemyLike(a)) locks.add(a);
|
||||
for (const a of locks) if (!a.alive || !a.deployed) locks.delete(a);
|
||||
if (!near.length && !locks.size) on = true;
|
||||
}
|
||||
}, { owner: unit, priority: 1 });
|
||||
toggleBuff(battle, unit, LONE_KEY, () => on, mods);
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// module traits (none on the normal form): CCR-X 无视目标10点法术抗性, CCR-Y SP per hit on an elite / leader
|
||||
if (!roguelikeOnly(chess)) {
|
||||
const tb = traitBb(chess);
|
||||
const pen = num(tb.magic_resist_penetrate_fixed);
|
||||
if (pen > 0) statBuff(battle, unit, 'trait:cerber:resIgnore', { resIgnoreFlat: pen });
|
||||
const sp = num(tb.sp);
|
||||
if (sp > 0) {
|
||||
battle.on('damaged', (ctx) => {
|
||||
const d = ctx.dmg;
|
||||
if (ctx.source !== unit || !d || !d.isAttack || d.isSplash || !isEliteEnemy(ctx.target)) return;
|
||||
giveSp(unit, sp, 'trait');
|
||||
}, { owner: unit });
|
||||
}
|
||||
}
|
||||
if (unit.skill?.id === S1) {
|
||||
// “很冰的斧” "优先攻击没有被阻挡的目标": the pending attack re-picks its target, unblocked enemies first
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !ctx.isSkill || !ctx.targets.length) return;
|
||||
const prof = ctx.profile;
|
||||
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof);
|
||||
for (const e of battle.blockedTargets(unit, prof)) if (!cands.includes(e)) cands.push(e);
|
||||
if (!cands.length) return;
|
||||
sortEnemyTargets(battle, unit, cands, prof.priority);
|
||||
ctx.targets = [cands.find((e) => !e.blockedBy) ?? cands[0]];
|
||||
}, { owner: unit });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
// server/sim/content/kits/ops/op-cgbird.js — 夜莺 (char_179_cgbird) 自选 operator kit: 6★ 群愈师 (医疗), an owned-6★ pick of
|
||||
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules at every form and her summon 幻影.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_179_cgbird, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json; the 幻影 token record
|
||||
// backups.json tokens.token_10003_cgbird_bird with its owner-form / module variants) and PRTS 夜莺 (S2 备注
|
||||
// "多个屏障吸收量可叠加,持续时间独立计算,优先消耗先生成的屏障,法术抗性增加效果不可叠加"; 转瞬即逝的幻影 备注 "幻影的持有上限为3个,
|
||||
// 每次部署夜莺时补充2个幻影 / Y模组2级时…补充3个 / 夜莺离场时,已部署的幻影会留在场上"); PRTS 卫戍协议/帮助 §作战阶段 (summons:
|
||||
// "所有手动部署的召唤物,无视所属干员的持有状态,不消耗持有数量,作战开始时立即部署一次", "若战场区初始部署有召唤物,若召唤物在
|
||||
// 战斗期间退场,将在满足条件后立即原地再部署1个").
|
||||
// - Trait (群愈师) "同时恢复三个友方单位的生命": the profession default (3 heals). Module RIN-Y “莺歌” "同时恢复四个友方单位的
|
||||
// 生命" (hidden module talent attack@max_target / skill@max_target 4): 4 heals (kit trait, as 调香师's RIN-Y). RIN-X
|
||||
// “封闭的希望” "攻击范围扩大": her range becomes the module's own grid (its range-only talent change, talentIndex −1: y-2
|
||||
// plus the centre tile [0,3] — PRTS y-3), as shared/loadoutRecord.js attackRangeGrid draws it; 圣域 keeps its own y-4.
|
||||
// - T1 白恶魔的庇护 "攻击范围内的友方单位法术抗性+15": every ally (operators and summons) standing in her current attack range
|
||||
// — her own tile included — RES +magic_resistance (flat). RIN-X stage 2+ "…且受到的治疗效果提升5%" (talent change
|
||||
// heal_scale): also healing received ×heal_scale there. Refreshed every 0.2 s; two sources keep the strongest.
|
||||
// - T2 转瞬即逝的幻影 (summon token_10003_cgbird_bird: no attack, blocks nothing, RES 75, taunt level 1 — its stats — and
|
||||
// its talent "30%的物理闪避…每秒流失3%的最大生命": dodgePhys prob, a 流失 of hp_ratio × its max HP every second; RIN-Y
|
||||
// stage 3 "40%的物理闪避": the module variant's talent). The 幻影 is a hand piece (the match's per-player shop): a placed
|
||||
// one deploys with the board at the battle start for free and without using her stock. Each deployment of 夜莺 adds
|
||||
// cnt (2; RIN-Y stage 2+: 3) to her stock, at most PHANTOM_HOLD_CAP; a placed 幻影 that falls comes back on its own tile
|
||||
// once its redeploy time (respawnTime) has passed and she holds one — using one, paying its DP cost as a deployment
|
||||
// does [ASSUMED: "满足条件" = a 幻影 held, its redeploy time, the DP], whether 夜莺 is on the field or not (deployed
|
||||
// 幻影 stay when she leaves: PRTS 备注). No tokens.js kit: its talent and its return are this kit's (owner-coupled,
|
||||
// as tokens.js "Managed mode"), applied at each of its deployments.
|
||||
// - S1 治疗强化·γ型 (MANUAL, 30 s, data DEFAULT — heal skill): ATK +atk.
|
||||
// - S2 法术护盾 (AUTO, 2 charges, data DEFAULT — a "next heal" waits for her heal, as 闪灵's S2): "下次治疗使所有目标获得一个
|
||||
// 持续N秒的屏障,屏障能吸收相当于夜莺攻击力X%的法术伤害,同时使目标法术抗性+Y" — every target of that heal (3 / 4) gets a
|
||||
// 屏障 of atk_scale × her ATK for duration s that absorbs arts damage only (buff shieldType 'arts', damage.js
|
||||
// absorbShields) and RES +magic_resistance (flat) while one of her barriers lasts (op-shining.js grantBarrier: each
|
||||
// barrier its own timer, older ones first, the RES bonus not stacking) [ASSUMED: the bonus goes with the last barrier].
|
||||
// - S3 圣域 (MANUAL, 60 s, data ACTIVE_RANGE on its y-4 — the owner's rule: an injured ally inside it): range y-4 while it
|
||||
// runs, ATK +atk; every ally in that range RES +magic_resistance (×(1 + v): "法术抗性+105%") and dodgeArts prob
|
||||
// ("获得20%的法术闪避"), refreshed every 0.2 s.
|
||||
// Medic: heals only (dmgType heal), ranged, no anti-air question; ground enemies target her normally (no 起飞 / 迷彩 / 隐匿).
|
||||
|
||||
import { num, talentBb, moduleBb, moduleOn, skillRec } from '../shared/tier1.js';
|
||||
import { rangeAura, grantBarrier } from './op-shining.js';
|
||||
|
||||
const S1 = 'skcom_heal_up[3]';
|
||||
const S2 = 'skchr_cgbird_2';
|
||||
const S3 = 'skchr_cgbird_3';
|
||||
export const PHANTOM = 'token_10003_cgbird_bird';
|
||||
/** PRTS 转瞬即逝的幻影 备注 "幻影的持有上限为3个" (no blackboard key). */
|
||||
export const PHANTOM_HOLD_CAP = 3;
|
||||
/** Seconds between two tries of a fallen 幻影 to come back (tile busy, DP short, none held). */
|
||||
const RETRY = 0.25;
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
|
||||
/**
|
||||
* RIN-X "攻击范围扩大": the picked module's own range grid (its range-only talent change, talentIndex −1), or null — the
|
||||
* rule of shared/loadoutRecord.js attackRangeGrid (the card / board range) and shared/tier5.js moduleRangeUp.
|
||||
*/
|
||||
function moduleRange(chess) {
|
||||
if (!chess?.isGolden || !moduleOn(chess) || !/攻击范围扩大/.test(String(chess?.trait?.moduleDesc ?? ''))) return null;
|
||||
const rec = (chess.modules ?? []).find((m) => m && m.uniEquipId === chess.module.id);
|
||||
const g = (rec?.talentChanges ?? []).find((t) => t && t.talentIndex === -1 && Array.isArray(t.rangeGrid) && t.rangeGrid.length)?.rangeGrid;
|
||||
return g ? g.map((p) => [p[0], p[1]]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 幻影's own talent blackboard (`prob`, `hp_ratio`) from its def at the owner's form and module: the last talent
|
||||
* holding `prob` — a module's upgrade (RIN-Y stage 3: 40 %) follows the base talent in the variant data.
|
||||
* @param {object} def normalised token def
|
||||
*/
|
||||
export function phantomTalent(def) {
|
||||
let out = {};
|
||||
for (const t of def?.talents ?? []) if (t && t.bb && t.bb.prob != null) out = t.bb;
|
||||
return out;
|
||||
}
|
||||
|
||||
export default {
|
||||
char_179_cgbird: (bb, chess) => {
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
|
||||
const t0 = talentBb(chess, 0); // 白恶魔的庇护: magic_resistance (RIN-X stage 2+: heal_scale)
|
||||
const t1 = talentBb(chess, 1); // 转瞬即逝的幻影: cnt (RIN-Y stage 2+: 3)
|
||||
const hidden = moduleBb(chess); // RIN-Y: attack@max_target
|
||||
const heals = Math.floor(num(hidden['attack@max_target']));
|
||||
const grid = moduleRange(chess);
|
||||
const picked = chess?.skill?.skillId ?? null;
|
||||
const tokenId = (chess?.talents ?? []).find((t) => t && t.tokenKey)?.tokenKey ?? PHANTOM;
|
||||
return {
|
||||
trait: heals > 0 ? { heal: { mode: 'multi', count: heals } } : undefined,
|
||||
skills: {
|
||||
[S1]: { kind: 'duration', heal: true, mods: { atkPct: num(b1.atk) } },
|
||||
[S2]: {
|
||||
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
heal: true,
|
||||
attack: { healScale: 1 }, // the next heal (ai.js doHeal calls onHit for every heal target)
|
||||
onHit({ battle, unit, target }) {
|
||||
if (!target || target.side !== 'ally') return;
|
||||
grantBarrier(battle, unit, target, {
|
||||
key: 'cgbird:barrier', statKey: 'cgbird:barrierRes', shield: unit.s.atk * num(b2.atk_scale),
|
||||
duration: num(b2.duration), mods: { resFlat: num(b2.magic_resistance) }, shieldType: 'arts',
|
||||
});
|
||||
},
|
||||
},
|
||||
[S3]: { kind: 'duration', heal: true, mods: { atkPct: num(b3.atk) }, targeting: s3?.rangeGrid ? { rangeGrid: s3.rangeGrid } : undefined },
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 白恶魔的庇护: allies in her attack range RES +magic_resistance; RIN-X stage 2+: healing ×heal_scale
|
||||
const r = num(t0.magic_resistance), hs = num(t0.heal_scale, 1);
|
||||
if (!(r > 0) && hs === 1) return;
|
||||
const mods = { ...(r ? { resFlat: r } : null), ...(hs !== 1 ? { healingTakenMul: hs } : null) };
|
||||
rangeAura(battle, unit, { key: 'talent:cgbird:res', value: r + (hs - 1) * 100, mods });
|
||||
} },
|
||||
{ install(battle, unit) { // 转瞬即逝的幻影: the 幻影's talent, her stock, a fallen 幻影's return
|
||||
const cnt = Math.max(0, Math.floor(num(t1.cnt)));
|
||||
if (!(cnt > 0)) return;
|
||||
unit.mem.phantomStock = 0;
|
||||
const mine = (t) => !!t && t.kind === 'token' && t.defId === tokenId && t.ownerUnit === unit;
|
||||
battle.on('deploy', (ctx) => {
|
||||
const u = ctx.unit;
|
||||
if (u === unit) {
|
||||
unit.mem.phantomStock = Math.min(PHANTOM_HOLD_CAP, (unit.mem.phantomStock ?? 0) + cnt);
|
||||
return;
|
||||
}
|
||||
if (!mine(u)) return;
|
||||
const tal = phantomTalent(u.def);
|
||||
const p = num(tal.prob), hr = num(tal.hp_ratio);
|
||||
if (p > 0) battle.addBuff(u, { key: 'cgbird:phantomDodge', mods: { dodgePhys: p }, tags: ['talent'] });
|
||||
if (hr > 0) {
|
||||
battle.addBuff(u, {
|
||||
key: 'cgbird:phantomDrain', interval: 1, tags: ['talent'],
|
||||
onTick: ({ unit: t }) => battle.loseHp(t, t.s.maxHp * hr, { silent: true, tags: ['cgbird:phantom'] }),
|
||||
});
|
||||
}
|
||||
}, { owner: unit });
|
||||
battle.on('death', (ctx) => {
|
||||
const t = ctx.unit;
|
||||
if (!mine(t) || ctx.reason !== 'killed' || battle.finished) return;
|
||||
t.removed = false; // the piece stays (Battle.redeploy accepts it; its hooks are kept)
|
||||
const at = battle.time + Math.max(0, num(t.base.respawnTime));
|
||||
battle.every(RETRY, (b, sched) => {
|
||||
if (t.alive || t.removed || b.finished) { sched.cancel(); return; }
|
||||
if (b.time + 1e-9 < at || !((unit.mem.phantomStock ?? 0) > 0)) return;
|
||||
if (b.redeploy(t, { free: false })) { unit.mem.phantomStock--; sched.cancel(); }
|
||||
}, { owner: unit });
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// RIN-X: the module's range is her own (the initial range of the DEFAULT trigger too); 圣域 runs its y-4
|
||||
if (grid) { unit.rangeGrid = grid; battle.refreshRange(unit); }
|
||||
// 圣域: allies in her (y-4) range RES ×(1 + magic_resistance), arts dodge prob, while the skill runs
|
||||
if (picked === S3) {
|
||||
const mr = num(b3.magic_resistance), p = num(b3.prob);
|
||||
if (mr > 0 || p > 0) rangeAura(battle, unit, { key: 'skill:cgbird:sanctuary', value: mr, mods: { resMul: 1 + mr, dodgeArts: p }, on: () => !!unit.skill?.active });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
// server/sim/content/kits/ops/op-chen.js — 陈 (char_010_chen) 自选 operator kit: 6★ 剑豪 (近卫), an owned-6★ pick of the
|
||||
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_010_chen, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), PRTS 陈 (S2 备注
|
||||
// "可对空;先造成法术伤害,后造成物理伤害"; S3 备注 "不可对空 / 技能生效期间,持有效果:无敌,无法阻挡,眩晕免疫,冻结免疫 / 若斩击次数
|
||||
// 未到10次之前技能范围内没有目标,立刻中止技能"; SWO-X 特性 修正 "技能期间造成的伤害提升10%", 原因 6 "描述与游戏实际表现不符合"),
|
||||
// PRTS 分支特性信息 §剑豪, her battle skeleton char_010_chen.skel (front; the client's own spine parser: Skill 1 OnAttack,
|
||||
// Skill_2 1 OnAttack, Skill_3 2.933 s with OnAttack at 0.6 + 0.2k (k = 0…8), 2.833 and 2.933).
|
||||
// - Trait (剑豪) "普通攻击连续造成两次伤害": the profession default (hits 2; professions.js `sword`), ground-only melee
|
||||
// (canHitFly false) on her 1-1 — PRTS 分支 "可以且优先攻击自身阻挡的单位(即使…为飞行单位)" is the engine's blocked-first rule.
|
||||
// Module SWO-X “罗德岛制式剑” adds "技能造成的伤害提升10%" (trait bb damage_scale) — PRTS corrects it to 技能期间: every damage
|
||||
// she deals while her skill runs (the S1 strike, the S2 cut, the S3 slashes: all of them her skill's) ×1.1; SWO-Y
|
||||
// “往昔时光” adds "攻击时无视敌人70点的防御力" (def_penetrate_fixed): defIgnoreFlat on all her damage (as 锏's SWO-Y).
|
||||
// - T1 呵斥 "在场时每4秒回复全场友方角色1点攻击/受击技力": every `interval` s from each deployment, +sp SP to every ally of the
|
||||
// field (her included, a teammate's in a shared field too; Battle.alliesFor: no 孤立 unit) whose skill recovers by
|
||||
// attacking or by being hit — not a time-SP skill, nobody whose timed skill runs (giveSp), nobody under 阻回. SWO-X
|
||||
// stage 2+ "每3秒" (the module talent's interval); stage 3 "自身额外回复1点技力": the hidden module talent part
|
||||
// (chen_equip_1_3_p2, its own interval / sp) — +1 more for her, read from the module's talentChanges (its bb merges
|
||||
// into the visible talent's with the same keys). [ASSUMED] two 陈 of a shared field (two players) both give theirs.
|
||||
// - T2 持刀格斗术 "攻击力+5%,防御力+5%,物理闪避+10%": a permanent ATK / DEF / 物理闪避 buff; SWO-Y stage 3: +15 % / +15 % /
|
||||
// 18 % (the module talent change replaces the numbers).
|
||||
// - S1 鞘击 (AUTO, attack SP): the next attack is one sheath strike (her skeleton's Skill clip: one OnAttack — not the
|
||||
// trait's two) for atk_scale × ATK physical that stuns the target `stun` s; the data's DEFAULT trigger.
|
||||
// - S2 赤霄·拔刀 (MANUAL, attack SP, 技能范围 3-12, data SKILL_RANGE): at once, up to max_target enemies of the skill range —
|
||||
// flyers too (PRTS 备注 可对空) — each take atk_scale × ATK arts, then atk_scale × ATK physical (PRTS: 先法术后物理);
|
||||
// the official target order (blocked first, then nearest the goal). Its range never becomes her attack range.
|
||||
// - S3 赤霄·绝影 (MANUAL, attack SP, 技能范围 x-1, data SKILL_RANGE): she seeks the nearest ground enemy of the skill range
|
||||
// and slashes it `times` (10) times for atk_scale × ATK physical each, the last stunning it `stun` s; a target that falls
|
||||
// or leaves is replaced by the next nearest; with no target left in the skill range the skill ends at once (PRTS 备注).
|
||||
// The slashes fall at her skeleton's Skill_3 OnAttack times (0.6 … 2.2 s every 0.2 s, then 2.833 s) and the skill lasts
|
||||
// the clip (2.933 s) [ASSUMED: the first ten of its eleven OnAttack events are the ten slashes, the eleventh marks the
|
||||
// clip's end]. Meanwhile (PRTS 备注): 无敌 (flag invulnerable), 无法阻挡 (noBlock: her blocked enemies walk on), stun and
|
||||
// freeze immunity, no normal attack. Ground only (不可对空): no flyer, no levitated enemy is slashed. The skill cannot be
|
||||
// opened while its range holds no ground enemy at all [ASSUMED: PRTS 艾丽妮 S3's 备注 "技能范围内仅存在飞行单位时,该技能
|
||||
// 也可开启" marks the opposite as the rule for a skill that strikes ground enemies only; the strategy's SKILL_RANGE still
|
||||
// counts unselectable ones, "无视其不可选中", so a stealthed ground enemy alone opens it and it ends with nobody to cut].
|
||||
|
||||
import { num, talentBb, traitBb, moduleOn, skillRec, statBuff, giveSp, onHitBy } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
|
||||
|
||||
const S1 = 'skchr_chen_1';
|
||||
const S2 = 'skchr_chen_2';
|
||||
const S3 = 'skchr_chen_3';
|
||||
/** char_010_chen.skel (front) Skill_3: the slashes' OnAttack times from the cast (s) and the clip's length. */
|
||||
const S3_SLASH_AT = Object.freeze([0.6, 0.8, 1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.833]);
|
||||
const S3_CLIP = 2.933;
|
||||
/** The skill ranges when a record carries none (data: 3-12 and x-1). */
|
||||
const R3_12 = Object.freeze([[1, 0], [1, 1], [0, 0], [0, 1], [0, 2], [0, 3], [-1, 0], [-1, 1]]);
|
||||
const X_1 = Object.freeze([[2, 0], [1, -1], [1, 0], [1, 1], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, -1], [-1, 0], [-1, 1], [-2, 0]]);
|
||||
const AIR = Object.freeze({ canHitFly: true });
|
||||
const GROUND = Object.freeze({ canHitFly: false });
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
/** The module the record fights with (its active one), or null. */
|
||||
function activeModule(chess) {
|
||||
if (!moduleOn(chess)) return null;
|
||||
return (chess.modules ?? []).find((m) => m && m.uniEquipId === chess.module.id) ?? null;
|
||||
}
|
||||
/** Absolute tile keys of a skill range at the unit's tile and facing (a 技能范围 ignores 攻击距离: no extend). */
|
||||
const keysOf = (unit, grid) => absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0);
|
||||
|
||||
/**
|
||||
* A skill that strikes ground enemies only cannot be opened while its range holds no ground enemy: every activation path
|
||||
* (the strategy's SKILL_RANGE, content) is refused and the charge stays — the pattern of 莎草 S2 (ops/chess_char_2_06-papyrs.js,
|
||||
* non-enumerable: never serialised).
|
||||
*/
|
||||
function guardActivation(battle, unit, grid) {
|
||||
const sk = unit.skill;
|
||||
if (!sk || Object.prototype.hasOwnProperty.call(sk, 'activate')) return;
|
||||
const activate = sk.activate;
|
||||
const ok = () => battle.unitsInGrid(unit, grid, { side: 'enemy' }).some((e) => !e.isFlying);
|
||||
Object.defineProperty(sk, 'activate', {
|
||||
configurable: true, writable: true, enumerable: false,
|
||||
value(reason, opts) { return ok() ? activate.call(this, reason, opts) : false; },
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
char_010_chen: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const mod = activeModule(chess);
|
||||
// SWO-X stage 3 "自身额外回复1点技力": the hidden part of the 呵斥 change (its own interval / sp)
|
||||
const self0 = mod?.talentChanges?.find((t) => t && t.talentIndex === 0 && t.hidden)?.bb ?? {};
|
||||
const tb = traitBb(chess);
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
|
||||
const grid2 = s2?.rangeGrid ?? R3_12, grid3 = s3?.rangeGrid ?? X_1;
|
||||
const times3 = Math.min(S3_SLASH_AT.length, Math.max(1, Math.floor(num(b3.times, 10))));
|
||||
|
||||
/** S3: the slash target — the current one while it stays a ground target of the skill range, else the nearest. */
|
||||
const slashTarget = (battle, unit, m) => {
|
||||
const list = battle.enemiesInKeys(keysOf(unit, grid3), unit, GROUND);
|
||||
if (m.target && list.includes(m.target)) return m.target;
|
||||
if (!list.length) return null;
|
||||
sortEnemyTargets(battle, unit, list, 'nearest');
|
||||
m.target = list[0];
|
||||
return m.target;
|
||||
};
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: num(skillRec(chess, S1)?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
attack: {
|
||||
atkScale: num(b1.atk_scale, 1),
|
||||
hits: 1, // one sheath strike (Skill clip: one OnAttack), not the trait's two
|
||||
onHit({ battle, unit, target }) {
|
||||
if (target && target.alive && target.side === 'enemy' && num(b1.stun) > 0) battle.applyStatus(target, 'stun', { duration: num(b1.stun), source: unit });
|
||||
},
|
||||
},
|
||||
},
|
||||
[S2]: {
|
||||
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
onStart({ battle, unit }) {
|
||||
const list = battle.enemiesInKeys(keysOf(unit, grid2), unit, AIR);
|
||||
sortEnemyTargets(battle, unit, list, null);
|
||||
const v = list.slice(0, Math.max(1, Math.floor(num(b2.max_target, 1))));
|
||||
battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length, skill: 'chen:draw' });
|
||||
const amount = () => unit.s.atk * num(b2.atk_scale, 1);
|
||||
for (const e of v) {
|
||||
// "先造成法术伤害,后造成物理伤害" (PRTS 备注)
|
||||
battle.dealDamage(unit, e, { amount: amount(), type: 'arts', isSkill: true, tags: ['skill'] });
|
||||
if (e.alive) battle.dealDamage(unit, e, { amount: amount(), type: 'phys', isSkill: true, tags: ['skill'] });
|
||||
}
|
||||
},
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
duration: S3_CLIP,
|
||||
flags: { invulnerable: true, noBlock: true },
|
||||
attack: { noAttack: true },
|
||||
onStart({ battle, unit }) {
|
||||
battle.releaseBlocked(unit); // 无法阻挡
|
||||
unit.mem.chenS3 = { t: 0, n: 0, target: null };
|
||||
battle.fx('blink', { x: unit.x, y: unit.y, id: unit.id, skill: 'chen:shadowless' });
|
||||
},
|
||||
onTick({ battle, unit, skill, dt }) {
|
||||
const m = unit.mem.chenS3;
|
||||
if (!m) return;
|
||||
m.t += dt;
|
||||
// "若斩击次数未到10次之前技能范围内没有目标,立刻中止技能" (after the last slash the clip runs out)
|
||||
if (m.n < times3 && !slashTarget(battle, unit, m)) { skill.end('noTarget'); return; }
|
||||
while (m.n < times3 && m.t + 1e-9 >= S3_SLASH_AT[m.n]) {
|
||||
const e = slashTarget(battle, unit, m);
|
||||
if (!e) { skill.end('noTarget'); return; }
|
||||
m.n++;
|
||||
battle.fx('slash', { x: e.x, y: e.y, id: unit.id, n: 1, skill: 'chen:shadowless' });
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill', 'slash'] });
|
||||
if (m.n === times3 && e.alive && num(b3.stun) > 0) battle.applyStatus(e, 'stun', { duration: num(b3.stun), source: unit });
|
||||
if (!unit.alive || !skill.active) return;
|
||||
}
|
||||
},
|
||||
onEnd({ unit }) { unit.mem.chenS3 = null; },
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 呵斥: every `interval` s on the field, +sp attack / hurt SP to every ally; SWO-X stage 3: +1 more for her
|
||||
const iv = num(t0.interval), sp = num(t0.sp);
|
||||
const selfIv = num(self0.interval, iv), selfSp = num(self0.sp);
|
||||
if (!(iv > 0) || !(sp > 0)) return;
|
||||
const mates = (b) => b.alliesFor(unit).filter((a) => a.skill && !a.skill.noSkill && (a.skill.spType === 'attack' || a.skill.spType === 'hurt'));
|
||||
battle.on('deploy', (ctx) => {
|
||||
if (ctx.unit !== unit) return;
|
||||
const seq = unit.deploySeq;
|
||||
const here = () => unit.alive && unit.deployed && unit.deploySeq === seq;
|
||||
battle.every(iv, (b, sc) => {
|
||||
if (!here()) { sc.cancel(); return; }
|
||||
for (const a of mates(b)) giveSp(a, sp);
|
||||
}, { owner: unit });
|
||||
if (selfSp > 0 && selfIv > 0) {
|
||||
battle.every(selfIv, (b, sc) => {
|
||||
if (!here()) { sc.cancel(); return; }
|
||||
giveSp(unit, selfSp);
|
||||
}, { owner: unit });
|
||||
}
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 持刀格斗术: ATK / DEF / 物理闪避 (SWO-Y stage 3: the module numbers)
|
||||
statBuff(battle, unit, 'talent:chen:knife', { atkPct: num(t1.atk), defPct: num(t1.def), dodgePhys: num(t1.prob) });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// SWO-Y “往昔时光”: 攻击时无视敌人70点的防御力
|
||||
statBuff(battle, unit, 'trait:chen:pierce', { defIgnoreFlat: num(tb.def_penetrate_fixed) });
|
||||
// SWO-X “罗德岛制式剑”: 技能期间造成的伤害提升10% (PRTS 修正)
|
||||
const skillMul = num(tb.damage_scale, 1);
|
||||
if (skillMul !== 1) onHitBy(battle, unit, ({ dmg }) => { if (dmg.isSkill || unit.skill?.active) dmg.mul *= skillMul; });
|
||||
// S3: 眩晕免疫, 冻结免疫 while the slashes run
|
||||
battle.on('beforeStatus', (c) => {
|
||||
if (c.target === unit && unit.mem.chenS3 && (c.status === 'stun' || c.status === 'freeze')) c.cancel = true;
|
||||
}, { owner: unit });
|
||||
if (unit.skill?.id === S3) guardActivation(battle, unit, grid3);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
// server/sim/content/kits/ops/op-chyue.js — 重岳 (char_2024_chyue) 自选 operator kit: 6★ 斗士 (近卫), an owned-6★ pick of
|
||||
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_2024_chyue, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 重岳 (talent and
|
||||
// skill 备注); the client's buff templates (buff_template_data `chyue_t_1` / `_passive`, `chyue_t_2` / `_sp`,
|
||||
// `chyue_e_002_t[unkill]`, `chyue_s_1_enhance_judge`, `chyue_s_2_levitate` / `_finish_levitate` / `_atk_scale`,
|
||||
// `chyue_s_3_enhance` / `_trigger` / `_end_check`); Arknights Terra Wiki "Chongyue".
|
||||
// - Trait (斗士) "能够阻挡一个敌人": the profession default (block 1, melee, ground only). Module FGT-X "拥有15%的物理闪避"
|
||||
// (trait bb prob): a permanent physical dodge. Module FGT-Y "生命值高于50%时攻击速度+10" (trait bb attack_speed /
|
||||
// hp_ratio): ASPD while HP > hp_ratio.
|
||||
// - T1 止戈 "对目标普通攻击时,有23%的概率使重岳2.5秒内对其造成的伤害提升65%" (bb prob / damage_scale / up_duration):
|
||||
// chyue_t_1 rolls on every damage instance a normal attack outputs (PRTS 备注 "于普通攻击每次造成伤害时独立触发,重复施加
|
||||
// 时仅刷新持续时间" — after it lands, so not for that instance) and marks the target for up_duration s (his own mark:
|
||||
// independentCharacterSource); chyue_t_1_passive: every damage of his to a marked enemy × damage_scale (DamageScale, a
|
||||
// final multiplier — skills included). FGT-Y stage 3: damage_scale 1.75. S2's second segment marks at 100 %.
|
||||
// - T2 万象为宾 "若重岳释放一次技能击倒不少于一个敌人,则回复3点技力" (bb sp): PRTS 备注 — only a kill by skill damage counts,
|
||||
// judged when the skill (or an ability of it) finishes; the SP ignores 阻回 (ModifySp forceFlag). FGT-X stage 3:
|
||||
// 4 SP, and "未击倒敌人时变为回复1点技力" (the hidden part's sp: chyue_e_002_t[unkill]). S2's second segment is judged on
|
||||
// its own (PRTS: "第二段攻击打出时恢复一次SP…只与这第二段攻击是否击杀了敌人有关").
|
||||
// - S1 冲盈 (MANUAL, attack SP, 可充能 3 次; data DEFAULT): the next attack strikes its target for atk_scale × ATK; cast
|
||||
// from a full stack (蓄力, chyue_s_1_enhance_judge: ≥ 3 charges) it spends every charge (ClearCharacterSp) and strikes
|
||||
// `times` times.
|
||||
// - S2 拂尘 (MANUAL, 可充能 2 次; data SKILL_RANGE on its x-4): at once up to max_target enemies around him (PRTS 备注
|
||||
// "可对空,优先选择自身第一天赋生效中的>自身阻挡的>仇恨值最高的敌人") take atk_scale × ATK physical, and those he marked are
|
||||
// 浮空 for 2 s (chyue_s_2_levitate: ground enemies only — the engine refuses data flyers); then the second segment
|
||||
// (SecAttack, outside the skill — 0.5 s later: his skeleton's Skill_2_Begin hits at 0.367 s of its 0.833 s, Skill_2_End
|
||||
// at 0.033 s): every 浮空 enemy around him loses every 浮空 buff (chyue_s_2_finish_levitate), takes atk_scale_down × ATK
|
||||
// physical and gets his mark (100 %, after that damage). The first segment lands at the cast [ASSUMED: the kits'
|
||||
// instant convention — the clip's 0.367 s wind-up is not modelled].
|
||||
// - S3 我无 (MANUAL, attack SP; data DEFAULT): the next attack strikes its target and the ground enemies within 0.8 of it
|
||||
// (PRTS 备注 "溅射半径0.8"; the skill cannot hit air) for atk_scale × ATK. Its cast_cnt-th cast of a deployment turns him
|
||||
// (【变身】, chyue_s_3_end_check — "第五次开启本技能时【变身】(每次部署单独计算)"; that cast keeps its single strike, the
|
||||
// text's "累计使用五次技能后"): his range becomes x-6, every normal attack hits twice and gives 1 more SP ignoring 阻回
|
||||
// (chyue_s_3_enhance), the skill fires by itself the moment its SP is full and a normal-attack target exists
|
||||
// (chyue_s_3_trigger TriggerSkill, checked every frame: not the 3 s automatic-operation cooldown) and its strike lands
|
||||
// twice. The fixed animation (不受攻击速度影响) is modelled as one attack interval [ASSUMED].
|
||||
|
||||
import { num, talentBb, moduleBb, traitBb, up, onHitBy, statBuff, toggleBuff, skillRec } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
|
||||
import { hasHp, isHpLoss } from '../../../damage.js';
|
||||
|
||||
const S1 = 'skchr_chyue_1';
|
||||
const S2 = 'skchr_chyue_2';
|
||||
const S3 = 'skchr_chyue_3';
|
||||
/** S2's 技能范围 when the data carries none (x-4: the 8 tiles around him and his own). */
|
||||
const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
|
||||
/** His range after the S3 【变身】 (PRTS 备注 "攻击范围扩大至 x-6"; range_table x-6). */
|
||||
const X6 = Object.freeze([[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]]);
|
||||
/** S3 "溅射半径0.8" (PRTS 备注). */
|
||||
const S3_SPLASH = 0.8;
|
||||
/** S2 浮空 "持续2s" (PRTS 备注; chyue_s_2_levitate lifeTime 2). */
|
||||
const S2_LEVITATE = 2;
|
||||
/** S2's second segment after the first: the skeleton's OnAttack events (Skill_2_Begin 0.367 / 0.833 s, Skill_2_End 0.033 s). */
|
||||
const S2_SECOND_DELAY = 0.833 + 0.033 - 0.367;
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const markKey = (unit) => `talent:chyue:mark#${unit.id}`;
|
||||
const marked = (unit, e) => !!e.findBuff(markKey(unit));
|
||||
|
||||
export default {
|
||||
char_2024_chyue: (bb, chess, def) => {
|
||||
const tb = traitBb(chess);
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const hb = moduleBb(chess); // FGT-X stage 3: sp when the skill killed nobody
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s2 = skillRec(chess, S2);
|
||||
const picked = def?.skill?.id ?? chess?.skill?.skillId ?? null;
|
||||
const castCnt = Math.max(1, Math.floor(num(b3.cast_cnt, 5)));
|
||||
const mark = (battle, unit, e) => {
|
||||
if (hasHp(e)) battle.addBuff(e, { key: markKey(unit), duration: num(t0.up_duration, 2.5), refresh: 'replace', source: unit, tags: ['talent'] });
|
||||
};
|
||||
/** 万象为宾: the SP of a finished skill / segment (a kill by his skill damage: sp, else the FGT-X stage 3 part). */
|
||||
const settle = (unit) => {
|
||||
const killed = !!unit.mem.chyueKilled;
|
||||
unit.mem.chyueKilled = false;
|
||||
const n = killed ? num(t1.sp) : num(hb.sp);
|
||||
if (n > 0 && up(unit) && unit.skill) unit.skill.gainSp(n, 'init'); // ModifySp forceFlag: 阻回 does not stop it
|
||||
};
|
||||
const s2Keys = (unit) => absoluteRangeKeys(s2?.rangeGrid?.length ? s2.rangeGrid : X4, unit.tileR, unit.tileC, unit.dir, 0);
|
||||
/** S2's second segment (SecAttack). */
|
||||
const secondSegment = (battle, unit, seq) => {
|
||||
if (!up(unit) || unit.deploySeq !== seq) return;
|
||||
unit.mem.chyueKilled = false;
|
||||
const atk = unit.s.atk;
|
||||
const lifted = battle.enemiesInKeys(s2Keys(unit), unit, { canHitFly: true }).filter((e) => e.s.flags.levitate);
|
||||
if (lifted.length) battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'chyue:down' });
|
||||
for (const e of lifted) {
|
||||
for (const b of e.buffs.slice()) if (b.flags && b.flags.levitate) battle.removeBuff(e, b);
|
||||
if (!e.alive) continue;
|
||||
battle.dealDamage(unit, e, { amount: atk * num(b2.atk_scale_down, 1), type: 'phys', isSkill: true, tags: ['skill'] });
|
||||
mark(battle, unit, e);
|
||||
}
|
||||
settle(unit);
|
||||
};
|
||||
/** After the S3 【变身】: x-6, double hits, +1 SP per attack, the self-cast. */
|
||||
const awaken = (battle, unit) => {
|
||||
unit.mem.chyueAwake = true;
|
||||
unit.rangeGrid = X6;
|
||||
battle.refreshRange(unit);
|
||||
battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'atk' });
|
||||
};
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'charges',
|
||||
attack: { atkScale: num(b1.atk_scale, 1), hitsFn: (b, u) => u.mem.chyueS1Hits || 1 },
|
||||
onStart({ unit, skill }) {
|
||||
// 蓄力: the cast took one charge of a full stack ⇒ the rest goes too and the strike lands `times` times
|
||||
const full = skill.maxCharges > 1 && skill.charges === skill.maxCharges - 1;
|
||||
unit.mem.chyueS1Hits = full ? Math.max(1, Math.floor(num(b1.times, skill.maxCharges))) : 1;
|
||||
if (full) { skill.charges = 0; skill.sp = 0; }
|
||||
},
|
||||
onEnd({ unit }) { unit.mem.chyueS1Hits = 1; settle(unit); },
|
||||
},
|
||||
[S2]: {
|
||||
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
onStart({ battle, unit }) {
|
||||
unit.mem.chyueKilled = false;
|
||||
const foes = battle.enemiesInKeys(s2Keys(unit), unit, { canHitFly: true });
|
||||
sortEnemyTargets(battle, unit, foes, null); // the ones he blocks first, then aggro
|
||||
foes.sort((a, b) => (marked(unit, b) ? 1 : 0) - (marked(unit, a) ? 1 : 0)); // his marked ones before them (stable)
|
||||
const atk = unit.s.atk;
|
||||
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'chyue:whisk' });
|
||||
for (const e of foes.slice(0, Math.max(1, Math.floor(num(b2.max_target, 4))))) {
|
||||
if (!e.alive) continue;
|
||||
const lift = marked(unit, e);
|
||||
battle.dealDamage(unit, e, { amount: atk * num(b2.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
|
||||
if (lift && e.alive) battle.applyStatus(e, 'levitate', { duration: S2_LEVITATE, source: unit });
|
||||
}
|
||||
const seq = unit.deploySeq;
|
||||
battle.after(S2_SECOND_DELAY, () => secondSegment(battle, unit, seq), { owner: unit });
|
||||
},
|
||||
onEnd({ unit }) { settle(unit); },
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'instant',
|
||||
attack: {
|
||||
atkScale: num(b3.atk_scale, 1), splashRadius: S3_SPLASH, hitsFn: () => 1,
|
||||
onHit({ battle, unit, target, x, y }) {
|
||||
if (!unit.mem.chyueAwake) return;
|
||||
// after the 【变身】 the strike lands once more: its target and the ground enemies within 0.8 of it
|
||||
const amount = unit.s.atk * num(b3.atk_scale, 1);
|
||||
const cx = target ? target.x : x, cy = target ? target.y : y;
|
||||
if (target && target.alive) battle.dealDamage(unit, target, { amount, type: 'phys', isSkill: true, tags: ['skill'] });
|
||||
for (const e of battle.foesInRadius(cx, cy, S3_SPLASH, true)) {
|
||||
if (e === target || !e.alive || e.isFlying || e.s.flags.untargetable) continue;
|
||||
battle.dealDamage(unit, e, { amount, type: 'phys', isSkill: true, isSplash: true, tags: ['skill'] });
|
||||
}
|
||||
},
|
||||
},
|
||||
onStart({ unit }) { unit.mem.chyueCasts = (unit.mem.chyueCasts || 0) + 1; },
|
||||
onEnd({ battle, unit }) {
|
||||
settle(unit);
|
||||
if (!unit.mem.chyueAwake && unit.mem.chyueCasts >= castCnt && up(unit)) awaken(battle, unit);
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 止戈
|
||||
const p = num(t0.prob), scale = num(t0.damage_scale, 1);
|
||||
if (scale !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (marked(unit, target)) dmg.mul *= scale; });
|
||||
if (!(p > 0)) return;
|
||||
battle.on('damaged', (c) => {
|
||||
const e = c.target, d = c.dmg;
|
||||
if (c.source !== unit || !e || e.side !== 'enemy' || !d || !d.isAttack || d.isSkill || isHpLoss(d) || c.type === 'element' || !hasHp(e)) return;
|
||||
if (battle.rng.chance(p)) mark(battle, unit, e);
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 万象为宾: a kill by his skill damage, for the running skill / segment (settle)
|
||||
battle.on('damaged', (c) => {
|
||||
if (c.source === unit && c.dmg && c.dmg.isSkill && c.target && c.target.side === 'enemy' && !hasHp(c.target)) unit.mem.chyueKilled = true;
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
// the S3 【变身】's double hit (normal attacks only: S1 / S3 strikes set their own hits)
|
||||
trait: picked === S3 ? { hitsFn: (b, u) => (u.mem.chyueAwake ? 2 : 1) } : undefined,
|
||||
install(battle, unit) {
|
||||
// FGT-X 物理闪避 / FGT-Y 生命值高于50%时攻击速度+10
|
||||
if (num(tb.prob) > 0) statBuff(battle, unit, 'trait:chyue:dodge', { dodgePhys: num(tb.prob) });
|
||||
if (num(tb.attack_speed)) toggleBuff(battle, unit, 'trait:chyue:aspd', () => unit.hpRatio > num(tb.hp_ratio, 0.5), { aspd: num(tb.attack_speed) });
|
||||
// every deployment starts untransformed with no cast counted (每次部署单独计算)
|
||||
battle.on('deploy', ({ unit: u }) => {
|
||||
if (u !== unit) return;
|
||||
unit.mem.chyueCasts = 0;
|
||||
unit.mem.chyueAwake = false;
|
||||
unit.mem.chyueS1Hits = 1;
|
||||
unit.mem.chyueKilled = false;
|
||||
const own = unit.def?.rangeGrid;
|
||||
if (own && unit.rangeGrid !== own) { unit.rangeGrid = own; battle.refreshRange(unit); }
|
||||
}, { owner: unit });
|
||||
if (picked !== S3) return;
|
||||
// 【变身】: +1 SP per normal attack (阻回 ignored) and the self-cast the moment SP and a target are there
|
||||
battle.on('attack', (c) => {
|
||||
if (c.attacker === unit && !c.isSkill && unit.mem.chyueAwake && unit.skill) unit.skill.gainSp(1, 'init');
|
||||
}, { owner: unit });
|
||||
battle.on('tick', () => {
|
||||
const sk = unit.skill;
|
||||
if (!unit.mem.chyueAwake || !sk || !up(unit) || !unit.canAct || !sk.ready || sk.pending || unit.s.flags.silence) return;
|
||||
const prof = battle.effectiveProfile(unit);
|
||||
if (!battle.enemiesInKeys(unit.rangeKeys, unit, prof).length && !battle.blockedTargets(unit, prof).length) return;
|
||||
if (!sk.activate('awake')) return;
|
||||
if (battle.forceAttack(unit)) unit.atkCd = Math.max(unit.atkCd, unit.s.interval);
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
// server/sim/content/kits/ops/op-helage.js — 赫拉格 (char_188_helage) 自选 operator kit: 6★ 武者 (近卫), an owned-6★ pick of
|
||||
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_188_helage, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), PRTS 赫拉格 (talents,
|
||||
// skills, modules: no 备注), PRTS 分支特性信息 §武者 ("常态持有禁疗;通过自身特性/天赋/技能产生的作用于自身的治疗效果会无视自身的
|
||||
// 禁疗"; "特性治疗于干员每次输出伤害时触发(不局限于攻击)"), gamedata_const ba.berserk 坚忍 ("根据已损失的生命值获得相应比例的
|
||||
// 属性加成,损失一定比例时达最大加成(同类属性取最高)") and ba.protect 庇护 ("受到的物理和法术伤害降低相应比例(同名效果取最高)"), his
|
||||
// battle skeleton char_188_helage.skel (Skill: two OnAttack, Skill_2: two, Skill_3_Loop: one).
|
||||
// - Trait (武者) "不成为其他角色的治疗目标,每次攻击到敌人后回复自身70生命": the profession default (professions.js `musha`: no
|
||||
// heal from others, value HP per enemy an attack hits), ground-only melee on his 1-1, block 1. The branch heals on every
|
||||
// damage instance he deals: the engine heals once per enemy an attack strikes, so the second hit of S1 / S2's 二连击 on
|
||||
// an enemy heals once more here (the trait's `value`, a self heal).
|
||||
// - T1 月盈星亏 "在场时,自身获得最高+100攻击速度的坚忍(损失70%生命值时达到最大加成)": ASPD + min_attack_speed × the share of
|
||||
// the HP lost up to 1 − min_hp_ratio (linear, 坚忍), refreshed every tick. SBL-X stage 3: +130 at 50 % lost (the module
|
||||
// talent change).
|
||||
// - T2 运筹帷幄 "未阻挡敌人时每秒回复60生命": hp_recovery_per_sec is the 生命回复速度 attribute (the stat hpRecoveryPerSec;
|
||||
// SIM.md §4: an hpRegen buff, not a heal — no 治疗加成, never stopped by 禁疗), on while he blocks nobody [ASSUMED: PRTS
|
||||
// has no 备注 on it; the blackboard key is the attribute's, as 角峰 S1 / 宴 S1 in their kits — the 0.2.0 生命回复速度 audit
|
||||
// lists such 「每秒回复」 + hp_recovery_per_sec effects as open; a heal would reach him too, 武者's own heals ignore his
|
||||
// 禁疗, and only 治疗加成 would differ]. SBL-Y stage 3
|
||||
// "未阻挡敌人或生命值低于30%时每秒回复80生命": 80 / s and the hidden module talent (index 2, hp_ratio / hp_recovery_per_sec):
|
||||
// the same regeneration below hp_ratio while he blocks [ASSUMED: the text's 或 — 80 / s when either holds, never both
|
||||
// parts at once].
|
||||
// - Module SBL-X “藏锋” "生命值低于50%时,获得25%的庇护" (a display trait part; the effect is the hidden talent part hp_ratio /
|
||||
// damage_resistance, merged into talent 0's blackboard): below hp_ratio of his max HP the physical and arts damage he takes
|
||||
// ×(1 − damage_resistance) (as 宴's elite module).
|
||||
// - Module SBL-Y “热的雪” "被击倒时不撤退且回复30%生命(单次部署只触发1次)" (trait bb hp_ratio): the first lethal blow of a
|
||||
// deployment leaves him on the field at hp_ratio of his max HP (a kit saver, priority −50 as 斯卡蒂's module).
|
||||
// - S1 新月 (AUTO, attack SP, DEFAULT): the next attack at atk_scale × ATK, twice (Skill clip: two OnAttack).
|
||||
// - S2 弦月 (MANUAL, time SP, DEFAULT): `duration` s — ATK +atk, every attack two hits (二连击), 物理闪避 +prob.
|
||||
// - S3 满月 (MANUAL, time SP, data ACTIVE_RANGE on the running 1-1 + 1): `duration` s — ATK +atk, attack range +1 tile
|
||||
// forward (ability_range_forward_extend), up to attack@max_target targets at once; back to 1-1 after.
|
||||
|
||||
import { num, traitBb, skillRec, toggleBuff, up, onHitOn } from '../shared/tier1.js';
|
||||
|
||||
const S1 = 'skchr_helage_1';
|
||||
const S2 = 'skchr_helage_2';
|
||||
const S3 = 'skchr_helage_3';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
/** The blackboard of the talent with data index `i` ({} when absent; module parts with a new index included). */
|
||||
const talentAt = (chess, i) => (chess?.talents ?? []).find((t) => t && t.index === i)?.bb ?? {};
|
||||
|
||||
export default {
|
||||
char_188_helage: (bb, chess) => {
|
||||
const t0 = talentAt(chess, 0), t1 = talentAt(chess, 1), t2 = talentAt(chess, 2);
|
||||
const tb = traitBb(chess);
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: num(skillRec(chess, S1)?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
attack: { atkScale: num(b1.atk_scale, 1), hits: 2 },
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b2.atk), dodgePhys: num(b2.prob) },
|
||||
attack: { hits: 2 },
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b3.atk) },
|
||||
targeting: { rangeExtend: num(b3.ability_range_forward_extend), maxTargets: Math.max(1, Math.floor(num(b3['attack@max_target'], 1))) },
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 月盈星亏: 坚忍 ASPD up to min_attack_speed, reached at min_hp_ratio HP (linear in the HP lost)
|
||||
const maxAs = num(t0.min_attack_speed), minHp = num(t0.min_hp_ratio);
|
||||
if (!(maxAs > 0) || !(minHp < 1)) return;
|
||||
const key = 'talent:helage:berserk';
|
||||
battle.on('tick', () => {
|
||||
const cur = unit.findBuff(key);
|
||||
if (!up(unit)) { if (cur) battle.removeBuff(unit, key); return; }
|
||||
const v = maxAs * Math.max(0, Math.min(1, (1 - unit.hpRatio) / (1 - minHp)));
|
||||
if (cur && Math.abs((cur.data?.v ?? 0) - v) < 0.05) return;
|
||||
if (v < 0.05) { if (cur) battle.removeBuff(unit, key); return; }
|
||||
battle.addBuff(unit, { key, mods: { aspd: v }, data: { v }, tags: ['talent'] });
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 运筹帷幄: 生命回复速度 while blocking nobody; SBL-Y stage 3: or below hp_ratio HP
|
||||
const regen = num(t1.hp_recovery_per_sec);
|
||||
if (regen > 0) toggleBuff(battle, unit, 'talent:helage:regen', () => unit.blocking.length === 0, { hpRegen: regen });
|
||||
const lowHp = num(t2.hp_ratio), lowRegen = num(t2.hp_recovery_per_sec);
|
||||
if (lowRegen > 0 && lowHp > 0) {
|
||||
toggleBuff(battle, unit, 'talent:helage:regenLow', () => unit.blocking.length > 0 && unit.hpRatio < lowHp, { hpRegen: lowRegen });
|
||||
}
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// 武者: the trait heal on every damage instance — the engine heals once per enemy an attack strikes, so each further
|
||||
// hit of the same attack on that enemy (S1, S2's 二连击) heals once more
|
||||
battle.on('damaged', (c) => {
|
||||
const d = c.dmg;
|
||||
if (c.source !== unit || !unit.alive || !d || !d.isAttack || !d.attackId || !c.target || c.target.side !== 'enemy') return;
|
||||
const m = unit.mem;
|
||||
if (m.helageAttackId !== d.attackId) { m.helageAttackId = d.attackId; m.helageStruck = new Set(); }
|
||||
if (!m.helageStruck.has(c.target.id)) { m.helageStruck.add(c.target.id); return; }
|
||||
battle.heal(unit, unit, num(unit.profile?.selfHeal, num(tb.value, 70)), { self: true });
|
||||
}, { owner: unit, priority: -10 });
|
||||
// SBL-X “藏锋”: 生命值低于50%时,获得25%的庇护 (physical and arts damage taken)
|
||||
const dr = num(t0.damage_resistance), drBelow = num(t0.hp_ratio);
|
||||
if (dr > 0 && drBelow > 0) {
|
||||
onHitOn(battle, unit, ({ dmg }) => {
|
||||
if ((dmg.type === 'phys' || dmg.type === 'arts') && unit.hpRatio < drBelow) dmg.mul *= 1 - dr;
|
||||
});
|
||||
}
|
||||
// SBL-Y “热的雪”: 被击倒时不撤退且回复30%生命(单次部署只触发1次)
|
||||
const rise = num(tb.hp_ratio);
|
||||
if (rise > 0) {
|
||||
battle.on('fatal', (ctx) => {
|
||||
if (ctx.unit !== unit || ctx.prevented || unit.mem.helageRoseSeq === unit.deploySeq) return;
|
||||
unit.mem.helageRoseSeq = unit.deploySeq;
|
||||
ctx.prevented = true;
|
||||
unit.hp = Math.max(1, unit.s.maxHp * rise);
|
||||
battle.fx('undying', { x: unit.x, y: unit.y, id: unit.id });
|
||||
}, { owner: unit, priority: -50 });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
// server/sim/content/kits/ops/op-heyak.js — 霍尔海雅 (char_4027_heyak) 自选 operator kit: 6★ 中坚术师 (术师), an owned-6★
|
||||
// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and the three modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_4027_heyak, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 霍尔海雅
|
||||
// (传承终焉 备注 "技能触发的浮空效果于本天赋结算前进行结算"; S1 备注 "“仅攻击到1名目标”仅判定实际发射的弹道数"; S3 备注 "攻击以自身格
|
||||
// 中心处为准,于此处、左1.0、右1.0格在0~0.3s内随机延迟后生成三股旋风", "移动速度1.0,碰撞范围为1×1的正方形,旋风移动至5格远处中心点
|
||||
// 自然消失,造成伤害时实时应用霍尔海雅的当前攻击力", "旋风在生成后1.77s内,攻击倍率由最低值线性递增至最大值", "霍尔海雅离场时已存在的旋风
|
||||
// 不会因此立刻消失,但其命中敌人时不享受模组加成,不触发第一天赋").
|
||||
// - Trait (中坚术师) "攻击造成法术伤害": the profession default (ranged arts, hits air, block 1). Module CCR-X “图书馆” adds
|
||||
// "无视目标10点法术抗性" (trait bb magic_resist_penetrate_fixed: a permanent resIgnoreFlat), CCR-Y “羽蛇的时间博物馆”
|
||||
// "普通攻击命中精英或领袖敌人时获得1点技力" (trait bb sp: per hit of one of her attacks on an elite / leader; a running timed
|
||||
// skill takes no SP). ISW-A “霍尔海雅特限证章”: its trait "在集成战略中,无视目标10点法术抗性,每个敌人首次进入目标点所在地块时,恢复
|
||||
// 自身30%的技力" and its hidden talents (首次部署 1 点临时目标生命值, the goal-tile 浮空 15 s, stage 3's radius-1.1 skill
|
||||
// splash "自身技能会对敌人及其周围的目标同时造成浮空效果和伤害效果") are battle_equip_table parts with `validInGameTag:
|
||||
// roguelike` — N/A here; what applies is its stats and, at stage 2+, 传承终焉's new numbers (a TALENT_DATA_ONLY part with
|
||||
// no game tag: 145 % / 5 s at stage 3).
|
||||
// - T1 传承终焉 "攻击空中目标时攻击力提升至120%,并使其特殊能力失效3秒": every hit of her attacks on an air unit (flying, 近地悬浮
|
||||
// or 浮空) at the hit is × atk_scale and silences it for `silence` s. Her skills' 浮空 of that very hit resolve first (PRTS
|
||||
// 备注), so a target her S1 / S2 / S3 lifts takes the ×atk_scale of that hit: the S1 / S2 lifts land in the hit's `hit`
|
||||
// step, ahead of this talent (a dodged hit has lifted already [ASSUMED]). CCR-X stage 3: 135 % / 5 s, ISW-A stage 3:
|
||||
// 145 % / 5 s (the talent's merged bb).
|
||||
// - T2 曾有羽翼 "攻击范围内所有生命值高于80%的敌人失重": every enemy of her current attack range (a running S3's included) above
|
||||
// hp_ratio of its HP is 失重 (weight −1 level, 同名效果不可叠加: none of hers while another source's 失重 is on it), off
|
||||
// again at once below it or outside the range. CCR-Y stage 3 lowers it to 50 % and adds "生命值低于50%的敌人获得20%的
|
||||
// 【法术脆弱】" (its hidden talent: damage_scale 1.2, hp_ratio 0.5): the 法术脆弱 status, renewed every 0.2 s for 0.3 s
|
||||
// (as 史尔特尔 AFT-Y's).
|
||||
// - S1 但为求索 (AUTO, 可充能 1 / 2, data DEFAULT): the next attack hits one more target (2), atk_scale × ATK arts each; when
|
||||
// it fires at one target only it lifts it (浮空) `levitate` s — counted at the shot (PRTS 备注).
|
||||
// - S2 群星逶迤 (MANUAL, 16 s, data DEFAULT): each attack becomes SHOTS (9, the text's "9连发"; no blackboard key) shots of
|
||||
// attack@atk_scale × ATK, each at a random enemy of her range (independent draws), each with attack@prob to lift it
|
||||
// attack@levitate s [ASSUMED: the nine leave with the attack; the burst's spacing is not modelled].
|
||||
// - S3 博览者的狂语 (MANUAL, 45 s, data ACTIVE_RANGE on 3-10): attack range 3-10 while it runs, attack interval +1.4 s
|
||||
// (base_attack_time, flat); each attack (an enemy in that range, her attack timer) releases three whirlwinds from the
|
||||
// centre of her tile and the tiles left and right of it, each after its own random 0–0.3 s delay. A whirlwind flies
|
||||
// straight forward at 1 tile/s over 5 tiles (PRTS; a 1×1 square), hits the first enemy it touches — an enemy her
|
||||
// attacks can select, air units too [ASSUMED] — lifting it attack@levitate s and then dealing (min + (max − min) ×
|
||||
// min(1, age / 1.77 s)) × her current ATK arts (attack@min_atk_scale → attack@max_atk_scale), and is gone; the nearest
|
||||
// of several touched at once is hit first [ASSUMED]. A whirlwind out when she leaves the field flies on, without 传承终焉
|
||||
// and without her module's RES ignore [ASSUMED: "模组加成" = the module's trait; its attributes stay in her ATK].
|
||||
// (wiki.gg reads the end of the flight as the far end of her range, 4 tiles; PRTS's 5 is kept.)
|
||||
// - No summon (the forms list no token).
|
||||
|
||||
import { num, talentBb, traitBb, skillRec, statBuff, batMod, giveSp, onHitBy, up } from '../shared/tier1.js';
|
||||
import { canTargetEnemy } from '../../../targeting.js';
|
||||
import { dirVec, rotateOffset } from '../../../dir.js';
|
||||
import { bodyDist, hitRect } from '../../../body.js';
|
||||
|
||||
const S1 = 'skchr_heyak_1';
|
||||
const S2 = 'skchr_heyak_2';
|
||||
const S3 = 'skchr_heyak_3';
|
||||
/** S1 "额外攻击一个目标": the next attack's targets (no blackboard key; `cnt` is the charge count). */
|
||||
const S1_TARGETS = 2;
|
||||
/** S2 "9连发": shots per attack when the text gives no number (no blackboard key). */
|
||||
const SHOTS = 9;
|
||||
/** S3 whirlwinds (PRTS 备注): lanes (facing-RIGHT row offsets: her row, "左1.0", "右1.0"), spawn delay, speed, flight, box. */
|
||||
const WIND_LANES = Object.freeze([0, 1, -1]);
|
||||
const WIND_DELAY = 0.3;
|
||||
const WIND_SPEED = 1;
|
||||
const WIND_RANGE = 5;
|
||||
const WIND_HALF = 0.5;
|
||||
/** "旋风在生成后1.77s内,攻击倍率由最低值线性递增至最大值". */
|
||||
const WIND_RAMP = 1.77;
|
||||
/** How often a flying whirlwind is drawn again (fx 'tornado'). */
|
||||
const WIND_FX_EVERY = 0.5;
|
||||
/** S3's 3-10 when the data carries no grid. */
|
||||
const R310 = Object.freeze([[1, 0], [1, 1], [1, 2], [1, 3], [1, 4], [0, 0], [0, 1], [0, 2], [0, 3], [0, 4], [-1, 0], [-1, 1], [-1, 2], [-1, 3], [-1, 4]]);
|
||||
/** A whirlwind hit after she left the field: no 传承终焉, no module trait (PRTS 备注). */
|
||||
const AWAY_TAG = 'heyak:away';
|
||||
const WEIGHTLESS_KEY = 'heyak:weightless';
|
||||
const FRAGILE_EVERY = 0.2;
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
/** An elite or leader enemy (精英或领袖敌人). */
|
||||
const isEliteEnemy = (e) => !!e && e.side === 'enemy' && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS');
|
||||
const tagged = (d, tag) => !!(d && d.tags && d.tags.includes(tag));
|
||||
/** A module trait that works 在集成战略中 only (ISW-A): nothing of it applies in this mode. */
|
||||
const roguelikeOnly = (chess) => /集成战略/.test(String(chess?.trait?.moduleDesc ?? ''));
|
||||
/** The record's active module, or null. */
|
||||
const activeModule = (chess) => (chess?.module?.active ? (chess.modules ?? []).find((m) => m && m.uniEquipId === chess.module.id) ?? null : null);
|
||||
/** Does the enemy's body touch the 1×1 square centred on (x, y)? */
|
||||
function inBox(e, x, y) {
|
||||
const R = hitRect(e);
|
||||
if (!R) return Math.abs(e.x - x) <= WIND_HALF + 1e-9 && Math.abs(e.y - y) <= WIND_HALF + 1e-9;
|
||||
return R.x0 <= x + WIND_HALF && R.x1 >= x - WIND_HALF && R.y0 <= y + WIND_HALF && R.y1 >= y - WIND_HALF;
|
||||
}
|
||||
|
||||
export default {
|
||||
char_4027_heyak: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const isw = roguelikeOnly(chess);
|
||||
const mod = activeModule(chess);
|
||||
// CCR-Y's hidden part (the 【法术脆弱】 rider); an ISW module's hidden parts are 集成战略-only
|
||||
const rider = isw || !mod ? {} : Object.assign({}, ...(mod.talentChanges ?? []).filter((t) => t && t.talentIndex === -1).map((t) => t.bb ?? {}));
|
||||
const tb = isw ? {} : traitBb(chess);
|
||||
const pen = num(tb.magic_resist_penetrate_fixed);
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s1 = skillRec(chess, S1), s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
|
||||
const shots = Math.max(1, Math.floor(num(+(/(\d+)连发/.exec(String(s2?.desc ?? ''))?.[1]), SHOTS)));
|
||||
|
||||
/** S3: release one attack's three whirlwinds. */
|
||||
function releaseWinds(battle, unit, winds) {
|
||||
const seq = unit.deploySeq;
|
||||
const [fr, fc] = dirVec(unit.dir);
|
||||
const x0 = unit.tileC, y0 = unit.tileR;
|
||||
for (const lane of WIND_LANES) {
|
||||
const [dr, dc] = rotateOffset(lane, 0, unit.dir);
|
||||
const delay = battle.rng.range(0, WIND_DELAY);
|
||||
battle.after(delay, () => {
|
||||
// a whirlwind not yet out when she left is never released [ASSUMED: "已存在的旋风" only fly on]
|
||||
if (!(unit.alive && unit.deployed && unit.deploySeq === seq)) return;
|
||||
const w = { x: x0 + dc, y: y0 + dr, dx: fc, dy: fr, left: WIND_RANGE, age: 0, fxAge: 0, seq };
|
||||
battle.fx('tornado', { x: w.x, y: w.y, id: unit.id, r: WIND_HALF, dur: WIND_FX_EVERY + 0.1 });
|
||||
if (!windHit(battle, unit, w)) winds.push(w);
|
||||
}, { owner: unit });
|
||||
}
|
||||
}
|
||||
/** The first enemy the whirlwind touches, struck: 浮空 first (PRTS: before 传承终焉), then the damage. True when it hit. */
|
||||
function windHit(battle, unit, w) {
|
||||
let best = null, bd = Infinity;
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive || !inBox(e, w.x, w.y) || !canTargetEnemy(unit, e, unit.profile)) continue;
|
||||
const d = bodyDist(e, w.x, w.y);
|
||||
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; }
|
||||
}
|
||||
if (!best) return false;
|
||||
const away = !(unit.alive && unit.deployed && unit.deploySeq === w.seq);
|
||||
const lev = num(b3['attack@levitate']);
|
||||
if (lev > 0) battle.applyStatus(best, 'levitate', { duration: lev, source: unit });
|
||||
const lo = num(b3['attack@min_atk_scale']), hi = num(b3['attack@max_atk_scale'], lo);
|
||||
const scale = lo + (hi - lo) * Math.min(1, w.age / WIND_RAMP);
|
||||
if (best.alive) {
|
||||
battle.dealDamage(unit, best, {
|
||||
amount: unit.s.atk * scale, type: 'arts', isAttack: true, isSkill: true,
|
||||
...(away ? { resIgnoreFlat: -pen, tags: [AWAY_TAG] } : null),
|
||||
});
|
||||
}
|
||||
battle.fx('tornadoPulse', { x: w.x, y: w.y, id: unit.id, r: WIND_HALF });
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
targeting: { maxTargets: S1_TARGETS },
|
||||
attack: { atkScale: num(b1.atk_scale, 1) },
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
attack: { atkScale: num(b2['attack@atk_scale'], 1) },
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
mods: { batPct: batMod(b3.base_attack_time, chess) },
|
||||
targeting: { rangeGrid: s3?.rangeGrid ?? R310 },
|
||||
// the attack becomes the whirlwinds (onTick): her attack timer, an enemy of the skill's range, no projectile
|
||||
attack: { noAttack: true },
|
||||
onTick({ battle, unit }) {
|
||||
if (unit.atkCd > 0 || !unit.canAct || unit.s.flags.disarm) return;
|
||||
const foes = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile);
|
||||
if (!foes.length) return;
|
||||
unit.atkCd = unit.s.interval;
|
||||
unit.lastAttackAt = battle.time;
|
||||
unit.stats.attacks++;
|
||||
releaseWinds(battle, unit, unit.mem.heyakWinds);
|
||||
// an attack was performed ("攻击时" content), the whirlwinds still on their way
|
||||
if (battle.hasHook('attack')) battle.emit('attack', { attacker: unit, targets: foes.slice(0, 1), isSkill: true });
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 传承终焉: × atk_scale and 沉默 on an air unit hit by her attack
|
||||
const sc = num(t0.atk_scale, 1), sil = num(t0.silence);
|
||||
onHitBy(battle, unit, (ctx) => {
|
||||
const d = ctx.dmg, t = ctx.target;
|
||||
if (!d.isAttack || tagged(d, AWAY_TAG) || !t.isFlying) return;
|
||||
if (sc !== 1) d.amount *= sc;
|
||||
if (sil > 0 && t.alive) battle.applyStatus(t, 'silence', { duration: sil, source: unit });
|
||||
});
|
||||
} },
|
||||
{ install(battle, unit) { // 曾有羽翼: 失重 above hp_ratio in her range; CCR-Y stage 3: 【法术脆弱】 below its hp_ratio
|
||||
const hiRatio = num(t1.hp_ratio, 1);
|
||||
battle.on('tick', () => {
|
||||
const on = up(unit);
|
||||
const inRange = on ? new Set(battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) : null;
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive) continue;
|
||||
// (another 霍尔海雅's 失重 carries the same key: it is hers to keep or drop)
|
||||
const mine = e.findBuff(WEIGHTLESS_KEY);
|
||||
const want = on && inRange.has(e) && e.hpRatio > hiRatio + 1e-9
|
||||
&& !e.buffs.some((b) => b.status === 'weightless' && b.key !== WEIGHTLESS_KEY);
|
||||
if (want && !mine) battle.addBuff(e, { key: WEIGHTLESS_KEY, status: 'weightless', visible: true, mods: { massFlat: -1 }, source: unit });
|
||||
else if (!want && mine && mine.source === unit) battle.removeBuff(e, mine);
|
||||
}
|
||||
}, { owner: unit });
|
||||
const fragile = Math.round((num(rider.damage_scale, 1) - 1) * 1e6) / 1e6, loRatio = num(rider.hp_ratio); // 1.2 → 0.2
|
||||
if (fragile > 0) {
|
||||
battle.every(FRAGILE_EVERY, () => {
|
||||
if (!up(unit)) return;
|
||||
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) {
|
||||
if (e.hpRatio < loRatio - 1e-9) battle.applyStatus(e, 'artsFragile', { duration: FRAGILE_EVERY + 0.1, value: fragile, source: unit });
|
||||
}
|
||||
}, { owner: unit, immediate: true });
|
||||
}
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// module traits (none on the normal form, nothing of ISW-A's): CCR-X RES ignore, CCR-Y SP per hit on an elite / leader
|
||||
if (pen > 0) statBuff(battle, unit, 'trait:heyak:resIgnore', { resIgnoreFlat: pen });
|
||||
const sp = num(tb.sp);
|
||||
if (sp > 0) {
|
||||
battle.on('damaged', (ctx) => {
|
||||
const d = ctx.dmg;
|
||||
if (ctx.source !== unit || !d || !d.isAttack || d.isSplash || tagged(d, AWAY_TAG) || !isEliteEnemy(ctx.target)) return;
|
||||
giveSp(unit, sp, 'trait');
|
||||
}, { owner: unit });
|
||||
}
|
||||
const sid = unit.skill?.id;
|
||||
if (sid === S1) {
|
||||
// 但为求索: the shot count decides the 浮空 (PRTS: the shots fired, whatever happens in flight)
|
||||
const lev = num(b1.levitate);
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !ctx.isSkill) return;
|
||||
unit.mem.heyakLift = ctx.targets.length === 1 ? ctx.targets[0] : null;
|
||||
}, { owner: unit, priority: -10 });
|
||||
onHitBy(battle, unit, (ctx) => { // before 传承终焉 (priority 0): the lift is settled first
|
||||
const d = ctx.dmg;
|
||||
if (!d.isAttack || !d.isSkill || unit.mem.heyakLift !== ctx.target) return;
|
||||
unit.mem.heyakLift = null;
|
||||
if (lev > 0) battle.applyStatus(ctx.target, 'levitate', { duration: lev, source: unit });
|
||||
}, 10);
|
||||
} else if (sid === S2) {
|
||||
// 群星逶迤: the nine shots, each at a random enemy of her range, each rolling its 浮空 before 传承终焉
|
||||
const prob = num(b2['attack@prob']), lev = num(b2['attack@levitate']);
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !unit.skill?.active || !ctx.targets.length) return;
|
||||
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
|
||||
for (const e of battle.blockedTargets(unit, ctx.profile)) if (!cands.includes(e)) cands.push(e);
|
||||
if (!cands.length) return;
|
||||
const out = [];
|
||||
for (let i = 0; i < shots; i++) out.push(battle.rng.pick(cands));
|
||||
ctx.targets = out;
|
||||
}, { owner: unit });
|
||||
onHitBy(battle, unit, (ctx) => {
|
||||
const d = ctx.dmg;
|
||||
if (!d.isAttack || !d.isSkill || !(prob > 0) || !(lev > 0)) return;
|
||||
if (battle.rng.chance(prob)) battle.applyStatus(ctx.target, 'levitate', { duration: lev, source: unit });
|
||||
}, 10);
|
||||
} else if (sid === S3) {
|
||||
// 博览者的狂语: the whirlwinds in flight (also after she left the field)
|
||||
const winds = (unit.mem.heyakWinds = []);
|
||||
battle.on('tick', ({ dt }) => {
|
||||
for (let i = winds.length - 1; i >= 0; i--) {
|
||||
const w = winds[i];
|
||||
const step = Math.min(WIND_SPEED * dt, w.left);
|
||||
w.x += w.dx * step;
|
||||
w.y += w.dy * step;
|
||||
w.left -= step;
|
||||
w.age += dt;
|
||||
if (windHit(battle, unit, w) || w.left <= 1e-9) { winds.splice(i, 1); continue; }
|
||||
w.fxAge += dt;
|
||||
if (w.fxAge + 1e-9 >= WIND_FX_EVERY) {
|
||||
w.fxAge = 0;
|
||||
battle.fx('tornado', { x: w.x, y: w.y, id: unit.id, r: WIND_HALF, dur: WIND_FX_EVERY + 0.1 });
|
||||
}
|
||||
}
|
||||
}, { owner: unit });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
// server/sim/content/kits/ops/op-huang.js — 煌 (char_017_huang) 自选 operator kit: 6★ 强攻手 (近卫), an owned-6★ pick of
|
||||
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_017_huang, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 煌 (talent and
|
||||
// skill 备注, the CEN-X note); the client's buff templates (buff_template_data `huang_s_3`, `huang_t_1[heal]` / `[lock]`,
|
||||
// `huang_e_003[lock]` / `[heal]`, `huang_t_2…`); Arknights Terra Wiki "Blaze" (the S3 notes).
|
||||
// - Trait (强攻手) "同时攻击阻挡的所有敌人": the profession default (centurion `hitAllBlocked`, block 3). Module CEN-X
|
||||
// "攻击被阻挡的敌人攻击力提升至110%" (trait bb atk_scale): PRTS "于每次计算伤害时检查目标是否处于阻挡/被阻挡状态…提升效果
|
||||
// 为攻击力倍率提升" — every damage instance of hers whose target is blocked (by anyone) takes ×atk_scale on its ATK-based
|
||||
// amount (before DEF): normal attacks, S1, the S3 cuts and burst. Module CEN-Y "生命值高于50%时受到的物理伤害降低20%"
|
||||
// (trait bb hp_ratio / damage_resistance): physical damage taken × (1 − damage_resistance) while HP > hp_ratio.
|
||||
// - T1 紧急除颤 "在生命值低于25%时,仅一次回复50%的生命值并在6秒内使生命值不低于50%" (bb hp_ratio,
|
||||
// huang_t_1[heal].hp_ratio, huang_t_1[lock].min_hp_ratio / .duration). PRTS 备注: every deployment she holds 不死 until
|
||||
// the first time her HP ratio is ≤ hp_ratio; then the heal (max HP × [heal].hp_ratio, a heal — huang_t_1[heal]
|
||||
// HealViaMaxHpRatio) and for [lock].duration s every damage that would take her below [lock].min_hp_ratio × max HP is
|
||||
// cut to what is left above it ("该伤害减少(伤害值-煌当前生命值+煌最大生命值×50%)点": the `hpDamage` hook after the
|
||||
// shields — a 伤判效果, so a 流失 such as her S3's is not held). CEN-Y stage 3 (talent hp_ratio 0.5 + the hidden
|
||||
// huang_e_003[lock] part): "在生命值首次低于25%、50%时…8秒内…": the talent fires at 50 %, and a second trigger at
|
||||
// check_hp_ratio (25 %) with its own 不死 (huang_e_003[undead]) fires only once the first is spent ("生命值首次低于50%
|
||||
// 始终先于…25%生效"), re-checking the HP after the first one's heal [ASSUMED: both read the current HP]; its heal is
|
||||
// skipped while the first lock runs (huang_e_003[heal] "CheckContainsBuff huang_t_1[lock] → IfNot → heal"). The hidden
|
||||
// part's hp_ratio / def_penetrate_fixed (CEN-Y stage 2+): "生命值高于50%时攻击无视目标150点防御力" — a defIgnoreFlat
|
||||
// buff while HP > hp_ratio (every damage of hers).
|
||||
// - T2 严酷训练 "在战场停留15秒后获得抵抗" (bb one_minus_status_resistance, interval): `interval` s after each deployment
|
||||
// the engine 抵抗 (huang_t_2 creates status_resistance[inf]: control statuses last half as long) until she leaves.
|
||||
// CEN-X stage 3 adds "30秒后攻击力+6%,45秒后攻速+12" (huang_t_2[e_002_atk].atk / .interval, [e_002_atk_speed]
|
||||
// .attack_speed / .interval: an ATK MULTIPLIER and an ATTACK_SPEED ADDITION, INFINITY lifetime).
|
||||
// - S1 强力击·γ型 (AUTO, attack SP): the next attack at atk_scale × ATK — every blocked enemy it hits (her trait); the
|
||||
// data's DEFAULT trigger (an AUTO "next attack" waits for her attack).
|
||||
// - S2 链锯延伸模块 (AUTO, 持续时间无限): ATK +atk, DEF +def and the skill range 2-2 for the rest of the deployment (PRTS 备注
|
||||
// "攻击距离加长…使用技能提供的攻击范围(非攻击距离+1)"); an AUTO skill acting on herself only fires as soon as its SP is
|
||||
// full (the owner's AUTO rule, kits/README.md checklist 5).
|
||||
// - S3 沸腾爆裂 (MANUAL, data DEFAULT): a 维持技能状态 (PRTS 游戏数据基础: no normal attack while it runs; its cadence
|
||||
// follows the animation — no attack speed change touches it — and a hard control (晕眩 / 冻结 …) ends it early).
|
||||
// PRTS 备注 + huang_s_3: every second for the first 8 s the ATK / DEF bonus is updated, then one physical cut hits every
|
||||
// ground enemy of her attack range ("对前方一格内的敌方单位进行切割" — her own 1-1 and the enemies she blocks; 不可对空;
|
||||
// 100 % ATK [ASSUMED: no blackboard key — her attack's own damage]); the bonus is atk / def × min(1, t ÷ (duration −
|
||||
// 1.05)) at the t-th update (huang_s_3 RemainingRatioToAttributeModifier `_endTime` 1.05: full at 8.95 s; the Terra
|
||||
// Wiki's "reaches maximum after 8.95 seconds"); at 9 s it reaches the maximum and the finish follows (PRTS "9s时…";
|
||||
// Terra "triggered at the 9 seconds mark"; her skeleton, whose Skill_2_* clips are S3's: the 3 s Skill_2_Loop_End's
|
||||
// last OnAttack falls at 9 s of the 10 s run — Begin 1.4 s + the 0.4 s loops to 7 s): the huang_s_3 ON_SKILL_FINISH part, she loses hp_ratio ×
|
||||
// MAX HP (DamageViaMaxHpRatio, undeadable — never below 1 HP; PRTS calls it 流失) and then every enemy of the 3 × 3
|
||||
// area ahead (PRTS "前方3×3区域 3-6…碰撞判定,可对空": air units too) takes damage_by_atk_scale × her ATK at that
|
||||
// moment, physical; the state runs on to its 10 s. A run cut short by a hard control finishes at once
|
||||
// [ASSUMED: ON_SKILL_FINISH fires on any end but her knock-out]. The range the game shows while it runs is the
|
||||
// burst's 3-6 (PRTS); the data has no skill range and the cuts use her own, so the card keeps her 1-1 and the trigger
|
||||
// stays DEFAULT.
|
||||
|
||||
import { num, talentBb, moduleBb, traitBb, up, onHitBy, onHitOn, toggleBuff, skillRec } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys } from '../../../targeting.js';
|
||||
|
||||
const S1 = 'skchr_huang_1';
|
||||
const S2 = 'skchr_huang_2';
|
||||
const S3 = 'skchr_huang_3';
|
||||
/** S3's final area "前方3×3区域" (PRTS 备注 range 3-6; range_table): her tile, the two ahead and the rows beside them. */
|
||||
const GRID_3_6 = Object.freeze([[1, 0], [1, 1], [1, 2], [0, 0], [0, 1], [0, 2], [-1, 0], [-1, 1], [-1, 2]]);
|
||||
/** huang_s_3 RemainingRatioToAttributeModifier `_endTime`: the ramp is full this long before the run's nominal end. */
|
||||
const S3_RAMP_END = 1.05;
|
||||
/** S3 updates: eight cuts (1 s apart), then the finish at the ninth (PRTS "9s时"). */
|
||||
const S3_CYCLES = 9;
|
||||
const S3_BUFF = 'skill:huang:ramp';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
|
||||
export default {
|
||||
char_017_huang: (bb, chess, def) => {
|
||||
const tb = traitBb(chess);
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const hb = moduleBb(chess); // CEN-Y stage 2+: hp_ratio / def_penetrate_fixed; stage 3: huang_e_003[lock].*
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s2 = skillRec(chess, S2);
|
||||
const ownGrid = chess?.rangeGrid ?? def?.rangeGrid ?? [[0, 0], [0, 1]];
|
||||
/** The ground enemies her own range holds and the ones she blocks (the S3 cuts). */
|
||||
const cutTargets = (battle, unit) => {
|
||||
const keys = absoluteRangeKeys(unit.rangeGrid || ownGrid, unit.tileR, unit.tileC, unit.dir, unit.s.rangeExtend || 0);
|
||||
const list = battle.enemiesInKeys(keys, unit, { canHitFly: false });
|
||||
for (const e of battle.blockedTargets(unit, { canHitFly: false })) if (!list.includes(e)) list.push(e);
|
||||
return list;
|
||||
};
|
||||
/** S3's finish (huang_s_3 ON_SKILL_FINISH): the 流失 first (undeadable), then the burst at her ATK of that moment. */
|
||||
const finish = (battle, unit, s) => {
|
||||
s.done = true;
|
||||
const loss = Math.min(unit.s.maxHp * num(b3.hp_ratio), unit.hp - 1);
|
||||
if (loss > 0) battle.loseHp(unit, loss, { source: unit });
|
||||
if (!up(unit)) return;
|
||||
const atk = unit.s.atk;
|
||||
const keys = absoluteRangeKeys(GRID_3_6, unit.tileR, unit.tileC, unit.dir, 0);
|
||||
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'huang:burst' });
|
||||
for (const e of battle.enemiesInKeys(keys, unit, { canHitFly: true })) {
|
||||
if (e.alive) battle.dealDamage(unit, e, { amount: atk * num(b3.damage_by_atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
|
||||
}
|
||||
};
|
||||
const setRamp = (battle, unit, r) => {
|
||||
const b = unit.findBuff(S3_BUFF);
|
||||
if (!b) return;
|
||||
b.mods = { atkPct: num(b3.atk) * r, defPct: num(b3.def) * r };
|
||||
unit.markDirty();
|
||||
};
|
||||
return {
|
||||
skills: {
|
||||
[S1]: { kind: 'instant', attack: { atkScale: num(b1.atk_scale, 1) } },
|
||||
[S2]: {
|
||||
kind: 'toggle', trigger: 'SP_FULL',
|
||||
mods: { atkPct: num(b2.atk), defPct: num(b2.def) },
|
||||
...(s2?.rangeGrid?.length ? { targeting: { rangeGrid: s2.rangeGrid } } : {}),
|
||||
onStart({ battle, unit }) { battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'atk' }); },
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
attack: { noAttack: true },
|
||||
onStart({ battle, unit }) {
|
||||
unit.mem.huangS3 = { t: 0, k: 0 };
|
||||
battle.addBuff(unit, { key: S3_BUFF, mods: { atkPct: 0, defPct: 0 }, tags: ['skill'], visible: true });
|
||||
},
|
||||
onTick({ battle, unit, skill, dt }) {
|
||||
const s = unit.mem.huangS3;
|
||||
if (!s) return;
|
||||
// 维持技能状态: a hard control ends it early (PRTS 游戏数据基础 "眩晕、冻结等硬性控制会提前结束")
|
||||
if (!unit.canAct || unit.s.flags.sleep) { skill.end('interrupted'); return; }
|
||||
s.t += dt;
|
||||
while (s.k < S3_CYCLES && s.t >= s.k + 1 - 1e-9) {
|
||||
s.k++;
|
||||
setRamp(battle, unit, Math.min(1, s.k / Math.max(1e-6, skill.duration - S3_RAMP_END)));
|
||||
if (s.k >= S3_CYCLES) { finish(battle, unit, s); return; }
|
||||
const foes = cutTargets(battle, unit);
|
||||
if (foes.length) battle.fx('aoe', { x: unit.x, y: unit.y, radius: 0.6, id: unit.id, skill: 'huang:cut' });
|
||||
for (const e of foes) if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk, type: 'phys', isSkill: true, tags: ['skill'] });
|
||||
}
|
||||
},
|
||||
onEnd({ battle, unit, reason }) {
|
||||
const s = unit.mem.huangS3;
|
||||
unit.mem.huangS3 = null;
|
||||
if (s && !s.done && reason !== 'death' && up(unit)) finish(battle, unit, s); // cut short: it finishes at once
|
||||
battle.removeBuff(unit, S3_BUFF);
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 紧急除颤 (+ CEN-Y stage 3's second trigger and stage 2+'s 防御穿透)
|
||||
const trig = num(t0.hp_ratio, 0.25);
|
||||
const healA = num(t0['huang_t_1[heal].hp_ratio'], 0.5);
|
||||
const floorA = num(t0['huang_t_1[lock].min_hp_ratio'], 0.5), durA = num(t0['huang_t_1[lock].duration'], 6);
|
||||
const second = hb['huang_e_003[lock].check_hp_ratio'] != null;
|
||||
const trigB = num(hb['huang_e_003[lock].check_hp_ratio'], 0.25);
|
||||
const healB = num(hb['huang_e_003[lock].hp_ratio'], 0.5);
|
||||
const floorB = num(hb['huang_e_003[lock].min_hp_ratio'], 0.5), durB = num(hb['huang_e_003[lock].duration'], 8);
|
||||
const st = { undeadA: false, undeadB: false, lockA: -Infinity, lockB: -Infinity };
|
||||
unit.mem.huangDefib = st;
|
||||
battle.on('deploy', ({ unit: u }) => {
|
||||
if (u !== unit) return;
|
||||
st.undeadA = true;
|
||||
st.undeadB = second;
|
||||
st.lockA = st.lockB = -Infinity;
|
||||
}, { owner: unit });
|
||||
// 不死 while a trigger is unspent: a lethal hit leaves her at 1 HP (the trigger check below follows)
|
||||
battle.on('fatal', (c) => { if (c.unit === unit && !c.prevented && (st.undeadA || st.undeadB)) c.prevented = true; }, { owner: unit });
|
||||
battle.on('hpDamage', (c) => {
|
||||
if (c.target !== unit) return;
|
||||
const t = battle.time;
|
||||
const floor = Math.max(t < st.lockA ? floorA : 0, t < st.lockB ? floorB : 0);
|
||||
if (floor > 0) c.amount = Math.min(c.amount, Math.max(0, unit.hp - floor * unit.s.maxHp));
|
||||
}, { owner: unit });
|
||||
battle.on('damaged', (c) => {
|
||||
if (c.target !== unit || !up(unit)) return;
|
||||
if (st.undeadA && unit.hpRatio <= trig + 1e-9) {
|
||||
st.undeadA = false;
|
||||
st.lockA = battle.time + durA;
|
||||
battle.heal(unit, unit, unit.s.maxHp * healA, { self: true, tags: ['talent'] });
|
||||
battle.fx('heal', { x: unit.x, y: unit.y, id: unit.id });
|
||||
}
|
||||
if (st.undeadB && !st.undeadA && unit.hpRatio <= trigB + 1e-9) {
|
||||
st.undeadB = false;
|
||||
st.lockB = battle.time + durB;
|
||||
if (!(battle.time < st.lockA)) battle.heal(unit, unit, unit.s.maxHp * healB, { self: true, tags: ['talent'] });
|
||||
battle.fx('heal', { x: unit.x, y: unit.y, id: unit.id });
|
||||
}
|
||||
}, { owner: unit });
|
||||
const pen = num(hb.def_penetrate_fixed);
|
||||
if (pen > 0) toggleBuff(battle, unit, 'talent:huang:defPen', () => unit.hpRatio > num(hb.hp_ratio, 0.5), { defIgnoreFlat: pen });
|
||||
} },
|
||||
{ install(battle, unit) { // 严酷训练 (+ CEN-X stage 3: ATK after 30 s, ASPD after 45 s)
|
||||
const resAt = num(t1['huang_t_2.interval'], num(t1.interval, 15));
|
||||
const res = Math.min(0.95, Math.max(0, -num(t1['huang_t_2.one_minus_status_resistance'], num(t1.one_minus_status_resistance))));
|
||||
const atk = num(t1['huang_t_2[e_002_atk].atk']), atkAt = num(t1['huang_t_2[e_002_atk].interval']);
|
||||
const as = num(t1['huang_t_2[e_002_atk_speed].attack_speed']), asAt = num(t1['huang_t_2[e_002_atk_speed].interval']);
|
||||
battle.on('deploy', ({ unit: u }) => {
|
||||
if (u !== unit) return;
|
||||
const seq = unit.deploySeq;
|
||||
const at = (s, fn) => battle.after(s, () => { if (up(unit) && unit.deploySeq === seq) fn(); }, { owner: unit });
|
||||
if (res > 0) at(resAt, () => battle.applyStatus(unit, 'resist', { value: res, source: unit }));
|
||||
if (atk) at(atkAt, () => battle.addBuff(unit, { key: 'talent:huang:atk', mods: { atkPct: atk }, tags: ['talent'] }));
|
||||
if (as) at(asAt, () => battle.addBuff(unit, { key: 'talent:huang:aspd', mods: { aspd: as }, tags: ['talent'] }));
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// CEN-X: ×atk_scale on her ATK-based damage to a blocked enemy (every damage calculation)
|
||||
const blockedMul = num(tb.atk_scale, 1);
|
||||
if (blockedMul !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (target.blockedBy) dmg.amount *= blockedMul; });
|
||||
// CEN-Y: 生命值高于50%时受到的物理伤害降低20%
|
||||
const dr = num(tb.damage_resistance);
|
||||
if (dr > 0) onHitOn(battle, unit, ({ dmg }) => { if (dmg.type === 'phys' && unit.hpRatio > num(tb.hp_ratio, 0.5)) dmg.mul *= 1 - dr; });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
// server/sim/content/kits/ops/op-ifrit.js — 伊芙利特 (char_134_ifrit) 自选 operator kit: 6★ 轰击术师 (术师), an owned-6★ pick
|
||||
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_134_ifrit, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 伊芙利特 (炎爆 备注
|
||||
// "灼伤:目标每秒受到伊芙利特当前攻击力33%的法术持续伤害,伊芙利特离场后也会生效" and "<模组相关>于伊芙利特在场时受模组基础效果影响";
|
||||
// 灼地 备注 "属于维持技能状态,时间模式为跟随动画", "降低法术抗性的效果持续1秒"; 精神融解 备注 "<Δ模组>造成的元素伤害为持续伤害";
|
||||
// “同熔” 特性 "目标距离自身0格或以上时应用加成,按距离线性提升,目标距离自身4格或以上时达到最大值"); PRTS 游戏数据基础 §维持技能状态;
|
||||
// PRTS 分支特性信息 轰击术师 ("可对空", "普通攻击同时攻击范围内的所有敌人"); the client's battle data (battle/prefabs [uc]skills
|
||||
// skchr_ifrit_1/2/3, [uc]equips ifrit_equip_*, charpack char_134_ifrit, buff_template_data: burn, atk_scale_by_distance,
|
||||
// periodic_damage_by_hp_ratio[skip_modifier_fix], ifrit_e_002[sp] / [dice_sp], ifrit_e_003_tr, ifrit_e_003_ele) and her
|
||||
// battle skeleton (Skill_2_Begin's OnAttack event).
|
||||
// - Trait (轰击术师) "攻击造成超远距离的群体法术伤害": the profession's `rangeAoe` — every selectable enemy of her 5-1 line at
|
||||
// once, air units too, the same damage near and far (professions.js blastcaster).
|
||||
// - Module BLA-X “同熔” (trait min_dist 0 / max_dist 4 / damage_scale 0.1): "距离越远伤害越高,最高达到110%" — ×(1 + 0.1 ×
|
||||
// clamp((d − 0) / (4 − 0), 0, 1)) by the Euclidean distance d between her and the target, on EVERY damage she outputs
|
||||
// while on the field (the module's buff atk_scale_by_distance: ON_OUTPUT_DAMAGE, no damage mask) — her attacks, the 灼地
|
||||
// ticks and the 炎爆 灼伤 (PRTS: "于伊芙利特在场时受模组基础效果影响"). Stage 3 also changes T2 (below).
|
||||
// - Module BLA-D “热成形记忆” (trait ep_damage_ratio 0.08): "造成法术伤害时附带相当于8%伤害的灼燃损伤" (ifrit_e_003_tr: ON_AFTER_
|
||||
// OUTPUT_DAMAGE, every MAGICAL damage of hers) — 8 % of the HP damage dealt as 灼燃损伤 (`type: 'element'`, docs/SIM.md §7.2
|
||||
// element conventions), the 灼伤 too while she stands. Stage 3 also changes T1 (below).
|
||||
// - T1 精神融解 "攻击范围内的敌军法术抗性-40%" (magic_resistance −0.4: a ratio; charpack ifrit_t_1, removed when the enemy leaves):
|
||||
// RES ×0.6 on every enemy on her range, refreshed every AURA_IV s; several 伊芙利特 keep one (applyStrongest — the mode's
|
||||
// "同名效果取最高" convention; [ASSUMED] the client's independentCharacterSource flag would let two stack, which a lone
|
||||
// base-game 伊芙利特 never shows). BLA-D stage 2+ "若攻击目标处于灼燃损伤爆发期间,攻击对其额外造成相当于攻击力50%的元素伤害" (the
|
||||
// module's hidden talent element_atk_scale; ifrit_e_003_ele: IsTargetInEPBreakRecovery FIRE ⇒ ELEMENT damage, attack type
|
||||
// BUFF, 持续伤害): every hit of her attack abilities — normal attacks and the 灼地 ticks (the equip adds it to the abilities of
|
||||
// `_targetFamilyMask` 31, [ASSUMED] every family: 1 / 3 mark the narrower equips) — on an enemy in its 灼燃 burst (its
|
||||
// `burnBurst` lock) adds 50 % ATK 元素伤害
|
||||
// (`type: 'elemental'`), tagged 'dot' (PRTS: "<Δ模组>造成的元素伤害为持续伤害"); never the 灼伤 (a buff's damage).
|
||||
// - T2 莱茵回路 "每6秒额外回复2点技力" (periodic_sp; BLA-X stage 2+ ifrit_e_002[sp]): +sp SP every `interval` s of a deployment
|
||||
// (the first one interval in: waitFirstTriggerInterval; a gift during a running 狂热 / 灼地 is lost — no SP during a
|
||||
// skill). BLA-X stage 2+ "有30%概率额外回复5点技力" (ifrit_e_002[dice_sp] prob / interval / sp): one roll per its own interval.
|
||||
// - S1 狂热 (MANUAL, data DEFAULT, 20 s): ATK +atk, ASPD +attack_speed.
|
||||
// - S2 炎爆 (AUTO, 可充能 2 / 3 次, data DEFAULT — the "next attack" waits for her attack): the next attack hits her whole line
|
||||
// (ground and air: its selector's targetMotion ALL) for atk_scale × ATK; every enemy it hits gets the buff ifrit_s_2
|
||||
// (template burn): DEF +def (−100 / −200, "同名效果取最高") for `duration` (3.01) s and the 灼伤 — burn.atk_scale (33 %) × her
|
||||
// CURRENT ATK arts every second of it (triggerInterval 1, the first a second in: 3 ticks), a 持续伤害 (no dodge) that keeps
|
||||
// ticking after she leaves the field. A new 炎爆 on the same enemy restarts both.
|
||||
// - S3 灼地 (MANUAL, data DEFAULT, 20 s): a 维持技能状态 — no normal attack. The ability (skchr_ifrit_3: `_triggerDelta` 1,
|
||||
// ground-only selector) strikes first at the attack event of its Begin clip (SCORCH_FIRST: 0.4 s; facing UP / DOWN the
|
||||
// back / down clips, 0.333 s) and then every S3_CADENCE (1) s — a fixed cadence no ASPD change touches: every ground enemy
|
||||
// on her range takes atk_scale × ATK arts and the active buff ifrit_s_3, RES +magic_resistance (−7 / −10, flat) for 1 s
|
||||
// (PRTS 备注) — the cut before the damage [ASSUMED order], so the tick it comes with counts it. Her own periodic_damage_by_
|
||||
// hp_ratio buff takes hp_ratio (2 %) of her max HP every second of the skill (the first a second in; a 流失 — it ignores
|
||||
// for SP and skips the damage events —, it can knock her out; PRTS "实际的生命流失量略低于标注值" gives no number [ASSUMED:
|
||||
// the stated 2 %]). 晕眩 / 冻结 (any state that stops her acting) ends it at once (PRTS 维持技能状态).
|
||||
|
||||
import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp } from '../shared/tier1.js';
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
|
||||
const S1 = 'skchr_ifrit_1';
|
||||
const S2 = 'skchr_ifrit_2';
|
||||
const S3 = 'skchr_ifrit_3';
|
||||
/** T1 aura refresh period and buff length (the non-stacking aura convention of the kits). */
|
||||
const AURA_IV = 0.25;
|
||||
const AURA_DUR = 0.4;
|
||||
/** S3 灼地: the ability's `_triggerDelta` (s) — one damage tick per second, "每秒". */
|
||||
const S3_CADENCE = 1;
|
||||
/** S3 灼地: the first tick at the OnAttack event of Skill_2_Begin (front 0.4 s; the back clip and Skill_Down_2_Begin 0.333 s). */
|
||||
const SCORCH_FIRST = Object.freeze({ side: 0.4, vertical: 0.333 });
|
||||
/** S3 灼地 RES cut length (ifrit_s_3 lifeTime 1; PRTS 备注 "降低法术抗性的效果持续1秒"). */
|
||||
const S3_RES_TIME = 1;
|
||||
/** S3 灼地 self 流失 period (periodic_damage_by_hp_ratio triggerInterval 1). */
|
||||
const S3_LOSS_IV = 1;
|
||||
const MELT_KEY = 'ifrit:meltdown';
|
||||
const S2_DEF_KEY = 'ifrit:pyroDef';
|
||||
const S2_BURN_KEY = 'ifrit:pyroBurn';
|
||||
const S3_RES_KEY = 'ifrit:scorchRes';
|
||||
const SCORCH_TAG = 'ifrit:scorch';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
/** RES cut mods for a blackboard magic_resistance value (applyStrongest): |v| < 1 = ×(1 + v), else flat v. */
|
||||
const resCut = (v) => (Math.abs(v) < 1 ? { resMul: Math.max(0, 1 + v) } : { resFlat: v });
|
||||
const defCut = (v) => ({ defFlat: v });
|
||||
|
||||
/** S2 炎爆 on one enemy its attack hit: DEF cut and the 灼伤 for `duration` s. */
|
||||
function pyroclasm(battle, unit, e, b2) {
|
||||
if (!e || !e.alive || e.side !== 'enemy') return;
|
||||
const dur = num(b2.duration, 3);
|
||||
const d = num(b2.def);
|
||||
if (d) battle.applyStrongest(e, S2_DEF_KEY, { duration: dur, value: d, mods: defCut, source: unit });
|
||||
const sc = num(b2['burn.atk_scale']);
|
||||
if (!(sc > 0)) return;
|
||||
battle.addBuff(e, {
|
||||
key: S2_BURN_KEY, duration: dur, interval: 1, source: unit, tags: ['dot'],
|
||||
// "伊芙利特当前攻击力": her ATK at each tick; it ticks on after she leaves the field (PRTS 备注)
|
||||
onTick: ({ battle: b, unit: tgt }) => b.dealDamage(unit, tgt, { amount: unit.s.atk * sc, type: 'arts', isSkill: true, canDodge: false, tags: ['dot', 'skill', 'ifrit:burn'] }),
|
||||
});
|
||||
}
|
||||
|
||||
/** S3 灼地 tick: every ground enemy on her range — the RES cut, then the damage. */
|
||||
function scorch(battle, unit, b3) {
|
||||
const scale = num(b3.atk_scale), mr = num(b3.magic_resistance);
|
||||
const victims = battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: false });
|
||||
for (const e of victims) {
|
||||
if (mr) battle.applyStrongest(e, S3_RES_KEY, { duration: S3_RES_TIME, value: mr, mods: resCut, source: unit });
|
||||
if (e.alive && scale > 0) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', SCORCH_TAG] });
|
||||
}
|
||||
battle.fx('flame', { x: unit.x, y: unit.y, id: unit.id, n: victims.length, skill: SCORCH_TAG });
|
||||
}
|
||||
|
||||
export default {
|
||||
char_134_ifrit: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const hidden = moduleBb(chess); // BLA-D stage 2+: element_atk_scale
|
||||
const tb = traitBb(chess); // BLA-X: min_dist / max_dist / damage_scale; BLA-D: ep_damage_ratio
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s2 = skillRec(chess, S2);
|
||||
return {
|
||||
skills: {
|
||||
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } },
|
||||
[S2]: {
|
||||
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
attack: {
|
||||
atkScale: num(b2.atk_scale, 1),
|
||||
onEachHit({ battle, unit, target }) { pyroclasm(battle, unit, target, b2); },
|
||||
},
|
||||
onStart({ battle, unit }) { battle.fx('aoe', { x: unit.x, y: unit.y, radius: 0.5, id: unit.id, skill: 'ifrit:pyroclasm' }); },
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
attack: { noAttack: true },
|
||||
onStart({ unit }) {
|
||||
unit.mem.scorchAt = 0;
|
||||
unit.mem.scorchNext = unit.dir === 'UP' || unit.dir === 'DOWN' ? SCORCH_FIRST.vertical : SCORCH_FIRST.side;
|
||||
unit.mem.scorchLoss = 0;
|
||||
},
|
||||
onTick({ battle, unit, skill, dt }) {
|
||||
// 维持技能状态: a hard control (晕眩 / 冻结 / 浮空 / 睡眠 — the unit cannot act) ends it at once
|
||||
if (!unit.canAct) { skill.end('interrupted'); return; }
|
||||
const t = (unit.mem.scorchAt = (unit.mem.scorchAt ?? 0) + dt);
|
||||
while (t >= unit.mem.scorchNext - 1e-6 && unit.alive && skill.active) {
|
||||
unit.mem.scorchNext += S3_CADENCE;
|
||||
scorch(battle, unit, b3);
|
||||
}
|
||||
const ratio = num(b3.hp_ratio);
|
||||
while (ratio > 0 && t >= (unit.mem.scorchLoss + 1) * S3_LOSS_IV - 1e-6 && unit.alive) {
|
||||
unit.mem.scorchLoss++;
|
||||
battle.loseHp(unit, unit.s.maxHp * ratio, { source: unit, tags: ['skill', SCORCH_TAG] });
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 精神融解: RES −40 % on her range; BLA-D stage 2+: +50 % ATK 元素伤害 on a bursting target
|
||||
const v = num(t0.magic_resistance);
|
||||
if (v) {
|
||||
battle.every(AURA_IV, () => {
|
||||
if (!up(unit) || !unit.rangeKeySet) return;
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive || e.hidden || !bodyInKeys(e, unit.rangeKeySet)) continue;
|
||||
battle.applyStrongest(e, MELT_KEY, { duration: AURA_DUR, value: v, mods: resCut, source: unit });
|
||||
}
|
||||
}, { owner: unit, immediate: true });
|
||||
}
|
||||
const ex = num(hidden.element_atk_scale);
|
||||
if (ex > 0) {
|
||||
battle.on('damaged', (c) => {
|
||||
const e = c.target, d = c.dmg;
|
||||
if (c.source !== unit || !d || !(d.isAttack || d.tags?.includes(SCORCH_TAG)) || !e || e.side !== 'enemy' || !hasHp(e) || !e.findBuff('burnBurst')) return;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * ex, type: 'elemental', element: 'burn', canDodge: false, tags: ['dot', 'module', 'ifrit:meltdown'] });
|
||||
}, { owner: unit });
|
||||
}
|
||||
} },
|
||||
{ install(battle, unit) { // 莱茵回路: +sp SP every interval s; BLA-X stage 2+: prob to +5 SP more
|
||||
const sp = num(t1.sp), iv = num(t1.interval, 6);
|
||||
const dProb = num(t1['ifrit_e_002[dice_sp].prob']), dSp = num(t1['ifrit_e_002[dice_sp].sp']);
|
||||
const dIv = num(t1['ifrit_e_002[dice_sp].interval'], iv);
|
||||
if (!(sp > 0 && iv > 0) && !(dProb > 0 && dSp > 0 && dIv > 0)) return;
|
||||
battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.rhineAcc = 0; unit.mem.rhineDice = 0; } }, { owner: unit });
|
||||
battle.on('tick', ({ dt }) => {
|
||||
if (!up(unit)) return;
|
||||
if (sp > 0 && iv > 0) {
|
||||
unit.mem.rhineAcc = (unit.mem.rhineAcc ?? 0) + dt;
|
||||
while (unit.mem.rhineAcc >= iv - 1e-6) { unit.mem.rhineAcc -= iv; giveSp(unit, sp); }
|
||||
}
|
||||
if (dProb > 0 && dSp > 0 && dIv > 0) {
|
||||
unit.mem.rhineDice = (unit.mem.rhineDice ?? 0) + dt;
|
||||
while (unit.mem.rhineDice >= dIv - 1e-6) {
|
||||
unit.mem.rhineDice -= dIv;
|
||||
if (battle.rng.chance(dProb)) giveSp(unit, dSp);
|
||||
}
|
||||
}
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// BLA-X “同熔”: farther targets take more of every damage she outputs (up to ×1 + damage_scale at max_dist tiles)
|
||||
const ds = num(tb.damage_scale);
|
||||
if (ds > 0 && tb.max_dist != null) {
|
||||
const lo = num(tb.min_dist), hi = num(tb.max_dist, 4);
|
||||
battle.on('hit', (c) => {
|
||||
const e = c.target;
|
||||
if (c.source !== unit || !e || e.side !== 'enemy' || c.dmg.type === 'element' || !up(unit)) return;
|
||||
const d = Math.hypot(e.x - unit.x, e.y - unit.y);
|
||||
c.dmg.mul *= 1 + ds * (hi > lo ? Math.max(0, Math.min(1, (d - lo) / (hi - lo))) : 1);
|
||||
}, { owner: unit });
|
||||
}
|
||||
// BLA-D “热成形记忆”: 8 % of each arts damage she deals as 灼燃损伤
|
||||
const ep = num(tb.ep_damage_ratio);
|
||||
if (ep > 0) {
|
||||
battle.on('damaged', (c) => {
|
||||
const e = c.target;
|
||||
if (c.source !== unit || c.type !== 'arts' || !(c.amount > 0) || !e || e.side !== 'enemy' || !hasHp(e) || !up(unit)) return;
|
||||
battle.dealDamage(unit, e, { type: 'element', element: 'burn', amount: c.amount * ep, tags: ['module', 'ifrit:thermal'] });
|
||||
}, { owner: unit });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
// server/sim/content/kits/ops/op-irene.js — 艾丽妮 (char_4009_irene) 自选 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 at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_4009_irene, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), PRTS 艾丽妮 (审判之火
|
||||
// 备注: "计算伤害前有概率(地面)/必定(空中单位)获取永久的物理穿透(百分比)Buff(不可叠加,直接加算),该效果仅在成功造成伤害后
|
||||
// 消耗", popup "对空中单位造成物理伤害时,不会占用随机数"; S3 备注 "技能范围内仅存在飞行单位时,该技能也可开启 / 技能动画为固定时长
|
||||
// (3.5秒),不受攻击速度影响 / 快速轰击可对空,溅射半径1.1"), PRTS 分支特性信息 §剑豪, gamedata_const ba.levitate ("变为空中单位").
|
||||
// - Trait (剑豪) "普通攻击连续造成两次伤害": the profession default (hits 2), ground-only melee on her 1-1. SWO-X “审判官口粮”
|
||||
// "技能造成的伤害提升10%" (trait bb damage_scale): her skill damage ×1.1; SWO-Y “厚重的经卷” "攻击时无视敌人70点的防御力"
|
||||
// (def_penetrate_fixed): defIgnoreFlat on all her damage; ISW-A “艾丽妮特限证章”: only its attributes act here — its
|
||||
// trait text and every talent part are 集成战略-only (battle_equip_table validInGameTag roguelike: 再部署 −60 %, +8 SP on
|
||||
// deploy, the stage-3 range effects) ⇒ N/A; its stage-3 审判之火 text keeps the base 50 % / 50 %.
|
||||
// - T1 审判之火 "对敌人造成物理伤害时有50%概率无视其50%防御力,对空中单位概率提升至100%": per PRTS 备注, before each physical
|
||||
// damage instance she deals (normal or skill) she holds a 物理穿透 charge or draws one — always against an air unit (no
|
||||
// random draw; 浮空 enemies are air units, ba.levitate), with `prob` against a ground one — and the charge adds
|
||||
// def_penetrate to that instance's defIgnorePct; it is spent only by a damage instance that lands (a dodge or a cancel
|
||||
// keeps it, and while she holds one she draws nothing: 不可叠加); a buff on her, so a new deployment starts without one.
|
||||
// SWO-X stage 3: 55 % and "技能期间若击倒空中单位,技能结束时获得6点技力" — an air unit she knocks out while her skill
|
||||
// is active (S1's strike, S2's cut, S3's 3.5 s) gives her +sp SP once that skill ends.
|
||||
// - T2 净化之剑 "攻击速度+18,场上有【海怪】敌人时效果翻倍": ASPD +attack_speed; SWO-Y stage 3 adds ATK +5 % (the module talent
|
||||
// change's atk); both doubled while a living 【海怪】 enemy (data tag seamonster) is on the field.
|
||||
// - S1 起风 (AUTO, attack SP, DEFAULT): the next attack strikes its target once for atk_scale × ATK physical and levitates it
|
||||
// `levitate` s, then hits it again for atk_scale × ATK physical (her skeleton's Skill_1 clip: two OnAttack) — the second
|
||||
// hit meets an air unit when the levitation took.
|
||||
// - S2 裂潮 (MANUAL, attack SP, 2 charges, 技能范围 3-12, data SKILL_RANGE): at once, up to max_target ground enemies of the
|
||||
// skill range take atk_scale × ATK physical, and those of 重量 ≤ value (3) are levitated `levitate` s. It cannot be opened
|
||||
// while its range holds no ground enemy at all [ASSUMED: PRTS's S3 note "仅存在飞行单位时,该技能也可开启" marks the opposite
|
||||
// as the rule for this ground-only skill; the strategy's SKILL_RANGE still counts unselectable ones, "无视其不可选中"].
|
||||
// - S3 判决 (MANUAL, attack SP, 技能范围 x-1, data SKILL_RANGE — PRTS: opens with only flyers in range): every ground enemy of
|
||||
// the skill range takes atk_scale × ATK physical and is levitated `levitate` s, then multi_times (10) bombardments, one
|
||||
// every multi_hit_interval (0.3) s, each on a random enemy of the skill range (flyers included) hitting every enemy within
|
||||
// 1.1 of it (中点判定; PRTS 溅射半径1.1, 可对空) for multi_atk_scale × ATK physical; the skill runs PRTS's fixed 3.5 s with
|
||||
// no normal attack [ASSUMED: the first bombardment one interval after the opening strike; a bombardment with no enemy in
|
||||
// the skill range is skipped].
|
||||
|
||||
import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff, giveSp, onHitBy } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
|
||||
|
||||
const S1 = 'skchr_irene_1';
|
||||
const S2 = 'skchr_irene_2';
|
||||
const S3 = 'skchr_irene_3';
|
||||
/** PRTS S3 备注: "技能动画为固定时长(3.5秒),不受攻击速度影响". */
|
||||
const S3_DURATION = 3.5;
|
||||
/** PRTS S3 备注: "快速轰击可对空,溅射半径1.1". */
|
||||
const S3_SPLASH = 1.1;
|
||||
/** The skill ranges when a record carries none (data: 3-12 and x-1). */
|
||||
const R3_12 = Object.freeze([[1, 0], [1, 1], [0, 0], [0, 1], [0, 2], [0, 3], [-1, 0], [-1, 1]]);
|
||||
const X_1 = Object.freeze([[2, 0], [1, -1], [1, 0], [1, 1], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, -1], [-1, 0], [-1, 1], [-2, 0]]);
|
||||
const AIR = Object.freeze({ canHitFly: true });
|
||||
const GROUND = Object.freeze({ canHitFly: false });
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
/** Absolute tile keys of a skill range at the unit's tile and facing (a 技能范围 ignores 攻击距离: no extend). */
|
||||
const keysOf = (unit, grid) => absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0);
|
||||
/** A living 【海怪】 enemy on the field. */
|
||||
const seaMonsterOnField = (battle) => battle.enemies.some((e) => e.alive && !e.hidden && Array.isArray(e.def?.tags) && e.def.tags.includes('seamonster'));
|
||||
|
||||
/**
|
||||
* A skill that strikes ground enemies only cannot be opened while its range holds no ground enemy: every activation path
|
||||
* (the strategy's SKILL_RANGE, content) is refused and the charge stays — the pattern of 莎草 S2 (ops/chess_char_2_06-papyrs.js,
|
||||
* non-enumerable: never serialised).
|
||||
*/
|
||||
function guardActivation(battle, unit, grid) {
|
||||
const sk = unit.skill;
|
||||
if (!sk || Object.prototype.hasOwnProperty.call(sk, 'activate')) return;
|
||||
const activate = sk.activate;
|
||||
const ok = () => battle.unitsInGrid(unit, grid, { side: 'enemy' }).some((e) => !e.isFlying);
|
||||
Object.defineProperty(sk, 'activate', {
|
||||
configurable: true, writable: true, enumerable: false,
|
||||
value(reason, opts) { return ok() ? activate.call(this, reason, opts) : false; },
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
char_4009_irene: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const tb = traitBb(chess);
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
|
||||
const grid2 = s2?.rangeGrid ?? R3_12, grid3 = s3?.rangeGrid ?? X_1;
|
||||
const levitate = (battle, unit, e, s) => { if (e.alive && s > 0) battle.applyStatus(e, 'levitate', { duration: s, source: unit }); };
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: num(skillRec(chess, S1)?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
attack: {
|
||||
atkScale: num(b1.atk_scale, 1),
|
||||
hits: 1, // the first strike; the follow-up below (Skill_1 clip: two OnAttack)
|
||||
onEachHit({ battle, unit, target, kind, attackId }) {
|
||||
if (kind !== 'main' || !target || target.side !== 'enemy' || !target.alive) return;
|
||||
levitate(battle, unit, target, num(b1.levitate));
|
||||
if (!target.alive || !unit.alive) return;
|
||||
battle.dealDamage(unit, target, { amount: unit.s.atk * num(b1.atk_scale, 1) * unit.s.atkScaleMul, type: 'phys', isAttack: true, isSkill: true, attackId, tags: ['skill'] });
|
||||
},
|
||||
},
|
||||
},
|
||||
[S2]: {
|
||||
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
onStart({ battle, unit }) {
|
||||
const list = battle.enemiesInKeys(keysOf(unit, grid2), unit, GROUND);
|
||||
sortEnemyTargets(battle, unit, list, null);
|
||||
const v = list.slice(0, Math.max(1, Math.floor(num(b2.max_target, 1))));
|
||||
battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length, skill: 'irene:tide' });
|
||||
for (const e of v) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
|
||||
// "使其中重量小于等于3的目标浮空"
|
||||
if (e.alive && e.weight <= num(b2.value, 3)) levitate(battle, unit, e, num(b2.levitate));
|
||||
}
|
||||
},
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
duration: S3_DURATION,
|
||||
attack: { noAttack: true },
|
||||
onStart({ battle, unit }) {
|
||||
unit.mem.ireneS3 = { t: 0, n: 0 };
|
||||
const v = battle.enemiesInKeys(keysOf(unit, grid3), unit, GROUND);
|
||||
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'irene:judgment' });
|
||||
for (const e of v) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
|
||||
levitate(battle, unit, e, num(b3.levitate));
|
||||
}
|
||||
},
|
||||
onTick({ battle, unit, dt }) {
|
||||
const m = unit.mem.ireneS3;
|
||||
if (!m) return;
|
||||
m.t += dt;
|
||||
const iv = Math.max(0.05, num(b3.multi_hit_interval, 0.3)), times = Math.max(0, Math.floor(num(b3.multi_times, 10)));
|
||||
while (m.n < times && m.t + 1e-9 >= (m.n + 1) * iv) {
|
||||
m.n++;
|
||||
const cands = battle.enemiesInKeys(keysOf(unit, grid3), unit, AIR);
|
||||
if (!cands.length) continue;
|
||||
const c = battle.rng.pick(cands);
|
||||
battle.fx('aoe', { x: c.x, y: c.y, radius: S3_SPLASH, id: unit.id, skill: 'irene:judgment' });
|
||||
for (const e of battle.foesInRadius(c.x, c.y, S3_SPLASH, true)) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.multi_atk_scale, 1), type: 'phys', isSkill: true, isSplash: e !== c, tags: ['skill'] });
|
||||
}
|
||||
if (!unit.alive || !unit.skill?.active) return;
|
||||
}
|
||||
},
|
||||
onEnd({ unit }) { unit.mem.ireneS3 = null; },
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 审判之火: a 物理穿透 charge per physical instance (air: always, ground: prob), spent on landing
|
||||
const prob = num(t0.prob), pen = num(t0.def_penetrate), sp = num(t0.sp);
|
||||
// the charge is a buff on her: a new deployment starts without one (an operator leaving the field loses its buffs)
|
||||
battle.on('deploy', (ctx) => {
|
||||
if (ctx.unit !== unit) return;
|
||||
unit.mem.ireneCharge = false;
|
||||
unit.mem.ireneChargeDmg = null;
|
||||
unit.mem.ireneAirKill = false;
|
||||
}, { owner: unit });
|
||||
if (pen > 0) {
|
||||
onHitBy(battle, unit, (ctx) => {
|
||||
const d = ctx.dmg, m = unit.mem;
|
||||
if (d.type !== 'phys') return;
|
||||
if (!m.ireneCharge) m.ireneCharge = ctx.target.isFlying || (prob > 0 && battle.rng() < prob);
|
||||
if (!m.ireneCharge) return;
|
||||
d.defIgnorePct += pen;
|
||||
m.ireneChargeDmg = d;
|
||||
});
|
||||
battle.on('damaged', (c) => {
|
||||
if (c.source !== unit || !c.dmg || c.dmg !== unit.mem.ireneChargeDmg) return;
|
||||
unit.mem.ireneCharge = false;
|
||||
unit.mem.ireneChargeDmg = null;
|
||||
}, { owner: unit });
|
||||
}
|
||||
// SWO-X stage 3: an air unit knocked out during her skill ⇒ +sp SP when the skill ends
|
||||
if (sp > 0) {
|
||||
battle.on('death', (c) => {
|
||||
if (c.reason === 'killed' && c.killer === unit && c.unit?.side === 'enemy' && c.unit.isFlying && unit.skill?.active) unit.mem.ireneAirKill = true;
|
||||
}, { owner: unit });
|
||||
battle.on('skillEnd', (c) => {
|
||||
if (c.unit !== unit || !unit.mem.ireneAirKill) return;
|
||||
unit.mem.ireneAirKill = false;
|
||||
if (unit.alive) giveSp(unit, sp);
|
||||
}, { owner: unit });
|
||||
}
|
||||
} },
|
||||
{ install(battle, unit) { // 净化之剑: ASPD (SWO-Y stage 3: ATK too), doubled while a 【海怪】 enemy is on the field
|
||||
const mods = { aspd: num(t1.attack_speed), atkPct: num(t1.atk) };
|
||||
statBuff(battle, unit, 'talent:irene:sword', mods);
|
||||
if (mods.aspd || mods.atkPct) {
|
||||
const extra = {};
|
||||
for (const [k, v] of Object.entries(mods)) if (v) extra[k] = v;
|
||||
toggleBuff(battle, unit, 'talent:irene:seamonster', () => seaMonsterOnField(battle), extra);
|
||||
}
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// SWO-Y “厚重的经卷”: 攻击时无视敌人70点的防御力
|
||||
statBuff(battle, unit, 'trait:irene:pierce', { defIgnoreFlat: num(tb.def_penetrate_fixed) });
|
||||
// SWO-X “审判官口粮”: 技能造成的伤害提升10%
|
||||
const skillMul = num(tb.damage_scale, 1);
|
||||
if (skillMul !== 1) onHitBy(battle, unit, ({ dmg }) => { if (dmg.isSkill) dmg.mul *= skillMul; });
|
||||
if (unit.skill?.id === S2) guardActivation(battle, unit, grid2);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
// server/sim/content/kits/ops/op-lin.js — 林 (char_4080_lin) 自选 operator kit: 6★ 阵法术师 (术师), an owned-6★ pick of the
|
||||
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_4080_lin, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 林 (计出万全 备注:
|
||||
// "通常情况下琉璃璧生效优先级为+1000", "琉璃璧破碎效果于满足条件的伤害应用前瞬间触发" — so a lethal hit still sets it off —,
|
||||
// "其破碎时的影响范围以持有该效果的单位为中心,与林的当前攻击范围一致,先造成晕眩后造成伤害", "可造成琉璃璧破碎的伤害结算时,伤害量减少
|
||||
// 相当于琉璃璧吸收伤害上限的数值"; 玲珑 备注 "非初始状态下…技能开启时的效果均会生效,但此状态下并不实际属于“技能期间”"; 荫庇 备注
|
||||
// "每个干员单独获得琉璃壁,单独计算琉璃壁破碎与恢复时间", "伤害计算使用林的攻击力,伤害来源为林自身"; 流光乍裂 备注 "第一天赋生效的
|
||||
// 优先级从+1000变为±0,琉璃璧破碎效果于满足条件的伤害应用后瞬间触发", "仅自身普通攻击造成击杀时可触发琉璃璧破碎/生成效果,单次普攻
|
||||
// 最多触发1次效果"); PRTS 卫戍协议/帮助 §技能操作 ("携带状态切换类技能的干员…每次部署后仅开启一次技能"; 阵法术师 row "在初始攻击范围内
|
||||
// 存在敌人时释放技能"; 林【流光乍裂】 x-2 "在自定义范围内存在敌人时释放技能"); PRTS 分支特性信息 阵法术师 ("攻击时可对空"); the client's
|
||||
// battle data (charpack char_4080_lin, battle/prefabs [uc]skills skchr_lin_1/2/3, buff_template_data lin_t_1 / lin_t_1[damage]
|
||||
// / lin_t_1_s_3 / lin_t_1_s_3[refresh] / lin_t_2 / lin_e_002[half_attribute] / lin_e_002_trait).
|
||||
// - Trait (阵法术师) "通常时不攻击且防御力和法术抗性大幅度提升,技能开启时攻击造成群体法术伤害": the profession (professions.js
|
||||
// phalanx — DEF +def ×, RES +magic_resistance while no skill runs; while one runs, `rangeAoe`: every selectable enemy of
|
||||
// her range at once, air units too). Module PLX-X “七窍玲珑” overrides it with "更大幅度提升" (def 2.15 / magic_resistance
|
||||
// 25, the profession's trait tunables) and "技能开启时保留部分效果" (lin_e_002[buff] def 1 / magic_resistance 10): DEF +100 %
|
||||
// and RES +10 while any skill runs — 玲珑's switched state too (PRTS: "技能开启时的效果均会生效"). Module PLX-Y “情与义”:
|
||||
// "范围内敌人越多造成的伤害越高(最高提升15%)" — the hidden module talent: +damage_scale (3 %) per enemy on her range, at most
|
||||
// max_valid_stack_cnt (5), on every damage she deals (the 琉璃璧 bursts too), counted like 卡涅利安's PLX-Y (every living
|
||||
// enemy standing on her current range).
|
||||
// - T1 计出万全 (lin_t_1, HIGH_PRIORITY: ON_TAKE_DAMAGE → BlockDamage of a fixed `value`, a break when the damage left it
|
||||
// negative): a 琉璃璧 on her from every deployment — an HP shield of `value` (200) that every damage instance refills
|
||||
// (`absorbShields` absorbs it after DEF / RES and the damage multipliers): a hit of at most 200 is absorbed whole; a hit
|
||||
// over 200 ("单次超过200点伤害") goes through less 200 (PRTS: "伤害量减少相当于琉璃璧吸收伤害上限的数值") and breaks it — the
|
||||
// burst (lin_t_1[damage] on the targets of Lin's "Attack" selector, i.e. her CURRENT attack range, centred on the holder):
|
||||
// stun `stun` s on every enemy there, then atk_scale × Lin's ATK arts (source Lin) — and lin_t_1[disable] brings it back
|
||||
// `interval` s later (8 s; PLX-Y stage 3: 110 %, 6 s). A 流失 or an element 损伤 never touches it. [ASSUMED] the shield sits
|
||||
// in the buff order (HIGH_PRIORITY puts it first; no other shield usually stands on her), and the burst comes right after
|
||||
// the hit has landed — before a knock-out, as PRTS's lethal-hit note says — in the 流光乍裂 order too (PRTS: "伤害应用后").
|
||||
// - T2 韬光 "受到攻击时,有50%几率回复1点技力" (PLX-X stage 3: 75 %, 2 SP): the client's lin_t_2 (ON_TAKE_DAMAGE → IsIgnoreForSp →
|
||||
// Dice prob → ModifySp sp, no damage-value filter): one roll per damage instance she takes that could give 受击回复 SP — a
|
||||
// hit the 琉璃璧 absorbs whole included (it is still taken, at 0); not a 流失 or an element 损伤. No SP while a skill runs
|
||||
// (玲珑's state included: it stays on).
|
||||
// - S1 玲珑 (MANUAL, data SEARCH, a 状态切换类 skill: switched on once per deployment and kept — the official rule, `toggle`):
|
||||
// attack interval +base_attack_time (+1.0 s on her 2.0 s), ATK +atk, every enemy each attack hits 停顿 attack@sluggish s.
|
||||
// PRTS's "并不实际属于技能期间" changes nothing here: the state is never switched back in the auto battle.
|
||||
// - S2 荫庇 (MANUAL, data SEARCH, 25 s): ASPD +attack_speed, taunt +taunt_level (−1: "自身不容易受到敌人攻击"); every allied
|
||||
// unit on her range (the client's validator: ground allies — every ally of this mode but the 孤立 “炎佑”, which no ally
|
||||
// ability selects) gets its own lin_t_1 — the plain T1 (threshold `value`, Lin's ATK and source, its own break and
|
||||
// `interval` regrowth; independentCharacterSource, not on Lin herself) — while the skill runs (removed when the ability
|
||||
// detaches), checked every SHELTER_IV s; an ally that leaves the field loses it (its buffs go).
|
||||
// - S3 流光乍裂 (MANUAL, data CUSTOM_RANGE on its x-2, 26 / 28 s): ATK +atk; range x-2 (the burst range follows: it is her
|
||||
// current range: lin_t_1_s_3_selector); her own 琉璃璧 threshold ×talent_scale (2 / 2.5) while it runs (lin_t_1_s_3);
|
||||
// each kill by her "Attack" ability (ON_TARGET_KILLED, one per attack) runs lin_t_1_s_3[refresh]: a standing 琉璃璧 bursts
|
||||
// and stays, a broken one comes back at once. The auto battle never switches it off early.
|
||||
|
||||
import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp, batMod } from '../shared/tier1.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { absoluteRangeKeys } from '../../../targeting.js';
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
import { isHpLoss } from '../../../damage.js';
|
||||
|
||||
const S1 = 'skchr_lin_1';
|
||||
const S2 = 'skchr_lin_2';
|
||||
const S3 = 'skchr_lin_3';
|
||||
/** The 琉璃璧 shield buff (one per holder: Lin's own, and the S2 copies on her allies). */
|
||||
const GLASS_KEY = 'lin:glass';
|
||||
/** PLX-X: the part of the trait kept while a skill runs. */
|
||||
const KEEP_KEY = 'lin:plxKeep';
|
||||
/** S2 荫庇: how often the allies on her range are checked for a 琉璃璧. */
|
||||
const SHELTER_IV = 0.1;
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const s3On = (lin) => !!(lin.skill && lin.skill.active && lin.skill.id === S3);
|
||||
|
||||
export default {
|
||||
char_4080_lin: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const hidden = moduleBb(chess); // PLX-Y: damage_scale per enemy, max_valid_stack_cnt
|
||||
const tb = traitBb(chess); // PLX-X: lin_e_002[buff] def / magic_resistance
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const g3 = skillRec(chess, S3)?.rangeGrid ?? null;
|
||||
const glass = { value: num(t0.value, 200), atkScale: num(t0.atk_scale, 1), stun: num(t0.stun, 1), interval: num(t0.interval, 8) };
|
||||
const s3Scale = num(b3.talent_scale, 1);
|
||||
|
||||
/** Threshold (= shield) of a holder's 琉璃璧: Lin's own ×talent_scale while 流光乍裂 runs, the plain value otherwise. */
|
||||
const thresholdOf = (lin, holder) => (holder === lin && s3On(lin) ? glass.value * s3Scale : glass.value);
|
||||
/** (Re)raise the 琉璃璧 of `holder`, full. `mods: {}` keeps the buff when an instance drains it exactly (absorbShields). */
|
||||
const raise = (battle, lin, holder) => {
|
||||
if (!holder.alive) return;
|
||||
const r = holder.mem.linGlassRegen;
|
||||
if (r) { r.cancel(); holder.mem.linGlassRegen = null; }
|
||||
battle.addBuff(holder, { key: GLASS_KEY, shield: thresholdOf(lin, holder), mods: {}, source: lin, tags: ['talent'] });
|
||||
battle.fx('shield', { x: holder.x, y: holder.y, id: holder.id, skill: 'lin:glass' });
|
||||
};
|
||||
/** The break: Lin's current attack range centred on the holder — stun first, then the damage (PRTS 备注). */
|
||||
const burst = (battle, lin, holder) => {
|
||||
const grid = lin.liveRangeGrid || lin.rangeGrid || [[0, 0]];
|
||||
const keys = absoluteRangeKeys(grid, holder.tileR, holder.tileC, lin.dir, 0);
|
||||
const victims = battle.enemiesInKeys(keys, lin, { canHitFly: true });
|
||||
if (glass.stun > 0) for (const e of victims) battle.applyStatus(e, 'stun', { duration: glass.stun, source: lin });
|
||||
for (const e of victims) {
|
||||
if (e.alive && glass.atkScale > 0) battle.dealDamage(lin, e, { amount: lin.s.atk * glass.atkScale, type: 'arts', tags: ['talent', 'lin:glass'] });
|
||||
}
|
||||
battle.fx('aoe', { x: holder.x, y: holder.y, radius: 1.5, id: lin.id, skill: 'lin:glass' });
|
||||
};
|
||||
/** Break `holder`'s 琉璃璧; it grows back `interval` s later (an S2 copy only while 荫庇 still covers it), or at once. */
|
||||
const shatter = (battle, lin, holder, now = false) => {
|
||||
battle.removeBuff(holder, GLASS_KEY);
|
||||
burst(battle, lin, holder);
|
||||
if (now) { raise(battle, lin, holder); return; }
|
||||
const seq = holder.deploySeq;
|
||||
holder.mem.linGlassRegen = battle.after(glass.interval, () => {
|
||||
holder.mem.linGlassRegen = null;
|
||||
if (!holder.alive || holder.deploySeq !== seq || holder.findBuff(GLASS_KEY)) return;
|
||||
if (holder !== lin && !lin.mem.shelter?.has(holder.id)) return;
|
||||
raise(battle, lin, holder);
|
||||
}, { owner: lin });
|
||||
};
|
||||
/** Drop an S2 copy (the ally left her range, or 荫庇 ended). */
|
||||
const dropCopy = (battle, holder) => {
|
||||
const g = holder.findBuff(GLASS_KEY);
|
||||
if (g) battle.removeBuff(holder, g);
|
||||
const r = holder.mem.linGlassRegen;
|
||||
if (r) { r.cancel(); holder.mem.linGlassRegen = null; }
|
||||
};
|
||||
/** S2: the allied units (not Lin, not devices) on her range get / keep / lose their 琉璃璧. */
|
||||
const shelterTick = (battle, lin) => {
|
||||
const st = lin.mem.shelter;
|
||||
if (!st || !lin.rangeKeySet) return;
|
||||
const now = new Set();
|
||||
for (const a of battle.alliesFor(lin)) {
|
||||
if (a === lin || a.kind === 'device' || !lin.rangeKeySet.has(a.tileR * COLS + a.tileC)) continue;
|
||||
now.add(a.id);
|
||||
if (!st.has(a.id)) { st.add(a.id); raise(battle, lin, a); }
|
||||
}
|
||||
for (const id of [...st]) {
|
||||
if (now.has(id)) continue;
|
||||
st.delete(id);
|
||||
const a = battle.unitById(id);
|
||||
if (a) dropCopy(battle, a);
|
||||
}
|
||||
};
|
||||
const endShelter = (battle, lin) => {
|
||||
const st = lin.mem.shelter;
|
||||
lin.mem.shelter = null;
|
||||
for (const id of st || []) { const a = battle.unitById(id); if (a) dropCopy(battle, a); }
|
||||
};
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'toggle',
|
||||
mods: { atkPct: num(b1.atk), batPct: batMod(b1.base_attack_time, chess) },
|
||||
attack: { onHitStatus: { key: 'sluggish', duration: num(b1['attack@sluggish']) } },
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
mods: { aspd: num(b2.attack_speed), taunt: num(b2.taunt_level) },
|
||||
onStart({ battle, unit }) { unit.mem.shelter = new Set(); shelterTick(battle, unit); },
|
||||
onTick({ battle, unit, dt }) {
|
||||
unit.mem.shelterAcc = (unit.mem.shelterAcc ?? 0) + dt;
|
||||
if (unit.mem.shelterAcc + 1e-9 < SHELTER_IV) return;
|
||||
unit.mem.shelterAcc = 0;
|
||||
shelterTick(battle, unit);
|
||||
},
|
||||
onEnd({ battle, unit }) { unit.mem.shelterAcc = 0; endShelter(battle, unit); },
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b3.atk) },
|
||||
targeting: g3 ? { rangeGrid: g3 } : undefined,
|
||||
onStart({ battle, unit }) { // the threshold ×talent_scale: a standing 琉璃璧 grows to it
|
||||
const g = unit.findBuff(GLASS_KEY);
|
||||
if (g) { g.shield = thresholdOf(unit, unit); unit.markDirty(); }
|
||||
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 2, id: unit.id, skill: 'lin:s3' });
|
||||
},
|
||||
onAttack({ battle, unit, targets }) { // a knock-out by this attack: break and raise again, once per attack
|
||||
if (!(targets || []).some((t) => t && t.side === 'enemy' && !t.alive)) return;
|
||||
if (unit.findBuff(GLASS_KEY)) shatter(battle, unit, unit, true);
|
||||
else raise(battle, unit, unit);
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 计出万全: her 琉璃璧 (and the S2 copies — the same hook: any holder of one she gave)
|
||||
battle.on('deploy', (c) => { if (c.unit === unit) raise(battle, unit, unit); }, { owner: unit });
|
||||
battle.on('damaged', (c) => {
|
||||
const h = c.target;
|
||||
const g = h && h.findBuff(GLASS_KEY);
|
||||
if (!g || g.source !== unit || c.type === 'element' || isHpLoss(c.dmg)) return;
|
||||
if (g.shield > 1e-9 || !(c.amount > 0)) { // absorbed whole: full again for the next instance
|
||||
g.shield = thresholdOf(unit, h);
|
||||
h.markDirty();
|
||||
return;
|
||||
}
|
||||
if (h === unit || unit.alive) shatter(battle, unit, h);
|
||||
}, { owner: unit, priority: 50 });
|
||||
battle.on('skillEnd', (c) => { // 流光乍裂 over: a standing 琉璃璧 shrinks to the plain threshold
|
||||
if (c.unit !== unit || c.skill.id !== S3) return;
|
||||
const g = unit.findBuff(GLASS_KEY);
|
||||
if (g) { g.shield = thresholdOf(unit, unit); unit.markDirty(); }
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 韬光: prob to +sp SP per damage instance taken (absorbed or not) that could give 受击回复 SP
|
||||
const p = num(t1.prob), sp = num(t1.sp);
|
||||
if (!(p > 0 && sp > 0)) return;
|
||||
battle.on('damaged', (c) => {
|
||||
if (c.target !== unit || !c.dmg || c.dmg.noSp || c.type === 'element' || isHpLoss(c.dmg) || !up(unit)) return;
|
||||
if (battle.rng.chance(p)) giveSp(unit, sp);
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// PLX-X “七窍玲珑”: 技能开启时保留部分效果 (any skill; 玲珑's switched state included)
|
||||
const keepDef = num(tb['lin_e_002[buff].def']), keepRes = num(tb['lin_e_002[buff].magic_resistance']);
|
||||
if (keepDef || keepRes) {
|
||||
battle.on('skillStart', (c) => { if (c.unit === unit) battle.addBuff(unit, { key: KEEP_KEY, mods: { defPct: keepDef, resFlat: keepRes }, tags: ['module'] }); }, { owner: unit });
|
||||
battle.on('skillEnd', (c) => { if (c.unit === unit) battle.removeBuff(unit, KEEP_KEY); }, { owner: unit });
|
||||
}
|
||||
// PLX-Y “情与义”: +damage_scale per enemy on her range (≤ max_valid_stack_cnt), every damage she deals
|
||||
const per = num(hidden.damage_scale), cap = Math.max(0, Math.floor(num(hidden.max_valid_stack_cnt, 5)));
|
||||
if (per > 0 && cap > 0) {
|
||||
battle.on('hit', (c) => {
|
||||
if (c.source !== unit || !c.target || c.target.side !== 'enemy' || c.dmg.type === 'element' || !unit.rangeKeySet) return;
|
||||
let n = 0;
|
||||
for (const e of battle.enemies) if (e.alive && !e.hidden && bodyInKeys(e, unit.rangeKeySet) && ++n >= cap) break;
|
||||
if (n > 0) c.dmg.mul *= 1 + per * n;
|
||||
}, { owner: unit });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,279 @@
|
||||
// server/sim/content/kits/ops/op-logos.js — 逻各斯 (char_4133_logos) 自选 operator kit: 6★ 中坚术师 (术师), an owned-6★ pick
|
||||
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules (CCR-Δ, CCR-Y) at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_4133_logos, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 逻各斯 (the
|
||||
// 备注 quoted below). Every number is read from the record (`chess` / the skill blackboards); the few constants below exist
|
||||
// only in the PRTS 备注.
|
||||
// - Trait (中坚术师) "攻击造成法术伤害": the profession default (ranged arts bolt, hits air units, targetable by ground
|
||||
// enemies). CCR-Δ “来自河谷的笔盒” adds "造成法术伤害时附带相当于8%伤害的凋亡损伤" (hidden module talent
|
||||
// `ep_damage_ratio`): every arts damage he deals — attacks, the talent's extra hits, 殁亡's follow-up — fills 8 % of the
|
||||
// HP it removed as 凋亡 损伤 (the kits' element convention, docs/SIM.md §7.2). CCR-Y “《语义范式百科》” adds
|
||||
// "普通攻击命中精英或领袖敌人时获得1点技力" (trait bb `sp`): +sp SP per normal-attack hit on an elite or leader enemy
|
||||
// (none while a timed skill runs — AK) and, per the 语汇演化 备注 "<Y模组>…可触发模组提供的额外特性", per talent hit too.
|
||||
// - T1 语汇演化 "对一个目标发起攻击时,有40%几率额外对攻击范围内一个随机目标造成相当于攻击力60%的法术伤害并使其停顿0.8秒"
|
||||
// (bb prob / atk_scale / sluggish): rolled for every target of each of his attacks and after 殁亡's follow-up hit (S1 备注
|
||||
// "可触发第一天赋"); the extra hit lands at once on a random targetable enemy of his current range [ASSUMED: the attacked
|
||||
// target can be drawn], then 停顿 (the engine `sluggish`) for `sluggish` s. 备注 "该天赋的触发不依赖普通攻击,不受缴械类效果
|
||||
// 制约". CCR-Δ stage 2+: prob 0.5 / 0.6 and, on a target in its 凋亡 burst, `element_atk_scale` × ATK 元素伤害 after the arts
|
||||
// hit (备注 "先造成法术伤害,后造成元素伤害"). CCR-Y stage 2+: `emit_count` 2 — 备注 "本质上是将该天赋重复执行2次": two
|
||||
// independent executions (each its own roll and random target).
|
||||
// - T2 剜魂具辞 "攻击使目标在5秒内法术抗性-10且受到的法术伤害提高150点" (bb duration / magic_resistance / atk_addition):
|
||||
// each of his attack hits gives the target, for `duration` s, RES `magic_resistance` and +`atk_addition` on every arts
|
||||
// damage it takes (备注 "在计算攻击倍率后、计算法术抗性前增加当次伤害", "可对当次伤害生效", "即使法术伤害的攻击力为0也可
|
||||
// 生效"): added to the hit's pre-mitigation amount, the hit that applies it included. One instance per enemy whoever
|
||||
// applies it (同名效果: applyStrongest).
|
||||
// - S1 殁亡 (AUTO, 持续时间无限 ⇒ toggle; the data's DEFAULT trigger — it acts on enemies): range 3-3, ATK +atk, and an aura
|
||||
// (备注 "光环效果,不依赖攻击行为"): every enemy of his range whose HP is below attack@kill_atk_scale × his ATK takes
|
||||
// attack@kill_damage (9999999) 无来源 true damage (备注 "无来源真实斩杀伤害"; no dodge), then another random enemy of his
|
||||
// range takes arts damage equal to the HP the first had left (备注: his T2 bonus applies, T1 rolls). At most one try per
|
||||
// enemy per entry into the range, and none ever again on an enemy that survived one (备注; a 频次 unit takes 1 HP, a
|
||||
// leader in a boss battle loses nothing to 限伤 — 备注 "卫戍协议的最终攻势Boss…只能以常规的“当前生命值”处理斩杀的触发").
|
||||
// - S2 提喻 (MANUAL, 20 s, data DEFAULT): RES +magic_resistance; the attack locks one target (an instant hit, no bolt
|
||||
// [ASSUMED: a lock-on beam]) every attack@cooldown s whatever the attack speed (备注 "攻击间隔固定为0.5s"), each hit
|
||||
// attack@atk_scale_base + attack@atk_scale_delta × n of his ATK and the target's movement speed × (1 + attack@move_speed
|
||||
// × n), n = the hits made on it so far, at most attack@max_stack_cnt (备注 "线性提升" — 3× and 40 % after 5 s). A stun /
|
||||
// freeze, the target's fall or no attack for 0.1 s past the cadence (备注 "每0.1s,若自身未处于攻击状态,则退出锁定状态")
|
||||
// resets the lock and its slow; the next attack locks anew.
|
||||
// - S3 延异视阈 (MANUAL, 30 s, data ACTIVE_RANGE on its 3-4): ATK +atk, attack@max_target targets, and every enemy
|
||||
// projectile whose position is on his range flies at projectile_move_scale × its speed (备注 "降低至0.05倍"; leaving the
|
||||
// range restores it) and is removed when the skill ends — not when he is disarmed then (备注 "受缴械类效果制约") nor when
|
||||
// he is knocked out [ASSUMED]. Every enemy projectile counts as 可变速 / 可消除 [ASSUMED]. The sim's projectiles live in
|
||||
// `battle.projectiles.list` (ProjectileSystem, server/sim/projectiles.js): the kit adjusts `speed` / `maxAge` there.
|
||||
|
||||
import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp, once } from '../shared/tier1.js';
|
||||
import { isElite, elementDmg } from '../shared/tier6.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
import { COLS, ROWS } from '../../../constants.js';
|
||||
|
||||
const S1 = 'skchr_logos_1';
|
||||
const S2 = 'skchr_logos_2';
|
||||
const S3 = 'skchr_logos_3';
|
||||
const ANY = Object.freeze({ canHitFly: true });
|
||||
/** 剜魂具辞: the debuff on an enemy (one instance whoever applies it). */
|
||||
const T2_KEY = 'logos:soulRend';
|
||||
/** 提喻 备注 "每0.1s,若自身未处于攻击状态,则退出锁定状态": the lock ends 0.1 s after a missed attack. */
|
||||
const LOCK_IDLE = 0.1;
|
||||
/** Enemy projectiles slowed by a running 延异视阈 (projectile → { orig speed, by: Set of unit ids }); per projectile. */
|
||||
const SLOWED = new WeakMap();
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const tileOf = (p) => {
|
||||
const r = Math.round(p.y), c = Math.round(p.x);
|
||||
return r >= 0 && r < ROWS && c >= 0 && c < COLS ? r * COLS + c : -1;
|
||||
};
|
||||
/** Enemy projectiles on the field (ProjectileSystem list: the sim's only projectile store). */
|
||||
const enemyShots = (battle) => (battle.projectiles?.list ?? []).filter((p) => p && p.source && p.source.side === 'enemy');
|
||||
|
||||
/** 延异视阈: slow the enemy projectiles on `unit`'s range, restore those that left it. */
|
||||
function slowShots(battle, unit, scale) {
|
||||
const set = unit.rangeKeySet;
|
||||
for (const p of enemyShots(battle)) {
|
||||
let s = SLOWED.get(p);
|
||||
if (set && set.has(tileOf(p))) {
|
||||
if (!s) { s = { orig: p.speed, by: new Set() }; SLOWED.set(p, s); }
|
||||
s.by.add(unit.id);
|
||||
p.speed = s.orig * scale;
|
||||
p.maxAge = Math.max(p.maxAge, p.age + 1); // a slowed shot never lands by age while it is held
|
||||
} else if (s && s.by.delete(unit.id) && !s.by.size) {
|
||||
p.speed = s.orig;
|
||||
SLOWED.delete(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 延异视阈 ends: clear the enemy projectiles on the range (unless disarmed / knocked out), release the rest. */
|
||||
function endShots(battle, unit, clear) {
|
||||
const set = unit.rangeKeySet;
|
||||
let n = 0;
|
||||
if (clear && set && battle.projectiles) {
|
||||
const keep = [];
|
||||
for (const p of battle.projectiles.list) {
|
||||
if (p && p.source && p.source.side === 'enemy' && set.has(tileOf(p))) { n++; SLOWED.delete(p); } else keep.push(p);
|
||||
}
|
||||
battle.projectiles.list = keep;
|
||||
}
|
||||
for (const p of enemyShots(battle)) {
|
||||
const s = SLOWED.get(p);
|
||||
if (s && s.by.delete(unit.id) && !s.by.size) { p.speed = s.orig; SLOWED.delete(p); }
|
||||
}
|
||||
if (n) battle.fx('bulletClear', { x: unit.x, y: unit.y, id: unit.id, n });
|
||||
}
|
||||
|
||||
export default {
|
||||
char_4133_logos: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0);
|
||||
const t1 = talentBb(chess, 1);
|
||||
const hidden = moduleBb(chess); // CCR-Δ: ep_damage_ratio
|
||||
const tb = traitBb(chess); // CCR-Y: sp
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s1 = skillRec(chess, S1), s3 = skillRec(chess, S3);
|
||||
const lex = {
|
||||
prob: num(t0.prob), scale: num(t0.atk_scale), sluggish: num(t0.sluggish),
|
||||
elemScale: num(t0.element_atk_scale), emit: Math.max(1, Math.floor(num(t0.emit_count, 1))),
|
||||
};
|
||||
|
||||
/** 语汇演化: `emit` executions, each a `prob` roll for a random enemy of his range. */
|
||||
function lexicon(battle, unit) {
|
||||
if (!(lex.prob > 0) || !(lex.scale > 0)) return;
|
||||
for (let k = 0; k < lex.emit; k++) {
|
||||
if (!up(unit) || !battle.rng.chance(lex.prob)) continue;
|
||||
const t = battle.rng.pick(battle.enemiesInKeys(unit.rangeKeys, unit, ANY));
|
||||
if (!t) continue;
|
||||
battle.fx('logosLexicon', { x: t.x, y: t.y, id: unit.id, target: t.id });
|
||||
battle.dealDamage(unit, t, { amount: unit.s.atk * lex.scale, type: 'arts', tags: ['talent', 'logosLexicon'] });
|
||||
if (t.alive && lex.sluggish > 0) battle.applyStatus(t, 'sluggish', { duration: lex.sluggish, source: unit });
|
||||
if (lex.elemScale > 0 && hasHp(t) && t.findBuff('apoptosisBurst')) {
|
||||
battle.dealDamage(unit, t, { amount: unit.s.atk * lex.elemScale, type: 'elemental', element: 'apoptosis', canDodge: false, tags: ['talent', 'logosLexicon'] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 殁亡's execution of `e` (HP below the threshold), then the follow-up arts hit on another random enemy of his range. */
|
||||
function execute(battle, unit, e, st) {
|
||||
const left = e.hp;
|
||||
battle.fx('logosExecute', { x: e.x, y: e.y, id: unit.id, target: e.id });
|
||||
battle.dealDamage(unit, e, { amount: num(b1['attack@kill_damage'], 9999999), type: 'true', canDodge: false, sourceless: true, isSkill: true, tags: ['skill', 'execute'] });
|
||||
if (e.alive) st.spared.add(e.id); // survived (不死, 频次, 限伤 …): never tried again
|
||||
const t = battle.rng.pick(battle.enemiesInKeys(unit.rangeKeys, unit, ANY).filter((x) => x !== e));
|
||||
if (!t || !(left > 0)) return;
|
||||
battle.dealDamage(unit, t, { amount: left, type: 'arts', isSkill: true, tags: ['skill', 'logosPerish'] });
|
||||
lexicon(battle, unit);
|
||||
}
|
||||
|
||||
/** 提喻: end the lock (its slow leaves the target). */
|
||||
function unlock(battle, unit) {
|
||||
const L = unit.mem.logosLock;
|
||||
if (L && L.target) battle.removeBuff(L.target, `logos:lockSlow:${unit.id}`);
|
||||
unit.mem.logosLock = null;
|
||||
}
|
||||
const lockIv = num(b2['attack@cooldown'], 0.5);
|
||||
const base2 = num(b2['attack@atk_scale_base'], 1), delta2 = num(b2['attack@atk_scale_delta']);
|
||||
const maxN = Math.max(0, Math.floor(num(b2['attack@max_stack_cnt'])));
|
||||
const s2On = (unit) => !!(unit.skill && unit.skill.active && unit.skill.id === S2);
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'toggle',
|
||||
mods: { atkPct: num(b1.atk) },
|
||||
targeting: { rangeGrid: s1?.rangeGrid ?? null },
|
||||
onStart({ unit }) { unit.mem.logosExec = { tried: new Set(), spared: unit.mem.logosExec?.spared ?? new Set() }; },
|
||||
onTick({ battle, unit }) {
|
||||
const st = unit.mem.logosExec;
|
||||
if (!st || !up(unit)) return;
|
||||
const inRange = battle.enemiesInKeys(unit.rangeKeys, unit, ANY);
|
||||
const ids = new Set(inRange.map((e) => e.id));
|
||||
for (const id of st.tried) if (!ids.has(id)) st.tried.delete(id); // left the range: a new entry may try again
|
||||
const thr = num(b1['attack@kill_atk_scale']) * unit.s.atk;
|
||||
for (const e of inRange) {
|
||||
if (st.tried.has(e.id) || st.spared.has(e.id) || !e.alive || !hasHp(e) || !(e.hp < thr)) continue;
|
||||
st.tried.add(e.id);
|
||||
execute(battle, unit, e, st);
|
||||
if (!up(unit)) return;
|
||||
}
|
||||
},
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
mods: { resFlat: num(b2.magic_resistance) },
|
||||
attack: {
|
||||
projectile: 'beam',
|
||||
atkScale: base2,
|
||||
// the hit's scale: base + delta × n (n = the hits already made on the locked target)
|
||||
dmgMul: (battle, unit, target) => {
|
||||
const L = unit.mem.logosLock;
|
||||
const n = L && L.target === target ? L.n : 0;
|
||||
return base2 > 0 ? (base2 + delta2 * n) / base2 : 1;
|
||||
},
|
||||
onEachHit({ battle, unit, target, kind }) {
|
||||
const L = unit.mem.logosLock;
|
||||
if (kind !== 'main' || !L || L.target !== target || !target.alive) return;
|
||||
L.n = Math.min(maxN, L.n + 1);
|
||||
const mul = Math.max(0, 1 + num(b2['attack@move_speed']) * L.n);
|
||||
battle.addBuff(target, { key: `logos:lockSlow:${unit.id}`, duration: lockIv + LOCK_IDLE + 0.2, source: unit, mods: { moveMul: mul } });
|
||||
},
|
||||
},
|
||||
onStart({ battle, unit }) { unlock(battle, unit); unit.mem.logosLockAt = null; },
|
||||
onAttack({ battle, unit }) { unit.mem.logosLockAt = battle.time; },
|
||||
onTick({ battle, unit }) {
|
||||
const L = unit.mem.logosLock;
|
||||
// a stun / freeze breaks the lock; so does no attack for LOCK_IDLE past the cadence
|
||||
if (L && (!unit.canAct || !L.target.alive || battle.time - unit.mem.logosLockAt > lockIv + LOCK_IDLE + 1e-9)) unlock(battle, unit);
|
||||
if (!unit.canAct) return;
|
||||
// 攻击间隔固定为0.5s: the next attack exactly lockIv after the last one (the engine counts atkCd down right after)
|
||||
const at = unit.mem.logosLockAt;
|
||||
if (at == null) { unit.atkCd = Math.min(unit.atkCd, lockIv); return; }
|
||||
const left = at + lockIv - battle.time;
|
||||
unit.atkCd = left > 1e-6 ? left + battle.dt : 0;
|
||||
},
|
||||
onEnd({ battle, unit }) { unlock(battle, unit); },
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b3.atk) },
|
||||
targeting: { rangeGrid: s3?.rangeGrid ?? null, maxTargets: num(b3['attack@max_target'], 1) },
|
||||
onTick({ battle, unit }) { slowShots(battle, unit, num(b3.projectile_move_scale, 1)); },
|
||||
onEnd({ battle, unit, reason }) { endShots(battle, unit, reason !== 'death' && !unit.s.flags.disarm); },
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 语汇演化: every target of each of his attacks
|
||||
battle.on('attack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !up(unit)) return;
|
||||
for (let i = 0; i < ctx.targets.length; i++) lexicon(battle, unit);
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 剜魂具辞: his attack hits mark the target (before mitigation: the hit itself counts)
|
||||
const dur = num(t1.duration), res = num(t1.magic_resistance), add = num(t1.atk_addition);
|
||||
if (!(dur > 0) || !(add > 0 || res)) return;
|
||||
battle.on('hit', (ctx) => {
|
||||
if (ctx.source !== unit || !ctx.dmg?.isAttack || !ctx.target || ctx.target.side !== 'enemy') return;
|
||||
battle.applyStrongest(ctx.target, T2_KEY, { duration: dur, value: add, source: unit, mods: () => (res ? { resFlat: res } : {}) });
|
||||
}, { owner: unit, priority: 10 });
|
||||
// "受到的法术伤害提高N点": every arts damage on a marked enemy, after the attack multiplier, before its RES
|
||||
once(battle, 'logos:artsAddition', () => battle.on('hit', (ctx) => {
|
||||
const d = ctx.dmg;
|
||||
if (!d || d.type !== 'arts' || !ctx.target || ctx.target.side !== 'enemy') return;
|
||||
const m = ctx.target.findBuff(T2_KEY);
|
||||
if (m && m.data && m.data.value > 0) d.amount += m.data.value;
|
||||
}, { priority: -50 }));
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// 提喻: keep attacking the locked target while it stays a target; a new lock otherwise
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !s2On(unit) || !ctx.targets.length) return;
|
||||
const L = unit.mem.logosLock;
|
||||
const prof = ctx.profile || unit.profile;
|
||||
if (L && L.target.alive) {
|
||||
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof);
|
||||
if (cands.includes(L.target) || battle.blockedTargets(unit, prof).includes(L.target)) { ctx.targets = [L.target]; return; }
|
||||
}
|
||||
unlock(battle, unit);
|
||||
unit.mem.logosLock = { target: ctx.targets[0], n: 0 };
|
||||
ctx.targets = [ctx.targets[0]];
|
||||
}, { owner: unit });
|
||||
// CCR-Δ: 8 % of every arts damage as 凋亡 损伤
|
||||
const ep = num(hidden.ep_damage_ratio);
|
||||
if (ep > 0) {
|
||||
battle.on('damaged', (ctx) => {
|
||||
if (ctx.source !== unit || ctx.type !== 'arts' || !(ctx.amount > 0) || !ctx.target || ctx.target.side !== 'enemy') return;
|
||||
elementDmg(battle, unit, ctx.target, 'apoptosis', ctx.amount * ep, ['trait', 'logosCcrd']);
|
||||
}, { owner: unit });
|
||||
}
|
||||
// CCR-Y: a normal-attack (or 语汇演化) hit on an elite / leader enemy gives SP
|
||||
const sp = num(tb.sp);
|
||||
if (sp > 0) {
|
||||
battle.on('damaged', (ctx) => {
|
||||
const d = ctx.dmg;
|
||||
if (ctx.source !== unit || !d || ctx.type === 'element' || !ctx.target || ctx.target.side !== 'enemy') return;
|
||||
if ((d.isAttack || (d.tags || []).includes('logosLexicon')) && isElite(ctx.target)) giveSp(unit, sp, 'trait');
|
||||
}, { owner: unit });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
// server/sim/content/kits/ops/op-mantra.js — 真言 (char_4204_mantra) 自选 operator kit: 6★ 本源术师 (术师), an owned-6★ pick of
|
||||
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and the module (PRI-X) at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_4204_mantra, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
|
||||
// elite = E2 Lv60, rank 7, PRI-X at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 真言 (the 备注
|
||||
// quoted below). 麻痹 (ba.palsy, PRTS 术语释义): each stack interrupts one normal attack of an enemy, at most 3, kept until
|
||||
// used — the engine `palsy` status; the moment a stack interrupts an attack is the engine hook `palsyTrigger` (ai.js).
|
||||
// - Trait (本源术师) "攻击造成法术伤害,可以造成元素伤害": the profession default (ranged arts bolt, hits air units,
|
||||
// targetable by ground enemies); 元素伤害 = the DamageInfo type 'elemental', 神经 损伤 = element 'neural' (SIM.md §3). PRI-X
|
||||
// “中枢神经探测模块” "对处于元素爆发期间的敌人造成的伤害提升至110%" (trait bb damage_scale): ×damage_scale on every damage
|
||||
// she deals to an enemy in an element burst (shared/tier5.js burstDamageUp, as 烛煌 / 妮芙).
|
||||
// - T1 噤声限域 "场上敌人触发麻痹效果时立即受到相当于真言攻击力135%的元素伤害,且触发麻痹时有10%概率不消耗麻痹层数" (bb
|
||||
// atk_scale / prob; PRI-X stage 3: 1.6 / 0.15): 备注 "触发麻痹时指的是敌人的攻击被麻痹打断时(而非获得麻痹时)", "造成的元素
|
||||
// 伤害为元素持续伤害" (tagged 'dot'), "全场光环存在0.5秒检测周期,单位无法被真言选择的情况下将失去效果" (every 0.5 s the
|
||||
// enemies she can select — not untargetable, 隐匿 or asleep; "三技能期间,此天赋的检测无视隐匿与沉睡" — carry her mark),
|
||||
// "麻痹不消耗效果在同类效果中取概率最高" (two 真言: one roll at the higher chance; each deals her own hit [ASSUMED]).
|
||||
// - T2 全局洞悉 "距离真言最近的一个侵入点出场的敌人出现时立刻获得1层麻痹": at each deployment she picks the 侵入点 (the
|
||||
// stage's 'start' tiles) nearest by Manhattan distance, a tie drawn at random (备注 "优先选取距离自身曼哈顿距离最近的侵入点,
|
||||
// 相同距离下随机选取"); every enemy spawning on it gets 1 麻痹 0.1 s later (备注 "【延迟麻痹】:0.1s后获得1层麻痹;付与选取
|
||||
// 无视隐匿").
|
||||
// - S1 共鸣溃缩 (AUTO, attack SP, data DEFAULT): the next attack deals atk_scale × ATK arts, then ep_damage_ratio of the
|
||||
// damage as 神经 损伤, then — on a target in its 神经 burst — element_atk_scale × ATK 元素伤害 (S2 备注 "伤害顺序为:法术伤害 →
|
||||
// 神经损伤 → 元素伤害", the same riders).
|
||||
// - S2 意识联协 (MANUAL, 25 s, data DEFAULT): attack interval base_attack_time (a flat −0.7 / −0.6 s); each attack deals
|
||||
// attack@atk_scale × ATK arts and jumps to chain_times other enemies (attack@chain.atk_scale × ATK arts each), every hit
|
||||
// with the S1 riders at attack@ep_damage_ratio / attack@element_atk_scale. 备注 "跳跃半径2.0,首次跳跃开始后弹道速度降低至
|
||||
// 3.2": each jump flies at 3.2 tiles/s to the nearest not-yet-hit enemy within 2.0 tiles [ASSUMED: nearest, as the
|
||||
// 链术师]. The first shot keeps the engine's bolt (11 tiles/s; 备注 10).
|
||||
// - S3 无言为真 (MANUAL, 40 s, data ACTIVE_RANGE on its 3-17): ATK +atk, attack@max_target targets; on her range (备注:
|
||||
// 【反隐】 / the overflow by 碰撞判定 — an enemy's body —, 【麻痹付与】 by the enemy's tile) enemies lose 隐匿 (reveal) and
|
||||
// their 麻痹 cap falls to 2 (text "降为2"; no blackboard key): stacks above it, held when the cap falls or gained later, become
|
||||
// "溢出麻痹" (麻痹免疫 enemies count too — 备注 "仅为令麻痹失效": the stacks 真言 gives them are kept here); each one turns
|
||||
// into an 元素伤害 bounce of atk_scale × ATK — on the holder, then (chain_times) to the nearest other enemy within 2.0 tiles
|
||||
// at 3.2 tiles/s, max_target hits — one at once when gained and one every interval_projectile_trigger s after, all of them
|
||||
// at once when the holder leaves the range, falls or the skill ends (备注). 【麻痹付与】: 0.1 s after the cast and whenever
|
||||
// another operator on her range starts a skill, every enemy on her range gets per_active 麻痹, max_trigger_cnt times per
|
||||
// cast, a grant with no enemy there kept (备注 "在范围内不存在可选敌方单位时保留可付与次数").
|
||||
|
||||
import { num, talentBb, traitBb, skillRec, batMod, up, once } from '../shared/tier1.js';
|
||||
import { elementDmg } from '../shared/tier6.js';
|
||||
import { burstDamageUp } from '../shared/tier5.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
import { canTargetEnemy, enemyStealthed } from '../../../targeting.js';
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
import { COLS, PALSY_MAX } from '../../../constants.js';
|
||||
|
||||
const S1 = 'skchr_mantra_1';
|
||||
const S2 = 'skchr_mantra_2';
|
||||
const S3 = 'skchr_mantra_3';
|
||||
const ANY = Object.freeze({ canHitFly: true });
|
||||
/** 意识联协 / 无言为真 备注: "跳跃半径2.0", "速度3.2,弹射半径2.0". */
|
||||
const JUMP_RADIUS = 2.0;
|
||||
const JUMP_SPEED = 3.2;
|
||||
/** 无言为真 "麻痹层数上限降为2" (text only). */
|
||||
const S3_CAP = 2;
|
||||
/** 无言为真 备注 "【麻痹付与】效果在自身技能开启后的0.1s触发一次". */
|
||||
const GRANT_DELAY = 0.1;
|
||||
/** 噤声限域 备注 "全场光环存在0.5秒检测周期". */
|
||||
const MARK_IV = 0.5;
|
||||
const MARK = 'mantra:hush:';
|
||||
/** 全局洞悉 备注 "0.1s后获得1层麻痹". */
|
||||
const SPAWN_DELAY = 0.1;
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const palsyImmune = (e) => !!e?.def?.immune?.has?.('palsy');
|
||||
/** 麻痹 stacks of an enemy — a 麻痹免疫 one keeps the ineffective stacks 真言 gave it in `mem.mantraPalsy`. */
|
||||
const palsyOf = (e) => (palsyImmune(e) ? (e.mem.mantraPalsy ?? 0) : (e.findBuff('palsy')?.stacks ?? 0));
|
||||
function setPalsy(battle, e, n) {
|
||||
if (palsyImmune(e)) { e.mem.mantraPalsy = n; return; }
|
||||
const b = e.findBuff('palsy');
|
||||
if (!b) return;
|
||||
if (n <= 0) battle.removeBuff(e, b);
|
||||
else { b.stacks = n; e.markDirty(); }
|
||||
}
|
||||
const tileIn = (e, set) => set.has(Math.round(e.y) * COLS + Math.round(e.x));
|
||||
|
||||
/** The S1 / S2 riders: share × the damage dealt as 神经 损伤, then elemScale × ATK 元素伤害 on a target in its 神经 burst. */
|
||||
function riders(battle, unit, t, dealt, share, elemScale) {
|
||||
if (!t || t.side !== 'enemy') return;
|
||||
if (share > 0 && dealt > 0) elementDmg(battle, unit, t, 'neural', dealt * share, ['skill', 'mantra']);
|
||||
if (elemScale > 0 && hasHp(t) && t.findBuff('neuralBurst')) {
|
||||
battle.dealDamage(unit, t, { amount: unit.s.atk * elemScale, type: 'elemental', element: 'neural', canDodge: false, isSkill: true, tags: ['skill', 'mantra'] });
|
||||
}
|
||||
}
|
||||
|
||||
/** The nearest enemy within `r` of (x, y) that `unit` can select, none of `skip`. */
|
||||
function nearestFoe(battle, unit, x, y, r, skip) {
|
||||
let best = null, bd = Infinity;
|
||||
for (const e of battle.enemiesInRadius(x, y, r)) {
|
||||
if (skip.has(e) || !canTargetEnemy(unit, e, ANY)) continue;
|
||||
const d = Math.hypot(e.x - x, e.y - y);
|
||||
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export default {
|
||||
char_4204_mantra: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0);
|
||||
const tb = traitBb(chess); // PRI-X: damage_scale
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s3 = skillRec(chess, S3);
|
||||
const s3On = (unit) => !!(unit.skill && unit.skill.active && unit.skill.id === S3 && unit.mem.mantraS3);
|
||||
|
||||
/** 意识联协: `left` jumps from `from`, each to the nearest not-yet-hit enemy, at JUMP_SPEED. */
|
||||
function chain(battle, unit, from, hit, left, attackId) {
|
||||
if (left <= 0) return;
|
||||
const next = nearestFoe(battle, unit, from.x, from.y, JUMP_RADIUS, hit);
|
||||
if (!next) return;
|
||||
hit.add(next);
|
||||
battle.addProjectile({
|
||||
from: { x: from.x, y: from.y }, target: next, speed: JUMP_SPEED, visual: 'bolt', source: unit, hitDead: true,
|
||||
onHit: ({ target, x, y }) => {
|
||||
if (target && target.alive) {
|
||||
const d = battle.dealDamage(unit, target, { amount: unit.s.atk * num(b2['attack@chain.atk_scale'], 1), type: 'arts', isAttack: true, isSkill: true, tags: ['chain'], attackId });
|
||||
riders(battle, unit, target, d, num(b2['attack@ep_damage_ratio']), num(b2['attack@element_atk_scale']));
|
||||
}
|
||||
chain(battle, unit, target && target.alive ? target : { x, y }, hit, left - 1, attackId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 无言为真: one 溢出麻痹 → an 元素伤害 bounce on `holder`, then to the nearest other enemy (chain_times jumps). */
|
||||
function bounce(battle, unit, holder) {
|
||||
const amount = unit.s.atk * num(b3.atk_scale);
|
||||
if (!(amount > 0)) return;
|
||||
const hit = new Set([holder]);
|
||||
const elem = (t) => battle.dealDamage(unit, t, { amount, type: 'elemental', element: 'neural', canDodge: false, isSkill: true, tags: ['skill', 'mantraOverflow'] });
|
||||
let left = Math.max(1, Math.floor(num(b3.max_target, 2)));
|
||||
if (holder.alive && hasHp(holder)) { elem(holder); left--; }
|
||||
battle.fx('mantraArc', { x: holder.x, y: holder.y, id: unit.id, target: holder.id });
|
||||
const jump = (from, jumps) => {
|
||||
if (jumps <= 0 || left <= 0) return;
|
||||
const next = nearestFoe(battle, unit, from.x, from.y, JUMP_RADIUS, hit);
|
||||
if (!next) return;
|
||||
hit.add(next);
|
||||
left--;
|
||||
battle.addProjectile({
|
||||
from: { x: from.x, y: from.y }, target: next, speed: JUMP_SPEED, visual: 'bolt', source: unit,
|
||||
onHit: ({ target }) => { if (target && target.alive) elem(target); if (target) jump(target, jumps - 1); },
|
||||
});
|
||||
};
|
||||
jump(holder, Math.max(0, Math.floor(num(b3.chain_times, 1))));
|
||||
}
|
||||
|
||||
/** 溢出麻痹 gained by `e`: one turns into a bounce at once, the next one interval later. */
|
||||
function overflow(battle, unit, st, e, rec, n) {
|
||||
if (!(n > 0)) return;
|
||||
rec.over += n;
|
||||
rec.over--;
|
||||
rec.next = battle.time + num(b3.interval_projectile_trigger, 1.5);
|
||||
bounce(battle, unit, e);
|
||||
}
|
||||
/** `e` leaves the overflow (range, fall, skill end): every 溢出麻痹 left bounces at once. */
|
||||
function flush(battle, unit, e, rec) {
|
||||
while (rec.over > 0) { rec.over--; bounce(battle, unit, e); }
|
||||
}
|
||||
/** 真言's own 麻痹 (S3 grants, 全局洞悉): a 麻痹免疫 enemy keeps them (ineffective) for the overflow count. */
|
||||
function givePalsy(battle, unit, e, n) {
|
||||
if (!e.alive || !(n > 0)) return;
|
||||
if (!palsyImmune(e)) { battle.applyStatus(e, 'palsy', { value: n, source: unit }); return; }
|
||||
const owner = battle.allyUnits.find((a) => s3On(a) && a.mem.mantraS3.map.has(e));
|
||||
const cap = owner ? S3_CAP : PALSY_MAX;
|
||||
const cur = palsyOf(e);
|
||||
const add = Math.min(n, Math.max(0, cap - cur));
|
||||
e.mem.mantraPalsy = cur + add;
|
||||
if (owner && n > add) overflow(battle, owner, owner.mem.mantraS3, e, owner.mem.mantraS3.map.get(e), n - add);
|
||||
}
|
||||
/** 【麻痹付与】: per_active 麻痹 to every enemy on her range (by its tile); kept when there is none. */
|
||||
function grant(battle, unit) {
|
||||
const st = unit.mem.mantraS3;
|
||||
if (!st || st.grants <= 0 || !s3On(unit)) return;
|
||||
const set = unit.rangeKeySet;
|
||||
const foes = battle.enemies.filter((e) => e.alive && !e.hidden && set && tileIn(e, set));
|
||||
if (!foes.length) return;
|
||||
st.grants--;
|
||||
battle.fx('mantraGrant', { x: unit.x, y: unit.y, id: unit.id, n: foes.length });
|
||||
for (const e of foes) givePalsy(battle, unit, e, Math.max(1, Math.floor(num(b3.per_active, 1))));
|
||||
}
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: num(skillRec(chess, S1)?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
attack: {
|
||||
atkScale: num(b1.atk_scale, 1),
|
||||
onHit({ battle, unit, target, dealt }) { riders(battle, unit, target, dealt, num(b1.ep_damage_ratio), num(b1.element_atk_scale)); },
|
||||
},
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
mods: { batPct: batMod(b2.base_attack_time, chess) },
|
||||
attack: {
|
||||
atkScale: num(b2['attack@atk_scale'], 1),
|
||||
onEachHit({ battle, unit, target, dealt, kind, attackId }) {
|
||||
if (kind !== 'main' || !target) return;
|
||||
riders(battle, unit, target, dealt, num(b2['attack@ep_damage_ratio']), num(b2['attack@element_atk_scale']));
|
||||
chain(battle, unit, target, new Set([target]), Math.max(0, Math.floor(num(b2.chain_times))), attackId);
|
||||
},
|
||||
},
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b3.atk) },
|
||||
targeting: { rangeGrid: s3?.rangeGrid ?? null, maxTargets: num(b3['attack@max_target'], 1) },
|
||||
onStart({ battle, unit, skill }) {
|
||||
const n = skill.activations;
|
||||
unit.mem.mantraS3 = { grants: Math.max(0, Math.floor(num(b3.max_trigger_cnt))), map: new Map() };
|
||||
battle.after(GRANT_DELAY, () => { if (skill.activations === n) grant(battle, unit); });
|
||||
},
|
||||
onTick({ battle, unit, dt }) {
|
||||
const st = unit.mem.mantraS3;
|
||||
const set = unit.rangeKeySet;
|
||||
if (!st || !set) return;
|
||||
const seen = new Set();
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive || e.hidden || !bodyInKeys(e, set)) continue;
|
||||
seen.add(e);
|
||||
// 【反隐】 (隐匿免疫) on her range
|
||||
if (e.s.flags.stealth) battle.applyStatus(e, 'reveal', { duration: 2 * dt + 0.05, source: unit });
|
||||
let rec = st.map.get(e);
|
||||
if (!rec) {
|
||||
rec = { over: 0, next: Infinity };
|
||||
st.map.set(e, rec);
|
||||
const cur = palsyOf(e);
|
||||
if (cur > S3_CAP) { setPalsy(battle, e, S3_CAP); overflow(battle, unit, st, e, rec, cur - S3_CAP); }
|
||||
} else if (rec.over > 0 && battle.time >= rec.next - 1e-9) {
|
||||
rec.over--;
|
||||
rec.next = battle.time + num(b3.interval_projectile_trigger, 1.5);
|
||||
bounce(battle, unit, e);
|
||||
}
|
||||
}
|
||||
for (const [e, rec] of [...st.map]) if (!seen.has(e)) { st.map.delete(e); flush(battle, unit, e, rec); }
|
||||
},
|
||||
onEnd({ battle, unit }) {
|
||||
const st = unit.mem.mantraS3;
|
||||
unit.mem.mantraS3 = null;
|
||||
if (st) for (const [e, rec] of st.map) flush(battle, unit, e, rec);
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 噤声限域: her mark on every enemy she can select (0.5 s); a 麻痹 interrupt hits it
|
||||
const scale = num(t0.atk_scale), prob = num(t0.prob);
|
||||
if (!(scale > 0) && !(prob > 0)) return;
|
||||
battle.every(MARK_IV, () => {
|
||||
if (!up(unit)) return;
|
||||
const all = s3On(unit); // 三技能期间: 隐匿 / 沉睡 do not hide an enemy from the check
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive || e.hidden || !e.deployed || e.s.flags.untargetable) continue;
|
||||
if (!all && (e.s.flags.sleep || (e.s.flags.stealth && enemyStealthed(e)))) continue;
|
||||
battle.addBuff(e, { key: MARK + unit.id, duration: MARK_IV + 0.1, source: unit, data: { scale, prob } });
|
||||
}
|
||||
}, { owner: unit, immediate: true });
|
||||
once(battle, 'mantra:palsyTrigger', () => battle.on('palsyTrigger', (ctx) => {
|
||||
const e = ctx.enemy;
|
||||
let p = 0;
|
||||
for (const m of e.buffs.slice()) {
|
||||
if (typeof m.key !== 'string' || !m.key.startsWith(MARK) || !m.data) continue;
|
||||
const src = m.source;
|
||||
if (!up(src)) continue;
|
||||
if (m.data.scale > 0 && hasHp(e)) {
|
||||
battle.dealDamage(src, e, { amount: src.s.atk * m.data.scale, type: 'elemental', element: 'neural', canDodge: false, tags: ['talent', 'dot', 'mantraHush'] });
|
||||
}
|
||||
p = Math.max(p, m.data.prob);
|
||||
if (!e.alive) return;
|
||||
}
|
||||
if (p > 0 && battle.rng.chance(p)) ctx.keep = true;
|
||||
}));
|
||||
} },
|
||||
{ install(battle, unit) { // 全局洞悉: the nearest 侵入点; its spawns get 1 麻痹 0.1 s later
|
||||
battle.on('deploy', (ctx) => {
|
||||
if (ctx.unit !== unit) return;
|
||||
let best = [], bd = Infinity;
|
||||
for (const [r, c] of battle.grid.specialTiles('start')) {
|
||||
const d = Math.abs(r - unit.tileR) + Math.abs(c - unit.tileC);
|
||||
if (d < bd) { bd = d; best = [[r, c]]; } else if (d === bd) best.push([r, c]);
|
||||
}
|
||||
unit.mem.mantraGate = best.length > 1 ? battle.rng.pick(best) : best[0] ?? null;
|
||||
if (unit.mem.mantraGate) battle.fx('mantraGate', { x: unit.mem.mantraGate[1], y: unit.mem.mantraGate[0], id: unit.id });
|
||||
}, { owner: unit });
|
||||
battle.on('enemySpawn', ({ enemy: e }) => {
|
||||
const g = unit.mem.mantraGate;
|
||||
if (!g || !up(unit) || Math.round(e.spawnY) !== g[0] || Math.round(e.spawnX) !== g[1]) return;
|
||||
battle.after(SPAWN_DELAY, () => givePalsy(battle, unit, e, 1));
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// PRI-X: ×damage_scale on enemies in an element burst
|
||||
burstDamageUp(battle, unit, num(tb.damage_scale));
|
||||
// 无言为真: the 麻痹 cap 2 on her range; the stacks above it become 溢出麻痹
|
||||
battle.on('beforeStatus', (c) => {
|
||||
if (c.status !== 'palsy' || !s3On(unit) || !c.target || c.target.side !== 'enemy') return;
|
||||
const st = unit.mem.mantraS3;
|
||||
const rec = st.map.get(c.target);
|
||||
if (!rec) return;
|
||||
const n = Math.max(1, Math.floor(Number.isFinite(c.value) ? c.value : 1));
|
||||
const room = Math.max(0, S3_CAP - palsyOf(c.target));
|
||||
if (n <= room) return;
|
||||
if (room > 0) c.value = room; else c.cancel = true;
|
||||
overflow(battle, unit, st, c.target, rec, n - room);
|
||||
}, { owner: unit });
|
||||
// 【麻痹付与】 when another operator on her range starts a skill
|
||||
battle.on('skillStart', (ctx) => {
|
||||
const u = ctx.unit;
|
||||
if (u === unit || !u || u.kind !== 'op' || !s3On(unit) || !unit.rangeKeySet?.has(u.tileR * COLS + u.tileC)) return;
|
||||
grant(battle, unit);
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,276 @@
|
||||
// server/sim/content/kits/ops/op-narant.js — 娜仁图亚 (char_4138_narant) 自选 operator kit: 6★ 回环射手 (狙击), an owned-6★ pick
|
||||
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_4138_narant, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 娜仁图亚 (备注 of 旋刃
|
||||
// and 恶魇); PRTS 分支特性信息 回环射手; gamedata_const ba.steal 偷取; PRTS 命中率.
|
||||
// - Trait (回环射手) "持有回旋投射物时才能够攻击(投射物需要时间回收)": the profession's boomerang (professions.js loopshooter,
|
||||
// ai.js throwBoomerang: out 15, back 3.75 tiles/s; she attacks again once every boomerang is caught); hits air units.
|
||||
// - Module LPS-X “吹尽狂沙”: trait "攻击周围8格的敌人时攻击力提升至110%" (trait bb atk_scale; the hidden module talent repeats it):
|
||||
// ×atk_scale on her attack hits on an enemy within one tile (3 × 3, as 跃跃's LPS-X). Stage 2+ “我见,我得” (below).
|
||||
// - Module LPS-Y “致我们的老大”: trait "每回收5次回旋投射物时获得1点技力" (come_back_cnt / sp): every come_back_cnt boomerangs caught
|
||||
// (hook boomerangCaught; an S3 flight carries cnt) give sp SP — none while a skill of hers runs (the AK rule: giveSp)
|
||||
// [ASSUMED: the catches count all the same]. Stage 2+ 婀娜虚影 (below).
|
||||
// - T1 “我见,我得” "在场时,每次攻击到敌人偷取其25点攻击力(最高250点)与20点防御力(最高200点)": every attack hit of hers that lands
|
||||
// (normal attacks, S1 bounces, S2 hits and its way back, each S3 boomerang — not S3's recall blast [ASSUMED]) steals:
|
||||
// the enemy loses steal_atk ATK / steal_def DEF of its base (flat, at most steal_atk_max / steal_def_max in all) and she
|
||||
// gains as much (flat, the same caps) — ba.steal "减少目标的基础属性作为自身加成,目标减少和自身加成的属性不超过指定上限(同类属性
|
||||
// 取最高)": one loss per enemy, the largest of the 娜仁图亚 that stole from it. Both last while she is on the field (在场时)
|
||||
// [ASSUMED: they end when she leaves]; each steal adds to both sides, each capped on its own [ASSUMED]. LPS-X stage 2+ (caps
|
||||
// 275 / 220 → 300 / 240) "攻击到周围8格的敌人时偷取触发2次": the count is parsed from the talent text (no blackboard key).
|
||||
// - T2 婀娜虚影 "获得35%的物理与法术闪避,周围8格内敌人的物理与法术命中率-20%": dodgePhys / dodgeArts +prob; every physical / arts
|
||||
// attack of an enemy within one tile of her misses with damage_hitrate_* (PRTS 命中率: one roll when the attack's first
|
||||
// damage instance comes — as Raidian's 诱引, standin-acsupo.js —, a miss cancels every instance and its riders; two
|
||||
// 娜仁图亚 never add up). LPS-Y stage 3 −30 % and "6秒内未受到伤害时,攻击力+15%" (hidden atk / interval): ATK +atk while she
|
||||
// has taken no damage (流失 aside) for `interval` s — from her deployment on, as 远牙's 专注 [ASSUMED].
|
||||
// - S1 旋刃 (切换: a toggle that stays on until she leaves — the kits' reading, 银灰 S2 [ASSUMED]): her range loses its far
|
||||
// column (攻击距离-1: ability_range_forward_extend −1; the engine's rangeExtend only grows), her boomerang hits for
|
||||
// attack@atk_scale × ATK and then bounces up to attack@times times (PRTS 备注: radius BOUNCE_RADIUS from the boomerang,
|
||||
// an enemy this attack has not hit first, then the nearest; BOUNCE_SPEED tiles/s), each bounce a hit of the same attack;
|
||||
// then it flies back. The data's DEFAULT trigger (the running range is smaller).
|
||||
// - S2 恶魇 (30 s, attack SP): each hit attack@atk_scale × ATK + 停顿 attack@sluggish s; the boomerang then flies on
|
||||
// move_ahead_time s at DASH_SPEED tiles/s (PRTS 备注 "抵达目标所在位置后以1.0的速度再向前运动0.5s,随后返回") and on its way
|
||||
// back hits every enemy within attack@projectile_range (备注 "投射物碰撞半径1.0") once for attack@atk_scale_comeback × ATK.
|
||||
// - S3 吞日 (20 s): each attack throws cnt boomerangs at the target (each attack@atk_scale × ATK; they fly together, as
|
||||
// 跃跃's S2 [ASSUMED: the text names no other targets]); when they are caught she deals atk_scale_aoe × ATK physical to at
|
||||
// most attack@aoe.max_target enemies within one tile (her order) and 停顿 `sluggish` s — a skill hit, not an attack.
|
||||
|
||||
import { num, talentBb, moduleBb, traitBb, skillRec, statBuff, toggleBuff, onHitBy, enemiesInGrid, cheb, up, giveSp } from '../shared/tier1.js';
|
||||
import { canTargetEnemy } from '../../../targeting.js';
|
||||
import { isHpLoss } from '../../../damage.js';
|
||||
|
||||
const S1 = 'skchr_narant_1';
|
||||
const S2 = 'skchr_narant_2';
|
||||
const S3 = 'skchr_narant_3';
|
||||
/** PRTS 旋刃 备注: "弹射半径1.7…弹射期间投射物移动速度6.0". */
|
||||
const BOUNCE_RADIUS = 1.7;
|
||||
const BOUNCE_SPEED = 6;
|
||||
/** PRTS 恶魇 备注: "以1.0的速度再向前运动0.5s" (the time is the blackboard's move_ahead_time). */
|
||||
const DASH_SPEED = 1;
|
||||
/** "周围8格": her tile and the eight around it. */
|
||||
const AROUND = Object.freeze([[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 0], [0, 1], [1, -1], [1, 0], [1, 1]].map(Object.freeze));
|
||||
/** 婀娜虚影's battle-wide `hit` handler runs before the enemy content's own riders (200), as Raidian's 诱引. */
|
||||
const MISS_PRIORITY = 300;
|
||||
const LOOT_KEY = 'talent:narant:loot';
|
||||
const STOLEN_KEY = 'narant:stolen';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
|
||||
/** The named talent record `i` (data index ≥ 0) of the composed record. */
|
||||
const talentRec = (chess, i) => (chess?.talents ?? []).filter((t) => t && t.index !== -1)[i] ?? null;
|
||||
|
||||
/** A range grid whose rows lose their `n` farthest tiles ("攻击距离-n"). */
|
||||
function shortenGrid(grid, n) {
|
||||
const far = new Map();
|
||||
for (const [dr, dc] of grid) far.set(dr, Math.max(far.get(dr) ?? -Infinity, dc));
|
||||
return grid.filter(([dr, dc]) => dc <= far.get(dr) - n).map(([dr, dc]) => [dr, dc]);
|
||||
}
|
||||
|
||||
// ---- T1 偷取: the losses of the enemies, one battle-wide table (同类属性取最高 over several thieves) ----------------------
|
||||
/** battle → Map(enemy → Map(thief id → { a, d })). */
|
||||
const STOLEN = new WeakMap();
|
||||
const stolenOf = (battle) => { let m = STOLEN.get(battle); if (!m) STOLEN.set(battle, (m = new Map())); return m; };
|
||||
function applyLoss(battle, e, per) {
|
||||
let a = 0, d = 0;
|
||||
for (const v of per.values()) { a = Math.max(a, v.a); d = Math.max(d, v.d); }
|
||||
if (!(a > 0) && !(d > 0)) { battle.removeBuff(e, STOLEN_KEY); return; }
|
||||
battle.addBuff(e, { key: STOLEN_KEY, mods: { atkFlat: -a, defFlat: -d }, tags: ['steal'] });
|
||||
}
|
||||
|
||||
// ---- T2 命中率: one battle-wide handler over every 娜仁图亚 of the battle -------------------------------------------------
|
||||
/** battle → { srcs: [{ unit, phys, arts }], last: WeakMap(enemy → { id, miss }) }. */
|
||||
const MISS = new WeakMap();
|
||||
function missCut(st, e, type) {
|
||||
let cut = 0;
|
||||
for (const s of st.srcs) {
|
||||
const v = type === 'arts' ? s.arts : s.phys;
|
||||
if (v > cut && up(s.unit) && cheb(s.unit, e) <= 1) cut = v;
|
||||
}
|
||||
return Math.min(1, cut);
|
||||
}
|
||||
function missHit(battle, st, ctx) {
|
||||
const e = ctx.source, d = ctx.dmg;
|
||||
if (!e || e.side !== 'enemy' || !d || !d.isAttack || d.cancel || (d.type !== 'phys' && d.type !== 'arts')) return;
|
||||
// one roll per attack: every damage instance of it shares the first instance's result
|
||||
const prev = d.attackId ? st.last.get(e) : null;
|
||||
let miss;
|
||||
if (prev && prev.id === d.attackId) miss = prev.miss;
|
||||
else {
|
||||
const cut = missCut(st, e, d.type);
|
||||
miss = cut > 0 && battle.rng() < cut;
|
||||
if (d.attackId) st.last.set(e, { id: d.attackId, miss });
|
||||
}
|
||||
if (!miss) return;
|
||||
d.cancel = true;
|
||||
ctx.stopPropagation = true;
|
||||
if (ctx.target) battle.fx('dodge', { x: ctx.target.x, y: ctx.target.y, id: ctx.target.id });
|
||||
}
|
||||
function installMiss(battle, unit, phys, arts) {
|
||||
if (!(phys > 0) && !(arts > 0)) return;
|
||||
let st = MISS.get(battle);
|
||||
if (!st) {
|
||||
st = { srcs: [], last: new WeakMap() };
|
||||
MISS.set(battle, st);
|
||||
// no owner: it serves every 娜仁图亚 of the battle, whichever is on the field
|
||||
battle.on('hit', (ctx) => missHit(battle, st, ctx), { priority: MISS_PRIORITY });
|
||||
}
|
||||
st.srcs.push({ unit, phys, arts });
|
||||
}
|
||||
|
||||
export default {
|
||||
char_4138_narant: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const tb = traitBb(chess);
|
||||
const hidden = moduleBb(chess);
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const cnt = count(b3.cnt, 3);
|
||||
|
||||
/** S1: the boomerang bounces from (ctx.x, ctx.y) — at most attack@times jumps — then flies back. */
|
||||
const bounce = (ctx) => {
|
||||
const { battle, unit } = ctx;
|
||||
const hit = new Set(ctx.target ? [ctx.target] : []);
|
||||
let on = ctx.target, x = ctx.x, y = ctx.y, left = count(b1['attack@times'], 3);
|
||||
const next = () => {
|
||||
if (left <= 0 || !ctx.home()) { ctx.comeBack(x, y); return; }
|
||||
const cands = battle.foesInRadius(x, y, BOUNCE_RADIUS).filter((e) => e !== on && canTargetEnemy(unit, e, ctx.profile));
|
||||
if (!cands.length) { ctx.comeBack(x, y); return; }
|
||||
const d2 = (e) => (e.x - x) ** 2 + (e.y - y) ** 2;
|
||||
cands.sort((a, b) => (hit.has(a) ? 1 : 0) - (hit.has(b) ? 1 : 0) || d2(a) - d2(b) || a.spawnSeq - b.spawnSeq);
|
||||
const tgt = cands[0];
|
||||
left--;
|
||||
battle.addProjectile({ from: { x, y }, target: tgt, speed: BOUNCE_SPEED, visual: 'boomerang', source: unit, hitDead: true,
|
||||
onHit: (c) => {
|
||||
x = c.x; y = c.y;
|
||||
on = c.target;
|
||||
if (c.target) { hit.add(c.target); ctx.hit(c.target, c.x, c.y); }
|
||||
next();
|
||||
} });
|
||||
battle.fx('bounce', { x, y, id: unit.id, target: tgt.id });
|
||||
};
|
||||
next();
|
||||
};
|
||||
|
||||
/** S2: the boomerang dashes on, then hits every enemy it passes on its way back (once each). */
|
||||
const dash = (ctx) => {
|
||||
const { battle, unit } = ctx;
|
||||
let dx = ctx.x - unit.x, dy = ctx.y - unit.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len > 1e-9) { dx /= len; dy /= len; } else { const [fr, fc] = unit.fwd; dx = fc; dy = fr; }
|
||||
const ahead = DASH_SPEED * Math.max(0, num(b2['attack@move_ahead_time'], 0.5));
|
||||
battle.addProjectile({ from: { x: ctx.x, y: ctx.y }, to: { x: ctx.x + dx * ahead, y: ctx.y + dy * ahead }, speed: DASH_SPEED, visual: 'boomerang', source: unit, hitDead: true,
|
||||
onHit: (c) => {
|
||||
const back = ctx.comeBack(c.x, c.y);
|
||||
if (!back) return;
|
||||
const r = num(b2['attack@projectile_range'], 1), scale = num(b2['attack@atk_scale_comeback'], 1);
|
||||
const passed = new Set();
|
||||
const h = battle.on('tick', () => {
|
||||
for (const e of battle.foesInRadius(back.x, back.y, r)) {
|
||||
if (passed.has(e) || !canTargetEnemy(unit, e, ctx.profile)) continue;
|
||||
passed.add(e);
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isAttack: true, isSkill: true, attackId: ctx.attackId, tags: ['skill', 'narant:comeback'] });
|
||||
}
|
||||
if ((back.x === back.tx && back.y === back.ty) || !ctx.home()) battle.off(h);
|
||||
}, { owner: unit });
|
||||
} });
|
||||
};
|
||||
|
||||
/** S3: the recall blast on at most attack@aoe.max_target enemies around her. */
|
||||
const blast = (battle, unit) => {
|
||||
if (!up(unit)) return;
|
||||
const n = count(b3['attack@aoe.max_target'], 3), scale = num(b3.atk_scale_aoe, 1), slug = num(b3.sluggish);
|
||||
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1, id: unit.id, skill: 'narant:sunswallower' });
|
||||
for (const e of enemiesInGrid(battle, unit, AROUND, { n, canHitFly: true })) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'narant:blast'] });
|
||||
if (e.alive && slug > 0) battle.applyStatus(e, 'sluggish', { duration: slug, source: unit });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'toggle',
|
||||
targeting: { rangeGrid: shortenGrid(chess.rangeGrid ?? [[0, 1]], Math.max(0, -Math.round(num(b1.ability_range_forward_extend, -1)))) },
|
||||
attack: { atkScale: num(b1['attack@atk_scale'], 1), boomerangOnward: bounce },
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
attack: { atkScale: num(b2['attack@atk_scale'], 1), onHitStatus: { key: 'sluggish', duration: num(b2['attack@sluggish'], 1) }, boomerangOnward: dash },
|
||||
},
|
||||
[S3]: { kind: 'duration', attack: { atkScale: num(b3['attack@atk_scale'], 1), hits: cnt } },
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // “我见,我得”: each landing attack hit steals ATK / DEF (LPS-X stage 2+: twice within one tile)
|
||||
const sa = num(t0['attack@steal_atk']), sd = num(t0['attack@steal_def']);
|
||||
if (!(sa > 0) && !(sd > 0)) return;
|
||||
const capA = num(t0['attack@steal_atk_max'], Infinity), capD = num(t0['attack@steal_def_max'], Infinity);
|
||||
const m = /周围8格的敌人时偷取触发(\d+)次/.exec(String(talentRec(chess, 0)?.desc ?? ''));
|
||||
const nearTimes = m ? Math.max(1, +m[1]) : 1;
|
||||
const losses = stolenOf(battle);
|
||||
const victims = new Set();
|
||||
const reset = () => { unit.mem.narantLoot = { a: 0, d: 0 }; };
|
||||
reset();
|
||||
battle.on('deploy', (c) => { if (c.unit === unit) reset(); }, { owner: unit });
|
||||
battle.on('damaged', (c) => {
|
||||
const e = c.target;
|
||||
if (c.source !== unit || !c.dmg?.isAttack || !e || e.side !== 'enemy' || !up(unit)) return;
|
||||
const times = nearTimes > 1 && cheb(unit, e) <= 1 ? nearTimes : 1;
|
||||
const me = unit.mem.narantLoot;
|
||||
let per = losses.get(e);
|
||||
if (!per) losses.set(e, (per = new Map()));
|
||||
const loss = per.get(unit.id) ?? { a: 0, d: 0 };
|
||||
for (let i = 0; i < times; i++) {
|
||||
me.a = Math.min(capA, me.a + sa); me.d = Math.min(capD, me.d + sd);
|
||||
loss.a = Math.min(capA, loss.a + sa); loss.d = Math.min(capD, loss.d + sd);
|
||||
}
|
||||
per.set(unit.id, loss);
|
||||
victims.add(e);
|
||||
battle.addBuff(unit, { key: LOOT_KEY, mods: { atkFlat: me.a, defFlat: me.d }, tags: ['talent'] });
|
||||
if (e.alive) applyLoss(battle, e, per);
|
||||
}, { owner: unit });
|
||||
// 在场时: off the field she keeps nothing (her buff goes with the removal) and the enemies get back what she took
|
||||
battle.on('death', (c) => {
|
||||
if (c.unit !== unit) return;
|
||||
for (const e of victims) {
|
||||
const per = losses.get(e);
|
||||
if (!per || !per.delete(unit.id)) continue;
|
||||
if (e.alive) applyLoss(battle, e, per);
|
||||
}
|
||||
victims.clear();
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 婀娜虚影: dodge; enemies within one tile miss; LPS-Y stage 3: ATK + while unhurt
|
||||
const p = num(t1.prob);
|
||||
statBuff(battle, unit, 'talent:narant:dodge', { dodgePhys: p, dodgeArts: p });
|
||||
installMiss(battle, unit, Math.abs(num(t1.damage_hitrate_physical)), Math.abs(num(t1.damage_hitrate_magical)));
|
||||
const atk = num(hidden.atk), iv = num(hidden.interval);
|
||||
if (!(atk > 0) || !(iv > 0)) return;
|
||||
unit.mem.narantHurt = -Infinity;
|
||||
battle.on('damaged', (c) => { if (c.target === unit && c.amount > 0 && !isHpLoss(c.dmg)) unit.mem.narantHurt = battle.time; }, { owner: unit });
|
||||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.narantHurt = -Infinity; }, { owner: unit });
|
||||
toggleBuff(battle, unit, 'talent:narant:calm', () => battle.time - unit.mem.narantHurt >= iv - 1e-9, { atkPct: atk });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// LPS-X: ×atk_scale on attack hits within one tile
|
||||
const near = num(tb.atk_scale, 1);
|
||||
if (near !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && cheb(unit, target) <= 1) dmg.amount *= near; });
|
||||
// LPS-Y: sp SP every come_back_cnt boomerangs caught; S3: the recall blast
|
||||
const every = Math.floor(num(tb.come_back_cnt)), sp = num(tb.sp);
|
||||
const s3 = unit.skill?.id === S3;
|
||||
if (!(every > 0 && sp > 0) && !s3) return;
|
||||
unit.mem.narantCaught = 0;
|
||||
battle.on('boomerangCaught', (c) => {
|
||||
if (c.unit !== unit) return;
|
||||
const flightS3 = s3 && c.isSkill;
|
||||
if (every > 0 && sp > 0) {
|
||||
unit.mem.narantCaught += flightS3 ? cnt : 1;
|
||||
while (unit.mem.narantCaught >= every) { unit.mem.narantCaught -= every; giveSp(unit, sp, 'module'); }
|
||||
}
|
||||
if (flightS3) blast(battle, unit);
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
// server/sim/content/kits/ops/op-poca.js — 早露 (char_197_poca) 自选 operator kit: 6★ 攻城手 (狙击), an owned-6★ pick of the
|
||||
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_197_poca, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 早露 (备注 of
|
||||
// 深入骨髓, 学生楷模 and 雪崩击); PRTS 分支特性信息 攻城手 ("可对空。攻击范围不包含自身所在地块").
|
||||
// - Trait (攻城手) "优先攻击重量最重的敌人": target priority 'heaviest' (targeting.js: the highest current 重量等级 first, then
|
||||
// the usual order); ranged physical arrows that hit air units (data canHitFly, PRTS "可对空"), range 4-3.
|
||||
// - Module SIE-X 攻城器械训练装置: trait "攻击重量较重(重量等级大于等于3)的敌人时,攻击力提升至115%" — the hidden module talent
|
||||
// (atk_scale / value, merged into 深入骨髓's blackboard): ×atk_scale on her attacks (normal attacks and the S3 strikes, the
|
||||
// profile dmgMul — the kits' "攻击…时攻击力提升至X%" convention, 斯卡蒂 DRE-X) on an enemy of 重量等级 ≥ value. Stage 2+ 深入骨髓
|
||||
// "…并额外造成60%攻击力的物理伤害" (extra_atk_scale): every attack hit that lands on such an enemy is followed by a physical hit
|
||||
// of extra_atk_scale × ATK (which 深入骨髓's 物理穿透 also gets).
|
||||
// - Module SIE-Y “心”: trait "攻击越远的敌人造成的伤害越高(最高提升12%)" (trait bb min_dist / max_dist / damage_scale): her attack
|
||||
// damage × (1 + damage_scale × clamp((d − min_dist) / (max_dist − min_dist), 0, 1)), d = her distance to the target in tiles —
|
||||
// the kits' reading of these keys (远牙, 协律, 芳汀 modules) [ASSUMED linear: PRTS states no formula]. Stage 2+: 学生楷模 below.
|
||||
// - T1 深入骨髓 "攻击重量较重(重量等级大于等于3)的敌人时,无视其防御力的60%": every damage she deals to an enemy of 重量等级 ≥ value
|
||||
// ignores def_penetrate of its DEF (DamageInfo defIgnorePct, added — PRTS 备注 "临时获取永久的物理穿透(百分比)Buff(不可叠加,
|
||||
// 直接加算)").
|
||||
// - T2 学生楷模 "编入队伍时,所有【乌萨斯学生自治团】干员攻击力+8%": every 【乌萨斯学生自治团】 operator of her player's team ATK +atk
|
||||
// for the whole battle, deployed or not (the kits' 编入队伍时 convention, 斯卡蒂 深海掠食者). SIE-Y stage 3: +12 % and
|
||||
// "每有一名【乌萨斯学生自治团】干员处于技能期间时,【乌萨斯学生自治团】干员的攻击力额外+15%(最多+45%)" (hidden init_atk /
|
||||
// max_atk): the number of those operators on the field whose skill runs, re-read every STUDENT_TICK s and at every skill start
|
||||
// (PRTS 备注 "每0.3秒及每次开启技能时更新一次自身的额外加成") [ASSUMED: her team's operators only, as the base talent].
|
||||
// - S1 攻击力强化·γ型 (30 s): ATK +atk. S2 分裂射击 (60 s): ATK +atk, attack@max_target targets.
|
||||
// - S3 雪崩击 (6 / 7 s): ATK +atk; at the cast, harpoons link the max_target heaviest enemies of her range (her own target
|
||||
// order); each linked enemy is 束缚 (bind) while linked and takes one attack every hit_interval s, hit_duration /
|
||||
// hit_interval attacks in all, the first as the harpoons land (the tick after the cast) [ASSUMED] — her own attacks (Battle.forceAttack:
|
||||
// every attack rider of hers applies), instant along the links; she makes no other attack meanwhile [ASSUMED: the
|
||||
// strikes are what "每秒受到一次攻击" names]. PRTS 备注: a link breaks when its enemy is knocked out, 离地 (OUT_OF_GROUND —
|
||||
// no enemy of the mode holds it) or 消失 (hidden), every link at once when she is stunned / frozen / silenced, and the
|
||||
// skill ends as soon as no link is left. The binds end with their links.
|
||||
|
||||
import { num, talentBb, moduleBb, moduleOn, traitBb, skillRec, statBuff, onHitBy, enemiesInGrid, up } from '../shared/tier1.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
|
||||
const S1 = 'skcom_atk_up[3]';
|
||||
const S2 = 'skchr_poca_2';
|
||||
const S3 = 'skchr_poca_3';
|
||||
/** 【乌萨斯学生自治团】: character_table teamId `student` (古米, 凛冬, 烈夏, 苦艾, 真理, 早露, 怒潮凛冬); data/chess.json has no team id. */
|
||||
const STUDENTS = new Set(['char_196_sunbr', 'char_115_headbr', 'char_194_leto', 'char_405_absin', 'char_195_glassb',
|
||||
'char_197_poca', 'char_1051_headb2']);
|
||||
/** PRTS 学生楷模 备注: the SIE-Y extra is re-read every 0.3 s (and at every skill start). */
|
||||
const STUDENT_TICK = 0.3;
|
||||
const STUDENT_KEY = 'talent:poca:students';
|
||||
const DRIVE_KEY = 'talent:poca:studentSkills';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
|
||||
const isStudent = (a) => !!a && a.kind === 'op' && STUDENTS.has(a.def?.charId);
|
||||
/** A running skill of `a` (a passive is no 技能期间). */
|
||||
const inSkill = (a) => !!(a.skill && a.skill.active && a.skill.kind !== 'passive');
|
||||
|
||||
export default {
|
||||
char_197_poca: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const heavyAt = num(t0.value, 3);
|
||||
const heavy = (e) => !!e && e.side === 'enemy' && e.weight >= heavyAt;
|
||||
// SIE-X (hidden part on 深入骨髓's index: atk_scale / value; stage 2+ extra_atk_scale), SIE-Y (trait bb; hidden
|
||||
// init_atk / max_atk at stage 3) — chess is the loadout-resolved record, so no module ⇒ none of these keys
|
||||
const xScale = moduleOn(chess) ? num(t0.atk_scale, 1) : 1;
|
||||
const extra = num(t0.extra_atk_scale);
|
||||
const tb = traitBb(chess);
|
||||
const hidden = moduleBb(chess);
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
|
||||
/** S3: the links of the running cast (unit.mem.pocaS3 = { links: Set, left, timer }). */
|
||||
const unbind = (battle, unit, e) => {
|
||||
const b = e.findBuff('bind');
|
||||
if (b && b.source === unit) battle.removeBuff(e, b);
|
||||
};
|
||||
const linkBroken = (e) => !e.alive || !e.deployed || e.hidden || !!e.s.flags.outOfGround;
|
||||
const strike = (battle, unit, st) => {
|
||||
const targets = [...st.links].filter((e) => !linkBroken(e));
|
||||
if (!targets.length || !unit.canAct || st.left <= 0) return;
|
||||
st.left--;
|
||||
battle.forceAttack(unit, targets);
|
||||
};
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk) } },
|
||||
[S2]: { kind: 'duration', mods: { atkPct: num(b2.atk) }, targeting: { maxTargets: count(b2['attack@max_target'], 2) } },
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b3.atk) },
|
||||
// no attack of her own while the links hold; the strikes are instant along them ('beam')
|
||||
attack: { noAttack: true, projectile: 'beam' },
|
||||
onStart({ battle, unit, skill }) {
|
||||
const links = enemiesInGrid(battle, unit, null, { n: count(b3.max_target, 3), priority: 'heaviest', canHitFly: true });
|
||||
const st = { links: new Set(links), left: count(num(b3.hit_duration, skill.duration) / Math.max(0.1, num(b3.hit_interval, 1)), 6), timer: null };
|
||||
unit.mem.pocaS3 = st;
|
||||
for (const e of links) {
|
||||
battle.applyStatus(e, 'bind', { duration: skill.timeLeft, source: unit });
|
||||
battle.fx('harpoon', { x: e.x, y: e.y, id: unit.id, target: e.id });
|
||||
}
|
||||
// the harpoons land on the next tick (after the cast's skillStart handlers — 学生楷模's re-read), then every
|
||||
// hit_interval s
|
||||
st.timer = battle.after(0, () => {
|
||||
if (unit.mem.pocaS3 !== st) return;
|
||||
strike(battle, unit, st);
|
||||
st.timer = battle.every(Math.max(0.1, num(b3.hit_interval, 1)), (b, sc) => {
|
||||
if (unit.mem.pocaS3 !== st || st.left <= 0) { sc.cancel(); return; }
|
||||
strike(battle, unit, st);
|
||||
}, { owner: unit });
|
||||
}, { owner: unit });
|
||||
},
|
||||
onTick({ battle, unit, skill }) {
|
||||
const st = unit.mem.pocaS3;
|
||||
if (!st) return;
|
||||
const f = unit.s.flags;
|
||||
if (f.stun || f.freeze || f.silence) { skill.end('interrupted'); return; }
|
||||
for (const e of [...st.links]) if (linkBroken(e)) { st.links.delete(e); unbind(battle, unit, e); }
|
||||
if (!st.links.size) skill.end('unlinked');
|
||||
},
|
||||
onEnd({ battle, unit }) {
|
||||
const st = unit.mem.pocaS3;
|
||||
unit.mem.pocaS3 = null;
|
||||
if (!st) return;
|
||||
if (st.timer) st.timer.cancel();
|
||||
for (const e of st.links) unbind(battle, unit, e);
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 深入骨髓: +def_penetrate 物理穿透 on heavy enemies; SIE-X stage 2+: the extra physical hit
|
||||
const pen = num(t0.def_penetrate);
|
||||
if (pen > 0) onHitBy(battle, unit, ({ target, dmg }) => { if (heavy(target)) dmg.defIgnorePct += pen; });
|
||||
if (extra > 0) {
|
||||
battle.on('damaged', (c) => {
|
||||
if (c.source !== unit || !c.dmg?.isAttack || !heavy(c.target) || !hasHp(c.target)) return;
|
||||
battle.dealDamage(unit, c.target, { amount: unit.s.atk * extra, type: 'phys', tags: ['talent', 'poca:extra'] });
|
||||
}, { owner: unit });
|
||||
}
|
||||
} },
|
||||
{ install(battle, unit) { // 学生楷模: every student of her team ATK +atk; SIE-Y stage 3: +init_atk per student in skill
|
||||
const team = battle.allyUnits.filter((a) => a.ownerId === unit.ownerId && isStudent(a));
|
||||
for (const a of team) statBuff(battle, a, STUDENT_KEY, { atkPct: num(t1.atk) });
|
||||
const per = num(hidden.init_atk), cap = num(hidden.max_atk, Infinity);
|
||||
if (!(per > 0)) return;
|
||||
const update = () => {
|
||||
const v = Math.min(cap, per * team.filter((a) => up(a) && inSkill(a)).length);
|
||||
for (const a of team) {
|
||||
const cur = a.findBuff(DRIVE_KEY);
|
||||
if (!(v > 0)) { if (cur) battle.removeBuff(a, cur); continue; }
|
||||
if (!cur || cur.mods?.atkPct !== v) battle.addBuff(a, { key: DRIVE_KEY, mods: { atkPct: v }, persist: true, allowDead: true, tags: ['talent'], source: unit });
|
||||
}
|
||||
};
|
||||
battle.every(STUDENT_TICK, update, { owner: unit, immediate: true });
|
||||
battle.on('skillStart', (c) => { if (team.includes(c.unit)) update(); }, { owner: unit });
|
||||
} },
|
||||
],
|
||||
// 攻城手: heaviest first; SIE-X ×atk_scale on her attacks against heavy enemies
|
||||
trait: { priority: 'heaviest', ...(xScale !== 1 ? { dmgMul: (b, u, target) => (heavy(target) ? xScale : 1) } : null) },
|
||||
install(battle, unit) {
|
||||
// SIE-Y: the farther the target, the more damage (attacks only)
|
||||
const ds = num(tb.damage_scale), lo = num(tb.min_dist, 1), hi = num(tb.max_dist, 4.5);
|
||||
if (ds > 0) {
|
||||
onHitBy(battle, unit, ({ target, dmg }) => {
|
||||
if (!dmg.isAttack) return;
|
||||
const d = Math.hypot(target.x - unit.x, target.y - unit.y);
|
||||
dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
// server/sim/content/kits/ops/op-saga.js — 嵯峨 (char_362_saga) 自选 operator kit: 6★ 尖兵 (先锋), an owned-6★ pick of the
|
||||
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_362_saga, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); gamedata_const ba.dying
|
||||
// "重伤: 移速下降且无法被阻挡,10秒后自然死亡,被击杀后使击杀者回复数点技力"; PRTS 嵯峨 (劝善 备注 "本天赋第一部分实际效果为:
|
||||
// 自身造成的伤害不会致命(至多使目标生命值降至1点),成功造成伤害后若目标生命值小于等于1则为目标附加重伤"; 清明 备注 "嵯峨每次
|
||||
// 部署都可以触发一次本天赋"; S2 备注 "可选择重伤单位", "攻击附加延迟检测Buff:0.7s后进行检测,强制击杀持有重伤的目标(击杀来源为
|
||||
// 嵯峨自身),随后解除目标的重伤"; S3 备注 "造成伤害前判断目标生命值"); PRTS 术语释义 重伤 ("重伤Buff包含:移动速度-80%,禁疗、
|
||||
// 禁用近战,阻挡数、生命回复速度、生命回复速度(百分比)归零,从消失状态变为出现的瞬间立刻死亡(消失状态期间不会因重伤自然死亡)",
|
||||
// "重伤造成的自然死亡为强制死亡(也即可以杀死持有不死的单位),视为无来源击杀", "重伤期间的单位无法再被施加重伤"); PRTS 异常效果
|
||||
// 禁用近战 "敌人类单位无法进行阻挡攻击,无法被阻挡".
|
||||
// - Trait (尖兵) "能够阻挡两个敌人": the profession default (block 2). SOL-X 何处寻驮? "阻挡敌人时攻击力和防御力各+8%"
|
||||
// (trait bb atk / def): +atk / +def while she blocks an enemy. SOL-Y 身向云泥 "首次部署时部署费用-4" (hidden
|
||||
// runtime_cost −4): the first deployment of a battle costs nothing in this mode ⇒ no effect (as 推进之王's SOL-Y).
|
||||
// - T1 劝善: her damage is never lethal to an enemy (a `fatal` hook keeps it at 1 HP — every damage of hers: attacks, 除恶;
|
||||
// not a 无来源 one, which has no source); after a damage of hers an enemy at ≤ 1 HP becomes 重伤 (the DYING buff, once:
|
||||
// none while it holds one): move speed ×(1 + move_speed) (−80 %), unblockable (禁用近战: released by its blocker, no
|
||||
// block attack), 禁疗 (healFree / noHeal), its HP regeneration zeroed; it dies `interval` s later (10) — a forced, 无来源
|
||||
// death (battle.kill with no killer: no SP, 不死 does not hold it) — not while it is 消失 (hidden), and at once when it
|
||||
// comes back from 消失. Whoever kills a 重伤 enemy gets `sp` SP (2; "击杀者技力+2", giveSp: not during a timed skill).
|
||||
// "嵯峨不攻击重伤单位": the kit trait `skipEnemy` (targeting.js canTargetEnemy) — no normal attack, block target or
|
||||
// trigger target of hers is a 重伤 enemy. SOL-X stage 2+ "对生命值低于一半的单位造成的伤害提升15%" (hidden hp_ratio /
|
||||
// damage_scale): ×damage_scale on every damage of hers whose target is below hp_ratio before it.
|
||||
// A shared-pool leader (boss rounds: its HP is the pool's, damage.js runs no `fatal` step for it) is outside 劝善
|
||||
// [ASSUMED: her damage there is ordinary].
|
||||
// - T2 清明 "生命值低于40%时,仅一次获得70%物理闪避和每秒回复5%的最大生命,持续15秒": once per deployment (PRTS 备注), the first
|
||||
// time a damage leaves her below hp_ratio of her max HP: dodgePhys prob and hpRegenRatio for `duration` s — the
|
||||
// blackboard key hp_recovery_per_sec_by_max_hp_ratio is the 生命回复速度(百分比) attribute (PRTS 异常效果 禁疗 / 术语释义
|
||||
// 重伤 name it), as 宴 S1 / 山 / 幽灵鲨 / 蜜蜡 / 烛煌 apply it [ASSUMED: no 备注 of 清明 says so; 余's 闲云隐市 with the same
|
||||
// key is still open, DESIGN 0.2.0.l3]. SOL-Y stage 2+: the talent change's numbers — at stage 3 the text reads 6 % (its
|
||||
// potential-5 text 7 %) while the blackboard is 0.065: the game applies the blackboard, so 6.5 % per second.
|
||||
// - S1 冲锋号令·γ型 (AUTO, no target): +cost DP at once; an AUTO skill acting on nobody fires as soon as its SP is full
|
||||
// (`trigger: 'SP_FULL'`, as 推进之王's / 德克萨斯's kits cast the same skill — the owner's AUTO rule).
|
||||
// - S2 除恶 (MANUAL, 2 charges, data SKILL_RANGE on its x-6 cross: any enemy there, 无视其不可选中): +cost DP; the up to
|
||||
// S2_MAX (6, the text's — no blackboard key) selectable ground enemies of the cross ("地面敌人"; a 重伤 one included:
|
||||
// "可选择重伤单位"), best targets first (her priority) [ASSUMED order], take atk_scale × ATK physical (a skill damage;
|
||||
// 劝善 applies); S2_CHECK s later each of them still 重伤 is killed (killer 嵯峨: she gets its SP, stats and bounty; a
|
||||
// forced kill — battle.kill, no `fatal` step), then its 重伤 is lifted (should a kill handler have revived it).
|
||||
// - S3 怒目 (MANUAL, 20 s, data ACTIVE_RANGE on her 1-1 + 1): +cost DP every interval s while it runs (value in all; the
|
||||
// rest of value at its natural end), attack interval +base_attack_time s (+0.5 on her 1.05), 攻击距离 +1
|
||||
// (targeting.rangeExtend), ATK +atk, every enemy she blocks at once (hitAllBlocked; one target in range while she
|
||||
// blocks none), and one more attack on each target that was below attack@hp_ratio before the hit (PRTS 备注: an HP
|
||||
// snapshot at beforeAttack; the extra attack is a forced one a tick later, no 重伤 target).
|
||||
// Melee physical, ground-only (canHitFly false), ground enemies target her normally (no 起飞 / 迷彩 / 隐匿).
|
||||
|
||||
import { num, talentBb, moduleBb, traitBb, skillRec, toggleBuff, batMod, giveSp, up } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys, canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
|
||||
|
||||
const S1 = 'skcom_charge_cost[3]';
|
||||
const S2 = 'skchr_saga_2';
|
||||
const S3 = 'skchr_saga_3';
|
||||
/** The 重伤 buff (gamedata_const ba.dying): one per enemy — "重伤期间的单位无法再被施加重伤". */
|
||||
export const DYING = 'saga:dying';
|
||||
/** S2 除恶 备注 "攻击附加延迟检测Buff:0.7s后进行检测" (no blackboard key). */
|
||||
export const S2_CHECK = 0.7;
|
||||
/** S2 除恶 "对十字范围内最多6名地面敌人" — the text's number when it cannot be read (no blackboard key). */
|
||||
const S2_MAX = 6;
|
||||
/** The x-6 cross (skill range) when the data carries no grid. */
|
||||
const X6 = Object.freeze([[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]]);
|
||||
/** 除恶's selection: ground enemies only ("地面敌人"), 重伤 ones included (no skipEnemy). */
|
||||
const GROUND = Object.freeze({ canHitFly: false, groundOnly: true });
|
||||
|
||||
/** Is `e` 重伤 (whoever put it there)? */
|
||||
export const isDying = (e) => !!(e && typeof e.findBuff === 'function' && e.findBuff(DYING));
|
||||
|
||||
/**
|
||||
* 重伤 on enemy `e` from `saga` (PRTS 术语释义 重伤): move speed ×(1 + ms), unblockable (禁用近战), 禁疗, no HP regeneration;
|
||||
* a forced, 无来源 death `life` s later — never while 消失, at once when it comes back from 消失. The killer of a 重伤
|
||||
* enemy gets `sp` SP (the talent's kill hook reads `data.sp`). Nothing when it already holds one.
|
||||
*/
|
||||
function applyDying(battle, saga, e, { sp, life, ms }) {
|
||||
if (!e || !e.alive || isDying(e)) return;
|
||||
battle.addBuff(e, {
|
||||
key: DYING, source: saga, visible: true,
|
||||
mods: { moveMul: Math.max(0, 1 + ms), hpRegen: -1e9 },
|
||||
flags: { unblockable: true, healFree: true, noHeal: true },
|
||||
data: { sp, until: battle.time + life, hidden: !!e.hidden },
|
||||
onTick: ({ battle: b, unit: x, buff }) => {
|
||||
const d = buff.data;
|
||||
if ((d.hidden && !x.hidden) || (!x.hidden && b.time + 1e-9 >= d.until)) { b.kill(x, null); return; }
|
||||
d.hidden = !!x.hidden;
|
||||
},
|
||||
});
|
||||
battle.fx('dying', { x: e.x, y: e.y, id: e.id, from: saga.id });
|
||||
}
|
||||
|
||||
export default {
|
||||
char_362_saga: (bb, chess) => {
|
||||
const b1 = skillRec(chess, S1)?.bb ?? {}, b2 = skillRec(chess, S2)?.bb ?? {}, b3 = skillRec(chess, S3)?.bb ?? {};
|
||||
const s2 = skillRec(chess, S2);
|
||||
const t0 = talentBb(chess, 0); // 劝善: move_speed, sp, interval
|
||||
const t1 = talentBb(chess, 1); // 清明: hp_ratio, hp_recovery_per_sec_by_max_hp_ratio, prob, duration
|
||||
const hidden = moduleBb(chess); // SOL-X stage 2+: hp_ratio, damage_scale; SOL-Y: runtime_cost (no effect)
|
||||
const tb = traitBb(chess); // SOL-X: atk, def while blocking
|
||||
const picked = chess?.skill?.skillId ?? null;
|
||||
const maxHit = Math.max(1, Math.floor(num(/最多(\d+)名/.exec(String(s2?.desc ?? ''))?.[1], S2_MAX)));
|
||||
return {
|
||||
// 劝善 "嵯峨不攻击重伤单位"
|
||||
trait: { skipEnemy: isDying },
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'instant', trigger: 'SP_FULL',
|
||||
onStart({ battle, unit }) {
|
||||
const n = num(b1.cost);
|
||||
battle.addDp(unit.ownerId, n);
|
||||
battle.fx('dp', { x: unit.x, y: unit.y, n, id: unit.id });
|
||||
},
|
||||
},
|
||||
[S2]: {
|
||||
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
onStart({ battle, unit }) {
|
||||
const n = num(b2.cost);
|
||||
battle.addDp(unit.ownerId, n);
|
||||
battle.fx('dp', { x: unit.x, y: unit.y, n, id: unit.id });
|
||||
const keys = absoluteRangeKeys(s2?.rangeGrid ?? X6, unit.tileR, unit.tileC, unit.dir, 0);
|
||||
const list = battle.enemiesInKeys(keys, unit, GROUND);
|
||||
sortEnemyTargets(battle, unit, list, unit.profile?.priority ?? null);
|
||||
const hit = list.slice(0, maxHit);
|
||||
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 2, id: unit.id, skill: 'saga:cleanse', n: hit.length });
|
||||
for (const e of hit) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale, 1), type: 'phys', isSkill: true, tags: ['saga:s2'] });
|
||||
if (!e.alive) continue;
|
||||
battle.addBuff(e, {
|
||||
key: 'saga:s2check', refresh: 'independent', maxStacks: 99, duration: S2_CHECK, source: unit,
|
||||
onExpire: ({ battle: b, unit: x }) => {
|
||||
if (!x.alive || !isDying(x)) return;
|
||||
b.kill(x, unit);
|
||||
if (x.alive) b.removeBuff(x, DYING);
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
|
||||
targeting: { rangeExtend: num(b3.ability_range_forward_extend) },
|
||||
attack: { hitAllBlocked: true },
|
||||
onStart({ unit }) { unit.mem.sagaDp = { acc: 0, got: 0 }; },
|
||||
onTick({ battle, unit, dt }) {
|
||||
const st = unit.mem.sagaDp;
|
||||
const iv = num(b3.interval, 1), n = num(b3.cost), total = num(b3.value);
|
||||
if (!st || !(iv > 0) || !(n > 0)) return;
|
||||
st.acc += dt;
|
||||
while (st.acc + 1e-9 >= iv && st.got + n <= total + 1e-9) {
|
||||
st.acc -= iv;
|
||||
st.got += n;
|
||||
battle.addDp(unit.ownerId, n);
|
||||
}
|
||||
},
|
||||
onEnd({ battle, unit, reason }) {
|
||||
const st = unit.mem.sagaDp;
|
||||
unit.mem.sagaDp = null;
|
||||
// the last grant falls on the very end of its 20 s
|
||||
if (st && reason === 'duration' && num(b3.value) - st.got > 1e-9) battle.addDp(unit.ownerId, num(b3.value) - st.got);
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 劝善
|
||||
const sp = num(t0.sp), life = num(t0.interval, 10), ms = num(t0.move_speed, -0.8);
|
||||
// "自身造成的伤害不会致命(至多使目标生命值降至1点)" — before any other saver: it never was lethal
|
||||
battle.on('fatal', (c) => {
|
||||
if (c.source === unit && c.unit && c.unit.side === 'enemy' && !c.prevented) c.prevented = true;
|
||||
}, { owner: unit, priority: 100 });
|
||||
// "成功造成伤害后若目标生命值小于等于1则为目标附加重伤"
|
||||
battle.on('damaged', (c) => {
|
||||
const e = c.target;
|
||||
if (c.source !== unit || !e || e.side !== 'enemy' || !e.alive || e.bossPool || c.type === 'element' || e.hp > 1) return;
|
||||
applyDying(battle, unit, e, { sp, life, ms });
|
||||
}, { owner: unit });
|
||||
// "被击杀后使击杀者回复数点技力" — the killer of a 重伤 enemy she marked (none for the 无来源 natural death)
|
||||
battle.on('kill', (c) => {
|
||||
const d = c.victim && typeof c.victim.findBuff === 'function' ? c.victim.findBuff(DYING) : null;
|
||||
if (!d || d.source !== unit || !c.killer || c.killer.side !== 'ally') return;
|
||||
giveSp(c.killer, num(d.data?.sp));
|
||||
}, { owner: unit });
|
||||
// SOL-X stage 2+: "对生命值低于一半的单位造成的伤害提升15%"
|
||||
const hr = num(hidden.hp_ratio), ds = num(hidden.damage_scale, 1);
|
||||
if (hr > 0 && ds !== 1) {
|
||||
battle.on('hit', (c) => {
|
||||
if (c.source === unit && c.target && c.target.side === 'enemy' && c.target.hpRatio < hr) c.dmg.mul *= ds;
|
||||
}, { owner: unit });
|
||||
}
|
||||
} },
|
||||
{ install(battle, unit) { // 清明: once per deployment below hp_ratio — dodgePhys prob, hpRegenRatio, `duration` s
|
||||
const hr = num(t1.hp_ratio), p = num(t1.prob), regen = num(t1.hp_recovery_per_sec_by_max_hp_ratio), dur = num(t1.duration);
|
||||
if (!(hr > 0) || !(dur > 0)) return;
|
||||
battle.on('damaged', (c) => {
|
||||
if (c.target !== unit || !up(unit) || unit.mem.qingmingAt === unit.deploySeq) return;
|
||||
if (!(unit.hp < unit.s.maxHp * hr)) return;
|
||||
unit.mem.qingmingAt = unit.deploySeq;
|
||||
battle.addBuff(unit, { key: 'talent:saga:qingming', duration: dur, mods: { dodgePhys: p, hpRegenRatio: regen }, visible: true, tags: ['talent'] });
|
||||
battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'saga:qingming' });
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// SOL-X 何处寻驮?: 阻挡敌人时攻击力和防御力各+8%
|
||||
const ma = num(tb.atk), md = num(tb.def);
|
||||
if (ma || md) toggleBuff(battle, unit, 'trait:saga:block', () => unit.blocking.length > 0, { atkPct: ma, defPct: md });
|
||||
// 怒目: one more attack on each target below attack@hp_ratio (checked before the hit) — a forced attack a tick later
|
||||
if (picked !== S3) return;
|
||||
const hp3 = num(b3['attack@hp_ratio']);
|
||||
if (!(hp3 > 0)) return;
|
||||
let half = null;
|
||||
battle.on('beforeAttack', (c) => {
|
||||
if (c.attacker !== unit || unit.mem.sagaExtra || !unit.skill?.active) return;
|
||||
half = (c.targets || []).filter((e) => e && e.side === 'enemy' && e.hpRatio < hp3);
|
||||
}, { owner: unit, priority: -100 });
|
||||
battle.on('attack', (c) => {
|
||||
if (c.attacker !== unit || unit.mem.sagaExtra) return;
|
||||
const list = half;
|
||||
half = null;
|
||||
if (!list || !list.length) return;
|
||||
const dep = unit.deploySeq;
|
||||
battle.after(0, () => {
|
||||
if (!up(unit) || unit.deploySeq !== dep || !unit.canAct || unit.s.flags.disarm) return;
|
||||
const t = list.filter((e) => e.alive && canTargetEnemy(unit, e, unit.profile));
|
||||
if (!t.length) return;
|
||||
unit.mem.sagaExtra = true;
|
||||
try { battle.forceAttack(unit, t, { noAmmo: true }); } finally { unit.mem.sagaExtra = false; }
|
||||
}, { owner: unit });
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
// server/sim/content/kits/ops/op-shining.js — 闪灵 (char_147_shining) 自选 operator kit: 6★ 医师 (医疗), an owned-6★ pick of
|
||||
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_147_shining, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 闪灵 (S2 备注
|
||||
// "多个屏障吸收量可叠加,持续时间独立计算,优先消耗先生成的屏障,防御力提升效果不可叠加"; PRTS 术语释义 屏障 "若无特殊说明,
|
||||
// 屏障可吸收全种类伤害").
|
||||
// - Trait (医师) "恢复友方单位生命": the profession default (one heal on the lowest-HP% injured ally of her 3-3 range).
|
||||
// Module PHY-Y 干枯剑鞘 "治疗地面单位时治疗量提升15%" (trait bb heal_scale): ×heal_scale on a heal whose target stands on
|
||||
// a 地面 tile (unit.ground, as 赫默's PHY-Y); PHY-X “使徒” "治疗生命值低于50%的友方单位时治疗量提升15%" (heal_scale,
|
||||
// hp_ratio): ×heal_scale when the target is below hp_ratio of its max HP before the heal (as 录武官's / 华法琳's PHY-X).
|
||||
// - T1 黑恶魔的庇护 "攻击范围内的友方单位防御力+60": every ally (operators and summons: 友方单位) standing in her current
|
||||
// attack range — her own tile included — DEF +def (flat). PHY-Y stage 2+ "防御力+100,地面单位防御力额外+40" (talent
|
||||
// change def / def_lowland): def_lowland more on a 地面 tile. Refreshed every AURA s; two sources keep the strongest.
|
||||
// - T2 法典 "攻击速度+10": ASPD +attack_speed. PHY-X stage 2+ "攻击速度+15,装备技能2时获得+25%攻击力,装备技能3时技力自然
|
||||
// 回复速度+0.6/秒" (talent change attack_speed; the hidden module talent's atk / sp_recovery_per_sec): ATK +atk while 自动
|
||||
// 掩护 is her picked skill, SP +sp_recovery_per_sec per second while 教条力场 is.
|
||||
// - S1 信条 (MANUAL, 20 s, data DEFAULT — a heal skill: cast as she is about to heal an injured ally in range): ATK +atk,
|
||||
// ASPD +attack_speed.
|
||||
// - S2 自动掩护 (AUTO, 1 charge at rank 4 / 2 at rank 7, data DEFAULT): "下次治疗使目标获得一个持续N秒的屏障,屏障可以吸收相当于
|
||||
// 闪灵攻击力X%的伤害,同时使目标防御力+Y%" — a "next heal" waits for her heal, as 推进之王's S2 waits for his attack (the
|
||||
// owner's SP_FULL rule is for AUTO skills that act on nobody). The heal's target gets a 屏障 of atk_scale × her ATK for
|
||||
// duration s (every damage type) and DEF +def while it holds: each barrier is its own buff (its own timer; older ones
|
||||
// absorb first — buff order), the DEF bonus one buff per target, kept while one of her barriers lasts [ASSUMED: it goes
|
||||
// with the last barrier, broken or expired — the 备注 only says it does not stack].
|
||||
// - S3 教条力场 (MANUAL, 60 s, data DEFAULT): ATK +atk; every ally in her attack range DEF +def (%), refreshed every AURA s.
|
||||
// Medic: heals only (dmgType heal), ranged, no anti-air question; ground enemies target her normally (no 起飞 / 迷彩 / 隐匿).
|
||||
|
||||
import { num, talentBb, moduleBb, traitBb, moduleOn, skillRec, statBuff, up } from '../shared/tier1.js';
|
||||
|
||||
const S1 = 'skchr_shining_1';
|
||||
const S2 = 'skchr_shining_2';
|
||||
const S3 = 'skchr_shining_3';
|
||||
const PHY_Y = 'uniequip_002_shining';
|
||||
const PHY_X = 'uniequip_003_shining';
|
||||
/** Aura refresh period and buff life (s): an ally leaving the range loses the buff within one refresh (tier-4 AURA). */
|
||||
const AURA = 0.2;
|
||||
const AURA_DUR = 0.25;
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const pickedSkill = (chess) => chess?.skill?.skillId ?? null;
|
||||
|
||||
/**
|
||||
* Keep buff `key` on every ally standing in `unit`'s current attack range (operators and summons, its own tile included;
|
||||
* never a 孤立 unit — Battle.alliesInGrid) while `on()` holds, refreshed every AURA s; it lapses AURA_DUR s after the
|
||||
* source stops refreshing it. Several sources of the same effect keep the strongest `value` (same-named buffs).
|
||||
* Shared with 夜莺's kit (op-cgbird.js).
|
||||
* @param {object} battle
|
||||
* @param {object} unit the source
|
||||
* @param {{ key: string, value: number, mods: object|((ally: object) => object), on?: () => boolean }} o
|
||||
*/
|
||||
export function rangeAura(battle, unit, { key, value, mods, on = () => true }) {
|
||||
battle.every(AURA, () => {
|
||||
if (!up(unit) || !on()) return;
|
||||
for (const a of battle.alliesInGrid(unit)) {
|
||||
const cur = a.findBuff(key);
|
||||
if (cur && cur.source !== unit && cur.source?.alive && (cur.data?.v ?? 0) > value && cur.timeLeft > 0.05) continue;
|
||||
battle.addBuff(a, { key, duration: AURA_DUR, mods: typeof mods === 'function' ? mods(a) : mods, source: unit, data: { v: value }, tags: ['aura'] });
|
||||
}
|
||||
}, { owner: unit, immediate: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* A 屏障 on `target` (闪灵 S2 自动掩护, 夜莺 S2 法术护盾): `shield` HP for `duration` s — of `shieldType` damage only when
|
||||
* given (damage.js absorbShields), else every type — plus the buff `statKey` (`mods`) while any barrier `key` lasts. PRTS
|
||||
* 备注 of both skills: "多个屏障吸收量可叠加,持续时间独立计算,优先消耗先生成的屏障,…效果不可叠加" — each barrier is its own
|
||||
* buff (refresh 'independent': its own timer; absorbShields walks the buffs oldest first), the stat bonus one buff whose
|
||||
* life follows the barriers (it ends when the last one breaks or expires). Shared with 夜莺's kit (op-cgbird.js).
|
||||
* @param {object} battle
|
||||
* @param {object} source the operator
|
||||
* @param {object} target
|
||||
* @param {{ key: string, statKey: string, shield: number, duration: number, mods: object, shieldType?: string|null }} o
|
||||
*/
|
||||
export function grantBarrier(battle, source, target, { key, statKey, shield, duration, mods, shieldType = null }) {
|
||||
if (!target || !target.alive || !(shield > 0) || !(duration > 0)) return;
|
||||
const sync = () => { if (!target.buffs.some((b) => b.key === key)) battle.removeBuff(target, statKey); };
|
||||
battle.addBuff(target, {
|
||||
key, refresh: 'independent', maxStacks: 99, duration, shield, shieldType, source, visible: true, onRemove: sync, onExpire: sync,
|
||||
});
|
||||
battle.addBuff(target, { key: statKey, refresh: 'extend', duration, mods, source });
|
||||
battle.fx('shield', { x: target.x, y: target.y, id: target.id, from: source.id });
|
||||
}
|
||||
|
||||
export default {
|
||||
char_147_shining: (bb, chess) => {
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s2 = skillRec(chess, S2);
|
||||
const t0 = talentBb(chess, 0); // 黑恶魔的庇护: def (PHY-Y stage 2+: def 100, def_lowland)
|
||||
const t1 = talentBb(chess, 1); // 法典: attack_speed (PHY-X stage 2+: 15)
|
||||
const hidden = moduleBb(chess); // PHY-X stage 2+: atk (with S2 picked), sp_recovery_per_sec (with S3 picked)
|
||||
const tb = traitBb(chess); // PHY-Y: heal_scale; PHY-X: heal_scale, hp_ratio
|
||||
const mod = moduleOn(chess) ? chess.module.id : null;
|
||||
const picked = pickedSkill(chess);
|
||||
return {
|
||||
skills: {
|
||||
[S1]: { kind: 'duration', heal: true, mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } },
|
||||
[S2]: {
|
||||
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
heal: true,
|
||||
attack: { healScale: 1 }, // the next heal (ai.js doHeal calls onHit per heal target)
|
||||
onHit({ battle, unit, target }) {
|
||||
if (!target || target.side !== 'ally') return;
|
||||
grantBarrier(battle, unit, target, {
|
||||
key: 'shining:barrier', statKey: 'shining:barrierDef', shield: unit.s.atk * num(b2.atk_scale),
|
||||
duration: num(b2.duration), mods: { defPct: num(b2.def) },
|
||||
});
|
||||
},
|
||||
},
|
||||
[S3]: { kind: 'duration', heal: true, mods: { atkPct: num(b3.atk) } },
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 黑恶魔的庇护: allies in her attack range DEF +def; PHY-Y stage 2+: 地面 units +def_lowland
|
||||
const d = num(t0.def), low = num(t0.def_lowland);
|
||||
if (!(d > 0) && !(low > 0)) return;
|
||||
rangeAura(battle, unit, { key: 'talent:shining:def', value: d + low, mods: (a) => ({ defFlat: d + (a.ground ? low : 0) }) });
|
||||
} },
|
||||
{ install(battle, unit) { // 法典: ASPD +attack_speed; PHY-X stage 2+: ATK +atk with S2, SP +0.6/s with S3
|
||||
statBuff(battle, unit, 'talent:shining:aspd', { aspd: num(t1.attack_speed) });
|
||||
if (picked === S2) statBuff(battle, unit, 'talent:shining:s2', { atkPct: num(hidden.atk) });
|
||||
if (picked === S3) statBuff(battle, unit, 'talent:shining:s3', { spRecoveryFlat: num(hidden.sp_recovery_per_sec) });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// 教条力场: allies in her attack range DEF +def % while the skill runs
|
||||
if (picked === S3 && num(b3.def) > 0) {
|
||||
rangeAura(battle, unit, { key: 'skill:shining:def', value: num(b3.def), mods: { defPct: num(b3.def) }, on: () => !!unit.skill?.active });
|
||||
}
|
||||
// module traits: PHY-Y heals on 地面 units, PHY-X heals on allies below hp_ratio, ×heal_scale
|
||||
const hs = num(tb.heal_scale, 1);
|
||||
if (!mod || hs === 1) return;
|
||||
const boosted = mod === PHY_X ? (t) => t.hpRatio < num(tb.hp_ratio) : mod === PHY_Y ? (t) => !!t.ground : null;
|
||||
if (!boosted) return;
|
||||
battle.on('heal', (ctx) => {
|
||||
if (ctx.source !== unit || ctx.opts?.regen || !ctx.target || !boosted(ctx.target)) return;
|
||||
ctx.amount *= hs;
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
// server/sim/content/kits/ops/op-shwaz.js — 黑 (char_340_shwaz) 自选 operator kit: 6★ 重射手 (狙击), an owned-6★ pick of the
|
||||
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules (ARC-X 定制弩配件套装, ARC-Y 老剃刀) at
|
||||
// every form. Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_340_shwaz): 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) and PRTS 黑
|
||||
// (破甲箭头 备注 "攻击力提升效果为提升攻击力倍率"; 战术的终结 "攻击间隔略微增大(+0.4)"; the {{**|80%|下降20%}} marks of the
|
||||
// DEF cut = a final ×0.8).
|
||||
// - Trait (重射手) "高精度的近距离射击": the plain closerange profile — ranged physical arrows, 3-6, can hit air units
|
||||
// (PRTS 分支特性信息 重射手 "可对空"), blocks 1, ground enemies target her (no flag).
|
||||
// - T1 破甲箭头 "攻击时,20%几率当次攻击的攻击力提升至160%,并使命中目标的防御力下降20%,持续5秒": one roll per attack
|
||||
// (`prob`); on a proc the attack's damage ×atk_scale (an ATK-scale multiplier, PRTS 备注, so it multiplies the skills'
|
||||
// own scales) and, once the hit landed (命中: a dodged hit does nothing), the target's DEF ×(1 + def) for
|
||||
// defdown_duration s — the catalogue `defDown` (final ×0.8, PRTS; 同名 keeps the strongest). The skills raise the chance:
|
||||
// S1's next attack to S1 talent@prob, S2 / S3's attacks while they run to their talent@prob (every attack a skill makes
|
||||
// carries dmg.isSkill — S2's empty attack override marks its attacks for that). ARC-Y stage 2+ upgrades it (stage 3:
|
||||
// 170 %, DEF −25 %) — read from the base talent and the module's NAMED change: ARC-Y's hidden talent part (talentIndex 0,
|
||||
// isHideTalent, atk_scale 1.05, prefabKey "10") is the trait's 105 % as a talent of its own in the game, which the
|
||||
// data's merge by talentIndex folds into 破甲箭头 at stage 1 (composed atk_scale 1.05) — reported, not used.
|
||||
// - T2 交叉火力 "场上存在黑和另外至少一名【狙击】干员时,所有【狙击】干员的攻击力+8%": while she is on the field and another
|
||||
// 【狙击】 operator is too, every 【狙击】 operator of the field (her included; a partner's in a shared field too [ASSUMED,
|
||||
// as 推进之王's 万兽之王]) ATK +atk, refreshed every 0.5 s (lapses within 0.6 s when the condition ends); several sources
|
||||
// keep the strongest. ARC-X stage 2+ "携带黑和另外至少一名【狙击】干员时…+13%": 携带 = in the squad — the player's own
|
||||
// pieces of the battle, deployed or not — so with another 【狙击】 piece in her player's battle every 【狙击】 operator of
|
||||
// that player ATK +13 % for the whole battle (persist; a partner's operators are not in her squad [ASSUMED]).
|
||||
// - Module ARC-X “定制弩配件套装”: "再部署时间减少" (attribute respawn_time −25, in the stats: 70 → 45 s) + HP / ATK.
|
||||
// - Module ARC-Y “老剃刀” trait "攻击正前方的敌人时攻击力提升至105%且无视其物理闪避" (trait bb atk_scale): her attack damage
|
||||
// on an enemy whose body is on her straight line ahead ×atk_scale and not dodgeable (canDodge false — her damage is
|
||||
// physical only). 正前方 = local row 0, column ≥ 0 in her facing — her own tile included, as S3's 3-2 "前方3格" counts
|
||||
// it [ASSUMED: no source draws the line].
|
||||
// - S1 强弩 (AUTO, attack SP 4, data DEFAULT): the next attack ×atk_scale (and the T1 chance above).
|
||||
// - S2 暮眼锐瞳 (MANUAL, data DEFAULT): ATK +atk for 33 / 36 s, T1 chance talent@prob.
|
||||
// - S3 战术的终结 (MANUAL): range → 3-2 (her tile + 3 ahead) while it runs, attack interval +base_attack_time s (a flat
|
||||
// +0.4 on her 1.6 s, PRTS), ATK +atk, T1 chance 100 %. Trigger: the data's DEFAULT (the basic strategy) — "攻击范围改为前方
|
||||
// 3格" is an attack-range change, not a 技能范围 (tools/build-data.mjs ATTACK_RANGE_CHANGE has "改为" since O6's report).
|
||||
|
||||
import { num, up, traitBb, moduleOn, skillRec, batMod } from '../shared/tier1.js';
|
||||
import { bodyKeys } from '../../../body.js';
|
||||
import { toLocal } from '../../../dir.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
|
||||
const S1 = 'skchr_shwaz_1';
|
||||
const S2 = 'skchr_shwaz_2';
|
||||
const S3 = 'skchr_shwaz_3';
|
||||
/** 交叉火力's buff (the field aura and the squad version share it: one effect per operator, the strongest). */
|
||||
const CROSSFIRE = 'talent:shwaz:crossfire';
|
||||
/** 交叉火力's refresh interval (s), as installAura. */
|
||||
const AURA_EVERY = 0.5;
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
/** A 【狙击】 operator (summons are never operators). */
|
||||
const isSniperOp = (a) => !!a && a.kind === 'op' && a.def?.profession === 'SNIPER';
|
||||
|
||||
/** The module the record fights with (its active one), or null. */
|
||||
function activeModule(chess) {
|
||||
if (!moduleOn(chess)) return null;
|
||||
return (chess.modules ?? []).find((m) => m && m.uniEquipId === chess.module.id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 破甲箭头's blackboard: the base talent (no module) with the module's NAMED changes of talent 0 only — never the hidden
|
||||
* trait part (see the header).
|
||||
*/
|
||||
function pierceBb(chess) {
|
||||
const base = (chess?.talentsBase ?? chess?.talents ?? []).find((t) => t && t.index === 0)?.bb ?? {};
|
||||
const ups = (activeModule(chess)?.talentChanges ?? []).filter((t) => t && t.talentIndex === 0 && !t.hidden && t.name);
|
||||
return Object.assign({}, base, ...ups.map((t) => t.bb || {}));
|
||||
}
|
||||
|
||||
/** The record's 交叉火力 (talent 1, the module's change included): its blackboard and whether it reads 携带 (squad). */
|
||||
function crossfireOf(chess) {
|
||||
const t = (chess?.talents ?? []).find((x) => x && x.index === 1) ?? null;
|
||||
return { atk: num(t?.bb?.atk), squad: /携带/.test(String(t?.desc ?? '')) };
|
||||
}
|
||||
|
||||
/** ARC-Y 正前方: the enemy's body touches the unit's straight line ahead (local row 0, local column ≥ 0). */
|
||||
function inFrontLine(unit, e) {
|
||||
for (const k of bodyKeys(e)) {
|
||||
const [lr, lc] = toLocal(Math.floor(k / COLS) - unit.tileR, (k % COLS) - unit.tileC, unit.dir);
|
||||
if (lr === 0 && lc >= 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default {
|
||||
char_340_shwaz: (bb, chess) => {
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s3 = skillRec(chess, S3);
|
||||
const t0 = pierceBb(chess);
|
||||
const cross = crossfireOf(chess);
|
||||
const frontScale = num(traitBb(chess).atk_scale, 1);
|
||||
return {
|
||||
skills: {
|
||||
[S1]: { kind: 'instant', attack: { atkScale: num(b1.atk_scale, 1) } },
|
||||
// (an empty attack override: the attacks made while it runs carry dmg.isSkill — 破甲箭头's raised chance)
|
||||
[S2]: { kind: 'duration', mods: { atkPct: num(b2.atk) }, attack: {} },
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
targeting: { rangeGrid: s3?.rangeGrid ?? [[0, 0], [0, 1], [0, 2], [0, 3]] },
|
||||
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 破甲箭头: one roll per attack; ×atk_scale on a proc, then DEF cut on the target hit
|
||||
const scale = num(t0.atk_scale, 1), cut = -num(t0.def), dur = num(t0.defdown_duration, 5), base = num(t0.prob);
|
||||
const sid = unit.skill?.id;
|
||||
const raised = num(bbOf(chess, sid)['talent@prob'], base);
|
||||
battle.on('hit', (ctx) => {
|
||||
const d = ctx.dmg, t = ctx.target;
|
||||
if (ctx.source !== unit || !t || t.side !== 'enemy' || !d.isAttack || d.isSplash || d.type === 'element') return;
|
||||
const m = unit.mem;
|
||||
if (!d.attackId || m.shwazRollId !== d.attackId) {
|
||||
m.shwazRollId = d.attackId;
|
||||
const p = d.isSkill ? raised : base;
|
||||
m.shwazProc = p > 0 && battle.rng.chance(p);
|
||||
if (m.shwazProc) battle.fx('crit', { x: t.x, y: t.y, id: unit.id });
|
||||
}
|
||||
if (!m.shwazProc) return;
|
||||
d.amount *= scale;
|
||||
d.shwazPierce = true;
|
||||
}, { owner: unit });
|
||||
battle.on('damaged', (ctx) => {
|
||||
if (ctx.source !== unit || !ctx.dmg?.shwazPierce || !(cut > 0) || !hasHp(ctx.target) || ctx.target.side !== 'enemy') return;
|
||||
battle.applyStatus(ctx.target, 'defDown', { duration: dur, value: cut, source: unit });
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 交叉火力: field aura, or (ARC-X stage 2+) the squad's snipers for the whole battle
|
||||
const v = cross.atk;
|
||||
if (!(v > 0)) return;
|
||||
const give = (a, extra) => {
|
||||
const cur = a.findBuff(CROSSFIRE);
|
||||
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > v && cur.timeLeft > 0.05) return;
|
||||
battle.addBuff(a, { key: CROSSFIRE, mods: { atkPct: v }, source: unit, data: { v }, ...extra });
|
||||
};
|
||||
if (cross.squad) {
|
||||
const squad = battle.allyUnits.filter((a) => isSniperOp(a) && a.ownerId === unit.ownerId);
|
||||
if (squad.some((a) => a !== unit)) for (const a of squad) give(a, { persist: true, allowDead: true, tags: ['talent'] });
|
||||
return;
|
||||
}
|
||||
battle.every(AURA_EVERY, () => {
|
||||
if (!up(unit)) return;
|
||||
const snipers = battle.alliesFor(unit).filter(isSniperOp);
|
||||
if (!snipers.some((a) => a !== unit)) return;
|
||||
for (const a of snipers) give(a, { duration: AURA_EVERY + 0.1, tags: ['aura'] });
|
||||
}, { owner: unit, immediate: true });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// ARC-Y 老剃刀: 攻击正前方的敌人时攻击力提升至105%且无视其物理闪避
|
||||
if (!(frontScale > 0) || frontScale === 1) return;
|
||||
battle.on('hit', (ctx) => {
|
||||
const d = ctx.dmg, t = ctx.target;
|
||||
if (ctx.source !== unit || !t || t.side !== 'enemy' || !d.isAttack || d.type === 'element' || !inFrontLine(unit, t)) return;
|
||||
d.amount *= frontScale;
|
||||
d.canDodge = false;
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
// server/sim/content/kits/ops/op-typhon.js — 提丰 (char_2012_typhon) 自选 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 at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_2012_typhon, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 提丰 (备注 of 锐如兽牙,
|
||||
// 重如沼泥, 冰原秩序 and “永恒狩猎”); PRTS 分支特性信息 攻城手 ("可对空").
|
||||
// - Trait (攻城手) "优先攻击重量最重的敌人": target priority 'heaviest' (targeting.js); ranged physical arrows that hit air units,
|
||||
// range 4-3 (as 早露, kits/ops/op-poca.js).
|
||||
// - Module SIE-X 自然的包容: trait "攻击重量较重(重量等级大于等于3)的敌人时,攻击力提升至115%" (the hidden module talent's atk_scale /
|
||||
// value on 锐如兽牙's index): ×atk_scale on her attacks against an enemy of 重量等级 ≥ value (profile dmgMul); stage 2+ 锐如兽牙's
|
||||
// numbers (12 % a stack, 10 s) come with the composed talent.
|
||||
// - Module SIE-Y 冰原的影子: trait "攻击越远的敌人造成的伤害越高(最高提升12%)" — as 早露's SIE-Y (her attack damage, linear between
|
||||
// min_dist and max_dist [ASSUMED]); ASPD in the stats; stage 2+ 重如沼泥 "前两次…攻击力提升至220%" (max_stack_cnt / atk_scale).
|
||||
// - Module ISW-A 提丰特限证章: its trait and talent parts say "在集成战略中" — no effect in this mode (the hidden damage_scale /
|
||||
// arrow-rain blackboards are ignored); its HP / ATK are in the stats.
|
||||
// - T1 锐如兽牙 "连续攻击时逐渐无视敌人的防御力,最高无视其防御力的50%(每次攻击提升10%的无视防御比例),8秒内未攻击则失去加成": every
|
||||
// attack she makes adds a stack (max_stack_cnt) of +def_penetrate 物理穿透 (defIgnorePct, added — PRTS 备注 "增加自身物理穿透(百分
|
||||
// 比)属性(直接加算)") before its damage ("获得的加成可应用于当次伤害"), all of them lost `duration` s after her last attack.
|
||||
// - T2 重如沼泥 "技能期间对每个敌人首次造成伤害时,攻击力提升至160%并使目标停顿3秒": while a skill of hers runs, her first (SIE-Y stage
|
||||
// 2+: first max_stack_cnt) damage instances on each enemy deal ×atk_scale (攻击力倍率) and, when they land, 停顿 `sluggish` s —
|
||||
// every damage of hers, the S3 arrow rain included ("造成伤害"); the marks are cleared when the skill ends or she leaves
|
||||
// (PRTS 备注 "非首次标记…自身技能结束时或自身离场时移除"). A dodged instance spends its mark [ASSUMED: "于计算伤害前触发"].
|
||||
// - S1 迅捷打击·γ型 (35 s): ATK +atk, ASPD +attack_speed.
|
||||
// - S2 冰原秩序: ATK +atk; every attack shoots two arrows, at two different enemies when there are two (targeting 2), else both
|
||||
// at the one (PRTS "优先攻击不同目标"); each arrow attack@prob to stun attack@stun s. first_duration s on its first cast of a
|
||||
// deployment, then "持续时间无限" until she leaves (a toggle the kit ends after first_duration s; PRTS 备注 "每次部署提丰时,
|
||||
// 重新计算该技能的使用次数").
|
||||
// - S3 “永恒狩猎” (ammo attack@s3_trigger_time): base attack time +base_attack_time s (flat: batMod). At the cast she marks the first
|
||||
// enemy of her own range (未开启技能时的攻击范围: the initial range, her heaviest-first order); the mark area (radius MARK_RADIUS,
|
||||
// 碰撞判定 — foesInRadius) follows that enemy until it leaves the field, then stays; its enemies are re-read every MARK_SCAN s.
|
||||
// Her range is the whole field (the card shows 全场) but she attacks only while an enemy is in the mark area; each attack
|
||||
// deals nothing itself and drops a round of s3_max_hit_num arrows, RAIN_GAP s apart, each on a random enemy of the area: a
|
||||
// physical skill hit (not an attack: PRTS "不属于普通攻击/技能攻击的直接伤害", so the SIE-X / SIE-Y attack riders do not apply
|
||||
// [ASSUMED]) of s3_atk_scale × ATK, then 晕眩 s3_stun s once that damage landed.
|
||||
// Auto-cast: the data's DEFAULT (the whole-field range exists only in PRTS's note; the mark is picked from her own range).
|
||||
|
||||
import { num, talentBb, moduleOn, traitBb, skillRec, batMod, onHitBy, skillBusy, up } from '../shared/tier1.js';
|
||||
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
|
||||
import { ROWS, COLS } from '../../../constants.js';
|
||||
|
||||
const S1 = 'skcom_quickattack[3]';
|
||||
const S2 = 'skchr_typhon_2';
|
||||
const S3 = 'skchr_typhon_3';
|
||||
/** PRTS S3 备注: "标记区域半径1.3(碰撞判定),每0.2s更新范围内的敌人", "箭雨的每支箭间隔0.1s且分别索敌". */
|
||||
const MARK_RADIUS = 1.3;
|
||||
const MARK_SCAN = 0.2;
|
||||
const RAIN_GAP = 0.1;
|
||||
/** S3 "攻击范围扩大至全场": every offset that reaches the whole 19 × 21 field whatever her direction. */
|
||||
const FIELD = (() => {
|
||||
const m = Math.max(ROWS, COLS) - 1;
|
||||
const g = [];
|
||||
for (let dr = -m; dr <= m; dr++) for (let dc = -m; dc <= m; dc++) g.push(Object.freeze([dr, dc]));
|
||||
return Object.freeze(g);
|
||||
})();
|
||||
const FANG_KEY = 'talent:typhon:fang';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
|
||||
|
||||
export default {
|
||||
char_2012_typhon: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const tb = traitBb(chess);
|
||||
// SIE-X: the hidden part merged into 锐如兽牙's blackboard (no module / SIE-Y / ISW-A ⇒ no atk_scale there)
|
||||
const xScale = moduleOn(chess) ? num(t0.atk_scale, 1) : 1;
|
||||
const heavyAt = num(t0.value, 3);
|
||||
const heavy = (e) => !!e && e.side === 'enemy' && e.weight >= heavyAt;
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
|
||||
/** S3: the enemies of the mark area (last scan) her attacks may still select. */
|
||||
const areaFoes = (unit, st) => st.inArea.filter((e) => e.alive && e.deployed && !e.hidden && canTargetEnemy(unit, e, unit.profile));
|
||||
const follow = (st) => { const t = st.target; if (t && t.alive && t.deployed && !t.hidden) { st.x = t.x; st.y = t.y; } };
|
||||
const scan = (battle, unit, st) => { st.inArea = battle.foesInRadius(st.x, st.y, MARK_RADIUS).filter((e) => canTargetEnemy(unit, e, unit.profile)); };
|
||||
const rain = (battle, unit) => {
|
||||
const st = unit.mem.typhonMark;
|
||||
if (!st) return;
|
||||
battle.fx('arrowRain', { x: st.x, y: st.y, radius: MARK_RADIUS, id: unit.id });
|
||||
const scale = num(b3['attack@s3_atk_scale'], 1), stun = num(b3['attack@s3_stun']);
|
||||
const arrow = () => {
|
||||
const pool = areaFoes(unit, st);
|
||||
if (!pool.length) return;
|
||||
const e = battle.rng.pick(pool);
|
||||
const dealt = battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'typhon:rain'] });
|
||||
if (dealt > 0 && e.alive && stun > 0) battle.applyStatus(e, 'stun', { duration: stun, source: unit });
|
||||
};
|
||||
arrow();
|
||||
for (let k = 1; k < count(b3['attack@s3_max_hit_num'], 4); k++) battle.after(RAIN_GAP * k, arrow, { owner: unit });
|
||||
};
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } },
|
||||
[S2]: {
|
||||
kind: 'toggle',
|
||||
mods: { atkPct: num(b2.atk) },
|
||||
targeting: { maxTargets: 2 },
|
||||
attack: {
|
||||
onHit({ battle, unit, target }) {
|
||||
if (target && target.alive && battle.rng.chance(num(b2['attack@prob']))) battle.applyStatus(target, 'stun', { duration: num(b2['attack@stun'], 1), source: unit });
|
||||
},
|
||||
},
|
||||
onStart({ unit, skill }) {
|
||||
unit.mem.typhonS2Uses = (unit.mem.typhonS2Uses ?? 0) + 1;
|
||||
skill.timeLeft = unit.mem.typhonS2Uses > 1 ? Infinity : Math.max(0.01, num(b2.first_duration, skill.duration));
|
||||
},
|
||||
onTick({ skill, dt }) {
|
||||
if (!Number.isFinite(skill.timeLeft)) return;
|
||||
skill.timeLeft -= dt;
|
||||
if (skill.timeLeft <= 1e-9) skill.end('duration');
|
||||
},
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'ammo',
|
||||
ammo: count(b3['attack@s3_trigger_time'], 8),
|
||||
mods: { batPct: batMod(b3.base_attack_time, chess) },
|
||||
targeting: { rangeGrid: FIELD },
|
||||
attack: {
|
||||
projectile: 'beam',
|
||||
hitsFn: () => 0,
|
||||
canAttack: (battle, unit) => !!unit.mem.typhonMark && areaFoes(unit, unit.mem.typhonMark).length > 0,
|
||||
onHit({ battle, unit }) { rain(battle, unit); },
|
||||
},
|
||||
onStart({ battle, unit }) {
|
||||
const cands = battle.enemiesInKeys(unit.baseRangeKeys ?? unit.rangeKeys, unit, unit.profile);
|
||||
sortEnemyTargets(battle, unit, cands, 'heaviest');
|
||||
const t = cands[0] ?? null;
|
||||
const st = { target: t, x: t ? t.x : unit.x, y: t ? t.y : unit.y, inArea: [], timer: null };
|
||||
unit.mem.typhonMark = st;
|
||||
scan(battle, unit, st);
|
||||
st.timer = battle.every(MARK_SCAN, (b, sc) => {
|
||||
if (unit.mem.typhonMark !== st) { sc.cancel(); return; }
|
||||
follow(st);
|
||||
scan(battle, unit, st);
|
||||
}, { owner: unit });
|
||||
battle.fx('mark', { x: st.x, y: st.y, radius: MARK_RADIUS, id: unit.id, target: t ? t.id : null });
|
||||
},
|
||||
onTick({ unit }) { const st = unit.mem.typhonMark; if (st) follow(st); },
|
||||
onEnd({ unit }) {
|
||||
const st = unit.mem.typhonMark;
|
||||
unit.mem.typhonMark = null;
|
||||
if (st && st.timer) st.timer.cancel();
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 锐如兽牙: +def_penetrate 物理穿透 a stack per attack (max_stack_cnt), gone `duration` s after the last
|
||||
const per = num(t0.def_penetrate);
|
||||
if (!(per > 0)) return;
|
||||
const max = count(t0.max_stack_cnt, 5), dur = num(t0.duration, 8);
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !up(unit)) return;
|
||||
battle.addBuff(unit, { key: FANG_KEY, duration: dur, refresh: 'stack', maxStacks: max, mods: { defIgnorePct: per }, tags: ['talent'] });
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 重如沼泥: during a skill, the first max_stack_cnt damages on each enemy ×atk_scale + 停顿
|
||||
const scale = num(t1.atk_scale, 1), slug = num(t1.sluggish), max = count(t1.max_stack_cnt, 1);
|
||||
const marked = new WeakSet();
|
||||
battle.on('skillStart', (c) => { if (c.unit === unit) unit.mem.typhonMud = new Map(); }, { owner: unit });
|
||||
battle.on('skillEnd', (c) => { if (c.unit === unit) unit.mem.typhonMud = null; }, { owner: unit });
|
||||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.typhonMud = null; }, { owner: unit });
|
||||
onHitBy(battle, unit, ({ target, dmg }) => {
|
||||
const marks = unit.mem.typhonMud;
|
||||
if (!marks || !skillBusy(unit)) return;
|
||||
const n = marks.get(target.id) ?? 0;
|
||||
if (n >= max) return;
|
||||
marks.set(target.id, n + 1);
|
||||
dmg.amount *= scale;
|
||||
marked.add(dmg);
|
||||
});
|
||||
battle.on('damaged', (c) => {
|
||||
if (c.source !== unit || !marked.has(c.dmg) || !c.target.alive || !(slug > 0)) return;
|
||||
battle.applyStatus(c.target, 'sluggish', { duration: slug, source: unit });
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
// 攻城手: heaviest first; SIE-X ×atk_scale on her attacks against heavy enemies
|
||||
trait: { priority: 'heaviest', ...(xScale !== 1 ? { dmgMul: (b, u, target) => (heavy(target) ? xScale : 1) } : null) },
|
||||
install(battle, unit) {
|
||||
// S2: two arrows per attack — both at the one enemy when it is the only target
|
||||
if (unit.skill?.id === S2) {
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !unit.skill.active || ctx.targets.length !== 1) return;
|
||||
ctx.targets = [ctx.targets[0], ctx.targets[0]];
|
||||
}, { owner: unit });
|
||||
}
|
||||
// S3: the round is aimed at the marked enemy (else another one of the area): its 'atk' line points there
|
||||
if (unit.skill?.id === S3) {
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
const st = unit.mem.typhonMark;
|
||||
if (ctx.attacker !== unit || !unit.skill.active || !st) return;
|
||||
const pool = areaFoes(unit, st);
|
||||
const t = pool.includes(st.target) ? st.target : pool[0];
|
||||
if (t) ctx.targets = [t];
|
||||
}, { owner: unit });
|
||||
}
|
||||
// S2 counts its casts per deployment
|
||||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.typhonS2Uses = 0; }, { owner: unit });
|
||||
// SIE-Y: the farther the target, the more damage (attacks only)
|
||||
const ds = num(tb.damage_scale), lo = num(tb.min_dist, 1), hi = num(tb.max_dist, 4.5);
|
||||
if (ds > 0) {
|
||||
onHitBy(battle, unit, ({ target, dmg }) => {
|
||||
if (!dmg.isAttack) return;
|
||||
const d = Math.hypot(target.x - unit.x, target.y - unit.y);
|
||||
dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
// server/sim/content/kits/ops/op-veen.js — 维伊 (char_4226_veen) 自选 operator kit: 6★ 秘术师 (术师), an owned-6★ pick of the
|
||||
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and the module (MSC-Y) at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_4226_veen, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
|
||||
// elite = E2 Lv60, rank 7, MSC-Y at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 维伊 (the 备注
|
||||
// quoted below).
|
||||
// - Trait (秘术师) "攻击造成法术伤害,在找不到攻击目标时可以将攻击能量储存起来之后一齐发射" (ranged arts, hits air units,
|
||||
// targetable by ground enemies). PRTS 分支特性信息 秘术师: "能量储存与攻击占用相同的攻击间隔:在进行攻击判定时,若范围内存在
|
||||
// 有效目标,则进行普通攻击;若无有效目标且能量储存数未满,则改为储存一份攻击能量(属于攻击行为)" — when her attack is ready
|
||||
// and she has no valid target she stores one energy and her attack interval starts again (this kit replaces the
|
||||
// engine's mystic profile, which stores one interval after the attack is ready); the energies fly with her next attack,
|
||||
// at its target ("由储存能量形成的弹道造成攻击力100%的法术普通伤害").
|
||||
// T1 “在挥刀之前” "特性储存的能量达到3个时,合成为1个转置能量(至多储存等同于9倍的能量)" (trait bb merge_cnt 3, times 9):
|
||||
// 3 stored make 1 转置能量, at most times / merge_cnt of those, then no more storing (备注 "转置能量数量达到上限时,停止储存
|
||||
// 攻击能量"); a 转置能量 hits for merge_cnt × ATK (备注 "攻击力300%的法术普通伤害"). Every shot uses the ATK it left with
|
||||
// (备注 "所有攻击、储存能量、转置能量的弹道均使用脱手时的缓存攻击力"), released 转置能量 → main attack → stored energies (S2
|
||||
// 备注 "弹道创建顺序"); one volley flies to one target and lands at once, so the kit resolves all its hits as the main bolt
|
||||
// lands (the engine's own hit makes none: `hitsFn` 0), with that attack's attackId, the later ones only on a target still
|
||||
// alive. [ASSUMED] a redeployment holds no energy.
|
||||
// - T1 “在挥刀之前” "拥有转置能量时攻击力+10%,未拥有转置能量时攻击速度+15" (bb atk / attack_speed): two toggles on her
|
||||
// stored state (checked every tick — the 备注's "攻击力加成失效存在短暂延迟": the volley that spends the 转置能量 keeps it).
|
||||
// - T2 战争技艺 "攻击和储存的能量使目标在5秒内每秒受到90点法术伤害(至多叠加3次,发射转置能量可以叠加3次),维伊不以该效果影响的
|
||||
// 敌人为攻击目标" (bb attack@duration / attack@value / attack@max_stack_cnt; MSC-Y stage 3: 120 / 4): each hit adds 1
|
||||
// stack (a 转置能量 3, an S3 bounce 1 — S3 备注 "相当于一发储存能量"), each stack attack@value arts per second, the 5 s
|
||||
// restarting with every stack [ASSUMED]; she never picks a marked enemy (canAttack / beforeAttack, as 深靛 skips bound
|
||||
// enemies — with only marked enemies in range she stores energy).
|
||||
// - MSC-Y “军械库” "拥有已储存的攻击能量时,攻击速度+30" (hidden module talent attack_speed): while she holds any energy.
|
||||
// - S1 “自呼号生发” (AUTO, 18 s): ASPD +attack_speed — an AUTO skill acting on herself fires at full SP (the owner's rule,
|
||||
// kits/README.md checklist 5: `trigger: 'SP_FULL'`).
|
||||
// - S2 “以鲜血洗去” (MANUAL, 28 s, data DEFAULT): attack interval base_attack_time (a flat −0.5 s on 3.0 s); every released
|
||||
// main attack or stored energy adds a stack (a 转置能量 3), at most …max_stack_cnt: ATK +…atk and ASPD +…attack_speed each,
|
||||
// until the skill ends; a shot leaving later in the same volley already has the stacks of those before it (备注).
|
||||
// - S3 “用赤铁铭记” (MANUAL, 21 / 18 bullets, data DEFAULT): ASPD +attack_speed; 备注 "技能期间发射的储存能量与转置能量不会
|
||||
// 产生伤害,维伊仅在每次攻击时根据当前储存能量与转置能量消耗相应数量的弹药(不考虑是否足够),并令本次攻击获得相应弹射次数":
|
||||
// an attack spends 1 bullet per stored energy and merge_cnt per 转置能量 (none without) and its main hit
|
||||
// (attack@base_atk_scale × ATK) bounces that many times, every 0.5 s within 1.7 tiles (备注 "弹射间隔0.5,弹射半径1.7"),
|
||||
// preferring an enemy not hit yet by it, else another one, else the same (text "优先不同目标" [ASSUMED order]), each
|
||||
// attack@bounce_atk_scale × the cached ATK arts. The skill ends when the bullets run out.
|
||||
|
||||
import { num, talentBb, moduleBb, traitBb, skillRec, batMod, up, toggleBuff } from '../shared/tier1.js';
|
||||
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
|
||||
|
||||
const S1 = 'skchr_veen_1';
|
||||
const S2 = 'skchr_veen_2';
|
||||
const S3 = 'skchr_veen_3';
|
||||
const ANY = Object.freeze({ canHitFly: true });
|
||||
/** 用赤铁铭记 备注 "弹射间隔0.5,弹射半径1.7". */
|
||||
const BOUNCE_IV = 0.5;
|
||||
const BOUNCE_R = 1.7;
|
||||
const S2_STACK = 'veen:s2stack';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const warKey = (unit) => `veen:war:${unit.id}`;
|
||||
const marked = (unit, e) => !!e.findBuff(warKey(unit));
|
||||
const skillOn = (unit, id) => !!(unit.skill && unit.skill.active && unit.skill.id === id);
|
||||
/**
|
||||
* Her stored energies (per deployment): `s` stored, `t` 转置能量, `q` the volleys in flight per target, `use` the S3 bullets of
|
||||
* the attack just made, `k` the S2 stacks.
|
||||
*/
|
||||
const stateOf = (unit) => unit.trait.veen ?? (unit.trait.veen = { s: 0, t: 0, q: new Map(), use: 0, k: 0 });
|
||||
|
||||
/** Enemies she may pick: in her range (or blocked by her), none under her 战争技艺. */
|
||||
function validTargets(battle, unit, prof) {
|
||||
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof);
|
||||
for (const e of battle.blockedTargets(unit, prof)) if (!cands.includes(e)) cands.push(e);
|
||||
return cands.filter((e) => !marked(unit, e));
|
||||
}
|
||||
|
||||
export default {
|
||||
char_4226_veen: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0);
|
||||
const t1 = talentBb(chess, 1);
|
||||
const tb = traitBb(chess);
|
||||
const hidden = moduleBb(chess); // MSC-Y: attack_speed
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const merge = Math.max(1, Math.floor(num(tb.merge_cnt, 3)));
|
||||
const tMax = Math.max(1, Math.floor(num(tb.times, 9) / merge));
|
||||
const war = { dur: num(t1['attack@duration']), value: num(t1['attack@value']), max: Math.max(1, Math.floor(num(t1['attack@max_stack_cnt'], 1))) };
|
||||
const s2 = { atk: num(b2['attack@veen_s_2_buff[stack].atk']), aspd: num(b2['attack@veen_s_2_buff[stack].attack_speed']), max: Math.max(0, Math.floor(num(b2['attack@veen_s_2_buff[stack].max_stack_cnt']))) };
|
||||
|
||||
/** 战争技艺: `n` stacks on `e`. */
|
||||
function warOn(battle, unit, e, n) {
|
||||
if (!e || !e.alive || !(war.value > 0) || !(war.dur > 0) || !(n > 0)) return;
|
||||
battle.addBuff(e, {
|
||||
key: warKey(unit), duration: war.dur, refresh: 'stack', stacks: Math.min(n, war.max), maxStacks: war.max, source: unit,
|
||||
interval: 1, data: { value: war.value },
|
||||
onTick: ({ battle: b, unit: t, buff }) => { b.dealDamage(unit, t, { amount: buff.data.value * buff.stacks, type: 'arts', canDodge: false, tags: ['talent', 'dot', 'veenWar'] }); },
|
||||
});
|
||||
}
|
||||
/** S2: `n` more stacks (a released shot). */
|
||||
function s2Stack(battle, unit, n) {
|
||||
if (!skillOn(unit, S2) || !(s2.max > 0)) return;
|
||||
const v = stateOf(unit);
|
||||
v.k = Math.min(s2.max, v.k + n);
|
||||
battle.addBuff(unit, { key: S2_STACK, mods: { atkPct: s2.atk * v.k, aspd: s2.aspd * v.k }, tags: ['skill'] });
|
||||
}
|
||||
/** One hit of the volley: `atk` (cached at release) × `scale` arts, then `stacks` of 战争技艺. */
|
||||
function hitOnce(battle, unit, t, atk, scale, stacks, attackId, isSkill) {
|
||||
battle.dealDamage(unit, t, { amount: atk * scale * unit.s.atkScaleMul, type: 'arts', isAttack: true, isSkill, attackId, tags: ['veen'] });
|
||||
warOn(battle, unit, t, stacks);
|
||||
}
|
||||
/** S3: `n` bounces from `from`, every BOUNCE_IV s, each bounce × the cached `atk`. */
|
||||
function bounces(battle, unit, from, n, atk, attackId) {
|
||||
const hit = new Set([from]);
|
||||
let cur = from;
|
||||
const step = (left) => {
|
||||
if (left <= 0) return;
|
||||
battle.after(BOUNCE_IV, () => {
|
||||
const near = battle.enemiesInRadius(cur.x, cur.y, BOUNCE_R).filter((e) => canTargetEnemy(unit, e, ANY));
|
||||
let pool = near.filter((e) => !hit.has(e));
|
||||
if (!pool.length) pool = near.filter((e) => e !== cur);
|
||||
if (!pool.length) pool = near;
|
||||
let next = null, bd = Infinity;
|
||||
for (const e of pool) {
|
||||
const d = Math.hypot(e.x - cur.x, e.y - cur.y);
|
||||
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && next && e.spawnSeq < next.spawnSeq)) { bd = d; next = e; }
|
||||
}
|
||||
if (!next) return; // nobody within reach: the bounces left are lost
|
||||
battle.fx('veenBounce', { x: cur.x, y: cur.y, id: unit.id, target: next.id });
|
||||
hit.add(next);
|
||||
cur = next;
|
||||
battle.dealDamage(unit, next, { amount: atk * num(b3['attack@bounce_atk_scale'], 1) * unit.s.atkScaleMul, type: 'arts', isAttack: true, attackId, tags: ['veen', 'veenBounce'] });
|
||||
warOn(battle, unit, next, 1);
|
||||
step(left - 1);
|
||||
});
|
||||
};
|
||||
step(n);
|
||||
}
|
||||
/** The main bolt of an attack lands on `victim`: every hit of its volley (released in order), then the S3 bounces. */
|
||||
function land(battle, unit, victim, attackId, isSkill) {
|
||||
const v = stateOf(unit);
|
||||
const list = v.q.get(victim.id);
|
||||
const rec = list ? list.shift() : null;
|
||||
if (list && !list.length) v.q.delete(victim.id);
|
||||
if (!rec) { hitOnce(battle, unit, victim, unit.s.atk, 1, 1, attackId, isSkill); return; }
|
||||
for (const p of rec.pre) { if (!victim.alive) return; hitOnce(battle, unit, victim, p.atk, p.scale, p.stacks, attackId, isSkill); }
|
||||
if (!victim.alive) return;
|
||||
hitOnce(battle, unit, victim, rec.main.atk, rec.main.scale, 1, attackId, isSkill);
|
||||
if (rec.main.bounces > 0) bounces(battle, unit, victim, rec.main.bounces, rec.main.atk, attackId);
|
||||
for (const p of rec.post) { if (!victim.alive) return; hitOnce(battle, unit, victim, p.atk, p.scale, p.stacks, attackId, isSkill); }
|
||||
}
|
||||
|
||||
return {
|
||||
trait: {
|
||||
hitsFn: () => 0, // the volley's hits are the kit's (land)
|
||||
onEachHit(battle, unit, victim, hc) { if (hc.kind === 'main' && victim && victim.side === 'enemy') land(battle, unit, victim, hc.attackId, !!hc.isSkill); },
|
||||
// only enemies under her 战争技艺 in range: she holds her fire (and stores energy)
|
||||
canAttack(battle, u) {
|
||||
const ok = validTargets(battle, u, u.profile).length > 0;
|
||||
if (!ok) u.trait.hadTarget = false;
|
||||
return ok;
|
||||
},
|
||||
install(battle, unit) {
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.trait.veen = null; }, { owner: unit });
|
||||
// the mystic store: her attack ready and no valid target ⇒ one energy, an attack action (the interval restarts);
|
||||
// 3 merge into a 转置能量 (the attack loop ran first this tick: a valid target was attacked instead)
|
||||
battle.on('tick', () => {
|
||||
const v = stateOf(unit);
|
||||
if (!up(unit) || !unit.canAct || unit.s.flags.disarm || unit.atkCd > 0 || v.t >= tMax) return;
|
||||
if (validTargets(battle, unit, unit.profile).length) return;
|
||||
if (++v.s >= merge) { v.s -= merge; v.t++; }
|
||||
unit.atkCd = unit.s.interval;
|
||||
}, { owner: unit });
|
||||
battle.on('death', (ctx) => { if (ctx.unit && ctx.unit.side === 'enemy') stateOf(unit).q.delete(ctx.unit.id); }, { owner: unit });
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
[S1]: { kind: 'duration', trigger: { rule: 'SP_FULL' }, mods: { aspd: num(b1.attack_speed) } },
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
mods: { batPct: batMod(b2.base_attack_time, chess) },
|
||||
onStart({ battle, unit }) { stateOf(unit).k = 0; battle.removeBuff(unit, S2_STACK); },
|
||||
onEnd({ battle, unit }) { stateOf(unit).k = 0; battle.removeBuff(unit, S2_STACK); },
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'ammo',
|
||||
ammo: num(b3['attack@trigger_time'], 1),
|
||||
mods: { aspd: num(b3.attack_speed) },
|
||||
// bullets: 1 per stored energy, merge_cnt per 转置能量 the attack released (none without)
|
||||
onAttack(ctx) {
|
||||
ctx.noAmmo = true;
|
||||
const { battle, unit, skill } = ctx;
|
||||
const v = stateOf(unit);
|
||||
const n = v.use;
|
||||
v.use = 0;
|
||||
for (let i = 0; i < n && skill.active; i++) {
|
||||
skill.ammoLeft--;
|
||||
battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill });
|
||||
if (skill.ammoLeft <= 0) skill.end('ammo');
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // “在挥刀之前”: ATK while holding a 转置能量, ASPD while holding none
|
||||
const atk = num(t0.atk), aspd = num(t0.attack_speed);
|
||||
if (atk) toggleBuff(battle, unit, 'talent:veen:merged', () => stateOf(unit).t > 0, { atkPct: atk });
|
||||
if (aspd) toggleBuff(battle, unit, 'talent:veen:unmerged', () => stateOf(unit).t === 0, { aspd });
|
||||
} },
|
||||
{ install(battle, unit) { // 战争技艺: the stacks come with every hit (warOn); she never picks a marked enemy
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || ctx.targets.every((e) => !marked(unit, e))) return;
|
||||
const prof = ctx.profile || unit.profile;
|
||||
const cands = validTargets(battle, unit, prof);
|
||||
sortEnemyTargets(battle, unit, cands, prof.priority);
|
||||
ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length));
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// MSC-Y: ASPD while she holds stored energy
|
||||
const msc = num(hidden.attack_speed);
|
||||
if (msc) toggleBuff(battle, unit, 'trait:veen:stored', () => { const v = stateOf(unit); return v.s + v.t > 0; }, { aspd: msc });
|
||||
// the volley leaves with the attack: 转置能量 → main → stored, each with the ATK of its release (S2 stacks between)
|
||||
battle.on('attack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !ctx.targets.length) return;
|
||||
const v = stateOf(unit);
|
||||
const t = v.t, s = v.s;
|
||||
v.t = 0; v.s = 0;
|
||||
const s3 = skillOn(unit, S3);
|
||||
const pre = [], post = [];
|
||||
for (let i = 0; i < t; i++) { if (!s3) pre.push({ atk: unit.s.atk, scale: merge, stacks: merge }); s2Stack(battle, unit, merge); }
|
||||
const main = { atk: unit.s.atk, scale: s3 ? num(b3['attack@base_atk_scale'], 1) : 1, bounces: s3 ? s + merge * t : 0 };
|
||||
s2Stack(battle, unit, 1);
|
||||
for (let i = 0; i < s; i++) { if (!s3) post.push({ atk: unit.s.atk, scale: 1, stacks: 1 }); s2Stack(battle, unit, 1); }
|
||||
v.use = s3 ? s + merge * t : 0;
|
||||
ctx.targets.forEach((tg, i) => {
|
||||
const list = v.q.get(tg.id) ?? [];
|
||||
list.push(i === 0 ? { pre, main, post } : { pre: [], main: { atk: main.atk, scale: main.scale, bounces: 0 }, post: [] });
|
||||
v.q.set(tg.id, list);
|
||||
});
|
||||
if (t || s) battle.fx('veenVolley', { x: unit.x, y: unit.y, id: unit.id, target: ctx.targets[0].id, stored: s, merged: t });
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
// server/sim/content/kits/ops/op-zuole.js — 左乐 (char_4121_zuole) 自选 operator kit: 6★ 武者 (近卫), an owned-6★ pick of
|
||||
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
|
||||
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_4121_zuole, 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). Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 左乐 (S2 / S3
|
||||
// 备注); the client's buff templates (buff_template_data `zuole_trait`, `zuole_t_2`, `zuole_s_2[shield]` /
|
||||
// `[shield_core]`, `zuole_s_3` / `[shield]`, `zuole_e_002[resistance]`, `zuole_e_003_talent`, `zuole_e_003_trait`);
|
||||
// Arknights Terra Wiki "Zuo Le" (the barrier caps count each skill's own barrier only).
|
||||
// - Trait (武者) "不成为其他角色的治疗目标,每次攻击到敌人后回复自身70生命": the profession default (musha: no heal from
|
||||
// others, `value` per enemy hit; zuole_trait heals on every damage he outputs — the profession heals a normal attack
|
||||
// per target and any other damage per instance, so S1's extra strikes are separate damage instances).
|
||||
// - T1 秉烛照影 "在场时,自身获得最高+50攻击速度和技力自然回复速度+2/秒的坚忍(损失70%生命值时达到最大加成)" (bb
|
||||
// min_attack_speed / min_sp_recovery_per_sec / min_hp_ratio): 坚忍 (ba.berserk "根据已损失的生命值获得相应比例的属性
|
||||
// 加成") — ASPD and SP recovery × min(1, lost HP ÷ (1 − min_hp_ratio)) while he is deployed. SBL-X stage 3: +70 /
|
||||
// +2.3 / at 50 % lost (the module talent's bb).
|
||||
// - T2 守正自明 "攻击时有20%的概率获得1点技力,生命低于50%时概率变为70%" (bb prob_1 / prob_2 / hp_ratio / sp): zuole_t_2
|
||||
// rolls on every damage he outputs (ON_OUTPUT_DAMAGE — skill damage too), prob_2 below hp_ratio. SBL-Y stage 3
|
||||
// (zuole_e_003_talent, ON_CALCULATE_DAMAGE): "…且当次攻击的攻击力提升至120%…80%": a success also scales that damage
|
||||
// instance by atk_scale.
|
||||
// - Module SBL-X “岂苦夜长” (trait moduleDesc "生命值低于50%时,获得25%的庇护"; the hidden part merged into the first talent's
|
||||
// bb: hp_ratio / damage_resistance): 庇护 (ba.protect "受到的物理和法术伤害降低相应比例") while HP < hp_ratio
|
||||
// (zuole_e_002[resistance]: the 庇护 buff is created when HP drops below it and finished when it is back). PRTS S2 备注:
|
||||
// 行险 resets it, it comes back 0.1 s later "并因此导致庇护的BUFF顺序落后于本技能的屏障" — a 庇护 created after his
|
||||
// barrier cuts only what passes the barrier (the `hpDamage` hook), one created before it cuts the hit first.
|
||||
// - Module SBL-Y “秉烛人的记录” (trait bb hp_ratio): "被击倒时不撤退且回复30%生命(单次部署只触发1次)" — the first lethal
|
||||
// hit of a deployment is prevented and he heals hp_ratio × max HP (zuole_e_003_trait[heal] HealViaMaxHpRatio, 无视禁疗);
|
||||
// not while a 不死 already holds him ("_dontConsumeWhenUndeadable": 坚固维式重锤's window, items/battle.js holdsUndying).
|
||||
// - S1 破虏 (AUTO, 可充能 2 次): the next attack at atk_scale × ATK; "自身生命低于80%时额外攻击1次,低于50%时额外攻击2次"
|
||||
// (hp_ratio_double / hp_ratio_tripple): one / two more strikes on that target at the same scale, separate damage
|
||||
// instances [ASSUMED: the same target — nothing when it fell]. The data's DEFAULT trigger (an AUTO "next attack").
|
||||
// - S2 行险 (MANUAL, 12 s): "立即流失50%当前生命" (zuole_s_2[shield] DamageViaCurHpRatio, undeadable: never below 1 HP;
|
||||
// a 流失), then a barrier of scale × max HP (ba.barrier "可以吸收一定数值的伤害") added to the one he still has, capped at
|
||||
// max_scale × max HP at each gain (PRTS "上限于每次获取屏障时实时发生变化"; Terra: the cap counts this skill's barrier
|
||||
// only); ATK +atk, block +block_cnt, every blocked enemy struck at once. Once the skill is over the barrier loses
|
||||
// −shield_decrease every second (PRTS "技能结束时屏障以每秒-200点的速度流失"; zuole_s_2[shield_core] ON_BUFF_TRIGGER).
|
||||
// - S3 佑序有炎 (MANUAL, data SKILL_RANGE on its 3-2): 7 slashes ("立刻…": all in the cast instant [ASSUMED: the kits'
|
||||
// instant convention — his skeleton's 2 s Skill_3 clip spreads its 7 OnAttack events over 0.2 … 1.2 s, and the engine
|
||||
// models no animation lock]), each on at most 3 enemies of the skill range (the 3 is the text's: no
|
||||
// blackboard key; air units too [ASSUMED: no 对空 note — docs/SIM.md §8]) for atk_scale × ATK physical, the last
|
||||
// ×last_atk_bonus and 晕眩 `stun` s (zuole_s_3: AtkScaleUp once spell_times reaches times). Meanwhile the trait heals
|
||||
// nothing (zuole_trait "CheckContainsBuff zuole_s_3 → IfNot → heal") and every damage instance he outputs adds
|
||||
// value × shield_scale to a barrier (zuole_s_3 ON_OUTPUT_DAMAGE; PRTS "屏障获取不经过治疗流程,不受治疗倍率等影响"), capped
|
||||
// at max_scale × max HP at each gain, lasting shield_duration s from the latest gain (PRTS "每次获取屏障时重置剩余持续
|
||||
// 时间为15s").
|
||||
|
||||
import { num, talentBb, traitBb, up, onHitBy, onHitOn, giveSp, skillRec } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
|
||||
import { isHpLoss } from '../../../damage.js';
|
||||
import { holdsUndying } from '../../items/battle.js';
|
||||
|
||||
const S1 = 'skchr_zuole_1';
|
||||
const S2 = 'skchr_zuole_2';
|
||||
const S3 = 'skchr_zuole_3';
|
||||
/** S3 "每次对最多3名敌人" — in the text only (no blackboard key). */
|
||||
const S3_TARGETS = 3;
|
||||
/** PRTS S2 备注 "庇护BUFF于技能开启0.1s后重新判定并生效". */
|
||||
const PROTECT_RESET = 0.1;
|
||||
/** zuole_s_2[shield_core] decays once per trigger: "每秒-200点" (PRTS S2 备注). */
|
||||
const S2_DECAY_EVERY = 1;
|
||||
const S2_BARRIER = 'skill:zuole:barrier2';
|
||||
const S3_BARRIER = 'skill:zuole:barrier3';
|
||||
const BERSERK = 'talent:zuole:berserk';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
/** The barrier of his skill he holds (one skill per pick), or null. */
|
||||
const barrierOf = (u) => u.findBuff(S2_BARRIER) || u.findBuff(S3_BARRIER);
|
||||
|
||||
export default {
|
||||
char_4121_zuole: (bb, chess) => {
|
||||
const tb = traitBb(chess);
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s1 = skillRec(chess, S1), s3 = skillRec(chess, S3);
|
||||
const healValue = num(tb.value, 70);
|
||||
// SBL-X 庇护 (merged into the first talent's bb) — its state is shared with S2's reset
|
||||
const protectRatio = num(t0.hp_ratio), protectCut = t0.hp_ratio != null ? num(t0.damage_resistance) : 0;
|
||||
const P = { since: null, blockedUntil: -Infinity };
|
||||
const s2Decay = ({ battle, unit, buff }) => {
|
||||
const sk = unit.skill;
|
||||
if (sk && sk.active && sk.id === S2) return;
|
||||
buff.shield = Math.max(0, buff.shield + num(b2.shield_decrease, -200));
|
||||
unit.markDirty();
|
||||
if (!(buff.shield > 0)) battle.removeBuff(unit, buff);
|
||||
};
|
||||
const gainS3Barrier = (battle, unit) => {
|
||||
const cap = unit.s.maxHp * num(b3.max_scale, 2);
|
||||
const cur = unit.findBuff(S3_BARRIER);
|
||||
const val = Math.min(cap, (cur ? cur.shield : 0) + healValue * num(b3.shield_scale));
|
||||
if (!(val > 0)) return;
|
||||
battle.addBuff(unit, { key: S3_BARRIER, shield: val, duration: num(b3.shield_duration, 15), refresh: 'replace', visible: true,
|
||||
tags: ['skill'], data: { createdAt: cur?.data?.createdAt ?? battle.time } });
|
||||
};
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
|
||||
attack: {
|
||||
atkScale: num(b1.atk_scale, 1),
|
||||
onHit({ battle, unit, target }) {
|
||||
const r = unit.hpRatio;
|
||||
const extra = (r < num(b1.hp_ratio_double, 0.8) ? 1 : 0) + (r < num(b1.hp_ratio_tripple, 0.5) ? 1 : 0);
|
||||
for (let i = 0; i < extra && target && target.alive; i++) {
|
||||
battle.dealDamage(unit, target, { amount: unit.s.atk * num(b1.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b2.atk), blockCnt: num(b2.block_cnt) },
|
||||
attack: { hitAllBlocked: true },
|
||||
onStart({ battle, unit }) {
|
||||
const loss = Math.min(unit.hp * num(b2.hp_ratio), unit.hp - 1);
|
||||
if (loss > 0) battle.loseHp(unit, loss, { source: unit });
|
||||
const cap = unit.s.maxHp * num(b2.max_scale, 2);
|
||||
const add = Math.min(cap, unit.s.maxHp * num(b2.scale));
|
||||
const cur = unit.findBuff(S2_BARRIER);
|
||||
if (cur) { cur.shield = Math.min(cap, cur.shield + add); unit.markDirty(); }
|
||||
else if (add > 0) {
|
||||
battle.addBuff(unit, { key: S2_BARRIER, shield: add, interval: S2_DECAY_EVERY, onTick: s2Decay, visible: true, tags: ['skill'],
|
||||
data: { createdAt: battle.time } });
|
||||
}
|
||||
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id });
|
||||
// SBL-X: 行险 resets the 庇护, which is judged again PROTECT_RESET s later (after the barrier)
|
||||
P.since = null;
|
||||
P.blockedUntil = battle.time + PROTECT_RESET;
|
||||
},
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'instant',
|
||||
onStart({ battle, unit }) {
|
||||
const grid = s3?.rangeGrid?.length ? s3.rangeGrid : [[0, 0], [0, 1], [0, 2], [0, 3]];
|
||||
const keys = absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0);
|
||||
const times = Math.max(1, Math.floor(num(b3.times, 7)));
|
||||
battle.fx('aoe', { x: unit.x + 1.5 * unit.fwd.x, y: unit.y + 1.5 * unit.fwd.y, radius: 1, id: unit.id, skill: 'zuole:slash' });
|
||||
unit.mem.zuoleS3 = true;
|
||||
try {
|
||||
for (let i = 1; i <= times && up(unit); i++) {
|
||||
const last = i === times;
|
||||
const foes = battle.enemiesInKeys(keys, unit, { canHitFly: true });
|
||||
sortEnemyTargets(battle, unit, foes, null);
|
||||
const amount = unit.s.atk * num(b3.atk_scale, 1) * (last ? num(b3.last_atk_bonus, 1) : 1);
|
||||
for (const e of foes.slice(0, S3_TARGETS)) {
|
||||
if (!e.alive) continue;
|
||||
battle.dealDamage(unit, e, { amount, type: 'phys', isSkill: true, tags: ['skill'] });
|
||||
if (last && e.alive) battle.applyStatus(e, 'stun', { duration: num(b3.stun), source: unit });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
unit.mem.zuoleS3 = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) { // 秉烛照影 (坚忍)
|
||||
const maxAs = num(t0.min_attack_speed), maxSp = num(t0.min_sp_recovery_per_sec), full = num(t0.min_hp_ratio, 0.3);
|
||||
if (!(maxAs > 0 || maxSp > 0) || !(full < 1)) return;
|
||||
let last = -1;
|
||||
const update = () => {
|
||||
if (!up(unit)) { last = -1; return; }
|
||||
const f = Math.max(0, Math.min(1, (1 - unit.hpRatio) / (1 - full)));
|
||||
if (Math.abs(f - last) < 1e-9) return;
|
||||
last = f;
|
||||
const cur = unit.findBuff(BERSERK);
|
||||
if (!(f > 0)) { if (cur) battle.removeBuff(unit, cur); return; }
|
||||
const mods = { aspd: maxAs * f, spRecoveryFlat: maxSp * f };
|
||||
if (cur) { cur.mods = mods; unit.markDirty(); } else battle.addBuff(unit, { key: BERSERK, mods, tags: ['talent'] });
|
||||
};
|
||||
battle.on('deploy', ({ unit: u }) => { if (u === unit) { last = -1; update(); } }, { owner: unit });
|
||||
battle.on('damaged', (c) => { if (c.target === unit) update(); }, { owner: unit });
|
||||
battle.on('tick', update, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) { // 守正自明 (+ SBL-Y stage 3: ×atk_scale on a success)
|
||||
const p1 = num(t1.prob_1), p2 = num(t1.prob_2), thr = num(t1.hp_ratio, 0.5), sp = num(t1.sp), scale = num(t1.atk_scale, 1);
|
||||
if (!(sp > 0)) return;
|
||||
const roll = () => battle.rng.chance(unit.hpRatio < thr ? p2 : p1);
|
||||
if (scale !== 1) {
|
||||
onHitBy(battle, unit, ({ dmg }) => { if (dmg.type !== 'element' && roll()) { giveSp(unit, sp); dmg.amount *= scale; } });
|
||||
} else {
|
||||
battle.on('damaged', (c) => {
|
||||
if (c.source !== unit || !c.target || c.target.side !== 'enemy' || isHpLoss(c.dmg) || c.type === 'element') return;
|
||||
if (roll()) giveSp(unit, sp);
|
||||
}, { owner: unit });
|
||||
}
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
unit.mem.zuoleProtect = P;
|
||||
// S3: the trait's heal turns into barrier while the slashes run (every damage instance he outputs)
|
||||
battle.on('heal', (c) => {
|
||||
if (unit.mem.zuoleS3 && c.source === unit && c.target === unit && c.opts && c.opts.self && !c.opts.tags) c.amount = 0;
|
||||
}, { owner: unit });
|
||||
battle.on('damaged', (c) => {
|
||||
if (!unit.mem.zuoleS3 || c.source !== unit || !c.target || c.target.side !== 'enemy' || isHpLoss(c.dmg) || c.type === 'element') return;
|
||||
gainS3Barrier(battle, unit);
|
||||
}, { owner: unit });
|
||||
// SBL-X 庇护: active while HP < protectRatio, judged again PROTECT_RESET s after 行险
|
||||
if (protectCut > 0) {
|
||||
const active = () => {
|
||||
const now = battle.time;
|
||||
if (!up(unit) || now < P.blockedUntil - 1e-9) { P.since = null; return false; }
|
||||
if (unit.hpRatio < protectRatio) { if (P.since == null) P.since = now; return true; }
|
||||
P.since = null;
|
||||
return false;
|
||||
};
|
||||
const afterBarrier = new WeakSet();
|
||||
battle.on('tick', active, { owner: unit });
|
||||
onHitOn(battle, unit, ({ dmg }) => {
|
||||
if ((dmg.type !== 'phys' && dmg.type !== 'arts') || !active()) return;
|
||||
const bar = barrierOf(unit);
|
||||
if (bar && bar.shield > 0 && (bar.data?.createdAt ?? Infinity) < P.since) afterBarrier.add(dmg);
|
||||
else dmg.mul *= 1 - protectCut;
|
||||
});
|
||||
battle.on('hpDamage', (c) => { if (c.target === unit && afterBarrier.has(c.dmg)) c.amount *= 1 - protectCut; }, { owner: unit });
|
||||
}
|
||||
// SBL-Y: 被击倒时不撤退且回复30%生命(单次部署只触发1次)
|
||||
const revive = num(tb.hp_ratio);
|
||||
if (revive > 0) {
|
||||
let usedSeq = -1;
|
||||
battle.on('fatal', (c) => {
|
||||
if (c.unit !== unit || c.prevented || usedSeq === unit.deploySeq || holdsUndying(battle, unit)) return;
|
||||
c.prevented = true;
|
||||
usedSeq = unit.deploySeq;
|
||||
battle.heal(unit, unit, unit.s.maxHp * revive, { self: true, ignoreHealFree: true, tags: ['trait'] });
|
||||
battle.fx('revive', { x: unit.x, y: unit.y, id: unit.id });
|
||||
}, { owner: unit, priority: -60 });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
+21
-8
@@ -5,8 +5,10 @@
|
||||
// → dodge (phys/arts, canDodge) → mitigation (phys: DEF, arts: RES, true: none)
|
||||
// → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul (not for 元素伤害) × type-taken mul × dmg.mul
|
||||
// → 限伤 (leaders in boss / hidden battles: a hit of ceil(final) ≥ BOSS_HIT_LIMIT is cancelled, see leaderHitCancelled)
|
||||
// → shields (hit-negating barriers first, then HP shields) → HP loss (boss pool routing) → 'damaged' hook
|
||||
// → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
|
||||
// → shields (hit-negating barriers first, then HP shields; a typed one — buff `shieldType` — only its damage type)
|
||||
// → 'hpDamage' hook (what passed the shields; handlers may only lower `amount`: the 伤判效果 that act after a
|
||||
// barrier — 煌's 紧急除颤 HP floor, 左乐's 庇护 re-applied after his 行险 barrier) → HP loss (boss pool routing)
|
||||
// → 'damaged' hook → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
|
||||
// Damage-dealt stats (the source's `stats.dmg`, the player's `damageDealt`) count only HP removed from the other side:
|
||||
// self and friendly damage (a 源石溶剂 drain, an operator's own 流失) is the target's `taken` and keeps the kill credit.
|
||||
// Phys: max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %·A); Arts: max(A×(1 − R′/100), 5 %·A) with
|
||||
@@ -180,14 +182,19 @@ export function leaderHitCancelled(battle, target, amount) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Absorb damage with shields on `target`. Returns the remaining amount. */
|
||||
export function absorbShields(battle, target, amount) {
|
||||
/**
|
||||
* Absorb damage with shields on `target`. Returns the remaining amount. `type` = the damage type: a shield buff with a
|
||||
* `shieldType` absorbs only that type (夜莺 S2 "屏障能吸收…法术伤害"); one without absorbs every type (PRTS 术语释义 屏障
|
||||
* "若无特殊说明,屏障可吸收全种类伤害"). Older shields first (buff order: "优先消耗先生成的屏障").
|
||||
*/
|
||||
export function absorbShields(battle, target, amount, type = null) {
|
||||
if (amount <= 0) return 0;
|
||||
let changed = false;
|
||||
let rest = amount;
|
||||
const absorbs = (b) => !b.shieldType || b.shieldType === type;
|
||||
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
|
||||
const b = target.buffs[i];
|
||||
if (b.shieldHits > 0) {
|
||||
if (b.shieldHits > 0 && absorbs(b)) {
|
||||
b.shieldHits--;
|
||||
rest = 0;
|
||||
if (b.shieldHits <= 0 && !(b.shield > 0) && !b.mods && !b.flags) { battle._removeBuffAt(target, i); i--; }
|
||||
@@ -197,7 +204,7 @@ export function absorbShields(battle, target, amount) {
|
||||
}
|
||||
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
|
||||
const b = target.buffs[i];
|
||||
if (b.shield > 0) {
|
||||
if (b.shield > 0 && absorbs(b)) {
|
||||
const take = Math.min(b.shield, rest);
|
||||
b.shield -= take;
|
||||
rest -= take;
|
||||
@@ -247,7 +254,7 @@ export function dealDamage(battle, source, target, dmgIn) {
|
||||
// recognise their own (tagged) damage — never re-create such a loss with a fresh loseHp.
|
||||
if (ts.flags.hitCount || ts.flags.hitCountArts) {
|
||||
const counts = !(ts.flags.hitCountArts && !ts.flags.hitCount && type === 'phys');
|
||||
return applyHpLoss(battle, source, target, absorbShields(battle, target, counts ? 1 : 0), dmg);
|
||||
return applyHpLoss(battle, source, target, absorbShields(battle, target, counts ? 1 : 0, type), dmg);
|
||||
}
|
||||
// 无来源 damage (element bursts) takes nothing from its source's stats; the source still gets the credit below
|
||||
const ss = source && source.s && !dmg.sourceless ? source.s : null;
|
||||
@@ -267,7 +274,13 @@ export function dealDamage(battle, source, target, dmgIn) {
|
||||
// 限伤: a leader's hit of ≥ BOSS_HIT_LIMIT in a boss / hidden battle is cancelled before it reaches shields / HP — what
|
||||
// ran before it (the attack, its SP, `hit` hook effects, separate element 损伤) stays; nothing after it happens
|
||||
if (final > 0 && leaderHitCancelled(battle, target, final)) return 0;
|
||||
final = absorbShields(battle, target, final);
|
||||
final = absorbShields(battle, target, final, type);
|
||||
// 伤判效果 after the barriers (header): a handler may lower what reaches the HP — never raise it (a 流失 skips this)
|
||||
if (final > 0 && battle._hooks.hpDamage) {
|
||||
const hctx = { source: hs, target, amount: final, dmg, credit: source };
|
||||
battle.emit('hpDamage', hctx);
|
||||
if (Number.isFinite(hctx.amount)) final = Math.max(0, Math.min(final, hctx.amount));
|
||||
}
|
||||
return applyHpLoss(battle, source, target, final, dmg);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
// noHeal bool (cannot be healed by others) blockFly bool onHitStatus {key, duration, value}
|
||||
// dmgMul(battle, unit, target) → number afterHit(battle, unit, target, {dealt,x,y})
|
||||
// canAttack(battle, unit) → bool afterAttack(battle, unit, targets)
|
||||
// skipEnemy(enemy) → bool (an enemy the unit never selects — targeting.js canTargetEnemy; 嵯峨 "不攻击重伤单位")
|
||||
// hitsFn(battle, unit) → n install(battle, unit) — per-unit hooks, called once at setup
|
||||
// dollNoAttack bool (傀儡师: its <替身> makes no normal attack and casts no skill — 归溟幽灵鲨, kit trait)
|
||||
// tb — the unit's trait blackboard (data `trait.bb`), used for tunables (module upgrades included on elites)
|
||||
|
||||
@@ -74,7 +74,11 @@ export function enemyStealthed(e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Can `attacker` (ally) target enemy `e` at all (ignoring range)? */
|
||||
/**
|
||||
* Can `attacker` (ally) target enemy `e` at all (ignoring range)? A profile's own exclusion `skipEnemy(e)` (a kit trait:
|
||||
* 嵯峨 劝善 "嵯峨不攻击重伤单位") keeps such an enemy out of the unit's normal targets, the enemies it blocks
|
||||
* (blockedTargets) and the target condition of its skill triggers (they pass `unit.profile`).
|
||||
*/
|
||||
export function canTargetEnemy(attacker, e, profile) {
|
||||
if (!e.alive || e.hidden || !e.deployed) return false;
|
||||
const f = e.s.flags;
|
||||
@@ -82,6 +86,7 @@ export function canTargetEnemy(attacker, e, profile) {
|
||||
if (f.stealth && enemyStealthed(e)) return false;
|
||||
if (e.isFlying && !(profile && profile.canHitFly)) return false;
|
||||
if (profile && profile.groundOnly && e.isFlying) return false;
|
||||
if (profile && typeof profile.skipEnemy === 'function' && profile.skipEnemy(e)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -178,6 +183,9 @@ const PRIORITY_FNS = {
|
||||
boss: (e) => (e.isBoss ? 0 : 1),
|
||||
notBurst: (e) => (e.s.flags.burstLock ? 1 : 0),
|
||||
ground: (e) => (e.isFlying ? 1 : 0),
|
||||
// 攻城手 trait "优先攻击重量最重的敌人" (早露 / 提丰: kits/ops/op-poca.js, op-typhon.js): the highest current 重量等级
|
||||
// (Unit.weight: massLevel with 失重 etc.) first, the usual order after that
|
||||
heaviest: (e) => -e.weight,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user