feedback5 O4: 自选 operator kits for 陈, 艾丽妮 and 赫拉格 — every skill, talent, trait and module (stage 1 and 3) at every form
- op-chen.js (char_010_chen, 剑豪): S1 鞘击 one sheath strike (her Skill clip: one OnAttack) + stun; S2 赤霄·拔刀 up to max_target enemies of 3-12, flyers too, arts then physical (PRTS 备注); S3 赤霄·绝影 10 slashes on the nearest ground enemy of x-1 at the Skill_3 clip's OnAttack times, the last stuns, 2.933 s of 无敌 / 无法阻挡 / stun and freeze immunity, ends at once with no target before the 10th (PRTS 备注), not opened with only flyers in range [ASSUMED]; 呵斥 per-deployment timer +1 attack / hurt SP to the field's allies (SWO-X 3: every 3 s, +1 more for her); 持刀格斗术; SWO-X x1.1 during her skill (PRTS 修正 技能期间), SWO-Y ignores 70 DEF. - op-irene.js (char_4009_irene, 剑豪): 审判之火 as PRTS 备注 (a 物理穿透 charge: air units always and without a draw, 浮空 enemies count, ground at 50 %, spent only by a landing instance); SWO-X 3: 55 % and +6 SP after a skill that knocked out an air unit; 净化之剑 doubled while a 【海怪】 is on the field; S1 起风 strike + 浮空 + second strike; S2 裂潮 2 charges, ground only, 重量 <= 3 float, not opened with only flyers in range [ASSUMED]; S3 判决 3.5 s (PRTS): opening strike + 浮空, 10 bombardments every 0.3 s, radius 1.1, air too; SWO-X x1.1 skill damage, SWO-Y 70 DEF, ISW-A attributes only (its talent parts are 集成战略-only). - op-helage.js (char_188_helage, 武者): S1 新月 two hits, S2 弦月 二连击 + 75 % dodge, S3 满月 range +1 / 3 targets; the 武者 heal per damage instance (the kit adds one per further hit of an attack); 坚忍 ASPD (SBL-X 3: +130 at 50 %); 运筹帷幄 生命回复速度 while not blocking [ASSUMED: no PRTS 备注], SBL-Y 3 also below 30 % HP (80 / s, never both); SBL-X 庇护 25 % below 50 % HP; SBL-Y once per deployment back to 30 % HP. - kits/index.js OPERATOR_KIT_FILES: the three files appended. Tests: op_chen / op_irene / op_helage (10 each, every form x module x skill, numbers from data/backups.json). - Golden: only the diy family moved — the packing now fields the new kits: diy-003 … diy-006 (the prototypes before) and the new diy-007 … diy-009 field 陈 / 艾丽妮 / 赫拉格 in every form x module x skill, the prototypes moved to the new diy-010 … diy-013; diy-001 / diy-002 (推进之王) and roster / bonds / fields / matches / standins unchanged. test/golden/README.md: the diy row no longer counts battles or names 推进之王 alone.
This commit is contained in:
1 parent
742f478526
commit
a62728c188
9 files changed
+1798
-102
No files matched your search
@@ -78,7 +78,11 @@ 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',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The 预备干员 whose every skill the generic kit covers exactly (冲锋号令, 攻击力 / 防御力 / 治疗强化, 战术咏唱, 一击即退 —
|
||||
|
||||
@@ -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,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,198 @@
|
||||
// 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: 不可叠加). 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);
|
||||
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);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user