A PlayerBattleInput operator entry may carry `potential` (潜能 1–6) and `cultivate` (练度 0–3, the 自持有 bonus) —
the player's 干员调配 settings (the owner's decision of 2026-10-08). Specs without them field what 0.2.1 fielded, so
no golden scenario moves here; the match states both next.
- shared/loadoutRecord.js: a resolved loadout carries `potential`; loadoutRecord composes the record at it first
(shared/potential.js atPotential), then the skill / module as before.
- server/sim/simdata.js: getChess(id, { …, potential }), getDiy(slot, pick, potential) (an owned 自选 pick's form at it;
a prototype has none — `diyProto` on its record, shared/diy.js), getToken / getDiyToken compose the owner variant at
the owner's potential (talents; a 自选 summon's deploy limit / holding / count); the per-battle loadout view keeps it
per unit; DataSource.cultivateMul reads the effects.json CHAR_MAP record's char_attribute_mul.
- battle/players.js: `potential` composes the def (never a stand-in's); `cultivate` sets unit.cultMul — ×ATK / ×DEF /
×max HP in the unit's Πmul (units.js), a multiplier of its own, never summed with the 直接乘算 percentages (PRTS
盟约记录 「…与自持有干员的属性加成独立」); a stand-in, a prototype 自选 pick and a summon take none [ASSUMED for summons].
- spec.js keeps both only when well-formed (never on a stand-in or a summon); content/index.js passes the potential to
the loadout sync; the kits that read a summon variant raw (content/tokens.js ownVariant, summoner.js tokenStat,
多萝西, 麦克尼斯特) compose it at the owner's potential; UnitInfo carries `potential` (below 6) / `cultivate`;
unitStatsEntry counts the 练度 in the unit's own numbers (no green delta for it on the card).
- Docs: SIM.md §2 (Πmul), §9 (UnitInfo), §12.
- Tests: test/sim/potential-battle.test.js (new, 6): a unit at each potential (刺玫 413 / 17 / 70 s and 8 % at 潜能1;
the elite on 不装备), the four 练度 tiers and their product with a +20 % ATK bonus, HP at deployment and the card's
numbers, stand-in / prototype take neither and an owned 自选 pick both, Mon3tr / 望's 棋子 / “小自在” at the owner's
potential, the spec round trip (kept, dropped, the JSON spec gives the same result digest), two players with the same
chess at different settings on one field; test/sim/loadout.test.js expects the resolved loadout's new fields.
371 lines
25 KiB
JavaScript
371 lines
25 KiB
JavaScript
// server/sim/content/kits/ops/op-mcnist.js — 机械师 (char_4230_mcnist) 自选 operator kit: 6★ 哨戒铁卫 (重装), an owned-6★ pick
|
||
// of the tier-5 and tier-6 自选 slots — not the 补位 stand-in Mechanist (char_610_acfend, standin-acfend.js); every skill,
|
||
// both talents, the trait and her module SO-A 机械师特勤证章 at every form, and the kit of her summon 结构性原理
|
||
// (token_10069_mcnist_mcgraf). Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
|
||
//
|
||
// Forms (data/backups.json units.char_4230_mcnist): 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. Full potential (the owner's decision of 2026-10-07).
|
||
// Sources: character_table / skill_table / battle_equip_table / token_table (zh_CN, as built into backups.json); PRTS 机械师
|
||
// (生命方程 备注: the 【超额防护】 of her own 屏障 — "当此屏障吸收部分伤害…后,若此时“持有的所有同来源的屏障”的屏障值总和等于0
|
||
// 时,将本次伤害直接视为抵挡"; S1 备注 "弹道溅射半径1.1(碰撞判定),可对空"; S2 备注 "屏障“被摧毁时...”的效果,其溅射半径1.5(中点
|
||
// 判定),可对空;该效果存在极短的触发冷却…且冷却期间强制格挡受到的所有伤害", "屏障持续时间无限,但技能结束后屏障不再具备“被摧毁时...”
|
||
// 的效果"; S3 备注 "每次攻击变为向攻击目标所在地块中心投掷十字星弹道,弹道延迟0.8秒后落地,伤害范围x-6(碰撞判定),可对空",
|
||
// "结构性原理的“冲锋”效果为向其部署方向发射弹道,弹道终点位于其正前方尽可能远的可通行、可部署地面地块(无法越过存在敌人的地块以及
|
||
// 高台地块)", "弹道停止时,以该地块为中心对半径1.5的敌方单位产生伤害(碰撞判定);那之后,场上的结构性原理立刻撤退并在停止地块上
|
||
// 重新部署(非移动),随后其获得半径1.5(中点判定)的【虚弱光环】…初始为50%,每秒减少2.5%,降至0%后虚弱光环结束", "技能开启时若场上
|
||
// 不存在结构性原理,则冲锋会保留至下次部署结构性原理后再触发"); PRTS 结构性原理 (备注 "持有禁疗", 天赋 备注 "此天赋提供的屏障视为
|
||
// 来源于自身的持有者"); the client's battle data (charpack char_4230_mcnist: Talents/1 charge_token[born] + die_to_kill_token,
|
||
// Talents/2 mcnist_t_2, the 精通 abilities tagged rogue_6; token prefab token_10069_mcnist_mcgraf: mcgraf_s_3_passive
|
||
// (abnormal flag 7 = 禁疗), charge_token[finish], Talents/2 mcnist_t_2, Talents/-1 the weaken aura; skill prefabs
|
||
// skchr_mcnist_2 (ammo counted by CharSkillManualTriggerCountEvent, not by attacks) / skchr_mcnist_3; projectiles
|
||
// projectile_chr_mcnist_s1 / _s3 / _s3_token_*; buff_template_data mcnist_t_2, mcnist_s_2_mode, mcnist_s_2_shield (BlockDamage
|
||
// PHYSICAL_AND_MAGICAL), mcnist_s_3_projectile*, mcgraf_s_3_trigger / _aura / _mark, mcgraf_t_suicide).
|
||
// - Trait (哨戒铁卫): the profession default — ranged physical, blocks 3, hits air units; ground enemies target her.
|
||
// - Module SO-A 机械师特勤证章: only its attributes (ATK / DEF, in the stats) act here. Its trait ("在【沉沦者的黑流树海】中,攻击
|
||
// 被自身或召唤物阻挡的敌人时攻击力提升至150%") and its stage-2 / 3 talent ("在【沉沦者的黑流树海】中,结构性原理的持续时间无限,
|
||
// 5秒内未受到伤害后每秒获得4%最大生命的屏障") name a 集成战略 theme (沉沦者的黑流树海 = rogue_6, PRTS 精通 "希望", "临时招募"):
|
||
// N/A outside it (the brief's rule) — the token's merged module talents (skill@duration −1, the barrier regen) are ignored.
|
||
// - T1 结构性原理: her 结构性原理 is a hand piece the player places; it deploys with the board, lasts the token talent's
|
||
// skill@duration (30 s; the base variant — the SO-A override is IS-only) from each deployment, blocks 3 and strikes one
|
||
// ground enemy in melee for physical damage (its data), holds 禁疗 (PRTS; mcgraf_s_3_passive flag 7 — the data's
|
||
// `abnormal` lacks it [ASSUMED: given here]). 拥有独立的再部署时间: back on its tile its respawnTime (40 s) after it left,
|
||
// paying its cost (10 DP), only while she stands; her (re)deployment readies a waiting one at once (charge_token[born]);
|
||
// it falls when she leaves (die_to_kill_token) [ASSUMED: the 卫戍 auto redeploy of a placed summon, as 鸿雪's 打字机].
|
||
// - T2 生命方程: she and her 结构性原理, at each deployment, get a 屏障 of hp_ratio × their own max HP (infinite until spent).
|
||
// Every 屏障 that comes from her (this one, S2's, the token's — "视为来源于自身的持有者") absorbs physical and arts damage
|
||
// only (BlockDamage PHYSICAL_AND_MAGICAL: buff `shieldType` ['phys', 'arts']) and has 超额防护: the hit that breaks it is
|
||
// cancelled whole when no 屏障 of hers is left on that unit afterwards (the `hpDamage` hook — what passed the shields).
|
||
// - S1 聚类分析 (AUTO, attack SP, data DEFAULT — an AUTO "next attacks" skill waits for her attack): 3 bullets (`ammo`); attack
|
||
// interval +base_attack_time (1.3 s); each attack lands where its target is and strikes 5 times (attack@times), every
|
||
// attack@interval s, every enemy within attack@projectile_range (1.1, air units too) for attack@atk_scale × ATK physical —
|
||
// the first strike on impact through the engine's splash, the next four from the impact point with the ATK of the first.
|
||
// - S2 协防术式 (MANUAL, data DEFAULT): ATK +atk; she and her 结构性原理 (one deployed later during the skill too) get a 屏障 of
|
||
// hp_ratio × their max HP (a re-cast replaces it). When one breaks while the skill runs: every enemy within range_radius
|
||
// (1.5, 中点判定, air units too) of its holder takes atk_scale × her ATK arts and 战栗 not_combat s (none on a dodged hit
|
||
// [ASSUMED]); meanwhile the holder takes no damage; then, with a bullet left (trigger_time bullets — attacks spend none), one
|
||
// is spent and the holder gets a new 屏障; the last bullet ends the skill and leaves that 屏障 (infinite, with no break effect).
|
||
// - S3 工程学十字星 (MANUAL, data DEFAULT, 40 s): ATK +atk, attack interval +base_attack_time (2.3 s); each attack throws a
|
||
// cross at the centre of its target's tile: X6_DELAY s later every selectable enemy on the x-6 around it (attack@
|
||
// projectile_range "x-6"; air units too) takes attack@atk_scale × her ATK of the throw, arts; the enemies her 结构性原理
|
||
// blocks are her targets too (setExtraRange: their tiles while the skill runs). The cast (or, without a 结构性原理 on the
|
||
// field, its next deployment during the skill — and every later one) makes it charge along its facing to the farthest
|
||
// passable, deployable low tile before an enemy's tile or high ground: CHARGE_DELAY s + the distance at CHARGE_SPEED, then
|
||
// every enemy within projectile_range (1.5) of the stop tile takes atk_scale × her ATK physical, and the 结构性原理 is
|
||
// deployed anew there (free; a new deployment: full HP, 生命方程 again — not a 移动). While the skill runs, each deployment of
|
||
// it carries a weaken aura of range_radius (1.5, 中点判定): 虚弱 atk (50 %) on the enemies inside, 2.5 % weaker every second
|
||
// (atk_interval / interval), gone at 0 or when the skill ends.
|
||
|
||
import { num, skillRec, batMod, up } from '../shared/tier1.js';
|
||
import { COLS } from '../../../constants.js';
|
||
import { dirVec } from '../../../dir.js';
|
||
import { hypot } from '../../../detmath.js';
|
||
import { atPotential } from '../../../../../shared/potential.js';
|
||
|
||
const S1 = 'skchr_mcnist_1';
|
||
const S2 = 'skchr_mcnist_2';
|
||
const S3 = 'skchr_mcnist_3';
|
||
/** Her summon 结构性原理 (talent 结构性原理). */
|
||
export const MCGRAF = 'token_10069_mcnist_mcgraf';
|
||
/** Her 屏障 absorb physical and arts damage only (BlockDamage PHYSICAL_AND_MAGICAL). */
|
||
const BARRIER_TYPES = Object.freeze(['phys', 'arts']);
|
||
const T2_KEY = 'mcnist:t2:barrier';
|
||
const S2_KEY = 'mcnist:s2:barrier';
|
||
/** S3 cross (range_table x-6: the plus of reach 2 around the landing tile) and its landing delay (PRTS 备注). */
|
||
const X6 = Object.freeze([[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]]);
|
||
const X6_DELAY = 0.8;
|
||
/** S3 charge (projectile_chr_mcnist_s3_token_*: `_delayToStart` 0.2, speed 20 tiles / s). */
|
||
const CHARGE_DELAY = 0.2;
|
||
const CHARGE_SPEED = 20;
|
||
/** Weaken aura refresh / buff life (s) and its fallback numbers (mcgraf hidden talent: atk −0.5, +0.025 per 1 s). */
|
||
const AURA = 0.2;
|
||
const AURA_DUR = 0.25;
|
||
/** 结构性原理's life when no source carries it (PRTS: 部署后持续30秒 at E2). */
|
||
const LIFE_FALLBACK = 30;
|
||
/** How often a waiting 结构性原理 tries to come back once ready. */
|
||
const RETRY = 0.1;
|
||
const TAG_CROSS = 'mcnist:cross';
|
||
|
||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||
const isGrafOf = (t, unit) => !!t && t.kind === 'token' && t.defId === MCGRAF && t.ownerUnit === unit;
|
||
const grafsOf = (battle, unit) => battle.allyUnits.filter((t) => isGrafOf(t, unit));
|
||
const skillOn = (unit, id) => !!(unit.skill && unit.skill.active && unit.skill.id === id);
|
||
/** 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)?.bb ?? null;
|
||
/** Every 屏障 that comes from `mcn` on `holder` (hers, or her token's: "视为来源于自身的持有者"). */
|
||
const barrierLeft = (holder, mcn) => {
|
||
let s = 0;
|
||
for (const b of holder.buffs) if (b.data && b.data.mcnist === mcn && b.shield > 0) s += b.shield;
|
||
return s;
|
||
};
|
||
|
||
/** A 屏障 from `mcn` on `holder`: `ratio` × the holder's max HP, physical and arts only, until spent. */
|
||
function grantBarrier(battle, mcn, holder, key, ratio) {
|
||
if (!(ratio > 0) || !holder.alive) return null;
|
||
battle.removeBuff(holder, key);
|
||
const buff = battle.addBuff(holder, {
|
||
key, shield: holder.s.maxHp * ratio, shieldType: BARRIER_TYPES, visible: true, source: mcn, tags: ['barrier'],
|
||
data: { mcnist: mcn },
|
||
// spent by a hit (damage.js absorbShields removes it at 0): S2's break effect waits for the hit to resolve (`damaged`)
|
||
onRemove: ({ buff: b }) => { if (key === S2_KEY && !(b.shield > 1e-9)) holder.mem.mcnistBroken = true; },
|
||
});
|
||
battle.fx('shield', { x: holder.x, y: holder.y, id: holder.id });
|
||
return buff;
|
||
}
|
||
|
||
/**
|
||
* Where the charge stops: from the token's tile along its facing, the farthest tile before a tile it cannot cross (outside
|
||
* the field, not ground-passable, high ground, an enemy on it); then, back towards the token, the first of those tiles it
|
||
* can be deployed on (free, standable for a melee unit, low). Its own tile when it cannot move.
|
||
*/
|
||
function chargeEnd(battle, t) {
|
||
const [fr, fc] = dirVec(t.dir);
|
||
const path = [[t.tileR, t.tileC]];
|
||
for (let k = 1; k < 32; k++) {
|
||
const r = t.tileR + fr * k, c = t.tileC + fc * k;
|
||
if (!battle.grid.inRect(r, c) || !battle.grid.groundPassable(r, c) || !battle.grid.isLow(r, c)) break;
|
||
if (battle.enemies.some((e) => e.alive && !e.hidden && Math.round(e.y) === r && Math.round(e.x) === c)) break;
|
||
path.push([r, c]);
|
||
}
|
||
const stop = path[path.length - 1];
|
||
for (let i = path.length - 1; i > 0; i--) {
|
||
const [r, c] = path[i];
|
||
if (!battle.isReservedTile(r, c) && battle.grid.canStand(r, c, { ranged: false })) return { stop, land: [r, c] };
|
||
}
|
||
return { stop, land: path[0] };
|
||
}
|
||
|
||
/**
|
||
* A 结构性原理 that left the field comes back on its tile its respawnTime later (her (re)deployment: at once —
|
||
* charge_token[born] sets readyAt), paying its cost, only while she stands; retried until it deploys.
|
||
*/
|
||
function scheduleReturn(battle, owner, t) {
|
||
t.mem.readyAt = battle.time + Math.max(0, num(t.base.respawnTime));
|
||
if (t.mem.retry) return;
|
||
t.mem.retry = battle.every(RETRY, (b, sched) => {
|
||
const stop = () => { sched.cancel(); t.mem.retry = null; };
|
||
if (t.alive || t.removed || b.finished) { stop(); return; }
|
||
if (!up(owner) || b.time + 1e-9 < t.mem.readyAt) return;
|
||
if (b.redeploy(t, { free: false })) stop();
|
||
}, { owner: t });
|
||
}
|
||
|
||
/**
|
||
* The 结构性原理's kit (lifetime, 禁疗, 生命方程, S2's 屏障, the S3 charge and weaken aura, its redeploy). `owner` = 机械师,
|
||
* `life` = its lifetime, `t2` = 生命方程's hp_ratio, `b2` / `b3` = her S2 / S3 blackboards.
|
||
*/
|
||
function grafKit(owner, { life, t2, b2, b3, aura }) {
|
||
return {
|
||
skill: null,
|
||
talents: [],
|
||
install(battle, t) {
|
||
battle.addBuff(t, { key: 'mcnist:graf:abnormal', flags: { noHeal: true }, persist: true, allowDead: true }); // 持有禁疗
|
||
battle.on('deploy', (c) => {
|
||
if (c.unit !== t) return;
|
||
const respawned = !!t.mem.chargeLanding;
|
||
t.mem.chargeLanding = false;
|
||
const seq = t.deploySeq;
|
||
if (life > 0) {
|
||
battle.after(life, () => { if (t.alive && t.deploySeq === seq) battle.retreat(t, { reason: 'expired', permanent: true }); }, { owner: t });
|
||
}
|
||
grantBarrier(battle, owner, t, T2_KEY, t2); // 生命方程
|
||
if (skillOn(owner, S2)) grantBarrier(battle, owner, t, S2_KEY, num(b2.hp_ratio)); // a deployment during S2
|
||
if (skillOn(owner, S3)) {
|
||
t.mem.weakenFrom = battle.time; // the weaken aura of this deployment
|
||
// a deployment during S3 (not the charge's own landing) charges — the cast's charge kept until then — once
|
||
// the deployment has resolved (the next tick: ON_OWNER_LOCATE)
|
||
if (!respawned) battle.after(0, () => { if (t.alive && t.deploySeq === seq && skillOn(owner, S3)) chargeGraf(battle, owner, t, b3); }, { owner: t });
|
||
} else t.mem.weakenFrom = null;
|
||
}, { owner: t });
|
||
// the weaken aura: 虚弱 on the enemies within its radius, 2.5 % weaker every second, gone at 0 or when S3 ends
|
||
battle.every(AURA, () => {
|
||
if (!up(t) || t.mem.weakenFrom == null || !skillOn(owner, S3)) return;
|
||
const steps = Math.floor((battle.time - t.mem.weakenFrom) / aura.interval + 1e-9);
|
||
const v = Math.max(0, aura.start - aura.step * steps);
|
||
if (!(v > 0)) return;
|
||
for (const e of battle.foesInRadius(t.x, t.y, aura.radius, true)) {
|
||
battle.applyStatus(e, 'weaken', { duration: AURA_DUR, value: v, source: t });
|
||
}
|
||
}, { owner: t });
|
||
battle.on('death', (c) => {
|
||
if (c.unit !== t || battle.finished) return;
|
||
t.removed = false; // the piece stays: back on its tile once ready (or on the charge's landing tile)
|
||
if (!t.mem.charging) scheduleReturn(battle, owner, t);
|
||
}, { owner: t, priority: -10 });
|
||
},
|
||
};
|
||
}
|
||
|
||
/** S3: the 结构性原理 charges along its facing, strikes around the stop tile and is deployed anew there. */
|
||
function chargeGraf(battle, mcn, t, b3) {
|
||
if (!up(t)) return;
|
||
const { stop, land } = chargeEnd(battle, t);
|
||
const dist = hypot(stop[0] - t.tileR, stop[1] - t.tileC);
|
||
t.mem.charging = true;
|
||
battle.fx('charge', { x: t.x, y: t.y, id: t.id, tx: stop[1], ty: stop[0] });
|
||
battle.retreat(t, { reason: 'expired', permanent: true });
|
||
t.mem.charging = false;
|
||
const atk = mcn.s.atk, scale = num(b3.atk_scale, 3), radius = num(b3.projectile_range, 1.5);
|
||
battle.after(CHARGE_DELAY + dist / CHARGE_SPEED, () => {
|
||
battle.fx('aoe', { x: stop[1], y: stop[0], radius, id: mcn.id, skill: 'mcnist:charge' });
|
||
for (const e of battle.foesInRadius(stop[1], stop[0], radius)) {
|
||
if (e.alive) battle.dealDamage(mcn, e, { amount: atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'mcnist:charge'] });
|
||
}
|
||
if (t.alive || t.removed || battle.finished) return;
|
||
// she fell meanwhile (die_to_kill_token): no landing, it waits for her like any piece [ASSUMED]
|
||
if (!up(mcn)) { scheduleReturn(battle, mcn, t); return; }
|
||
t.mem.chargeLanding = true;
|
||
if (!battle.redeploy(t, { free: true, tile: land }) && !battle.redeploy(t, { free: true })) {
|
||
t.mem.chargeLanding = false;
|
||
scheduleReturn(battle, mcn, t); // nowhere to land: an ordinary wait for its redeploy [ASSUMED]
|
||
}
|
||
}, { owner: mcn });
|
||
}
|
||
|
||
/** S3's cross: every selectable enemy on the x-6 around (r, c) takes `atk` × scale arts, X6_DELAY s after the throw. */
|
||
function throwCross(battle, unit, r, c, atk, scale) {
|
||
battle.fx('aoe', { x: c, y: r, radius: 2, id: unit.id, skill: 'mcnist:cross', tiles: X6.map(([dr, dc]) => [r + dr, c + dc]) });
|
||
battle.after(X6_DELAY, () => {
|
||
const keys = X6.map(([dr, dc]) => (r + dr) * COLS + (c + dc)).filter((k) => k >= 0);
|
||
for (const e of battle.enemiesInKeys(keys, unit, { canHitFly: true })) {
|
||
if (e.alive) battle.dealDamage(unit, e, { amount: atk * scale, type: 'arts', isAttack: true, isSkill: true, tags: ['skill', TAG_CROSS] });
|
||
}
|
||
}, { owner: unit });
|
||
}
|
||
|
||
export default {
|
||
char_4230_mcnist: (bb, chess) => {
|
||
const t1 = (chess?.talents ?? []).find((t) => t && t.index === 1)?.bb ?? {}; // 生命方程: hp_ratio
|
||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||
const s1Scale = num(b1['attack@atk_scale'], 1), s1Times = Math.max(1, Math.floor(num(b1['attack@times'], 5)));
|
||
const s1Iv = num(b1['attack@interval'], 0.2), s1Radius = num(b1['attack@projectile_range'], 1.1);
|
||
return {
|
||
skills: {
|
||
[S1]: {
|
||
kind: 'ammo', ammo: num(b1['attack@trigger_time'], 3),
|
||
mods: { batPct: batMod(b1.base_attack_time, chess) },
|
||
attack: {
|
||
atkScale: s1Scale, splashRadius: s1Radius,
|
||
// the four strikes after the impact's, from the impact point (the target dead meanwhile: where it was)
|
||
onHit({ battle, unit, x, y }) {
|
||
const atk = unit.s.atk;
|
||
for (let i = 1; i < s1Times; i++) {
|
||
battle.after(s1Iv * i, () => {
|
||
for (const e of battle.foesInRadius(x, y, s1Radius)) {
|
||
if (e.alive) battle.dealDamage(unit, e, { amount: atk * s1Scale, type: 'phys', isAttack: true, isSplash: true, isSkill: true, tags: ['skill', 'mcnist:cluster'] });
|
||
}
|
||
}, { owner: unit });
|
||
}
|
||
},
|
||
},
|
||
},
|
||
[S2]: {
|
||
kind: 'ammo', ammo: num(b2.trigger_time, 6),
|
||
mods: { atkPct: num(b2.atk) },
|
||
onAttack(ctx) { ctx.noAmmo = true; }, // her bullets go on 屏障 only (CharSkillManualTriggerCountEvent)
|
||
onStart({ battle, unit }) {
|
||
grantBarrier(battle, unit, unit, S2_KEY, num(b2.hp_ratio));
|
||
for (const t of grafsOf(battle, unit)) if (up(t)) grantBarrier(battle, unit, t, S2_KEY, num(b2.hp_ratio));
|
||
},
|
||
},
|
||
[S3]: {
|
||
kind: 'duration',
|
||
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
|
||
// the attack is the cross: no damage on the target itself (its 0.8 s later landing does it)
|
||
attack: { dmgType: 'arts', hitsFn: () => 0 },
|
||
onAttack({ battle, unit, targets }) {
|
||
const tg = (targets || []).find((e) => e && e.side === 'enemy');
|
||
if (tg) throwCross(battle, unit, Math.round(tg.y), Math.round(tg.x), unit.s.atk, num(b3['attack@atk_scale'], 1));
|
||
},
|
||
onStart({ battle, unit }) {
|
||
const t = grafsOf(battle, unit).find(up);
|
||
if (t) chargeGraf(battle, unit, t, b3); // without one: its next deployment during the skill charges
|
||
},
|
||
onEnd({ battle, unit }) { battle.setExtraRange(unit, null); },
|
||
},
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 结构性原理: her pieces run the token's kit (set up before the battle starts)
|
||
const raw = atPotential(battle.data.rawToken?.(MCGRAF)?.variants?.[unit.def?.tokenOwner] ?? null, unit.def?.loadout?.potential);
|
||
for (const t of battle.allyUnits) {
|
||
if (!isGrafOf(t, unit) || t.alive || t.deployed) continue;
|
||
// its life: the base variant's (the SO-A stage-3 skill@duration −1 is 集成战略-only)
|
||
const base = num(tokTalent(raw ? { talents: raw.talents } : null, 'skill@duration')?.['skill@duration'], NaN);
|
||
const merged = num(tokTalent(t.def, 'skill@duration')?.['skill@duration'], NaN);
|
||
const life = base > 0 ? base : merged > 0 ? merged : LIFE_FALLBACK;
|
||
const w = tokTalent(t.def, 'atk_interval') ?? {};
|
||
const aura = { start: Math.abs(num(w.atk, -0.5)), step: Math.abs(num(w.atk_interval, 0.025)), interval: num(w.interval, 1) || 1, radius: num(w.range_radius, 1.5) };
|
||
const t2 = num(tokTalent(t.def, 'hp_ratio')?.hp_ratio, num(t1.hp_ratio));
|
||
if (t.kit) battle.offOwner(t);
|
||
battle._setupUnit(t, grafKit(unit, { life, t2, b2, b3, aura }));
|
||
}
|
||
// die_to_kill_token: it falls when she leaves; charge_token[born]: her (re)deployment readies a waiting one
|
||
battle.on('death', (c) => {
|
||
if (c.unit !== unit) return;
|
||
for (const t of grafsOf(battle, unit)) if (t.alive) battle.kill(t, null);
|
||
}, { owner: unit });
|
||
battle.on('deploy', (c) => {
|
||
if (c.unit !== unit) return;
|
||
for (const t of grafsOf(battle, unit)) if (!t.alive && !t.removed) t.mem.readyAt = battle.time;
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 生命方程: her own 屏障 at each deployment (the token's: its kit)
|
||
battle.on('deploy', (c) => { if (c.unit === unit) grantBarrier(battle, unit, unit, T2_KEY, num(t1.hp_ratio)); }, { owner: unit });
|
||
} },
|
||
],
|
||
install(battle, unit) {
|
||
const holds = (h) => h === unit || isGrafOf(h, unit);
|
||
// 超额防护: the hit that breaks the last 屏障 of hers on a unit is cancelled whole (PRTS 生命方程 备注)
|
||
const before = new WeakMap();
|
||
battle.on('hit', (c) => {
|
||
const h = c.target;
|
||
if (!holds(h) || !c.dmg || c.dmg.cancel || (c.dmg.type !== 'phys' && c.dmg.type !== 'arts')) return;
|
||
before.set(c.dmg, barrierLeft(h, unit));
|
||
}, { owner: unit, priority: -1000 });
|
||
// S2's break effect is running: the holder takes nothing meanwhile ("冷却期间强制格挡受到的所有伤害")
|
||
battle.on('hit', (c) => { if (holds(c.target) && c.target.mem.mcnistLock && c.dmg) c.dmg.cancel = true; }, { owner: unit, priority: 1000 });
|
||
battle.on('hpDamage', (c) => {
|
||
if (!holds(c.target) || !(before.get(c.dmg) > 0)) return;
|
||
if (barrierLeft(c.target, unit) <= 1e-9) c.amount = 0;
|
||
}, { owner: unit });
|
||
// S2: a 屏障 broken while the skill runs — the burst around its holder, then a bullet buys a new one
|
||
battle.on('damaged', (c) => {
|
||
const h = c.target;
|
||
if (!holds(h) || !h.mem.mcnistBroken) return;
|
||
h.mem.mcnistBroken = false;
|
||
const sk = unit.skill;
|
||
if (!skillOn(unit, S2) || !h.alive) return;
|
||
h.mem.mcnistLock = true;
|
||
try {
|
||
const r = num(b2.range_radius, 1.5);
|
||
battle.fx('aoe', { x: h.x, y: h.y, radius: r, id: unit.id, skill: 'mcnist:shieldBurst' });
|
||
const dodged = new Set();
|
||
const dh = battle.on('dodge', (d) => { if (d.source === unit && d.dmg?.tags?.includes('mcnist:shieldBurst')) dodged.add(d.target); });
|
||
for (const e of battle.foesInRadius(h.x, h.y, r, true)) {
|
||
if (!e.alive) continue;
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale, 2), type: 'arts', isSkill: true, tags: ['skill', 'mcnist:shieldBurst'] });
|
||
if (e.alive && !dodged.has(e)) battle.applyStatus(e, 'tremble', { duration: num(b2.not_combat, 2), source: unit });
|
||
}
|
||
battle.off(dh);
|
||
} finally { h.mem.mcnistLock = false; }
|
||
if (!sk || !sk.active || sk.id !== S2 || !(sk.ammoLeft > 0) || !h.alive) return;
|
||
sk.ammoLeft--;
|
||
battle.emit('ammoUsed', { unit, left: sk.ammoLeft, skill: sk });
|
||
if (sk.ammoLeft <= 0) sk.end('ammo');
|
||
grantBarrier(battle, unit, h, S2_KEY, num(b2.hp_ratio)); // the last bullet's 屏障 outlives the skill
|
||
}, { owner: unit, priority: -50 });
|
||
// S3: the enemies her 结构性原理 blocks are her targets too
|
||
battle.on('tick', () => {
|
||
if (!skillOn(unit, S3)) return;
|
||
const keys = [];
|
||
for (const t of grafsOf(battle, unit)) {
|
||
if (!up(t)) continue;
|
||
for (const e of t.blocking) if (e.alive) keys.push(Math.round(e.y) * COLS + Math.round(e.x));
|
||
}
|
||
const cur = unit.extraRangeKeys ?? [];
|
||
if (keys.length !== cur.length || keys.some((k, i) => k !== cur[i])) battle.setExtraRange(unit, keys.length ? keys : null);
|
||
}, { owner: unit });
|
||
},
|
||
};
|
||
},
|
||
};
|