feedback5 O23: 自选 kits for 白铁, 娜斯提 (with their 支援装置) and 死芒 (with her 悲叹的仆役); ally targets for 白铁's 铁钳号·原型机
- kits/ops/op-ironmn.js — 白铁 (char_4072_ironmn): S1 / S2 / S3, 战地工程师 (device stock 3, CRA-X +1, refilled at each deployment; a device that left comes back on its tile after its redeploy time, paying its cost, one stock, while he stands; his devices leave with him), 节约经费 (prob 0.7 / CRA-X stage 3 0.9), CRA-X (stock, costs, stage 3 SP +0.2/s), CRA-Y (redeploy times, stage 3 ASPD +6 on device-buffed operators); the device kits 白铁™多功能平台 (S1 ATK +12 % ×2.5 / 3 in S1; S2 1 SP / 3.5 s, 2.5 / 2 s in S2, 0.4 % / s 流失 ×2) and 铁钳号·原型机 (an ally target: damage cancelled, 1 SP per hit, a 工匠's hit heals 1 %; its skill −2.5 % then 230 / 260 % + 110 / 120 % within 1.1 of 白铁's ATK; 团结的力量 ×0.8 physical behind it). Exports the shared 工匠 device helpers. - kits/ops/op-nasti.js — 娜斯提 (char_4212_nasti): S1 / S2 / S3, 前方施工 (stock; CRA-X stage 3 first device −3, combined ranges), 注意安全 (10 % 庇护, 15 % + 1 SP / 6 s for her and our ranged operators once a ranged enemy appeared), CRA-X; the device kits “质检专员”, “监工专员” (3 / 8 HP, its S2 pulses of SP and her barrier) and “应急承重小组” (小工程师 + the 高台 it builds and levels). - kits/ops/op-necras.js — 死芒 (char_450_necras): S1 / S2 / S3, 复燃 (summon on the kill tile or a random tile of her range, upgrade at 3), 回光黯淡, the trait (the enemies her summons block are her targets; SOC-X ×1.15), SOC-X stage 3; the 悲叹的仆役 kit and its special form (S3). - Engine (additive): Battle.setAllyTarget / allyTargetsInKeys / isAllyTarget (battle/queries.js); ai.js acquireTargets puts a registered ally target of the range after every enemy, performAttack flies ranged attacks to it. Nothing changes while none is registered: every golden family but diy unchanged. docs/SIM.md §6 documents it. - kits/index.js: the three files appended to OPERATOR_KIT_FILES. - Tests: test/content/op_ironmn.test.js, op_nasti.test.js, op_necras.test.js, test/sim/feedback5-o23-engine.test.js. - Golden: only diy moved — diy-106 … diy-109 re-chunked (the new operators' configurations come before the prototypes), diy-110 … diy-114 new.
This commit is contained in:
1 parent
996f5d20fe
commit
5ef47b0cf1
12 files changed
+2859
-100
No files matched your search
+15
-3
@@ -15,7 +15,9 @@
|
||||
// range, blocker first); every blocker whose attack hits enemies — a ranged operator on a melee tile included — may
|
||||
// always target the enemies it blocks, in range or not, whatever its facing, and targets them first (acquireTargets,
|
||||
// Battle.blockedTargets; user playtest #6: "阻挡了就一定要能打到"); a heal attack keeps selecting injured allies while
|
||||
// its unit blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位").
|
||||
// its unit blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"). An ally target
|
||||
// (Battle.setAllyTarget: 白铁's 铁钳号·原型机, an enemy-camp summon our operators attack, 嘲讽等级 −2) on the range comes after
|
||||
// every enemy (acquireTargets) and a ranged attack flies to it like to an enemy.
|
||||
// Unblocked ranged enemies attack allies within their radius and stand for each attack's clip — through its wind-up
|
||||
// and until the clip ends — then walk on (attackStand, GitHub #58; ATTACK_PAUSE after the strike when no clip is known;
|
||||
// 「不停止移动」 attackers never stop); the candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered
|
||||
@@ -135,6 +137,15 @@ export function acquireTargets(b, u, prof) {
|
||||
// (0.7071) reaches past its own tile, so a blocked enemy may stand outside a short range or behind its facing (user
|
||||
// playtest #5 item 4); a ranged operator on a melee tile too (user playtest #6: "阻挡了就一定要能打到")
|
||||
if (u.blocking.length) for (const e of b.blockedTargets(u, prof)) if (!cands.includes(e)) cands.push(e);
|
||||
// an ally target (Battle.setAllyTarget: 白铁's 铁钳号·原型机, an enemy-camp summon with 嘲讽等级 −2) after every enemy
|
||||
const extra = b._allyTargets && b._allyTargets.size ? b.allyTargetsInKeys(u.rangeKeys, u) : null;
|
||||
if (extra && extra.length) {
|
||||
if (prof.allInRange) return cands.concat(extra);
|
||||
sortEnemyTargets(b, u, cands, prof.priority);
|
||||
const all = cands.concat(extra);
|
||||
const n = Math.max(1, Math.floor((prof.maxTargets || 1) + u.s.maxTargets));
|
||||
return n >= all.length ? all : all.slice(0, n);
|
||||
}
|
||||
if (!cands.length) return cands;
|
||||
if (prof.allInRange) return cands;
|
||||
const n = Math.max(1, Math.floor((prof.maxTargets || 1) + u.s.maxTargets));
|
||||
@@ -162,9 +173,10 @@ export function performAttack(b, u, prof, targets, opts = null) {
|
||||
b._ev(['atk', u.id, t.id, vis]);
|
||||
if (isHeal) { doHeal(b, u, prof, t); continue; }
|
||||
const info = { isSkill, index: i, attackId };
|
||||
if (ranged && t.side === 'enemy' && prof.projectile === 'boomerang') {
|
||||
const foe = t.side === 'enemy' || b.isAllyTarget(t);
|
||||
if (ranged && foe && prof.projectile === 'boomerang') {
|
||||
throwBoomerang(b, u, prof, t, info);
|
||||
} else if (ranged && t.side === 'enemy') {
|
||||
} else if (ranged && foe) {
|
||||
const speed = PROJECTILE_SPEEDS[prof.projectile] ?? PROJECTILE_SPEED;
|
||||
// projectiles land even if the shooter died meanwhile (damage is credited to it)
|
||||
b.addProjectile({ from: u, target: t, speed, visual: prof.projectile, source: u, hitDead: prof.splashRadius > 0,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// server/sim/battle/queries.js — Battle methods: queries: the per-tick enemy tile index, enemies / allies by tile keys,
|
||||
// grid, radius and selectability, players, and the range rebuild of an ally (current, base and extra range keys).
|
||||
// grid, radius and selectability, the ally targets (allies our attacks select like enemies), players, and the range
|
||||
// rebuild of an ally (current, base and extra range keys).
|
||||
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
|
||||
|
||||
import { ROWS, COLS } from '../constants.js';
|
||||
@@ -41,6 +42,35 @@ export class BattleQueries {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ally units an ally's attack selects like an enemy — the "ally targets" content registers (setAllyTarget): 白铁's
|
||||
* 铁钳号·原型机, a summon of the enemy camp that "可被我方干员攻击但不受伤害" (PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方";
|
||||
* kits/ops/op-ironmn.js). Those standing on one of `keys`, alive and deployed — never `attacker` itself, and an ally target
|
||||
* selects none. ai.js acquireTargets appends them after the enemies (their 嘲讽等级 −2 puts them last). [] at once
|
||||
* while none is registered, so every other battle runs exactly as before.
|
||||
*/
|
||||
allyTargetsInKeys(keys, attacker) {
|
||||
const set = this._allyTargets;
|
||||
if (!set || !set.size || !keys || !attacker || set.has(attacker)) return [];
|
||||
const ks = keys === attacker.rangeKeys && attacker.rangeKeySet ? attacker.rangeKeySet : new Set(keys);
|
||||
const out = [];
|
||||
for (const a of set) if (a.alive && a.deployed && !a.hidden && ks.has(a.tileR * COLS + a.tileC)) out.push(a);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Register (`on`) or drop an ally target (allyTargetsInKeys); one off the field is skipped while registered. */
|
||||
setAllyTarget(unit, on = true) {
|
||||
if (!unit || unit.side !== 'ally') return false;
|
||||
if (on) (this._allyTargets ??= new Set()).add(unit);
|
||||
else if (this._allyTargets) this._allyTargets.delete(unit);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Is `unit` a registered ally target (setAllyTarget)? */
|
||||
isAllyTarget(unit) {
|
||||
return !!(unit && this._allyTargets && this._allyTargets.has(unit));
|
||||
}
|
||||
|
||||
/**
|
||||
* A living enemy whose body is on any of `keys` — targetable or not (stealthed, untargetable, asleep, flying): the
|
||||
* 技能范围 trigger "技能范围内存在敌人(无视其不可选中)" (skills.js SKILL_RANGE).
|
||||
|
||||
@@ -133,6 +133,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
|
||||
'op-haak.js',
|
||||
'op-phatom.js',
|
||||
'op-crosly.js',
|
||||
'op-ironmn.js',
|
||||
'op-nasti.js',
|
||||
'op-necras.js',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
// server/sim/content/kits/ops/op-ironmn.js — 白铁 (char_4072_ironmn) 自选 operator kit: 6★ 工匠 (辅助), an owned-6★ pick of
|
||||
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules (CRA-X 铁钳号·爬行者, CRA-Y 海布里印记)
|
||||
// at every form, and the kits of his three <支援装置>: 白铁™多功能平台 (token_10027_ironmn_pile1, 极致火力 / pile2, 高效补给)
|
||||
// and 铁钳号·原型机 (token_10027_ironmn_pile3). Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_4072_ironmn): 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 / the token rows (zh_CN, as built into backups.json), PRTS
|
||||
// 白铁 (S1 / S2 备注 "支援装置的效果可叠加", S2 "携带该技能时,支援装置每秒流失最大生命值0.4%的生命值", S3 "此时的支援装置为敌对
|
||||
// 阵营的召唤物单位"; 节约经费 "手动撤回白铁周围8格的装置也可以触发本天赋"; the module pages: CRA-X 白铁™多功能平台 −1 / 铁钳号 −4
|
||||
// 部署费用, CRA-Y −5 s / −10 s 再部署时间), PRTS 白铁™多功能平台 / 铁钳号·原型机 (备注: 持有禁疗、无敌 / "该召唤物阵营为敌方…提供
|
||||
// 我方视野", 禁疗 孤立 不可阻挡 失衡免疫 元素免疫, 晕眩 寒冷 冻结 浮空 束缚 沉默 沉睡免疫, "受到伤害时取消此伤害,若该伤害来源为非
|
||||
// 无职业的辅助职业的工匠单位,治疗自身相当于最大生命值1%的生命值"; 团结的力量 "实际范围为自身及身后一格…多个支援装置的天赋效果可
|
||||
// 叠加"; skill "伤害半径1.1,造成物理普通伤害…可对空", "仅能选择不持有不可选中/隐匿/无敌的敌方单位(无视孤立)"), PRTS 分支特性信息
|
||||
// 工匠 ("干员部署后,按天赋描述数量补充支援装置持有数", "干员离场后,附属的支援装置随之消失"), PRTS 卫戍协议/帮助 (§战斗部署 a hand
|
||||
// summon comes in its deploy-limit count; §作战阶段 "所有手动部署的召唤物…作战开始时立即部署一次", "若战场区初始部署有召唤物,若召唤
|
||||
// 物在战斗期间退场,将在满足条件后立即原地再部署1个"), and the client's battle data read from the local install — charpack
|
||||
// char_4072_ironmn (ironmn_t_1 charge_token[born]; ironmn_t_2 on his own tokens of his x-4: ON_OWNER_FINISH ⇒ Dice(prob) ⇒
|
||||
// RechargeToken(cnt); ironmn_c die_to_kill_token), the token prefabs pile1 / 2 / 3 (talent auras on ally operators —
|
||||
// professionMask 639 — of the device range, `disableOverride` = one buff per device; pile2 modify_sp[trigger] + the
|
||||
// ironmn_pile_bleed timer of 1 s; pile3: Trait ironmn_pile3_trait, 团结的力量 damage_resistance[phy] on targetSide 2 = our
|
||||
// operators of its b-1, `ignoreTargetFree`), the skill prefabs (skchr_ironmn_1 / 2 / 3: devices to their skill mode,
|
||||
// RechargeToken at S1 start / S2 end / S3 start, KillTokens at S1 end; sktok_ironmn_pile3: one target (`_maxTarget` 1),
|
||||
// projectile_chr_ironmn_pile3 at 10 tiles/s, `_exceptTraceTarget` splash, the bleed on the cast event), the equip prefabs
|
||||
// (CRA-X: trigger_charge_token +cnt at each deployment, stage 2+ ironmn_e_002[sp_recover] on the device's x-4; CRA-Y stage
|
||||
// 2+: ironmn_e_003_t[attr] ATTACK_SPEED on the device talent's targets, its own buff per device) and buff_template_data
|
||||
// (ironmn_s_1 / 2 / 3, ironmn_t_2, ironmn_pile_bleed / pile3_bleed: PURE DamageViaMaxHpRatio `_skipModifierEvent` — a 流失 —,
|
||||
// ironmn_pile3_s[main] / [splash]: PHYSICAL AdvancedApplyDamage on the host's ATK, attackType NORMAL, charge_token[born],
|
||||
// trigger_charge_token, die_to_kill_token).
|
||||
//
|
||||
// - Trait (工匠) "能够阻挡两个敌人,使用<支援装置>协助作战": melee physical (professions.js craftsman), 1-1, blocks 2, ground only;
|
||||
// the enemies he blocks are always his targets (Battle.blockedTargets, PRTS 分支特性信息 "可以且优先攻击自身阻挡的单位").
|
||||
// - <支援装置> = the hand pieces of the picked skill's device (data `overrideTokenKey`: S1 pile1, S2 pile2, S3 pile3 — the
|
||||
// variant `sources`, server/match/player/diy.js placeableTokens). Every placed piece deploys with the board at the battle
|
||||
// start for free (the mode's rule). His 持有 (stock) is cnt (T1, 3) + CRA-X's trait cnt (1) after each of his deployments
|
||||
// (charge_token[born] + trigger_charge_token), capped there (持有上限). A device that leaves the field comes back on its own
|
||||
// tile — after its redeploy time (data respawnTime, CRA-Y less), paying its cost (data, CRA-X less) and spending one of the
|
||||
// stock — while he stands; when he leaves, his devices go with him (die_to_kill_token) and come back the same way after his
|
||||
// return (stock refilled). The devices never attack, block nobody, are 禁疗; 白铁™多功能平台 is untargetable and invulnerable
|
||||
// ("不会受到攻击"; PRTS 持有无敌).
|
||||
// - T1 战地工程师 "可以携带3个<支援装置>(最多可部署2个)": the stock above; the 2 deployable are the hand pieces. CRA-Y stage 2+
|
||||
// "自身装置天赋生效的干员攻击速度+6(此效果不受技能影响)": every operator a device's talent reaches ASPD +attack_speed (the
|
||||
// token's module talent; one per device), never scaled by S1.
|
||||
// - T2 节约经费 "当白铁周围8格的自身装置损毁时,有70%的几率回收使白铁额外获得1个装置": a device of his leaving the field (killed,
|
||||
// destroyed by S1, withdrawn) while on his x-4 (talent range) ⇒ prob ⇒ stock +cnt (capped) — not when he leaves himself
|
||||
// (his talents go first). CRA-X stage 2+ adds "当白铁周围8格存在自身装置时技力回复速度+0.2/秒" (the token's module talent
|
||||
// sp_recovery_per_sec; one effect however many devices — the client's ironmn_e_002[sp_recover], one buff key) and prob 0.9.
|
||||
// - Module CRA-X “铁钳号·爬行者” "<支援装置>的持有上限+1且部署费用减少": the stock +1 (trait cnt) and the device costs of the data
|
||||
// (2 / 2 / 6). CRA-Y “海布里印记” "<支援装置>的再部署时间减少": the data's respawn times (5 / 5 / 10 s).
|
||||
// - 白铁™多功能平台 (S1): "使攻击范围内一名友方干员的攻击力+12%" — every operator on its range (1-1: its tile and the one it
|
||||
// faces) ATK +atk (Σpct); while 白铁's S1 runs ×talent_scale (2.5 / 3: "装置效果提升至…倍").
|
||||
// - 白铁™多功能平台 (S2): "使攻击范围内一名友方干员每3.5秒额外获得1点技力,每秒流失0.4%生命" — every operator on its range gets 1
|
||||
// SP each `default.interval` s it stays there (the first one interval after it came; none while its skill runs — AK: no SP
|
||||
// during a skill); the device loses hp_ratio of its max HP every second (a 流失: invulnerable as it is, it runs out). While
|
||||
// 白铁's S2 runs: s2.interval s and s2.hp_ratio (both timers restart when the mode changes, as the client re-creates the
|
||||
// buffs on its mode switch).
|
||||
// - 铁钳号·原型机 (S3), an enemy-camp summon: our attacks select it like an enemy, after every real one (嘲讽等级 −2;
|
||||
// Battle.setAllyTarget — the engine extension of this kit), enemies never (untargetable); every damage it takes is cancelled
|
||||
// and gives it 1 SP (受击回复), and +hp_ratio of its max HP when the source is a 工匠 operator (trait, ignores 禁疗). 团结的力
|
||||
// 量: the operator on its b-1 (its tile and the one behind it) takes physical damage ×(1 − damage_resistance) — one effect per
|
||||
// device. Its skill (AUTO, 5 SP, data DEFAULT — it never attacks, so the engine checks it every tick: an enemy, flyers too, in
|
||||
// its 3-13): it loses hp_ratio of its max HP (流失), and a projectile (10 tiles/s) to the first enemy of its range deals
|
||||
// 白铁's ATK (at the cast) × atk_scale physical to it and × atk_scale_2 to the other enemies within 1.1 of it (中点判定;
|
||||
// not that one: `_exceptTraceTarget`), 普通伤害, air units too. Immune to the control statuses PRTS lists, never displaced.
|
||||
// - S1 极致火力 (MANUAL, data DEFAULT): 16 / 17 s, every attack attack@atk_scale × ATK; +1 stock at the start; at its end every
|
||||
// device of his on the field is destroyed (KillTokens — T2 may recover them).
|
||||
// - S2 高效补给 (MANUAL, DEFAULT): 30 s, ATK +atk, DEF +def, every enemy he blocks at once (hitAllBlocked); +1 stock at its end.
|
||||
// - S3 铁钳号·原型机 (MANUAL, DEFAULT): 30 s, ATK +atk, ASPD +attack_speed; +1 stock at the start.
|
||||
// [ASSUMED] the stock refilled by +cnt on each deployment, capped at cnt + the CRA-X +1; a RechargeToken beyond the cap is lost;
|
||||
// the crab charges only from the attacks that select it (an ally's area skill that picks enemies does not see it); summons
|
||||
// attack it too. Open (data, report): the hand count of his devices is the data's deployLimit 1 — tools/build-data.mjs takes
|
||||
// the attribute frame's maxDeployCount and leaves out the token's hidden talent max_deploy_count +1 (E2 "最多可部署2个").
|
||||
|
||||
import { num, talentBb, talentGrid, traitBb, skillRec, up, giveSp } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { frontOf } from '../../../dir.js';
|
||||
|
||||
const S1 = 'skchr_ironmn_1';
|
||||
const S2 = 'skchr_ironmn_2';
|
||||
const S3 = 'skchr_ironmn_3';
|
||||
export const PILE1 = 'token_10027_ironmn_pile1';
|
||||
export const PILE2 = 'token_10027_ironmn_pile2';
|
||||
export const CRAB = 'token_10027_ironmn_pile3';
|
||||
const DEVICES = new Set([PILE1, PILE2, CRAB]);
|
||||
/** 节约经费's range when the data carries none: his tile and the 8 around it (x-4). */
|
||||
const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
|
||||
/** A device's 1-1 when its def carries none: its tile and the one it faces. */
|
||||
const FRONT1 = Object.freeze([[0, 0], [0, 1]]);
|
||||
/** 铁钳号·原型机's skill: PRTS 备注 "伤害半径1.1"; projectile_chr_ironmn_pile3 `_speed` 10. */
|
||||
const CRAB_SPLASH = 1.1;
|
||||
const CRAB_SPEED = 10;
|
||||
/** Statuses 铁钳号·原型机 is immune to (PRTS 备注: 晕眩 寒冷 冻结 浮空 束缚 沉默 沉睡免疫). */
|
||||
const CRAB_IMMUNE = new Set(['stun', 'cold', 'freeze', 'levitate', 'bind', 'silence', 'sleep']);
|
||||
/** Seconds between two tries of a device waiting to come back (timer, stock, DP, tile). */
|
||||
const RETRY = 0.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;
|
||||
const tokBb = (def, key) => tokTalent(def, key)?.bb ?? {};
|
||||
/** An operator of our side a device's talent may reach (professionMask 639: operators, not summons). */
|
||||
const isOp = (a) => !!a && a.kind === 'op' && a.side === 'ally';
|
||||
/**
|
||||
* A 工匠's <支援装置> state for the battle: `stock` (持有), `cap` (持有上限), `devices` (its pieces). Shared with 娜斯提
|
||||
* (op-nasti.js).
|
||||
*/
|
||||
export const deviceState = (unit) => unit.mem.craftsman ?? (unit.mem.craftsman = { stock: 0, cap: 0, devices: new Set() });
|
||||
/** The owner's devices standing on the field. */
|
||||
export const devicesUp = (unit) => [...deviceState(unit).devices].filter((d) => up(d));
|
||||
/** Stock +n, capped at the 持有上限 (RechargeToken). */
|
||||
export function rechargeDevices(unit, n) {
|
||||
const st = deviceState(unit);
|
||||
st.stock = Math.min(st.cap, st.stock + Math.max(0, n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep buff `key` (one per device: `disableOverride`) on every operator `select` returns, exactly while it does; `mods(a)`
|
||||
* re-read whenever `sig()` changes (a skill switching the device's mode). Removed from everyone when the device leaves.
|
||||
*/
|
||||
export function deviceAura(battle, dev, { key, select, mods, sig = () => 0 }) {
|
||||
const held = new Map(); // operator → signature it was given
|
||||
const drop = (a) => { if (a.findBuff(key)) battle.removeBuff(a, key); held.delete(a); };
|
||||
battle.on('tick', () => {
|
||||
if (!up(dev)) { for (const a of [...held.keys()]) drop(a); return; }
|
||||
const want = select();
|
||||
const s = sig();
|
||||
for (const a of [...held.keys()]) if (!want.includes(a) || !up(a)) drop(a);
|
||||
for (const a of want) {
|
||||
if (!up(a)) continue;
|
||||
if (held.get(a) === s && a.findBuff(key)) continue;
|
||||
const m = mods(a);
|
||||
if (!Object.keys(m).length) { drop(a); continue; }
|
||||
battle.addBuff(a, { key, mods: m, source: dev, tags: ['talent', 'device'] });
|
||||
held.set(a, s);
|
||||
}
|
||||
}, { owner: dev });
|
||||
battle.on('death', (ctx) => { if (ctx.unit === dev) for (const a of [...held.keys()]) drop(a); }, { owner: dev, priority: 10 });
|
||||
}
|
||||
|
||||
/** Operators of our side standing on `keys` (absolute tile keys), the device itself excluded. */
|
||||
export function opsOn(battle, dev, keys, { ignoreSelect = false } = {}) {
|
||||
const set = keys instanceof Set ? keys : new Set(keys);
|
||||
const out = [];
|
||||
for (const a of battle.allyUnits) {
|
||||
if (!up(a) || a.hidden || !isOp(a) || !set.has(a.tileR * COLS + a.tileC)) continue;
|
||||
if (!ignoreSelect && !battle.allySelectable(a, dev)) continue;
|
||||
out.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A device piece's return (PRTS 卫戍协议/帮助 "若战场区初始部署有召唤物…退场,将在满足条件后立即原地再部署1个"): once it left the
|
||||
* field it comes back on its own tile after its redeploy time (data respawnTime), paying its cost (`costOf(dev)`, default its
|
||||
* data cost) and spending one of the owner's stock, while the owner stands. `onLeave(dev)` runs as it leaves (节约经费 —
|
||||
* not when it leaves with its owner: `dev.mem.withOwner`), `onBack(dev)` when it is back. Shared with 娜斯提 (op-nasti.js).
|
||||
*/
|
||||
export function deviceReturn(battle, dev, owner, { onLeave = null, costOf = null, onBack = null } = {}) {
|
||||
battle.on('death', (ctx) => {
|
||||
if (ctx.unit !== dev || battle.finished) return;
|
||||
if (!dev.mem.withOwner && onLeave) onLeave(dev);
|
||||
dev.mem.withOwner = false;
|
||||
dev.removed = false; // the piece stays its tile's (content/tokens.js enableRespawn): back there later
|
||||
const at = battle.time + Math.max(0, num(dev.base.respawnTime));
|
||||
const seq = (dev.mem.returnSeq ?? 0) + 1;
|
||||
dev.mem.returnSeq = seq;
|
||||
const tryBack = () => {
|
||||
if (battle.finished || dev.alive || dev.removed || dev.mem.returnSeq !== seq) return;
|
||||
const st = deviceState(owner);
|
||||
if (battle.time + 1e-9 >= at && up(owner) && st.stock > 0) {
|
||||
const cost = Math.max(0, costOf ? costOf(dev) : num(dev.base.cost));
|
||||
const ps = battle.getPlayer(dev.ownerId);
|
||||
if (ps && ps.dp + 1e-9 >= cost && battle.redeploy(dev, { free: true })) {
|
||||
ps.dp = Math.max(0, ps.dp - cost);
|
||||
st.stock--;
|
||||
battle.fx('summon', { x: dev.x, y: dev.y, id: dev.id, src: owner.id });
|
||||
if (onBack) onBack(dev);
|
||||
return;
|
||||
}
|
||||
}
|
||||
battle.after(Math.max(RETRY, at - battle.time), tryBack, { owner: dev });
|
||||
};
|
||||
battle.after(Math.max(0, at - battle.time), tryBack, { owner: dev });
|
||||
}, { owner: dev, priority: -10 });
|
||||
}
|
||||
|
||||
/** The owner leaving withdraws its devices on the field (die_to_kill_token / KillTokens), but those `keep(dev)` spares. */
|
||||
export function devicesLeaveWithOwner(battle, owner, keep = null) {
|
||||
battle.on('death', (ctx) => {
|
||||
if (ctx.unit !== owner) return;
|
||||
for (const d of devicesUp(owner)) {
|
||||
if (keep && keep(d)) continue;
|
||||
d.mem.withOwner = true;
|
||||
battle.retreat(d, { reason: 'expired' });
|
||||
}
|
||||
}, { owner });
|
||||
}
|
||||
|
||||
export default {
|
||||
char_4072_ironmn: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const grid1 = talentGrid(chess, 1) ?? X4;
|
||||
const extraCnt = num(traitBb(chess).cnt); // CRA-X: 持有上限+1 (and trigger_charge_token +1)
|
||||
const cnt = num(t0.cnt, 3) + extraCnt;
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
|
||||
/** Device kit (`dev` = the piece, `owner` = 白铁). */
|
||||
const deviceKit = (dev, owner) => {
|
||||
const def = dev.def;
|
||||
const sk = def.skill ?? null;
|
||||
const tb = sk?.bb ?? {};
|
||||
const aspd = num(tokBb(def, 'attack_speed').attack_speed); // CRA-Y stage 2+ (the token's module talent)
|
||||
const grid = dev.rangeGrid ?? def.rangeGrid ?? FRONT1;
|
||||
const keys = () => absoluteRangeKeys(grid, dev.tileR, dev.tileC, dev.dir, 0);
|
||||
const s1On = () => up(owner) && !!owner.skill?.active && owner.skill.id === S1;
|
||||
const s2On = () => up(owner) && !!owner.skill?.active && owner.skill.id === S2;
|
||||
const common = (battle) => {
|
||||
battle.addBuff(dev, { key: 'ironmn:device', flags: { untargetable: true, noHeal: true, ...(dev.defId === CRAB ? { isolated: true, noDisplace: true } : { invulnerable: true }) }, persist: true, allowDead: true });
|
||||
if (aspd) {
|
||||
// CRA-Y: "自身装置天赋生效的干员攻击速度+6(此效果不受技能影响)" — on the operators the device's talent reaches
|
||||
const reach = dev.defId === CRAB
|
||||
? () => opsOn(battle, dev, [dev.tileR * COLS + dev.tileC, (([r, c]) => r * COLS + c)(frontOf(dev.tileR, dev.tileC, dev.dir, -1))], { ignoreSelect: true })
|
||||
: () => opsOn(battle, dev, keys());
|
||||
deviceAura(battle, dev, { key: `ironmn:aspd:${dev.id}`, select: reach, mods: () => ({ aspd }) });
|
||||
}
|
||||
};
|
||||
if (dev.defId === PILE1) {
|
||||
const atk = num(tokBb(def, 'atk').atk);
|
||||
const scale = num(tb.talent_scale, num(b1.fake_scale, 1));
|
||||
return {
|
||||
skill: null,
|
||||
trait: { noAttack: true },
|
||||
talents: [],
|
||||
install(battle) {
|
||||
common(battle);
|
||||
// 支援火力: ATK +atk on the operators of its 1-1 (×talent_scale while 白铁's S1 runs)
|
||||
deviceAura(battle, dev, { key: `ironmn:pile1:${dev.id}`, select: () => opsOn(battle, dev, keys()),
|
||||
sig: () => (s1On() ? 1 : 0), mods: () => ({ atkPct: atk * (s1On() ? scale : 1) }) });
|
||||
},
|
||||
};
|
||||
}
|
||||
if (dev.defId === PILE2) {
|
||||
const iDef = num(tokBb(def, 'default.interval')['default.interval'], 3.5);
|
||||
const iS2 = num(tb['s2.interval'], num(b2.fake_interval, iDef));
|
||||
const hpDef = num(tokBb(def, 'hp_ratio').hp_ratio);
|
||||
const hpS2 = num(tb['s2.hp_ratio'], hpDef * 2);
|
||||
return {
|
||||
skill: null,
|
||||
trait: { noAttack: true },
|
||||
talents: [],
|
||||
install(battle) {
|
||||
common(battle);
|
||||
const m = { mode: null, next: new Map(), bleedAt: Infinity };
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === dev) { m.mode = null; m.next.clear(); } }, { owner: dev });
|
||||
battle.on('tick', () => {
|
||||
if (!up(dev)) return;
|
||||
const mode = s2On() ? 'S2' : 'default';
|
||||
if (mode !== m.mode) { m.mode = mode; m.next.clear(); m.bleedAt = battle.time + 1; } // the buffs re-made on a mode switch
|
||||
const every = mode === 'S2' ? iS2 : iDef;
|
||||
// 战地补给: 1 SP every `every` s to each operator staying on its 1-1
|
||||
const now = opsOn(battle, dev, keys());
|
||||
for (const a of [...m.next.keys()]) if (!now.includes(a)) m.next.delete(a);
|
||||
for (const a of now) {
|
||||
if (!m.next.has(a)) { m.next.set(a, battle.time + every); continue; }
|
||||
if (battle.time + 1e-9 < m.next.get(a)) continue;
|
||||
m.next.set(a, m.next.get(a) + every);
|
||||
if (giveSp(a, 1, 'device') > 0) battle.fx('spGift', { x: a.x, y: a.y, id: a.id, from: dev.id });
|
||||
}
|
||||
// 每秒流失0.4%生命 (S2: ×2): a 流失, invulnerable or not
|
||||
if (battle.time + 1e-9 >= m.bleedAt) {
|
||||
m.bleedAt += 1;
|
||||
const r = mode === 'S2' ? hpS2 : hpDef;
|
||||
if (r > 0) battle.loseHp(dev, dev.s.maxHp * r, { source: dev, tags: ['device'] });
|
||||
}
|
||||
}, { owner: dev });
|
||||
},
|
||||
};
|
||||
}
|
||||
// 铁钳号·原型机
|
||||
const dr = num(tokBb(def, 'damage_resistance').damage_resistance);
|
||||
const healRatio = num(def.traitBb?.hp_ratio, 0.01);
|
||||
const atkScale = num(tb.atk_scale, 1), atkScale2 = num(tb.atk_scale_2, 0), loss = num(tb.hp_ratio);
|
||||
const skillGrid = sk?.rangeGrid ?? grid;
|
||||
return {
|
||||
skill: {
|
||||
kind: 'instant',
|
||||
onStart({ battle, unit }) {
|
||||
// 技能开启时自身流失2.5%的生命 (PURE DamageViaMaxHpRatio, `_skipModifierEvent`: a 流失 its trait does not cancel)
|
||||
const atk = up(owner) ? owner.s.atk : 0;
|
||||
const keysNow = absoluteRangeKeys(skillGrid, unit.tileR, unit.tileC, unit.dir, 0);
|
||||
const list = battle.enemiesInKeys(keysNow, unit, { canHitFly: true });
|
||||
sortEnemyTargets(battle, unit, list, null);
|
||||
const target = list[0] ?? null;
|
||||
if (loss > 0) battle.loseHp(unit, unit.s.maxHp * loss, { source: unit, tags: ['device'] });
|
||||
if (!target || !(atk > 0)) return;
|
||||
battle.addProjectile({ from: unit, target, speed: CRAB_SPEED, visual: 'bomb', source: unit, hitDead: true,
|
||||
onHit: ({ target: t, x, y }) => {
|
||||
if (t && t.alive) battle.dealDamage(unit, t, { amount: atk * atkScale, type: 'phys', isSkill: true, tags: ['summon', 'skill'] });
|
||||
const cx = t ? t.x : x, cy = t ? t.y : y;
|
||||
if (atkScale2 > 0) {
|
||||
for (const e of battle.foesInRadius(cx, cy, CRAB_SPLASH, true)) {
|
||||
if (e === target || !e.alive) continue;
|
||||
battle.dealDamage(unit, e, { amount: atk * atkScale2, type: 'phys', isSkill: true, tags: ['summon', 'skill'] });
|
||||
}
|
||||
}
|
||||
battle.fx('aoe', { x: cx, y: cy, radius: CRAB_SPLASH, id: unit.id, skill: 'ironmn:crab' });
|
||||
} });
|
||||
},
|
||||
},
|
||||
trait: { noAttack: true, canHitFly: true },
|
||||
talents: [],
|
||||
install(battle) {
|
||||
common(battle);
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === dev) battle.setAllyTarget(dev, true); }, { owner: dev });
|
||||
battle.on('death', (ctx) => { if (ctx.unit === dev) battle.setAllyTarget(dev, false); }, { owner: dev, priority: 5 });
|
||||
// trait: every damage cancelled; 受击回复 +1 SP; a 工匠 operator's damage heals it hp_ratio of its max HP
|
||||
battle.on('hit', (ctx) => {
|
||||
if (ctx.target !== dev || !up(dev)) return;
|
||||
ctx.dmg.cancel = true;
|
||||
if (dev.skill && !dev.skill.active) dev.skill.gainSp(1, 'hurt');
|
||||
const src = ctx.source ?? ctx.credit;
|
||||
if (src && src.kind === 'op' && src.def?.profession === 'SUPPORT' && src.profile?.sub === 'craftsman' && healRatio > 0) {
|
||||
battle.heal(dev, dev, dev.s.maxHp * healRatio, { self: true, ignoreHealFree: true });
|
||||
}
|
||||
}, { owner: dev, priority: 100 });
|
||||
battle.on('elementHit', (ctx) => { if (ctx.target === dev) ctx.dmg.cancel = true; }, { owner: dev }); // 元素免疫
|
||||
battle.on('beforeStatus', (ctx) => { if (ctx.target === dev && CRAB_IMMUNE.has(ctx.status)) ctx.cancel = true; }, { owner: dev });
|
||||
// 团结的力量: the operator on its tile / the one behind it — physical damage taken ×(1 − damage_resistance), per device
|
||||
if (dr > 0) {
|
||||
const behind = () => {
|
||||
const [r, c] = frontOf(dev.tileR, dev.tileC, dev.dir, -1);
|
||||
return opsOn(battle, dev, [dev.tileR * COLS + dev.tileC, r * COLS + c], { ignoreSelect: true });
|
||||
};
|
||||
deviceAura(battle, dev, { key: `ironmn:crab:${dev.id}`, select: behind, mods: () => ({ physTakenMul: 1 - dr }) });
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'duration',
|
||||
attack: { atkScale: num(b1['attack@atk_scale'], 1) },
|
||||
onStart({ unit }) { rechargeDevices(unit, 1); },
|
||||
onEnd({ battle, unit }) {
|
||||
// 技能结束时所有场上的装置被销毁 (KillTokens)
|
||||
for (const d of devicesUp(unit)) battle.kill(d, null);
|
||||
},
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b2.atk), defPct: num(b2.def) },
|
||||
attack: { hitAllBlocked: true },
|
||||
onEnd({ unit }) { rechargeDevices(unit, 1); },
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b3.atk), aspd: num(b3.attack_speed) },
|
||||
onStart({ unit }) { rechargeDevices(unit, 1); },
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) {
|
||||
// 战地工程师: his device pieces run the device kits (set up here, before the battle starts — as op-bgsnow.js); the
|
||||
// stock refilled on each of his deployments; his devices leave with him (die_to_kill_token)
|
||||
const st = deviceState(unit);
|
||||
st.cap = cnt;
|
||||
const prob = num(t1.prob), gain = num(t1.cnt, 1);
|
||||
// 节约经费: a device of his leaving his x-4 ⇒ prob ⇒ +cnt
|
||||
const onLeave = (dev) => {
|
||||
if (!up(unit) || !absoluteRangeKeys(grid1, unit.tileR, unit.tileC, unit.dir, 0).includes(dev.tileR * COLS + dev.tileC)) return;
|
||||
if (!(prob > 0) || !battle.rng.chance(prob)) return;
|
||||
rechargeDevices(unit, gain);
|
||||
battle.fx('spGift', { x: unit.x, y: unit.y, id: unit.id, from: dev.id });
|
||||
};
|
||||
for (const t of battle.allyUnits) {
|
||||
if (t.kind !== 'token' || !DEVICES.has(t.defId) || t.ownerUnit !== unit || t.alive || t.deployed) continue;
|
||||
if (t.kit) battle.offOwner(t);
|
||||
battle._setupUnit(t, deviceKit(t, unit));
|
||||
deviceReturn(battle, t, unit, { onLeave });
|
||||
st.devices.add(t);
|
||||
}
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === unit) rechargeDevices(unit, cnt); }, { owner: unit });
|
||||
devicesLeaveWithOwner(battle, unit);
|
||||
} },
|
||||
{ install(battle, unit) {
|
||||
// 节约经费 (the roll is the device lifecycle's, above); CRA-X stage 2+: SP +0.2/s while a device of his stands on
|
||||
// his x-4 (one effect)
|
||||
const spDef = [...deviceState(unit).devices].map((d) => num(tokBb(d.def, 'sp_recovery_per_sec').sp_recovery_per_sec)).find((v) => v > 0) ?? 0;
|
||||
if (!(spDef > 0)) return;
|
||||
const key = 'talent:ironmn:spRecover';
|
||||
battle.on('tick', () => {
|
||||
const area = new Set(absoluteRangeKeys(grid1, unit.tileR, unit.tileC, unit.dir, 0));
|
||||
const want = up(unit) && devicesUp(unit).some((d) => area.has(d.tileR * COLS + d.tileC));
|
||||
const has = unit.findBuff(key);
|
||||
if (want && !has) battle.addBuff(unit, { key, mods: { spRecoveryFlat: spDef }, tags: ['talent'] });
|
||||
else if (!want && has) battle.removeBuff(unit, key);
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,423 @@
|
||||
// server/sim/content/kits/ops/op-nasti.js — 娜斯提 (char_4212_nasti) 自选 operator kit: 6★ 工匠 (辅助), an owned-6★ pick of the
|
||||
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait, the module CRA-X 工程师们 at every form, and the kits of her
|
||||
// three <支援装置>: “质检专员” (token_10059_nasti_nstdef, S1), “监工专员” (token_10060_nasti_nstchr, S2) and “应急承重小组”
|
||||
// (token_10061_nasti_nstbld, S3: the 小工程师 and the 高台 it builds). Kit contract and the 自选 rules: ../README.md ("How to
|
||||
// add an operator (自选)"). The device stock / return / leave-with-the-owner rules are 白铁's (op-ironmn.js), shared.
|
||||
//
|
||||
// Forms (data/backups.json units.char_4212_nasti): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7, the
|
||||
// module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table / the token rows (zh_CN, as built into backups.json), PRTS
|
||||
// 娜斯提 (前方施工 备注 "所有装置的效果均为对自身攻击范围内所有单位生效的光环效果", "自身<支援装置>提供的效果均可叠加", "<X模组>首个
|
||||
// 装置部署费用-3;…若装置攻击范围内存在另一同来源同名的装置,将使用自身攻击范围+对方攻击范围形成复合范围"; 注意安全 备注 "远程敌人出现后,
|
||||
// 此天赋额外效果在娜斯提退场前永久生效", "对敌人/干员的判定无视不可选中类效果/孤立", "“高台干员”指实际可部署于远程位的干员"; S2 备注 "此
|
||||
// 技能提供的屏障持续时间无限;自身退场时,清除此技能提供的所有屏障"; CRA-X: 质检 / 监工 −2, 应急承重小组 −3 部署费用), PRTS “质检专员”
|
||||
// / “监工专员” / “应急承重小组” (备注: 常态持有禁疗、无敌 / 禁疗、静默; 监工 "生命值满/持有者技能开启期间,获得阻回、静默", 取缔无效流程
|
||||
// "生命回复速度属性不受此影响", skill "流失生命效果于获得技力/屏障效果后紧接着处理…会先提供技力和屏障再导致装置死亡"; 应急承重小组: the two
|
||||
// forms, "前方地块为可部署近战位的低地地块…则在前方地块上召唤一个高台形态的此装置。高台形态的此装置不计入部署数量上限", "仅高台形态装置
|
||||
// 已激活,且生命值未满时…可对其进行治疗,仅成功治疗后流失生命值", 未激活 = 装置类 + 无敌 + 不可阻挡, 激活 = 默认类, 阻挡数+1, 高地 /
|
||||
// 仅远程, 重叠部署; 第二次升级起 +1000 / +100 / +5; 最高级 阻回), PRTS 分支特性信息 工匠, PRTS 卫戍协议/帮助 (§战斗部署, §作战阶段 —
|
||||
// as op-ironmn.js), and the client's battle data read from the local install — charpack char_4212_nasti (nasti_t_1
|
||||
// charge_token[born]; T2: PassiveTrigger nasti_t2[res] = damage_resistance[inf], AuraTrigger on enemies of applyWay RANGED
|
||||
// (`_applyWay` 2, every level and motion) ⇒ AuraBuff: OwnerBuff nasti_t2[res_plus] (overrides [res]) + nasti_t2[add_sp], and
|
||||
// AuraBuffCore on our operators (professionMask 639, `_buildableType` 2, ignoring 孤立) the same two; nasti_a[die_kill_token]
|
||||
// KillTokens of modes 0–4 — a 3-times-upgraded 高台 (mode 5) stays; S1 / S2 / S3 modes), the token prefabs nstdef / nstchr /
|
||||
// nstbld (range abilities with the CRA-X stage-3 SharedValidator: same token id, same host, host holding nasti_e[class_3_mark];
|
||||
// nstchr: EnsureBlood nasti_nstchr[blood_one], full_blood ⇒ 阻回, Special mode RecoverSp add_char_sp / update_shield /
|
||||
// dec_blood; nstbld: BuildFactory create_m3 (Nstlv1Tile: buildableType melee, low ground, not tile type 16), m345_update on a
|
||||
// full SP, M4–M6 modes `_keepCurrentPassableMask`), the skill prefabs skchr_nasti_1 / 2 / 3 and sktok_nasti_nstchr (add_hp
|
||||
// +value, `_hpRatio` 1), the equip prefabs (trigger_charge_token +cnt at each deployment; stage 2+ nasti_e[first_token_cost_dec]
|
||||
// UNTIL_NEXT_SPAWN; stage 3 nasti_e[class_3_mark]) and buff_template_data (the templates named here).
|
||||
//
|
||||
// - Trait (工匠) "能够阻挡两个敌人,使用<支援装置>协助作战": melee physical, 1-1, blocks 2, ground only — and the device rules of
|
||||
// op-ironmn.js: the picked skill's hand pieces deploy with the board for free; her stock is cnt (3) + CRA-X's trait cnt (1)
|
||||
// after each of her deployments, capped there; a device that left comes back on its tile after its redeploy time, paying
|
||||
// its cost, spending one stock, while she stands; her leaving withdraws her devices (a level-3 高台 stays).
|
||||
// - T1 前方施工 "可以携带3个<支援装置>(最多可部署2个)": that stock; 2 hand pieces (data deployLimit 2). Every device's effect is
|
||||
// an aura on the operators of its range (1-1: its tile and the one it faces); her devices' effects stack (one buff per
|
||||
// device). CRA-X stage 2+ "首个装置部署费用减少": after each of her deployments her next device deployment costs value (−3)
|
||||
// less (UNTIL_NEXT_SPAWN — the battle-start deployment, free here, uses it up); stage 3 "装置可与攻击范围内其他装置共同提供效
|
||||
// 果": a device with another same-name device of hers on its range also reaches that device's range (one step).
|
||||
// - T2 注意安全 "获得10%庇护;远程敌人出现后,改为使自身和场上的高台干员获得15%庇护,且每6秒获得1点技力": 庇护 (ba.protect, the
|
||||
// shared applyStrongest key 'protect': physical and arts damage taken ×(1 − v), the strongest wins) nasti_t2[res] on her;
|
||||
// once a 远程 enemy (applyWay RANGED — ALL too [ASSUMED: the 2 bit]) is on the field, untargetable or stealthed too, until she
|
||||
// leaves: [res_plus] on her and on every operator of our side whose position is 远程 (RANGED / ALL — "实际可部署于远程位"), 孤立
|
||||
// ones too, and each of them +1 SP every interval s it holds it (the first one interval after; none at full SP or during a
|
||||
// skill).
|
||||
// - Module CRA-X “工程师们” "<支援装置>的持有上限+1且部署费用减少": stock +1, the data costs (2 / 2 / 3).
|
||||
// - “质检专员” (S1): "使前方干员的防御力+25%" (Σpct); while her S1 runs: DEF +talent@def instead, block +talent@block_cnt, and
|
||||
// it loses talent@hp_ratio of its max HP each second (流失; the first one 1 s into the skill).
|
||||
// - “监工专员” (S2): max HP 8 (data), init_hp 3 at each deployment ("仅可通过自身技能补充生命"); its skill (AUTO, 6 SP over
|
||||
// time) "开启技能时回复1点生命值" fires whenever its SP is full and its HP is not (阻回 while its HP is full or 娜斯提's skill
|
||||
// runs — the client's full_blood / Special mode). Activated by her S2 (the devices on the field at the cast): every
|
||||
// talent@interval s, the first 0.05 s in: each operator of its range +talent@sp SP (none during its skill) and a 屏障 of
|
||||
// talent@shield_each_hp_rate × 娜斯提's max HP (one barrier per operator from her, filled up to talent@shield_max_hp_rate ×
|
||||
// her max HP; it lasts until used or until she leaves), then the device loses talent@hp HP; activated devices leave when
|
||||
// her skill ends.
|
||||
// - “应急承重小组” (S3): the 小工程师 (invulnerable, untargetable, 禁疗, 阻回) builds the 高台 on the tile it faces when it is
|
||||
// deployed, if she has none and that tile is a free low melee tile (canStand melee, low); the 高台 (one per 娜斯提, outside the
|
||||
// deploy limit) gains 1 SP / s of its own (the token's INCREASE_WITH_TIME) and each 小工程师 whose range holds it gives it
|
||||
// m1_sp SP each second (m2_sp while her S3 runs) while it is not at its top level; a full SP (50) upgrades it (SP cleared):
|
||||
// level 1 = activated (attackable, block 1), level 2 = +1000 max HP / +100 DEF / +5 RES (m5 attribute_up), level 3 = the
|
||||
// m6 values instead + 阻回. A 小工程师 heals an activated 高台 of its range that is not at full HP hp_ratio of its max HP
|
||||
// each second (×2 in her S3) and then loses 2 % of its own (hp_ratio × cnt — "仅成功治疗后流失生命值", 流失). Before level 1 the
|
||||
// 高台 is invulnerable, untargetable and blocks nobody. The 高台's tile stays walkable (`_keepCurrentPassableMask`) and keeps
|
||||
// its look of ground; the operator bonuses of a 高台 (m4 / m5 / m6_bonus: +ATK / +max HP for the operator deployed on it)
|
||||
// never apply here — nobody deploys during a battle of this mode and the 高台 is only built on a free tile [ASSUMED: N/A].
|
||||
// - S1 “拱卫” (MANUAL, data DEFAULT): 38 / 40 s, ATK +atk, DEF +def, every enemy she blocks at once; +1 stock at the start.
|
||||
// - S2 “执行” (MANUAL, DEFAULT): 4 s, 停止攻击; every attack@nasti_s2[update_shield].interval s a 屏障 of shield_each_hp_rate ×
|
||||
// her max HP on herself, up to shield_max_hp_rate × it (infinite, gone when she leaves); activates her 监工专员; +1 stock at
|
||||
// its end.
|
||||
// - S3 栖脚地 (MANUAL, DEFAULT): 15 s, ATK +atk, DEF +def; her 小工程师 work twice as hard; +1 stock at the start.
|
||||
// [ASSUMED] (beyond op-ironmn.js's): the 监工专员 loses its 1 HP per pulse with or without an operator in range (dec_blood is a
|
||||
// passive buff of the ability); the combined range one step deep; the 小工程师's 1 s heal / SP timer runs from its deployment.
|
||||
|
||||
import { num, talentBb, traitBb, skillRec, up, giveSp } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys } from '../../../targeting.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { frontOf } from '../../../dir.js';
|
||||
import { deviceState, devicesUp, rechargeDevices, deviceAura, opsOn, deviceReturn, devicesLeaveWithOwner } from './op-ironmn.js';
|
||||
|
||||
const S1 = 'skchr_nasti_1';
|
||||
const S2 = 'skchr_nasti_2';
|
||||
const S3 = 'skchr_nasti_3';
|
||||
export const NSTDEF = 'token_10059_nasti_nstdef';
|
||||
export const NSTCHR = 'token_10060_nasti_nstchr';
|
||||
export const NSTBLD = 'token_10061_nasti_nstbld';
|
||||
const DEVICES = new Set([NSTDEF, NSTCHR, NSTBLD]);
|
||||
/** A device's 1-1 when its def carries none. */
|
||||
const FRONT1 = Object.freeze([[0, 0], [0, 1]]);
|
||||
/** 庇护 (ba.protect): one "同名效果取最高" effect per unit, whoever grants it (battle.applyStrongest; op-judge.js, op-zumama.js). */
|
||||
const PROTECT = 'protect';
|
||||
const protectMods = (v) => ({ physTakenMul: 1 - v, artsTakenMul: 1 - v });
|
||||
/** 注意安全's 庇护 refresh: a little longer than the check period, so it never lapses while it holds. */
|
||||
const T2_PERIOD = 0.25;
|
||||
const PROTECT_HOLD = T2_PERIOD + 0.05;
|
||||
/** 监工专员 Special mode: dec_blood `firstTriggerInterval` 0.05 s. */
|
||||
const CHR_FIRST = 0.05;
|
||||
/** 小工程师 talent buffs: triggerInterval 1 s. */
|
||||
const BLD_EVERY = 1;
|
||||
/** Enemies that count as 远程 (AuraTriggerCore `_applyWay` 2). */
|
||||
const RANGED_WAYS = new Set(['RANGED', 'ALL']);
|
||||
/** Operators "实际可部署于远程位" (`_buildableType` 2). */
|
||||
const RANGED_POS = new Set(['RANGED', 'ALL']);
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const tokBb = (def, key) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null)?.bb ?? {};
|
||||
const tileOf = (u) => u.tileR * COLS + u.tileC;
|
||||
const ownKeys = (u) => absoluteRangeKeys(u.rangeGrid ?? u.def?.rangeGrid ?? FRONT1, u.tileR, u.tileC, u.dir, 0);
|
||||
/** The barrier key of the shields her S2 devices give (one per operator from her: nasti_nstchr_m2[shield_core]). */
|
||||
const shieldKey = (owner) => `nasti:shield:${owner.id}`;
|
||||
const SELF_SHIELD = 'nasti:s2shield';
|
||||
|
||||
export default {
|
||||
char_4212_nasti: (bb, chess) => {
|
||||
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
|
||||
const cnt = num(t0.cnt, 3) + num(traitBb(chess).cnt);
|
||||
const firstCut = -num(t0.value); // CRA-X stage 2+: 首个装置部署费用 −3
|
||||
const combined = !!(chess?.module?.active && num(chess.module.level) >= 3); // stage 3: nasti_e[class_3_mark]
|
||||
const resBase = num(t1['nasti_t2[res].damage_resistance']);
|
||||
const resPlus = num(t1['nasti_t2[res_plus].damage_resistance'], resBase);
|
||||
const spEvery = num(t1['nasti_t2[add_sp].interval']);
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s2Every = num(b2['attack@nasti_s2[update_shield].interval'], 0.5);
|
||||
const s2Each = num(b2['attack@nasti_s2[update_shield].shield_each_hp_rate']);
|
||||
const s2Max = num(b2['attack@nasti_s2[update_shield].shield_max_hp_rate']);
|
||||
|
||||
const skillOn = (owner, id) => up(owner) && !!owner.skill?.active && owner.skill.id === id;
|
||||
/**
|
||||
* The tiles a device's effect reaches: its own range, and with CRA-X stage 3 the range of every same-name device of hers
|
||||
* standing on it ("复合范围", one step).
|
||||
*/
|
||||
const reachOf = (battle, dev, owner) => {
|
||||
const keys = new Set(ownKeys(dev));
|
||||
if (combined) {
|
||||
for (const d of devicesUp(owner)) {
|
||||
if (d === dev || d.defId !== dev.defId || !keys.has(tileOf(d))) continue;
|
||||
for (const k of ownKeys(d)) keys.add(k);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
/** “质检专员”. */
|
||||
const defKit = (dev, owner) => {
|
||||
const def = dev.def, tb = def.skill?.bb ?? {};
|
||||
const base = num(tokBb(def, 'def').def);
|
||||
const s1Def = num(tb['talent@def'], num(b1['talent@def'], base));
|
||||
const s1Block = num(tb['talent@block_cnt']);
|
||||
const s1Loss = num(tb['talent@hp_ratio']);
|
||||
return {
|
||||
skill: null,
|
||||
trait: { noAttack: true },
|
||||
talents: [],
|
||||
install(battle) {
|
||||
battle.addBuff(dev, { key: 'nasti:device', flags: { untargetable: true, invulnerable: true, noHeal: true }, persist: true, allowDead: true });
|
||||
// 严禁偷工减料 / S1 mode: the operators of its range
|
||||
deviceAura(battle, dev, { key: `nasti:nstdef:${dev.id}`, select: () => opsOn(battle, dev, reachOf(battle, dev, owner)),
|
||||
sig: () => (skillOn(owner, S1) ? 1 : 0),
|
||||
mods: () => (skillOn(owner, S1) ? { defPct: s1Def, ...(s1Block ? { blockCnt: s1Block } : {}) } : { defPct: base }) });
|
||||
// S1 mode: −talent@hp_ratio of its max HP each second (nasti_nstdef_t[self_damage], first after 1 s)
|
||||
let next = Infinity, was = false;
|
||||
battle.on('tick', () => {
|
||||
const on = up(dev) && skillOn(owner, S1);
|
||||
if (on && !was) next = battle.time + 1;
|
||||
was = on;
|
||||
if (!on || battle.time + 1e-9 < next) return;
|
||||
next += 1;
|
||||
if (s1Loss > 0) battle.loseHp(dev, dev.s.maxHp * s1Loss, { source: dev, tags: ['device'] });
|
||||
}, { owner: dev });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** “监工专员”. */
|
||||
const chrKit = (dev, owner) => {
|
||||
const def = dev.def, tb = def.skill?.bb ?? {};
|
||||
const initHp = num(tokBb(def, 'init_hp').init_hp, 3);
|
||||
const heal = num(tb.value, 1);
|
||||
const sp = num(tb['talent@sp'], 1);
|
||||
const each = num(tb['talent@shield_each_hp_rate'], num(b2['talent@shield_each_hp_rate']));
|
||||
const cap = num(tb['talent@shield_max_hp_rate'], num(b2['talent@shield_max_hp_rate']));
|
||||
const loss = num(tb['talent@hp'], 1);
|
||||
return {
|
||||
skill: {
|
||||
kind: 'instant', trigger: 'SP_FULL',
|
||||
onStart({ battle, unit }) { battle.heal(unit, unit, heal, { self: true, ignoreHealFree: true }); },
|
||||
},
|
||||
trait: { noAttack: true },
|
||||
talents: [],
|
||||
install(battle) {
|
||||
battle.addBuff(dev, { key: 'nasti:device', flags: { untargetable: true, invulnerable: true, noHeal: true }, persist: true, allowDead: true });
|
||||
// 取缔无效流程: deployed with init_hp of its (data) max HP
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === dev) { dev.hp = Math.max(1, Math.min(dev.s.maxHp, initHp)); dev.mem.nastiActive = false; } }, { owner: dev });
|
||||
// 阻回 while its HP is full or 娜斯提's skill runs (full_blood; the Special mode)
|
||||
battle.on('tick', () => {
|
||||
if (!up(dev)) return;
|
||||
const stop = dev.hp >= dev.s.maxHp - 1e-9 || (up(owner) && !!owner.skill?.active);
|
||||
const has = dev.findBuff('nasti:nstchr:stop');
|
||||
if (stop && !has) battle.addBuff(dev, { key: 'nasti:nstchr:stop', flags: { noSp: true } });
|
||||
else if (!stop && has) battle.removeBuff(dev, 'nasti:nstchr:stop');
|
||||
}, { owner: dev, priority: 5 });
|
||||
// activated by her S2: pulses (SP + her barrier to the operators of its range), then −1 HP each
|
||||
dev.mem.nastiPulse = (b) => {
|
||||
if (!up(dev) || !dev.mem.nastiActive) return;
|
||||
for (const a of opsOn(b, dev, reachOf(b, dev, owner))) {
|
||||
if (giveSp(a, sp, 'device') > 0) b.fx('spGift', { x: a.x, y: a.y, id: a.id, from: dev.id });
|
||||
const hp = up(owner) ? owner.s.maxHp : 0;
|
||||
const key = shieldKey(owner);
|
||||
const cur = a.findBuff(key);
|
||||
const have = cur ? cur.shield : 0;
|
||||
const next = Math.min(hp * cap, have + hp * each);
|
||||
if (next > have + 1e-9) {
|
||||
if (cur) { cur.shield = next; a.markDirty(); } else b.addBuff(a, { key, shield: next, visible: true, source: owner, tags: ['skill'] });
|
||||
b.fx('shield', { x: a.x, y: a.y, id: a.id, from: dev.id });
|
||||
}
|
||||
}
|
||||
if (loss > 0) b.loseHp(dev, loss, { source: dev, tags: ['device'] });
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** “应急承重小组” — the 小工程师 (a hand piece). */
|
||||
const bldKit = (dev, owner) => {
|
||||
const def = dev.def;
|
||||
const tb0 = tokBb(def, 'm1_sp');
|
||||
const sp1 = num(tb0.m1_sp, 1), sp2 = num(tb0.m2_sp, 2 * sp1);
|
||||
const heal1 = num(tb0['nasti_nstbld_m1[heal_m456].hp_ratio']), heal2 = num(tb0['nasti_nstbld_m2[heal_m456].hp_ratio'], 2 * heal1);
|
||||
const cost1 = heal1 * num(tb0['nasti_nstbld_m1[heal_m456].cnt'], 1), cost2 = heal2 * num(tb0['nasti_nstbld_m2[heal_m456].cnt'], 1);
|
||||
return {
|
||||
skill: null,
|
||||
// its heal of the 高台 ignores the 高台's 禁疗 ("_ignoreHealFree"): it heals through it (damage.js heal healThrough)
|
||||
trait: { noAttack: true, healThrough: (h, t) => !!t && t.mem?.nastiPlatform === true && t.ownerUnit === owner },
|
||||
talents: [],
|
||||
install(battle) {
|
||||
battle.addBuff(dev, { key: 'nasti:device', flags: { untargetable: true, invulnerable: true, noHeal: true, noSp: true }, persist: true, allowDead: true });
|
||||
battle.on('deploy', (ctx) => {
|
||||
if (ctx.unit !== dev || !up(owner)) return;
|
||||
// 承重结构部署: the 高台 on the tile it faces, if she has none and the tile is a free low melee tile
|
||||
const cur = owner.mem.nastiPlatform;
|
||||
if (cur && (cur.alive || !cur.removed)) return;
|
||||
const [r, c] = frontOf(dev.tileR, dev.tileC, dev.dir, 1);
|
||||
if (!battle.grid.inRect(r, c) || battle.isReservedTile(r, c) || !battle.grid.canStand(r, c, { ranged: false }) || !battle.grid.isLow(r, c)) return;
|
||||
const p = battle.spawnToken(owner, NSTBLD, r, c, { kit: platformKit(owner), dir: dev.dir, anySource: true });
|
||||
if (p) battle.fx('summon', { x: p.x, y: p.y, id: p.id, src: dev.id });
|
||||
}, { owner: dev });
|
||||
// every second: SP to the 高台 of its range below its top level; an activated, injured one healed (then −2 % own)
|
||||
battle.every(BLD_EVERY, () => {
|
||||
if (!up(dev)) return;
|
||||
const p = owner.mem.nastiPlatform;
|
||||
if (!p || !up(p) || !reachOf(battle, dev, owner).has(tileOf(p))) return;
|
||||
const m2 = skillOn(owner, S3);
|
||||
const lvl = p.mem.nastiLevel ?? 0;
|
||||
if (lvl < 3 && p.skill && p.skill.sp < p.skill.spCost) p.skill.gainSp(m2 ? sp2 : sp1, 'device');
|
||||
if (lvl >= 1 && p.hp < p.s.maxHp - 1e-6) {
|
||||
const healed = battle.heal(dev, p, p.s.maxHp * (m2 ? heal2 : heal1), { ignoreHealFree: true });
|
||||
if (healed > 0) battle.loseHp(dev, dev.s.maxHp * (m2 ? cost2 : cost1), { source: dev, tags: ['device'] });
|
||||
}
|
||||
}, { owner: dev });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** The 高台 (a spawned unit, no hand piece): levels 0 (未激活) → 3. */
|
||||
const platformKit = (owner) => {
|
||||
const levelUp = (battle, p) => {
|
||||
const lvl = Math.min(3, (p.mem.nastiLevel ?? 0) + 1);
|
||||
p.mem.nastiLevel = lvl;
|
||||
const tb = p.def.skill?.bb ?? {};
|
||||
if (lvl === 1) {
|
||||
battle.removeBuff(p, 'nasti:platform:inert');
|
||||
battle.addBuff(p, { key: 'nasti:platform:block', mods: { blockCnt: 1 }, persist: true, allowDead: true });
|
||||
} else {
|
||||
const m = lvl === 2 ? 'm5' : 'm6';
|
||||
battle.addBuff(p, { key: 'nasti:platform:attr', persist: true, allowDead: true, mods: {
|
||||
hpFlat: num(tb[`attack@nasti_nstbld_${m}[attribute_up].max_hp`]), defFlat: num(tb[`attack@nasti_nstbld_${m}[attribute_up].def`]),
|
||||
resFlat: num(tb[`attack@nasti_nstbld_${m}[attribute_up].magic_resistance`]) } });
|
||||
if (lvl === 3) battle.addBuff(p, { key: 'nasti:platform:top', flags: { noSp: true }, persist: true, allowDead: true });
|
||||
}
|
||||
battle.fx('grow', { x: p.x, y: p.y, id: p.id, n: lvl });
|
||||
};
|
||||
return {
|
||||
skill: { kind: 'instant', trigger: 'SP_FULL', onStart({ battle, unit }) { levelUp(battle, unit); } },
|
||||
trait: { noAttack: true },
|
||||
talents: [],
|
||||
install(battle, p) {
|
||||
p.mem.nastiPlatform = true;
|
||||
p.mem.nastiLevel = 0;
|
||||
owner.mem.nastiPlatform = p;
|
||||
// the 小工程师's "不会受到攻击" (the token text, Battle._setupUnit) is not the 高台's: untargetable only until activated
|
||||
battle.removeBuff(p, 'trait:untargetable');
|
||||
battle.addBuff(p, { key: 'nasti:platform', flags: { noHeal: true }, persist: true, allowDead: true }); // 常态持有禁疗
|
||||
battle.addBuff(p, { key: 'nasti:platform:inert', flags: { untargetable: true, invulnerable: true }, persist: true, allowDead: true });
|
||||
battle.on('death', (ctx) => { if (ctx.unit === p && owner.mem.nastiPlatform === p) owner.mem.nastiPlatform = null; }, { owner: p });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const kitOf = (dev, owner) => (dev.defId === NSTDEF ? defKit(dev, owner) : dev.defId === NSTCHR ? chrKit(dev, owner) : bldKit(dev, owner));
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b1.atk), defPct: num(b1.def) },
|
||||
attack: { hitAllBlocked: true },
|
||||
onStart({ unit }) { rechargeDevices(unit, 1); },
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
attack: { noAttack: true },
|
||||
onStart({ battle, unit }) {
|
||||
const m = unit.mem;
|
||||
m.nastiS2 = { n: 0, at: battle.time };
|
||||
// 立即激活场上装置: the 监工专员 on the field now
|
||||
const chr = devicesUp(unit).filter((d) => d.defId === NSTCHR);
|
||||
for (const d of chr) d.mem.nastiActive = true;
|
||||
const seq = unit.skill.activations;
|
||||
const shield = () => {
|
||||
const s = m.nastiS2;
|
||||
if (!s) return;
|
||||
s.n++;
|
||||
if (!up(unit)) return;
|
||||
const cap = unit.s.maxHp * s2Max;
|
||||
const cur = unit.findBuff(SELF_SHIELD);
|
||||
const have = cur ? cur.shield : 0;
|
||||
const next = Math.min(cap, have + unit.s.maxHp * s2Each);
|
||||
if (next > have + 1e-9) {
|
||||
if (cur) { cur.shield = next; unit.markDirty(); } else battle.addBuff(unit, { key: SELF_SHIELD, shield: next, visible: true, tags: ['skill'] });
|
||||
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id });
|
||||
}
|
||||
};
|
||||
m.nastiS2.shield = shield;
|
||||
battle.every(s2Every, (b, sc) => {
|
||||
if (!unit.skill.active || unit.skill.activations !== seq || unit.skill.id !== S2) { sc.cancel(); return; }
|
||||
shield();
|
||||
}, { owner: unit });
|
||||
// the devices' pulses: CHR_FIRST s in, then every talent@interval
|
||||
const ev = chr.length ? num(chr[0].def.skill?.bb?.['talent@interval'], 0.5) : 0.5;
|
||||
const pulse = () => { for (const d of chr) if (d.mem.nastiPulse) d.mem.nastiPulse(battle); };
|
||||
battle.after(CHR_FIRST, () => {
|
||||
if (!unit.skill.active || unit.skill.activations !== seq) return;
|
||||
pulse();
|
||||
battle.every(ev, (b, sc) => {
|
||||
if (!unit.skill.active || unit.skill.activations !== seq) { sc.cancel(); return; }
|
||||
pulse();
|
||||
}, { owner: unit });
|
||||
}, { owner: unit });
|
||||
},
|
||||
onEnd({ battle, unit, skill }) {
|
||||
const s = unit.mem.nastiS2;
|
||||
// the last shield tick that lands with the end (every interval of the duration)
|
||||
if (s && s.shield && up(unit)) while (s.n < Math.floor(skill.duration / s2Every + 1e-9)) s.shield();
|
||||
unit.mem.nastiS2 = null;
|
||||
// 激活的装置在娜斯提技能结束时离场
|
||||
for (const d of devicesUp(unit)) if (d.defId === NSTCHR && d.mem.nastiActive) battle.kill(d, null);
|
||||
rechargeDevices(unit, 1);
|
||||
},
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(b3.atk), defPct: num(b3.def) },
|
||||
onStart({ unit }) { rechargeDevices(unit, 1); },
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) {
|
||||
// 前方施工: her device pieces run their kits; the stock; CRA-X stage 2+ the first device after each deployment −3 DP
|
||||
const st = deviceState(unit);
|
||||
st.cap = cnt;
|
||||
for (const t of battle.allyUnits) {
|
||||
if (t.kind !== 'token' || !DEVICES.has(t.defId) || t.ownerUnit !== unit || t.alive || t.deployed) continue;
|
||||
if (t.kit) battle.offOwner(t);
|
||||
battle._setupUnit(t, kitOf(t, unit));
|
||||
deviceReturn(battle, t, unit, {
|
||||
costOf: (d) => Math.max(0, num(d.base.cost) - (unit.mem.nastiFirst ? firstCut : 0)),
|
||||
});
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === t) unit.mem.nastiFirst = false; }, { owner: t, priority: -20 });
|
||||
st.devices.add(t);
|
||||
}
|
||||
battle.on('deploy', (ctx) => {
|
||||
if (ctx.unit !== unit) return;
|
||||
rechargeDevices(unit, cnt);
|
||||
unit.mem.nastiFirst = firstCut > 0;
|
||||
}, { owner: unit });
|
||||
devicesLeaveWithOwner(battle, unit);
|
||||
// she leaves: her S2 barriers go (nasti_s2[finish_shield]); a 高台 below level 3 goes with her (KillTokens modes 0–4)
|
||||
battle.on('death', (ctx) => {
|
||||
if (ctx.unit !== unit) return;
|
||||
for (const a of battle.allyUnits) if (a.findBuff(shieldKey(unit))) battle.removeBuff(a, shieldKey(unit));
|
||||
battle.removeBuff(unit, SELF_SHIELD);
|
||||
const p = unit.mem.nastiPlatform;
|
||||
if (p && up(p) && (p.mem.nastiLevel ?? 0) < 3) battle.kill(p, null);
|
||||
}, { owner: unit });
|
||||
} },
|
||||
{ install(battle, unit) {
|
||||
// 注意安全: 庇护 res; once a 远程 enemy is on the field (until she leaves): res_plus on her and our 远程 operators, +1 SP
|
||||
// every interval s
|
||||
let triggered = false;
|
||||
const timers = new Map();
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === unit) { triggered = false; timers.clear(); } }, { owner: unit });
|
||||
battle.every(T2_PERIOD, () => {
|
||||
if (!up(unit)) { triggered = false; timers.clear(); return; }
|
||||
if (!triggered) triggered = battle.enemies.some((e) => e.alive && !e.hidden && RANGED_WAYS.has(String(e.def?.applyWay ?? '').toUpperCase()));
|
||||
if (!triggered) {
|
||||
if (resBase > 0) battle.applyStrongest(unit, PROTECT, { duration: PROTECT_HOLD, value: resBase, mods: protectMods, source: unit });
|
||||
return;
|
||||
}
|
||||
const who = [unit, ...battle.allyUnits.filter((a) => a !== unit && up(a) && !a.hidden && a.kind === 'op' && RANGED_POS.has(String(a.def?.position ?? '').toUpperCase()))];
|
||||
for (const a of [...timers.keys()]) if (!who.includes(a)) timers.delete(a);
|
||||
for (const a of who) {
|
||||
if (resPlus > 0) battle.applyStrongest(a, PROTECT, { duration: PROTECT_HOLD, value: resPlus, mods: protectMods, source: unit });
|
||||
if (!(spEvery > 0)) continue;
|
||||
if (!timers.has(a)) { timers.set(a, battle.time + spEvery); continue; }
|
||||
if (battle.time + 1e-9 < timers.get(a)) continue;
|
||||
timers.set(a, timers.get(a) + spEvery);
|
||||
if (a.skill && a.skill.sp < a.skill.spCost && giveSp(a, 1, 'talent') > 0) battle.fx('spGift', { x: a.x, y: a.y, id: a.id, from: unit.id });
|
||||
}
|
||||
}, { owner: unit, immediate: true });
|
||||
} },
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,396 @@
|
||||
// server/sim/content/kits/ops/op-necras.js — 死芒 (char_450_necras) 自选 operator kit: 6★ 塑灵术师 (术师), an owned-6★ pick of the
|
||||
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait, the module SOC-X 未处理的遗产 at every form, and the kit of
|
||||
// her summon 悲叹的仆役 (token_10043_necras_skeltn) and its special form. Kit contract and the 自选 rules: ../README.md ("How to
|
||||
// add an operator (自选)").
|
||||
//
|
||||
// Forms (data/backups.json units.char_450_necras): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7, the
|
||||
// module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no account].
|
||||
// Sources: character_table / skill_table / battle_equip_table / the token row (zh_CN, as built into backups.json), PRTS 死芒
|
||||
// (复燃 备注 "天赋无视敌人隐匿效果。优先在被击倒敌人所在地块进行召唤,此地块不可部署或非通过击杀触发此天赋时于攻击范围内随机可部署地块
|
||||
// (优先存在敌人的)进行召唤,召唤物的朝向与死芒保持一致", "无法再次召唤时尝试升级召唤物(无视孤立):优先选择未升级的>生命值最低>仇恨值
|
||||
// 最高的召唤物,令其升级(若未升级)并治疗其100%生命值(无视禁疗…不受治疗效果增减影响)"; the module page; S1 备注 "重新召唤以第一天赋的
|
||||
// 逻辑进行。每个被移除的已升级的召唤物提供2次召唤;重新召唤每间隔0.1s进行一次", "被动伤害范围半径1.5(中点判定),可对空,伤害来源为死芒
|
||||
// 自身"; S2 备注 "额外召唤以第一天赋的逻辑进行", "可被眩晕/冰冻/沉默效果打断;目标被击倒/离地,或进入消失状态时中断与该目标的链接;所有链接
|
||||
// 全部中断时立刻中止技能"; S3 备注 "场上不存在特殊召唤物时,下一个生成的召唤物会变为特殊召唤物…最大生命值+200%,攻击力+200%,防御力+100%,
|
||||
// 法术抗性+10,攻击间隔+0.7,阻挡数+1", "特殊召唤物每次升级时:最大生命值+80%,攻击力+80%,防御力+40%;第3/6次升级时额外获得:获得阻挡数
|
||||
// +1,技能攻击距离+1", "攻击范围扩大并进行技能描述的普通攻击(触发攻击无需目标),直至达到重复上限后停止技能", "可以消耗普通召唤物时,消耗一个
|
||||
// 普通召唤物进行治疗(必定治疗且无视禁疗)…消耗已升级的普通召唤物时该流程会进行2次"), PRTS 悲叹的仆役 (备注: 持有禁疗; "自身不是特殊形态
|
||||
// 召唤物时,持有阻回、沉默,且自身的技力自然回复速度属性强制归0"; "普通攻击造成法术伤害,可以且优先攻击自身阻挡的单位"; 怨火缠身 "仅持有
|
||||
// 者携带技能冠死以冕时,自身才会携带此技能"), PRTS 分支特性信息 塑灵术师 ("可对空", "干员离场时,其附属的召唤物会随之退场"), and the client's
|
||||
// battle data read from the local install — charpack char_450_necras (talent 1 necras_t_1 on the enemies of her range,
|
||||
// stealth ignored; NecrasTokenSelector: melee low buildable tiles of her range, enemy tiles first, random; SpawnToken =
|
||||
// [TrySpawnToken, UpgradeToken], UpgradeToken = [TryUpgradeToken (a token without necras_t_1[upgrade], lowest HP first), HealToken
|
||||
// (a token that is no special form)]; talent 2 necras_t_2 on her and her tokens; talent 3 necras_t_3 (S1 only) and talent 4
|
||||
// necras_t_4 (S3 only) on her tokens; UpgradeDragon; the S3 mode attack with `_allowNoTarget` and necras_s_3[before_attack] /
|
||||
// [after_attack]), the token prefab token_10043_necras_skeltn (skeltn_block_skill: 沉默 + 阻回 + SP_RECOVERY 0; skeltn_heal_free
|
||||
// 禁疗; Default / Dragon / Skill modes; the special form holds necras_t_1[upgrade]), the skill prefabs skchr_necras_1 / 2 / 3 and
|
||||
// sktok_necras_skeltn_3, projectile_chr_necras_s2 (`_getLifeTimeFromBB` hit_duration, `_isSilenceable`, `_dontHitInvincibleTarget`,
|
||||
// `_detachAllBuffsWhenStopped`), the equip prefabs (necras_e_002_tr; stage 3 necras_e_002_t on the enemies of her range and
|
||||
// necras_e_002_t[token] on those of her tokens' ranges) and buff_template_data (necras_t_1 / [spawn_token] / [upgrade],
|
||||
// necras_t_2 DamageScale, necras_t_3 / [damage], necras_t_4 / [dragon], necras_s_1 / [fallback], necras_s_2 / [damage_sleep],
|
||||
// necras_s_3 / [after_attack], necras_upgrade_dragon, necras_e_002_tr AtkScaleUp, necras_e_002_t / [token]).
|
||||
//
|
||||
// - Trait (塑灵术师) "攻击造成法术伤害,可以通过击倒敌人生成召唤物,可攻击到自身召唤物阻挡的敌人": ranged arts, 3-1, hits air; the tiles of
|
||||
// the enemies her 悲叹的仆役 block are her targets too (Battle.setExtraRange, re-read every tick). SOC-X adds "且攻击力提升至115%"
|
||||
// (trait bb atk_scale; necras_e_002_tr AtkScaleUp): her damage on an enemy one of her summons blocks × atk_scale.
|
||||
// - T1 复燃 "攻击范围内敌人被击倒时生成一个悲叹的仆役,最多召唤3个,若无法再次召唤则使一个悲叹的仆役升级(提升阻挡数与更多的生命、攻击和防御)":
|
||||
// an enemy knocked out on her attack range (by anyone; stealth ignored) queues one summon on its tile; queued summons are
|
||||
// made one every 0.1 s: with fewer than max_token_cnt of hers on the field, a 悲叹的仆役 on the hinted tile when it is a free,
|
||||
// low melee tile of her range, else on a random such tile of her range (those with an enemy first), facing as she does —
|
||||
// no tile ⇒ the upgrade instead; at the cap: the upgrade — a 悲叹的仆役 not yet upgraded (lowest HP, then the highest 仇恨值)
|
||||
// gets max_hp / atk / def (Σpct) and block_cnt, every one already upgraded: the lowest-HP one (not the special form) — and is
|
||||
// healed to full (无视禁疗). SOC-X stage 2+ (the module's own talent part, read apart from the base talent whose `atk` it
|
||||
// shares): "自身和召唤物攻击范围内敌人被击倒时在10秒内自身攻击力+25%,并在攻击范围内生成一个悲叹的仆役": a knock-out on her range
|
||||
// or on one of her summons' ranges ⇒ her ATK +atk for atk_duration s (refreshed), and one on a summon's range only queues a
|
||||
// summon with no tile (a random tile of her range).
|
||||
// - T2 回光黯淡 "自身和召唤物攻击生命低于50%的敌人时攻击力提升至140%": every damage she or her summons deal to an enemy below
|
||||
// hp_ratio HP × damage_scale (necras_t_2 DamageScale: the final damage).
|
||||
// - 悲叹的仆役: the token's stats for her form; arts melee, x-5, blocks 1 (upgraded 2), 禁疗; the normal form holds 阻回 / 沉默 (no
|
||||
// skill). Her leaving withdraws them; each is withdrawn when she leaves (WithdrawTokens).
|
||||
// - S1 噩愿 (MANUAL, data DEFAULT): passive — each 悲叹的仆役 made or upgraded (the T1 upgrade) strikes every enemy within
|
||||
// range_radius (中点判定, air units too) for her ATK × atk_scale arts (source: her); active — every 悲叹的仆役 of hers is
|
||||
// withdrawn and queues 1 summon (2 for an upgraded one); none ⇒ 1.
|
||||
// - S2 折朽 (MANUAL, DEFAULT): 12 s; up to max_target enemies of her range are linked: 沉睡 while linked and every interval s her
|
||||
// ATK × atk_scale arts on each (through the sleep: necras_s_2[damage_sleep]); she makes no attack meanwhile. A link breaks when
|
||||
// its target is knocked out, airborne (浮空 / a flyer's ascent) or gone (hidden, removed); the skill ends when none is left,
|
||||
// or when she is stunned, frozen or silenced. A linked target knocked out queues additional_token_cnt summons (no tile).
|
||||
// - S3 冠死以冕 (MANUAL, data SKILL_RANGE on its 3-3): passive — when she has no special form, the next 悲叹的仆役 made becomes it
|
||||
// (her hidden talent: +200 % max HP / ATK, +100 % DEF, +10 RES, +0.7 s attack interval, block +1), which holds 怨火缠身
|
||||
// (AUTO, 15 SP over time, 8 s: every second its ATK × attack@atk_scale arts on every enemy of its 1-1, the first at the cast
|
||||
// [ASSUMED]) and is upgraded only by this skill (≤ attack@max_stack_cnt): +attack@max_hp / atk / def (Σpct per upgrade), healed
|
||||
// attack@hp_ratio of its max HP; the 3rd and 6th add block +attack@block_cnt and 攻击距离 +attack@ability_range_forward_extend.
|
||||
// Active — her range is the 3-3 while it runs; two "attacks" at her attack interval (the first at the cast; no target needed):
|
||||
// each strikes every enemy of the 3-3 for ATK × attack@atk_scale arts, then consumes her earliest normal 悲叹的仆役 (withdrawn)
|
||||
// and upgrades the special form once — twice (0.1 s apart) when the consumed one was upgraded.
|
||||
// [ASSUMED] the earliest-made normal summon is the one consumed (PRTS: 实体栈 order, unpredictable); the special form's 攻击距离
|
||||
// widens its x-5 too (the attribute ABILITY_RANGE_FORWARD_EXTEND); the S3 strikes are skill damage (no normal-attack procs).
|
||||
|
||||
import { num, talentBb, traitBb, skillRec, moduleOn, up, batMod } from '../shared/tier1.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets, aggroCmp } from '../../../targeting.js';
|
||||
import { bodyInKeys } from '../../../body.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
|
||||
const S1 = 'skchr_necras_1';
|
||||
const S2 = 'skchr_necras_2';
|
||||
const S3 = 'skchr_necras_3';
|
||||
export const SKELETON = 'token_10043_necras_skeltn';
|
||||
/** The summon queue: one summon / upgrade every 0.1 s (necras_t_1[spawn_token] interval). */
|
||||
const QUEUE_EVERY = 0.1;
|
||||
/** UpgradeDragon twice for an upgraded summon: the second 0.1 s later (necras_s_3[delay_upgrade]). */
|
||||
const DELAY_UPGRADE = 0.1;
|
||||
const UPGRADE_KEY = 'necras:upgrade';
|
||||
const DRAGON_KEY = 'necras:dragon';
|
||||
const DRAGON_UP_KEY = 'necras:dragonUp';
|
||||
|
||||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||||
const tileKey = (r, c) => r * COLS + c;
|
||||
/** 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;
|
||||
}
|
||||
/** Her state for the battle. */
|
||||
const stateOf = (unit) => unit.mem.necras ?? (unit.mem.necras = { queue: [], skels: [], dragon: null, timer: null });
|
||||
const skelsUp = (unit) => stateOf(unit).skels.filter((s) => up(s));
|
||||
/** Her own attack range (no extra keys): the live grid behind her rangeKeys. */
|
||||
const ownRange = (unit) => new Set(absoluteRangeKeys(unit.liveRangeGrid ?? unit.rangeGrid ?? [[0, 0]], unit.tileR, unit.tileC, unit.dir, 0));
|
||||
|
||||
export default {
|
||||
char_450_necras: (bb, chess) => {
|
||||
const base0 = talentBb({ talents: chess?.talentsBase ?? chess?.talents }, 0); // 复燃 without the module part
|
||||
const mod = activeModule(chess);
|
||||
const modT0 = (mod?.talentChanges ?? []).filter((t) => t && t.talentIndex === 0).reduce((o, t) => Object.assign(o, t.bb ?? {}), {});
|
||||
const t1 = talentBb(chess, 1);
|
||||
const dragonBb = (chess?.talents ?? []).find((t) => t && t.bb && t.bb.base_attack_time != null)?.bb ?? {};
|
||||
const maxCnt = num(base0.max_token_cnt, 3);
|
||||
const up0 = { hpPct: num(base0.max_hp), atkPct: num(base0.atk), defPct: num(base0.def), blockCnt: num(base0.block_cnt) };
|
||||
const killAtk = num(modT0.atk), killDur = num(modT0.atk_duration);
|
||||
const lowHp = num(t1.hp_ratio), lowMul = num(t1.damage_scale, 1);
|
||||
const traitScale = num(traitBb(chess).atk_scale, 1);
|
||||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||||
const s3 = skillRec(chess, S3);
|
||||
|
||||
/** Free low melee tiles of her range, those with an enemy first (NecrasTokenSelector). */
|
||||
const spawnTiles = (battle, unit) => {
|
||||
const keys = ownRange(unit);
|
||||
const free = [], withEnemy = [];
|
||||
for (const k of keys) {
|
||||
const r = Math.floor(k / COLS), c = k % COLS;
|
||||
if (!battle.grid.inRect(r, c) || battle.isReservedTile(r, c) || !battle.grid.canStand(r, c, { ranged: false })) continue;
|
||||
free.push([r, c]);
|
||||
if (battle.enemies.some((e) => e.alive && !e.hidden && Math.round(e.y) === r && Math.round(e.x) === c)) withEnemy.push([r, c]);
|
||||
}
|
||||
return withEnemy.length ? withEnemy : free;
|
||||
};
|
||||
const tileOk = (battle, unit, r, c) => Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c)
|
||||
&& ownRange(unit).has(tileKey(r, c)) && !battle.isReservedTile(r, c) && battle.grid.canStand(r, c, { ranged: false });
|
||||
|
||||
/** S1's passive strike around a summon made or upgraded. */
|
||||
const s1Strike = (battle, unit, s) => {
|
||||
if (unit.skill?.id !== S1 || !up(unit)) return;
|
||||
const scale = num(b1.atk_scale), rad = num(b1.range_radius, 1.5);
|
||||
if (!(scale > 0)) return;
|
||||
for (const e of battle.foesInRadius(s.x, s.y, rad, true)) {
|
||||
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', 'necras:s1'] });
|
||||
}
|
||||
battle.fx('aoe', { x: s.x, y: s.y, radius: rad, id: unit.id, skill: 'necras:s1' });
|
||||
};
|
||||
|
||||
/** The T1 upgrade (or, every summon upgraded, the heal of the lowest one) — never the special form. */
|
||||
const upgradeOne = (battle, unit) => {
|
||||
const list = skelsUp(unit).filter((s) => !s.mem.necrasDragon);
|
||||
if (!list.length) return false;
|
||||
const byHp = (a, b) => a.hp - b.hp || aggroCmp(a, b);
|
||||
const fresh = list.filter((s) => !s.findBuff(UPGRADE_KEY)).sort(byHp);
|
||||
const s = fresh[0] ?? list.slice().sort(byHp)[0];
|
||||
if (fresh.length) {
|
||||
const mods = Object.fromEntries(Object.entries(up0).filter(([, v]) => v));
|
||||
battle.addBuff(s, { key: UPGRADE_KEY, mods, persist: true, allowDead: true, tags: ['talent'] });
|
||||
s1Strike(battle, unit, s);
|
||||
}
|
||||
battle.heal(s, s, s.s.maxHp, { self: true, ignoreHealFree: true });
|
||||
battle.fx('grow', { x: s.x, y: s.y, id: s.id });
|
||||
return true;
|
||||
};
|
||||
|
||||
/** A new 悲叹的仆役 on [r, c]; the special form when S3 is carried and she has none. */
|
||||
const makeSkeleton = (battle, unit, r, c) => {
|
||||
const s = battle.spawnToken(unit, SKELETON, r, c, { kit: skeletonKit(unit, battle.tokenDef(SKELETON, unit)), dir: unit.dir, anySource: true });
|
||||
if (!s) return null;
|
||||
const st = stateOf(unit);
|
||||
st.skels.push(s);
|
||||
if (unit.skill?.id === S3 && !(st.dragon && up(st.dragon))) {
|
||||
st.dragon = s;
|
||||
s.mem.necrasDragon = true;
|
||||
battle.addBuff(s, { key: DRAGON_KEY, persist: true, allowDead: true, tags: ['talent'], mods: {
|
||||
hpPct: num(dragonBb.max_hp), atkPct: num(dragonBb.atk), defPct: num(dragonBb.def), resFlat: num(dragonBb.magic_resistance),
|
||||
batPct: batMod(dragonBb.base_attack_time, { stats: s.def.stats }), blockCnt: num(dragonBb.block_cnt) } });
|
||||
battle.removeBuff(s, 'necras:normal');
|
||||
s.hp = s.s.maxHp;
|
||||
}
|
||||
battle.fx('summon', { x: s.x, y: s.y, id: s.id, src: unit.id });
|
||||
s1Strike(battle, unit, s);
|
||||
return s;
|
||||
};
|
||||
|
||||
/** Process the queue head (one summon / upgrade per QUEUE_EVERY). */
|
||||
const step = (battle, unit) => {
|
||||
const st = stateOf(unit);
|
||||
if (!up(unit)) { st.queue.length = 0; return; }
|
||||
if (!st.queue.length) return;
|
||||
const hint = st.queue.shift();
|
||||
if (skelsUp(unit).length < maxCnt) {
|
||||
let tile = hint && tileOk(battle, unit, hint[0], hint[1]) ? hint : null;
|
||||
if (!tile) { const opts = spawnTiles(battle, unit); tile = opts.length ? battle.rng.pick(opts) : null; }
|
||||
if (tile && makeSkeleton(battle, unit, tile[0], tile[1])) return;
|
||||
}
|
||||
upgradeOne(battle, unit);
|
||||
};
|
||||
/** Queue `n` summons (tile hint or null). */
|
||||
const queue = (battle, unit, n, hint = null) => {
|
||||
const st = stateOf(unit);
|
||||
for (let i = 0; i < n; i++) st.queue.push(hint);
|
||||
if (st.timer) return;
|
||||
st.timer = battle.every(QUEUE_EVERY, (b, sc) => {
|
||||
if (!st.queue.length) { sc.cancel(); st.timer = null; return; }
|
||||
step(b, unit);
|
||||
}, { owner: unit });
|
||||
};
|
||||
|
||||
/** UpgradeDragon: one more upgrade of her special form (≤ max), healed attack@hp_ratio. */
|
||||
const upgradeDragon = (battle, unit) => {
|
||||
const d = stateOf(unit).dragon;
|
||||
if (!d || !up(d)) return;
|
||||
const max = num(b3['attack@max_stack_cnt'], 6);
|
||||
const n = Math.min(max, (d.mem.necrasUps ?? 0) + 1);
|
||||
d.mem.necrasUps = n;
|
||||
const third = Math.floor(n / 3);
|
||||
battle.addBuff(d, { key: DRAGON_UP_KEY, persist: true, allowDead: true, tags: ['skill'], mods: {
|
||||
hpPct: num(b3['attack@max_hp']) * n, atkPct: num(b3['attack@atk']) * n, defPct: num(b3['attack@def']) * n,
|
||||
...(num(b3['attack@magic_resistance']) ? { resFlat: num(b3['attack@magic_resistance']) * n } : {}),
|
||||
...(third ? { blockCnt: num(b3['attack@block_cnt'], 1) * third, rangeExtend: num(b3['attack@ability_range_forward_extend'], 1) * third } : {}) } });
|
||||
battle.heal(d, d, d.s.maxHp * num(b3['attack@hp_ratio']), { self: true, ignoreHealFree: true });
|
||||
battle.fx('grow', { x: d.x, y: d.y, id: d.id, n });
|
||||
};
|
||||
|
||||
/** 悲叹的仆役 kit (`def` = its token def for her loadout: 怨火缠身 only when she carries S3). */
|
||||
const skeletonKit = (owner, def) => {
|
||||
const sk = def?.skill ?? null;
|
||||
const sb = sk?.bb ?? {};
|
||||
const grid = sk?.rangeGrid ?? [[0, 0], [0, 1]];
|
||||
const scale = num(sb['attack@atk_scale']);
|
||||
const dragonSkill = sk && sk.id === 'sktok_necras_skeltn_3' ? {
|
||||
// 怨火缠身 (the special form only: the normal form holds 阻回 / 沉默): every second its ATK × attack@atk_scale arts on every
|
||||
// enemy of its 1-1 (攻击距离 included), 8 s
|
||||
kind: 'duration',
|
||||
duration: num(sb['attack@duration'], num(sk.duration, 8)),
|
||||
attack: { noAttack: true },
|
||||
onStart({ battle: b, unit: me }) {
|
||||
const seq = me.skill.activations;
|
||||
const hit = () => {
|
||||
if (!me.skill.active || me.skill.activations !== seq || !up(me)) return false;
|
||||
const keys = absoluteRangeKeys(grid, me.tileR, me.tileC, me.dir, me.s.rangeExtend || 0);
|
||||
for (const e of b.enemiesInKeys(keys, me, { canHitFly: false })) b.dealDamage(me, e, { amount: me.s.atk * scale, type: 'arts', isSkill: true, tags: ['summon', 'skill'] });
|
||||
b.fx('aoe', { x: me.x, y: me.y, radius: 1, id: me.id, skill: 'necras:dragon' });
|
||||
return true;
|
||||
};
|
||||
hit();
|
||||
b.every(1, (b2, sc) => { if (!hit()) sc.cancel(); }, { owner: me });
|
||||
},
|
||||
} : null;
|
||||
return {
|
||||
skill: dragonSkill,
|
||||
trait: {},
|
||||
talents: [],
|
||||
install(battle, me) {
|
||||
battle.addBuff(me, { key: 'necras:healFree', flags: { noHeal: true }, persist: true, allowDead: true }); // 禁疗
|
||||
battle.addBuff(me, { key: 'necras:normal', flags: { noSp: true, silence: true }, persist: true, allowDead: true });
|
||||
// T2 回光黯淡: her summons' damage too
|
||||
battle.on('hit', (ctx) => {
|
||||
if (ctx.source !== me || !ctx.target || ctx.target.side !== 'enemy') return;
|
||||
if (lowHp > 0 && lowMul !== 1 && ctx.target.hpRatio < lowHp) ctx.dmg.mul = (ctx.dmg.mul ?? 1) * lowMul;
|
||||
}, { owner: me });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'instant',
|
||||
onStart({ battle, unit }) {
|
||||
// 立刻重新召唤所有悲叹的仆役: each withdrawn, 1 summon (2 if upgraded); none ⇒ 1
|
||||
const list = skelsUp(unit);
|
||||
let n = 0;
|
||||
for (const s of list) {
|
||||
n += s.findBuff(UPGRADE_KEY) ? 2 : 1;
|
||||
battle.retreat(s, { reason: 'expired', permanent: true });
|
||||
}
|
||||
queue(battle, unit, n || 1, null);
|
||||
},
|
||||
},
|
||||
[S2]: {
|
||||
kind: 'duration',
|
||||
attack: { noAttack: true },
|
||||
onStart({ battle, unit }) {
|
||||
const max = Math.max(1, Math.floor(num(b2.max_target, 2)));
|
||||
const list = battle.enemiesInKeys([...ownRange(unit)], unit, unit.profile);
|
||||
sortEnemyTargets(battle, unit, list, unit.profile?.priority ?? null);
|
||||
const links = list.slice(0, max).map((e) => ({ e, seq: e.deploySeq, flying: e.isFlying }));
|
||||
const seq = unit.skill.activations;
|
||||
const dur = unit.skill.timeLeft;
|
||||
unit.mem.necrasLinks = links;
|
||||
for (const l of links) {
|
||||
if (battle.applyStatus(l.e, 'sleep', { duration: dur, source: unit })) battle.fx('sleep', { x: l.e.x, y: l.e.y, id: l.e.id });
|
||||
}
|
||||
const broken = (l) => !l.e.alive || l.e.hidden || l.e.removed || l.e.deploySeq !== l.seq || (!l.flying && l.e.isFlying);
|
||||
const scale = num(b2.atk_scale);
|
||||
const every = num(b2.interval, 0.5);
|
||||
battle.every(every, (b, sc) => {
|
||||
if (!unit.skill.active || unit.skill.activations !== seq || unit.skill.id !== S2) { sc.cancel(); return; }
|
||||
for (const l of links) if (!l.done && !broken(l)) b.dealDamage(unit, l.e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, ignoreSleep: true, tags: ['skill', 'necras:s2'] });
|
||||
}, { owner: unit });
|
||||
// the links: a broken one releases its target; none left (or her 晕眩 / 冰冻 / 沉默) ends the skill
|
||||
battle.every(0.05, (b, sc) => {
|
||||
if (!unit.skill.active || unit.skill.activations !== seq || unit.skill.id !== S2) { sc.cancel(); return; }
|
||||
for (const l of links) {
|
||||
if (l.done || !broken(l)) continue;
|
||||
l.done = true;
|
||||
if (l.e.alive && l.e.findBuff('sleep')) b.removeStatus(l.e, 'sleep');
|
||||
}
|
||||
const f = unit.s.flags;
|
||||
if (links.every((l) => l.done) || f.stun || f.freeze || f.silence) unit.skill.end('links');
|
||||
}, { owner: unit });
|
||||
},
|
||||
onEnd({ battle, unit }) {
|
||||
for (const l of unit.mem.necrasLinks ?? []) {
|
||||
if (!l.done && l.e.alive && l.e.findBuff('sleep')) battle.removeStatus(l.e, 'sleep');
|
||||
l.done = true;
|
||||
}
|
||||
unit.mem.necrasLinks = null;
|
||||
},
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'duration',
|
||||
duration: 999,
|
||||
targeting: { rangeGrid: s3?.rangeGrid ?? null },
|
||||
attack: { noAttack: true },
|
||||
onStart({ battle, unit }) {
|
||||
const seq = unit.skill.activations;
|
||||
const strike = (k) => {
|
||||
if (!unit.skill.active || unit.skill.activations !== seq || !up(unit)) return;
|
||||
// a normal attack of the S3 mode: none while she cannot act (晕眩 / 冻结 …) — it waits
|
||||
if (!unit.canAct) { battle.after(0.1, () => strike(k), { owner: unit }); return; }
|
||||
const scale = num(b3['attack@atk_scale']);
|
||||
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile)) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', 'necras:s3'] });
|
||||
}
|
||||
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 3, id: unit.id, skill: 'necras:s3' });
|
||||
// 消耗一个悲叹的仆役使特殊仆役升级 (twice for an upgraded one)
|
||||
const prey = skelsUp(unit).filter((s) => !s.mem.necrasDragon).sort((a, b) => a.deploySeq - b.deploySeq)[0] ?? null;
|
||||
if (prey) {
|
||||
const twice = !!prey.findBuff(UPGRADE_KEY);
|
||||
battle.retreat(prey, { reason: 'expired', permanent: true });
|
||||
upgradeDragon(battle, unit);
|
||||
if (twice) battle.after(DELAY_UPGRADE, () => upgradeDragon(battle, unit), { owner: unit });
|
||||
}
|
||||
const reps = Math.max(1, Math.floor(num(b3['attack@necras_s_3[attack_cnt].max_stack_cnt'], 2)));
|
||||
if (k + 1 >= reps) { unit.skill.end('done'); return; }
|
||||
battle.after(unit.s.interval, () => strike(k + 1), { owner: unit });
|
||||
};
|
||||
strike(0);
|
||||
},
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
{ install(battle, unit) {
|
||||
// 复燃 (+ SOC-X stage 2+): a knock-out on her range / on her summons' ranges
|
||||
battle.on('death', (ctx) => {
|
||||
const e = ctx.unit;
|
||||
if (!e || e.side !== 'enemy' || ctx.reason !== 'killed' || !up(unit)) return;
|
||||
const mine = bodyInKeys(e, ownRange(unit));
|
||||
const theirs = !mine && killAtk > 0 && skelsUp(unit).some((s) => bodyInKeys(e, new Set(s.rangeKeys ?? [])));
|
||||
if (!mine && !theirs) return;
|
||||
if (killAtk > 0 && killDur > 0) battle.addBuff(unit, { key: 'necras:killAtk', duration: killDur, mods: { atkPct: killAtk }, tags: ['talent'] });
|
||||
queue(battle, unit, 1, mine ? [Math.round(e.y), Math.round(e.x)] : null);
|
||||
}, { owner: unit });
|
||||
// her summons leave with her (WithdrawTokens)
|
||||
battle.on('death', (ctx) => {
|
||||
if (ctx.unit !== unit) return;
|
||||
const st = stateOf(unit);
|
||||
st.queue.length = 0;
|
||||
for (const s of skelsUp(unit)) battle.retreat(s, { reason: 'expired', permanent: true });
|
||||
}, { owner: unit });
|
||||
// S2: a linked target knocked out ⇒ additional_token_cnt summons
|
||||
battle.on('death', (ctx) => {
|
||||
if (ctx.reason !== 'killed' || !up(unit) || unit.skill?.id !== S2 || !unit.skill.active) return;
|
||||
const l = (unit.mem.necrasLinks ?? []).find((x) => x.e === ctx.unit && !x.done);
|
||||
if (!l) return;
|
||||
l.done = true;
|
||||
queue(battle, unit, Math.max(0, Math.floor(num(b2.additional_token_cnt, 2))), null);
|
||||
}, { owner: unit, priority: -5 });
|
||||
} },
|
||||
{ install(battle, unit) {
|
||||
// 回光黯淡: her damage on an enemy below hp_ratio × damage_scale (final); SOC-X: × atk_scale on one her summon blocks
|
||||
battle.on('hit', (ctx) => {
|
||||
if (ctx.source !== unit || !ctx.target || ctx.target.side !== 'enemy') return;
|
||||
if (traitScale !== 1 && ctx.target.blockedBy && stateOf(unit).skels.includes(ctx.target.blockedBy)) ctx.dmg.amount *= traitScale;
|
||||
if (lowHp > 0 && lowMul !== 1 && ctx.target.hpRatio < lowHp) ctx.dmg.mul = (ctx.dmg.mul ?? 1) * lowMul;
|
||||
}, { owner: unit });
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// trait: the enemies her summons block are her targets too
|
||||
battle.on('tick', () => {
|
||||
if (!up(unit)) return;
|
||||
const keys = [];
|
||||
for (const s of skelsUp(unit)) for (const e of s.blocking) if (e.alive && e.blockedBy === s) keys.push(tileKey(Math.round(e.y), Math.round(e.x)));
|
||||
const uniq = [...new Set(keys)].sort((a, b) => a - b);
|
||||
const cur = unit.extraRangeKeys ?? [];
|
||||
if (uniq.length === cur.length && uniq.every((k, i) => k === cur[i])) return;
|
||||
battle.setExtraRange(unit, uniq.length ? uniq : null);
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user