294 lines
20 KiB
JavaScript
294 lines
20 KiB
JavaScript
// server/sim/content/kits/ops/op-doroth.js — 多萝西 (char_4048_doroth) 自选 operator kit: 6★ 陷阱师 (特种), an owned-6★ pick
|
||
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules (TRP-Y 童话书, TRP-X 梦中人) at
|
||
// every form, and the kit of her trap 共振装置 (token_10025_doroth_recttp). Kit contract and the 自选 rules: ../README.md
|
||
// ("How to add an operator (自选)").
|
||
//
|
||
// Forms (data/backups.json units.char_4048_doroth): 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. 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 多萝西 / 共振装置 (备注) and 分支特性信息 §陷阱师, and the client's battle data (charpack char_4048_doroth: SpawnTokens,
|
||
// KillTokens, the T2 mode switch; skills skchr_doroth_1–3 / sktok_doroth_1–3; equips doroth_equip_1_* / 2_3_p1; buff
|
||
// templates doroth_t_2, doroth_token[*], trapper_e_trait[*], doroth_e_003[advanced_spawn_token], charge_token[refresh],
|
||
// trigger_charge_token, die_to_kill_token).
|
||
// - Trait (陷阱师) "可以使用陷阱来协助作战,但陷阱无法放置于敌人已在的格子中": ranged physical arrows, 3-3, hits air units
|
||
// (分支特性信息 "可对空"; the data's canHitFly over the profession table), blocks 1, ground enemies target her. Her traps
|
||
// never (re)deploy on a tile a ground enemy stands on (分支特性信息: "适用飞行单位判定的敌人(如浮空、近地悬浮)不影响陷阱的部署")
|
||
// and leave when she leaves the field (分支特性信息 "干员离场后,附属的陷阱随之消失"; die_to_kill_token).
|
||
// - The traps in this mode: her 共振装置 are hand pieces the player places (data `placeable`; the prep hands out a stack —
|
||
// content/tokens.js, server/match/player/diy.js). Placed pieces deploy with the board, free and ignoring her stock (PRTS
|
||
// 卫戍协议/帮助 "所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次"); a used-up one stays its tile's piece and
|
||
// comes back there (PRTS "若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个") once the deployment conditions hold: she is on
|
||
// the field, her stock holds a trap, the token card's redeploy time has run since the last trap deployment (data
|
||
// respawnTime 5 s: 备注 "…会让共振装置进入再部署CD"), its player has its cost in DP (3; TRP-X 2), fewer of her traps than
|
||
// the deploy limit stand (data deployLimit 10; TRP-X 13 — 备注 "陷阱部署上限与最多拥有数量相同") and no ground enemy is on
|
||
// the tile; one piece per check (uid order), paying the cost and spending one trap.
|
||
// - T1 共振装置 "可以使用8个共振装置(最多拥有10个),踩上去的第一个敌人会触发其效果,部署后立刻在攻击范围内召唤2个共振装置"
|
||
// (full potential: 10个; bb cnt, attack@max_cnt; TRP-X: 13 and stage 3: 3): her stock is refilled to cnt at each deployment
|
||
// (charge_token[refresh]) and holds at most the deploy limit; at each deployment attack@max_cnt traps appear on free melee
|
||
// tiles of her attack range with no ground enemy (the enemies' ground paths first, random among equals — [ASSUMED]: the client's selector
|
||
// filter 16 is not decoded), spending no trap but counting toward the limit (备注); the placed pieces off the field keep
|
||
// their slots (no summon takes one) [ASSUMED]. 阻回 while the stock is full (备注; `_stopSpWhenTokenIsFull`) — at full
|
||
// potential her deployment stock 10 fills the limit 10 (TRP-X 13 leaves room), so 阻回 until a trap is spent.
|
||
// - 共振装置 (its kit below): 不会受到攻击 (data: untargetable), blocks nothing, no normal attack. The first selectable ground
|
||
// enemy standing on its tile (sktok_doroth_* SkillTrigger: targetMotion ground, range 0-1; the earliest spawned of
|
||
// several) sets it off — the effect of the token skill of her pick (bySkill), with her current ATK (PRTS "技能伤害始终借用
|
||
// 持有者的攻击力计算"), from the trap (its source) —, then it withdraws (doroth_token[withdraw]; the blast fx carries
|
||
// `consumed`). S1 危险目标清除: that enemy takes atk_scale × ATK physical and DEF ×(1 + def) for `duration` s (formula
|
||
// FINAL_SCALER: the catalogue `defDown`). S2 流沙区域生成: every selectable ground enemy within 1.2 (备注 "陷阱爆炸范围为1.2半径
|
||
// 的圆") takes atk_scale × ATK physical and 束缚 `duration` s — `duration_2` s when it hit only one (cnt_2). S3
|
||
// 高速共振排障: every selectable ground enemy on the trap's x-6 takes atk_scale × ATK arts and 停顿 `sluggish` s, and every
|
||
// other trap of hers on that x-6 goes off `interval` s later (备注 "延迟触发时长为2s"; doroth_token_s3[trigger]: once — a
|
||
// trap already waiting keeps its time [ASSUMED]), with no enemy needed, and sets off its own x-6 the same way.
|
||
// - T2 梦想家 "陷阱触发后,多萝西获得2%的攻击力,最多叠加10层" (full potential: 12层; bb atk, max_stack_cnt; TRP-Y stage 3: 4%):
|
||
// every trap that goes off gives her one stack (ATK +atk, 直接乘算) until she leaves the field; with S1 / S2 before its
|
||
// damage, with S3 after it (备注). Full stacks switch her attack mode (doroth_t_2 SwitchMode: the Attack_2 clip — cosmetic here).
|
||
// - TRP-Y 童话书 "有20%概率部署造成2倍伤害的陷阱" (trait bb prob, atk_scale): each trap deployment rolls prob; such a trap deals
|
||
// ×atk_scale (trapper_e_trait[token_atk_scale] AtkScaleUp; the red "!" — fx 'mark'). Stage 3: T2 4 %.
|
||
// - TRP-X 梦中人: traps cost 1 less and the limit is 13 (the token's module stats), T1 "最多拥有13个"; stage 3: T1 summons 3 and
|
||
// "部署共振装置时有50%概率在攻击范围内额外召唤一个共振装置" (hidden talent prob, max_cnt, rangeGrid): on each deployment of a
|
||
// placed piece (备注 "仅手动部署的共振装置可触发模组3级效果" — the pieces the player placed [ASSUMED]: the start deployment and
|
||
// the redeploys) prob to summon max_cnt more on a tile as T1's, within the limit, spending no trap and ignoring the card's
|
||
// redeploy time but restarting it (备注); and when her T2 stacks fill (her attack mode switch) every placed piece standing
|
||
// rolls again (备注 "多萝西切换攻击模式时…为所有已手动部署的共振装置重新判定“概率额外召唤”").
|
||
// - S1 / S2 / S3 (AUTO, 19 / 16 SP, the data's DEFAULT): 被动效果 = the trap's effect above; 主动效果 "立即获得一个陷阱" (bb cnt):
|
||
// +cnt to her stock (trigger_charge_token). The cast acts on herself only, so it fires as soon as its SP is full
|
||
// (`trigger: 'SP_FULL'` — the owner's AUTO rule, kits/README.md checklist 5; skchr_doroth_* `_allowNoTarget`).
|
||
|
||
import { num, talentBb, moduleBb, traitBb, up, toggleBuff, summonTileFree } from '../shared/tier1.js';
|
||
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
|
||
import { bodyOnTile } from '../../../body.js';
|
||
import { COLS } from '../../../constants.js';
|
||
|
||
const S1 = 'skchr_doroth_1';
|
||
const S2 = 'skchr_doroth_2';
|
||
const S3 = 'skchr_doroth_3';
|
||
/** 共振装置, her trap. */
|
||
export const RESONATOR = 'token_10025_doroth_recttp';
|
||
const TS1 = 'sktok_doroth_1';
|
||
const TS2 = 'sktok_doroth_2';
|
||
const TS3 = 'sktok_doroth_3';
|
||
/** S2's blast (PRTS S2 备注 "陷阱爆炸范围为1.2半径的圆"). */
|
||
const S2_RADIUS = 1.2;
|
||
/** S3's area when the token skill carries no grid: range x-6 (the cross of radius 2). */
|
||
const X6 = Object.freeze([[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]]);
|
||
/** S3's delayed trigger when the blackboard carries none (PRTS 备注 "延迟触发时长为2s"). */
|
||
const S3_DELAY = 2;
|
||
/** How often a used-up piece checks its redeploy conditions. */
|
||
const WATCH = 0.25;
|
||
const GROUND = Object.freeze({ canHitFly: false, groundOnly: true });
|
||
const DREAM_KEY = 'talent:doroth:dream';
|
||
|
||
const tokenOf = (t) => t && t.kind === 'token' && t.defId === RESONATOR;
|
||
/** A ground enemy stands on tile (r, c) — a huge one on any tile of its body (body.js); air units never count. */
|
||
function groundEnemyOn(battle, r, c) {
|
||
for (const e of battle.enemies) {
|
||
if (!e.alive || e.hidden || e.isFlying) continue;
|
||
if (e.hitArea ? bodyOnTile(e, r, c) : Math.abs(e.x - c) <= 0.5 && Math.abs(e.y - r) <= 0.5) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export default {
|
||
char_4048_doroth: (bb, chess) => {
|
||
const t0 = talentBb(chess, 0); // cnt, attack@max_cnt
|
||
const t1 = talentBb(chess, 1); // atk, max_stack_cnt
|
||
const tb = traitBb(chess); // TRP-Y: prob, atk_scale
|
||
const hidden = moduleBb(chess); // TRP-X stage 3: prob, max_cnt
|
||
const hiddenGrid = (chess?.talents ?? []).find((t) => t && t.index === -1 && t.rangeGrid)?.rangeGrid ?? null;
|
||
const stockRefill = Math.max(0, Math.floor(num(t0.cnt, 0)));
|
||
const onDeploy = Math.max(0, Math.floor(num(t0['attack@max_cnt'], 0)));
|
||
const dream = { atk: num(t1.atk), max: Math.max(0, Math.floor(num(t1.max_stack_cnt, 0))) };
|
||
const dbl = { prob: num(tb.prob), scale: num(tb.atk_scale, 1) };
|
||
const extra = { prob: num(hidden.prob), cnt: Math.max(0, Math.floor(num(hidden.max_cnt, 0))) };
|
||
// her trap stock, the card's ready time, whether her T2 has filled this deployment, the deploy limit (per unit: the
|
||
// factory runs per unit; also `unit.trait.doroth` for diagnostics and tests)
|
||
const D = { stock: 0, readyAt: -Infinity, full: false, limit: Infinity };
|
||
|
||
const traps = (battle, unit) => battle.allyUnits.filter((t) => tokenOf(t) && t.ownerUnit === unit);
|
||
const standing = (battle, unit) => traps(battle, unit).filter((t) => t.alive).length;
|
||
/** Placed pieces still to come (not on the field, not gone): the T1 summons leave their slots free. */
|
||
const waiting = (battle, unit) => traps(battle, unit).filter((t) => t.uid != null && !t.alive && !t.removed).length;
|
||
|
||
/** The token's deploy limit for her loadout (variant stats, the module's when it carries one); her max stock too. */
|
||
function limitOf(battle, unit) {
|
||
const raw = battle.data.rawToken?.(RESONATOR);
|
||
const v = raw?.variants?.[unit.def?.tokenOwner];
|
||
const mod = unit.def?.loadout?.moduleId;
|
||
const n = num(v?.byModule?.[mod]?.stats?.deployLimit, num(v?.stats?.deployLimit, num(raw?.deployLimit, 0)));
|
||
if (n > 0) return n;
|
||
const m = String(chess?.talents?.find((t) => t && t.index === 0)?.desc ?? '').match(/最多拥有(\d+)个/);
|
||
return m ? +m[1] : Infinity;
|
||
}
|
||
|
||
/** Free melee tiles of her range (or of `grid` around her) with no ground enemy: enemy paths first, then random. */
|
||
function summonTiles(battle, unit, grid = null) {
|
||
const keys = grid ? absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, unit.s.rangeExtend || 0) : unit.rangeKeys || [];
|
||
const path = battle.groundPathTiles();
|
||
const out = [];
|
||
for (const k of keys) {
|
||
const r = Math.floor(k / COLS), c = k % COLS;
|
||
if (!summonTileFree(battle, r, c) || !battle.grid.canStand(r, c) || groundEnemyOn(battle, r, c)) continue;
|
||
out.push({ r, c, p: path.has(k) ? 0 : 1 });
|
||
}
|
||
battle.rng.shuffle(out);
|
||
return out.sort((a, b) => a.p - b.p);
|
||
}
|
||
|
||
/** Summon up to `n` traps (no stock spent) on summonTiles, within the limit (`room` more at most). */
|
||
function summon(battle, unit, n, grid = null, room = Infinity) {
|
||
let left = Math.min(n, room, D.limit - standing(battle, unit));
|
||
if (!(left > 0)) return 0;
|
||
let made = 0;
|
||
for (const { r, c } of summonTiles(battle, unit, grid)) {
|
||
if (left <= 0) break;
|
||
const t = battle.spawnToken(unit, RESONATOR, r, c, { kit: trapKit(unit) });
|
||
if (!t) continue;
|
||
battle.fx('summon', { x: t.x, y: t.y, id: t.id, token: RESONATOR });
|
||
left--; made++;
|
||
}
|
||
return made;
|
||
}
|
||
|
||
/** TRP-X stage 3 on a placed piece's deployment (and her mode switch): prob to summon max_cnt more. */
|
||
function rollExtra(battle, unit) {
|
||
if (!(extra.prob > 0) || !(extra.cnt > 0) || !up(unit) || !battle.rng.chance(extra.prob)) return;
|
||
summon(battle, unit, extra.cnt, hiddenGrid, D.limit - standing(battle, unit) - waiting(battle, unit));
|
||
}
|
||
|
||
/** 梦想家: one stack (to max_stack_cnt); full stacks are her attack mode switch (TRP-X stage 3 re-rolls). */
|
||
function dreamStack(battle, unit) {
|
||
if (!up(unit) || !(dream.atk > 0) || !(dream.max > 0)) return;
|
||
const b = unit.findBuff(DREAM_KEY);
|
||
const n = b ? b.stacks : 0;
|
||
if (n >= dream.max) return;
|
||
battle.addBuff(unit, { key: DREAM_KEY, refresh: 'stack', stacks: 1, maxStacks: dream.max, mods: { atkPct: dream.atk }, tags: ['talent'] });
|
||
if (n + 1 >= dream.max && !D.full) {
|
||
D.full = true;
|
||
for (const t of traps(battle, unit)) if (t.alive && t.uid != null) rollExtra(battle, unit);
|
||
}
|
||
}
|
||
|
||
/** The trap `t` goes off (`e` = the enemy that stepped on it; null for a delayed S3 trigger). */
|
||
function fire(battle, unit, t, e) {
|
||
if (!t.alive || t.mem.dorFired) return;
|
||
t.mem.dorFired = true;
|
||
const sk = t.def?.skill ?? null;
|
||
const tb2 = sk?.bb ?? {};
|
||
const id = sk?.id ?? TS1;
|
||
const s3 = id === TS3;
|
||
if (!s3) dreamStack(battle, unit); // 备注: with S1 / S2 before the damage
|
||
const atk = (unit.s ? unit.s.atk : t.s.atk) * (t.mem.dorDouble ? dbl.scale : 1);
|
||
const tags = ['summon', 'trap', 'doroth:trap'];
|
||
if (id === TS2) {
|
||
const victims = battle.foesInRadius(t.x, t.y, S2_RADIUS, true).filter((x) => canTargetEnemy(t, x, GROUND));
|
||
const dur = victims.length === Math.max(1, Math.floor(num(tb2.cnt_2, 1))) ? num(tb2.duration_2, num(tb2.duration)) : num(tb2.duration);
|
||
for (const x of victims) {
|
||
battle.dealDamage(t, x, { amount: atk * num(tb2.atk_scale), type: 'phys', isSkill: true, tags });
|
||
if (x.alive && dur > 0) battle.applyStatus(x, 'bind', { duration: dur, source: t });
|
||
}
|
||
battle.fx('explode', { x: t.x, y: t.y, r: S2_RADIUS, id: t.id, consumed: true, kind: 'doroth:quicksand' });
|
||
} else if (s3) {
|
||
const keys = absoluteRangeKeys(sk?.rangeGrid ?? X6, t.tileR, t.tileC, t.dir, 0);
|
||
for (const x of battle.enemiesInKeys(keys, t, GROUND)) {
|
||
battle.dealDamage(t, x, { amount: atk * num(tb2.atk_scale), type: 'arts', isSkill: true, tags });
|
||
if (x.alive && num(tb2.sluggish) > 0) battle.applyStatus(x, 'sluggish', { duration: num(tb2.sluggish), source: t });
|
||
}
|
||
// 并延迟触发范围内的其他共振装置
|
||
const set = new Set(keys);
|
||
const delay = num(tb2.interval, S3_DELAY);
|
||
for (const o of traps(battle, unit)) {
|
||
if (o === t || !o.alive || o.mem.dorFired || o.mem.dorWaiting || !set.has(o.tileR * COLS + o.tileC)) continue;
|
||
o.mem.dorWaiting = true;
|
||
const seq = o.deploySeq;
|
||
battle.after(delay, () => { if (o.alive && o.deploySeq === seq) fire(battle, unit, o, null); }, { owner: o });
|
||
}
|
||
battle.fx('explode', { x: t.x, y: t.y, r: 2, id: t.id, consumed: true, dmgType: 'arts', kind: 'doroth:resonance' });
|
||
dreamStack(battle, unit); // 备注: with S3 after the damage
|
||
} else {
|
||
if (e && e.alive) {
|
||
battle.dealDamage(t, e, { amount: atk * num(tb2.atk_scale), type: 'phys', isSkill: true, tags });
|
||
if (e.alive && num(tb2.duration) > 0 && num(tb2.def) < 0) battle.applyStatus(e, 'defDown', { duration: num(tb2.duration), value: -num(tb2.def), source: t });
|
||
}
|
||
battle.fx('explode', { x: t.x, y: t.y, r: 0.6, id: t.id, consumed: true, kind: 'doroth:clear' });
|
||
}
|
||
battle.retreat(t, { reason: 'expired', permanent: true });
|
||
}
|
||
|
||
/** 共振装置's kit (`unit` = 多萝西): its rolls at each deployment, the trigger, a placed piece kept for its redeploys. */
|
||
function trapKit(unit) {
|
||
return {
|
||
skill: null,
|
||
trait: { noAttack: true },
|
||
install(battle, t) {
|
||
battle.on('deploy', (ctx) => {
|
||
if (ctx.unit !== t) return;
|
||
t.mem.dorFired = false;
|
||
t.mem.dorWaiting = false;
|
||
t.mem.dorDouble = dbl.prob > 0 && dbl.scale !== 1 && battle.rng.chance(dbl.prob);
|
||
if (t.mem.dorDouble) battle.fx('mark', { x: t.x, y: t.y, id: t.id, kind: 'doroth:double' });
|
||
D.readyAt = battle.time + Math.max(0, num(t.base.respawnTime, 0));
|
||
if (t.uid != null) rollExtra(battle, unit);
|
||
}, { owner: t });
|
||
battle.on('tick', () => {
|
||
if (!t.alive || t.mem.dorFired) return;
|
||
let hit = null;
|
||
for (const e of battle.enemies) {
|
||
if (!canTargetEnemy(t, e, GROUND)) continue;
|
||
const on = e.hitArea ? bodyOnTile(e, t.tileR, t.tileC) : Math.abs(e.x - t.tileC) <= 0.5 && Math.abs(e.y - t.tileR) <= 0.5;
|
||
if (on && (!hit || e.spawnSeq < hit.spawnSeq)) hit = e;
|
||
}
|
||
if (hit) fire(battle, unit, t, hit);
|
||
}, { owner: t });
|
||
// a placed piece stays its tile's piece (redeployed by her kit's watch), whatever took it off the field
|
||
if (t.uid != null) battle.on('death', (ctx) => { if (ctx.unit === t && !battle.finished) t.removed = false; }, { owner: t, priority: -10 });
|
||
},
|
||
};
|
||
}
|
||
|
||
const skillSpec = () => ({
|
||
kind: 'instant', trigger: 'SP_FULL',
|
||
onStart({ skill }) { // 主动效果:立即获得一个陷阱
|
||
D.stock = Math.min(D.limit, D.stock + Math.max(1, Math.floor(num(skill.bb?.cnt, 1))));
|
||
},
|
||
});
|
||
|
||
return {
|
||
skills: { [S1]: skillSpec(), [S2]: skillSpec(), [S3]: skillSpec() },
|
||
talents: [
|
||
{ install(battle, unit) { // 共振装置: the pieces' kit, the stock, the summons at each deployment, the redeploys
|
||
D.limit = limitOf(battle, unit);
|
||
unit.trait.doroth = D;
|
||
for (const t of battle.allyUnits) {
|
||
if (!tokenOf(t) || t.ownerUnit !== unit || t.alive || t.deployed) continue;
|
||
if (t.kit) battle.offOwner(t); // a piece set up before her: drop its generic kit's hooks
|
||
battle._setupUnit(t, trapKit(unit));
|
||
}
|
||
battle.on('deploy', (ctx) => {
|
||
if (ctx.unit !== unit || ctx.move) return;
|
||
D.stock = Math.min(D.limit, stockRefill); // charge_token[refresh]
|
||
D.full = false;
|
||
if (onDeploy > 0) summon(battle, unit, onDeploy, null, D.limit - standing(battle, unit) - waiting(battle, unit));
|
||
}, { owner: unit });
|
||
// her traps leave with her (die_to_kill_token); the placed pieces wait for her return
|
||
battle.on('death', (ctx) => {
|
||
if (ctx.unit !== unit) return;
|
||
for (const t of traps(battle, unit)) if (t.alive) battle.retreat(t, { reason: 'expired', permanent: true });
|
||
}, { owner: unit });
|
||
// a used-up / withdrawn piece comes back on its tile once the deployment conditions hold (see the header)
|
||
battle.every(WATCH, () => {
|
||
if (!up(unit) || D.stock < 1 || battle.time + 1e-9 < D.readyAt || standing(battle, unit) >= D.limit) return;
|
||
const pieces = traps(battle, unit).filter((t) => t.uid != null && !t.alive && !t.removed).sort((a, b) => a.uid - b.uid);
|
||
for (const p of pieces) {
|
||
if (groundEnemyOn(battle, p.homeR, p.homeC)) continue;
|
||
if (battle.redeploy(p, { free: false })) { D.stock--; break; }
|
||
}
|
||
}, { owner: unit });
|
||
// 阻回 while her stock is full (_stopSpWhenTokenIsFull)
|
||
toggleBuff(battle, unit, 'talent:doroth:full', () => D.stock >= D.limit, {}, { flags: { noSp: true } });
|
||
} },
|
||
{ install() {} }, // 梦想家: the stacks come with every trap that goes off (fire → dreamStack)
|
||
],
|
||
};
|
||
},
|
||
};
|