415 lines
26 KiB
JavaScript
415 lines
26 KiB
JavaScript
// server/sim/content/kits/ops/op-wang.js — 望 (char_2027_wang) 自选 operator kit: 6★ 陷阱师 (特种), an owned-6★ pick of the
|
||
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait, the module TRP-X 兽形棋盒 at every form, and the kit of
|
||
// her summon 棋子 (token_10064_wang_stone1) with its 跟子. Kit contract and the 自选 rules: ../README.md ("How to add an
|
||
// operator (自选)").
|
||
//
|
||
// Forms (data/backups.json units.char_2027_wang, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
|
||
// elite = E2 Lv60, rank 7, TRP-X at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
|
||
// Sources: character_table / skill_table / battle_equip_table / token_table (zh_CN, as built into backups.json; the 棋子
|
||
// owner-form / skill / module variants) and PRTS 望 (铸子 备注: the 跟子 are 望's own projectiles — no unit, they take no
|
||
// tile, act and connect like a 棋子; they go on deployable tiles and on undeployable ground with no character unit; order
|
||
// "有敌人的>没有敌人的;不可部署地块>可部署地面地块>可部署高台地块…相同优先级时按棋子上>右>下>左"; a 棋子 deals its damage as
|
||
// itself, a 跟子 as 望. 料敌机先 备注: the RES ignore is the source's 法术穿透 for that damage. S1 备注 "棋子持有库存达到上限的情况下
|
||
// 具有阻回 / 法术伤害效果可独立叠加生效(每个效果会独立记录触发棋子的第二天赋层数)…可对空 / …由望依自身属性预计算伤害后、以无来源
|
||
// 的形式施加给目标". S2 备注 "若相连棋子/跟子在上下侧则激活垂直效果范围(自身及上下3格),左右侧则激活水平效果范围…效果范围不因相连
|
||
// 棋子/跟子消失而取消激活 / 移动速度降低为直接乘算,可无限叠加,每层独立计时(但移动速度存在0.1数值下限)". S3 备注 "棋子与跟子的
|
||
// 攻击范围/触发范围为…,触发时对范围内所有敌方单位造成伤害(可对空) / 技能开启时自动“部署”的为第一天赋的跟子"), PRTS 棋子 (备注
|
||
// "技能伤害始终借用持有者的攻击力计算,但伤害来源于自身"), PRTS 分支特性信息 陷阱师 ("可对空 / “敌人已在”…地面行动敌人… / 干员离场
|
||
// 后,附属的陷阱随之消失"), PRTS 卫戍协议/帮助 §作战阶段 (placed summons deploy with the board "无视所属干员的持有状态,不消耗持有数量";
|
||
// "若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个").
|
||
// - Trait (陷阱师): ranged physical arrows, 3-3, hits air units (PRTS 分支特性信息 "可对空"), blocks 1, ground-targetable.
|
||
// "陷阱无法放置于敌人已在的格子中": a 棋子 does not come back while a ground enemy stands on its tile (S3: inside her range it
|
||
// may). Module TRP-X "部署费用更低(-1)…可同时部署的陷阱数量提升(+1)": the 棋子 variant's cost 2 (the DP of its returns) and
|
||
// deploy limit 7 (the hand count — the match's); its attributes in the stats.
|
||
// - T1 铸子 (cnt / attack@max_spawn_cnt; "最多拥有7枚"): the 棋子 are hand pieces the player places; they deploy with the board
|
||
// for free. Her stock (持有库存: the 棋子 in her hand — cnt at the start, at most STOCK_CAP; the skills add to it) counts
|
||
// them — the owner's decision of 2026-10-06, a deliberate deviation from PRTS 卫戍协议/帮助 (placed summons "无视所属干员的
|
||
// 持有状态,不消耗持有数量", which left her stock at cnt: one S1 / S2 cast reached the cap and 阻回 held her SP for the rest
|
||
// of the battle): every deployment of a 棋子 (the battle start's and each return) takes one from it, like one she deployed
|
||
// herself (never below 0 [ASSUMED: a 7th placed piece of TRP-X's deploy limit takes none]), and a 棋子 that leaves the
|
||
// field (set off, or gone with her) goes back into it at once by the official summon rule — its card back in her hand
|
||
// while its redeploy time runs (as shared/summoner.js's recall) —, at most the cap [ASSUMED: one over the cap is lost].
|
||
// So "立即获得两枚棋子" and the 阻回 at the cap keep cycling through the battle. 棋子 / 跟子
|
||
// that touch on a side (上下 / 左右) activate each other and stay active once activated [ASSUMED from the S2 备注]; an
|
||
// enemy (air units too) on an active one's tile sets it off: its effect, then it is used up (a 棋子 leaves the field; a
|
||
// 跟子 is gone) [ASSUMED: a trap is spent by its trigger]. A spent 棋子 comes back on its tile ("原地再部署") after its
|
||
// redeploy time (2 s), paying its cost and one of her stock, while 望 is on the field. That return is the mode's
|
||
// deployment of her card: it counts as a 手动部署 for 铸子 —
|
||
// one 跟子 on a tile next to it (the PRTS order above), at most attack@max_spawn_cnt on the field [ASSUMED: the battle-start
|
||
// deployment does not count]. When 望 leaves the field her 棋子 and 跟子 vanish (PRTS 分支特性信息); the 棋子 come back
|
||
// with her.
|
||
// - T2 料敌机先 (attack@per_atk_scale / attack@per_magic_resist_penetrate_fixed / attack@max_trigger_cnt): an active 棋子 / 跟子
|
||
// gets one stack per piece of the unbroken line it is part of (itself included; the longer of its lines; ≤ the cap),
|
||
// kept once reached [ASSUMED]; its damage ×(1 + n × per) and n × pen RES ignored. TRP-X stage 3: 13 % / 12.
|
||
// - S1 取势 (AUTO): passive — a triggered piece puts 停顿 attack@sluggish s on the enemy that set it off and a 法术 DoT of
|
||
// attack@atk_scale × her ATK every second for attack@sluggish s (precomputed at the trigger with the piece's stacks and
|
||
// her damage multipliers; 无来源, credited to her; each one independent). Active — "立即获得两枚棋子": +cnt stock (≤ the
|
||
// cap); with the stock full she holds 阻回. An AUTO skill acting on nobody fires at full SP (`trigger: 'SP_FULL'`, the
|
||
// owner's AUTO rule).
|
||
// - S2 连星 (AUTO, SP_FULL): passive — a triggered piece deals attack@atk_scale × her ATK arts to every enemy on its active
|
||
// lines (its tile ± 3 along each side it was ever connected on; air units too) and a 直接乘算 slow attack@move_speed for
|
||
// attack@duration s, each one on its own timer, added up (speed ≥ 0.1). Active as S1.
|
||
// - S3 天下劫 (MANUAL, data ACTIVE_RANGE on its 4-12; ammo trigger_time): passive — the pieces' trigger and damage area is the
|
||
// token skill's range (x-6 in the data; PRTS writes x-1), atk_scale × her ATK arts on every enemy there. Active — no
|
||
// attacks, range 4-12; +cnt stock, the part over the cap placed at once as 跟子 on the enemies of her range first (her
|
||
// target order), then on other tiles of it by the 铸子 order [ASSUMED for the ties: nearest to her, then by tile]; a 棋子
|
||
// coming back inside her range during it drops up to 3 跟子, one bullet each ("第一天赋额外至多部署3枚棋子并消耗等量弹药");
|
||
// it ends when the bullets are spent ("棋子耗尽" [ASSUMED]); bullets left at another end return to the stock.
|
||
|
||
import { num, talentBb, skillRec, up } from '../shared/tier1.js';
|
||
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
|
||
import { bodyOnTile } from '../../../body.js';
|
||
import { COLS, ROWS } from '../../../constants.js';
|
||
|
||
const S1 = 'skchr_wang_1';
|
||
const S2 = 'skchr_wang_2';
|
||
const S3 = 'skchr_wang_3';
|
||
export const STONE = 'token_10064_wang_stone1';
|
||
/** 铸子 "最多拥有7枚" (the 棋子's max deck stack at E2; the talent text). */
|
||
export const STOCK_CAP_FALLBACK = 7;
|
||
/** 料敌机先's stack cap when the data carries none. */
|
||
const T2_CAP_FALLBACK = 3;
|
||
/** 连星 "两侧3格" (the token blackboard's tile_count 6, both sides). */
|
||
const LINE_FALLBACK = 6;
|
||
/** The S3 token skill's range when the data carries none (range_table x-6). */
|
||
const X6 = Object.freeze([[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]]);
|
||
/** 跟子 neighbours in the PRTS order 上 > 右 > 下 > 左 (UP = +row; the mirrored field swaps 右 / 左). */
|
||
const SIDES = Object.freeze([[1, 0], [0, 1], [-1, 0], [0, -1]]);
|
||
const SIDES_MIRRORED = Object.freeze([[1, 0], [0, -1], [-1, 0], [0, 1]]);
|
||
/** "额外至多部署3枚棋子" (S3, no blackboard key). */
|
||
const S3_EXTRA = 3;
|
||
/** Seconds between two tries of a spent 棋子 to come back. */
|
||
const RETRY = 0.25;
|
||
const SLOW_KEY = 'wang:slow';
|
||
const DOT_KEY = 'wang:qushi';
|
||
const TAG = 'wang:stone';
|
||
/** Profile the pieces select enemies with: air units too. */
|
||
const ANY = Object.freeze({ canHitFly: true });
|
||
|
||
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
|
||
const keyOf = (r, c) => (r >= 0 && r < ROWS && c >= 0 && c < COLS ? r * COLS + c : -1);
|
||
|
||
/**
|
||
* The 跟子 placement class of a tile: 0 undeployable ground (walkable, nothing may be deployed there), 1 deployable
|
||
* ground, 2 deployable 高台; null where no 跟子 may go (outside the field, walls, flyer-only tiles that take nobody).
|
||
*/
|
||
function tileClass(battle, r, c) {
|
||
if (!battle.grid.inRect(r, c)) return null;
|
||
const t = battle.grid.tile(r, c);
|
||
if (t.build === 'NONE') return t.height === 'LOW' && t.pass === 'ALL' ? 0 : null;
|
||
if (t.height === 'LOW' && (t.build === 'ALL' || t.build === 'MELEE')) return 1;
|
||
if (t.build === 'RANGED' || t.build === 'ALL') return 2;
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 连星's slow (PRTS 备注 "移动速度降低为直接乘算,可无限叠加,每层独立计时(但移动速度存在0.1数值下限)"): one buff per enemy holding
|
||
* every application (its value, its end); moveMul = max(0.1 ÷ base speed, 1 + Σ values).
|
||
*/
|
||
function addSlow(battle, src, e, value, duration) {
|
||
if (!e.alive || !(duration > 0) || !value) return;
|
||
const refresh = (unit, b) => {
|
||
const now = battle.time;
|
||
b.data.list = b.data.list.filter((x) => x.until > now + 1e-9);
|
||
if (!b.data.list.length) { battle.removeBuff(unit, b); return; }
|
||
const sum = b.data.list.reduce((a, x) => a + x.v, 0);
|
||
const floor = Math.min(1, 0.1 / Math.max(1e-6, unit.base.moveSpeed));
|
||
const mul = Math.max(floor, 1 + sum);
|
||
if (b.mods?.moveMul !== mul) { b.mods = { moveMul: mul }; unit.markDirty(); }
|
||
};
|
||
let b = e.findBuff(SLOW_KEY);
|
||
if (!b) b = battle.addBuff(e, { key: SLOW_KEY, source: src, tags: ['skill'], data: { list: [] }, mods: { moveMul: 1 }, onTick: ({ unit, buff }) => refresh(unit, buff) });
|
||
if (!b) return;
|
||
b.data.list.push({ v: value, until: battle.time + duration });
|
||
refresh(e, b);
|
||
}
|
||
|
||
export default {
|
||
char_2027_wang: (bb, chess) => {
|
||
const t0 = talentBb(chess, 0); // 铸子: cnt, attack@max_spawn_cnt
|
||
const t1 = talentBb(chess, 1); // 料敌机先 (TRP-X stage 2+: its change)
|
||
const t0desc = String((chess?.talents ?? []).find((t) => t && t.index === 0)?.desc ?? '');
|
||
const capText = /最多拥有(\d+)枚/.exec(t0desc);
|
||
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
|
||
const s3 = skillRec(chess, S3);
|
||
const per = num(t1['attack@per_atk_scale']), pen = num(t1['attack@per_magic_resist_penetrate_fixed']);
|
||
const t2cap = Math.max(1, Math.floor(num(t1['attack@max_trigger_cnt'], T2_CAP_FALLBACK)));
|
||
const followCap = Math.max(0, Math.floor(num(t0['attack@max_spawn_cnt'])));
|
||
const stock0 = Math.max(0, Math.floor(num(t0.cnt)));
|
||
const stockCap = capText ? +capText[1] : STOCK_CAP_FALLBACK;
|
||
|
||
/** Every live piece of 望: her deployed 棋子 and her 跟子, in the order they came. */
|
||
const piecesOf = (battle, unit) => {
|
||
const out = [];
|
||
for (const a of battle.allyUnits) {
|
||
if (a.kind !== 'token' || a.defId !== STONE || a.ownerUnit !== unit || !a.alive || !a.deployed || !a.mem.wangNode) continue;
|
||
const n = a.mem.wangNode;
|
||
n.r = a.tileR; n.c = a.tileC; n.stone = a;
|
||
out.push(n);
|
||
}
|
||
for (const f of unit.mem.wang.followers) out.push(f);
|
||
return out.sort((a, b) => a.seq - b.seq);
|
||
};
|
||
const groundFoeOn = (battle, r, c) => battle.enemies.some((e) => e.alive && !e.hidden && !e.isFlying && bodyOnTile(e, r, c));
|
||
const anyFoeOn = (battle, r, c) => battle.enemies.some((e) => e.alive && !e.hidden && bodyOnTile(e, r, c));
|
||
|
||
/** Place a 跟子 on (r, c) (the caller checked the tile). */
|
||
const addFollower = (battle, unit, r, c) => {
|
||
const w = unit.mem.wang;
|
||
const f = { r, c, seq: ++w.seq, act: false, h: false, v: false, stacks: 0, follower: true };
|
||
w.followers.push(f);
|
||
battle.fx('summon', { x: c, y: r, id: unit.id, src: unit.id, token: STONE, follower: true });
|
||
return f;
|
||
};
|
||
/** Tiles a 跟子 may take: in the field, a class, no character unit or knocked-out operator, no piece. */
|
||
const followerFree = (battle, unit, r, c, taken) => {
|
||
const k = keyOf(r, c);
|
||
return k >= 0 && !taken.has(k) && tileClass(battle, r, c) != null && !battle.unitAt(r, c) && !battle.downOn(r, c);
|
||
};
|
||
/** 铸子: up to `n` 跟子 next to (r, c) in the PRTS order; returns how many were placed. */
|
||
const dropFollowers = (battle, unit, r, c, n) => {
|
||
const w = unit.mem.wang;
|
||
let placed = 0;
|
||
for (let i = 0; i < n && w.followers.length < followCap; i++) {
|
||
const taken = new Set(piecesOf(battle, unit).map((p) => keyOf(p.r, p.c)));
|
||
const sides = battle.getPlayer(unit.ownerId)?.mirror ? SIDES_MIRRORED : SIDES;
|
||
const cands = [];
|
||
sides.forEach(([dr, dc], idx) => {
|
||
const rr = r + dr, cc = c + dc;
|
||
if (!followerFree(battle, unit, rr, cc, taken)) return;
|
||
cands.push({ rr, cc, rank: [anyFoeOn(battle, rr, cc) ? 0 : 1, tileClass(battle, rr, cc), idx] });
|
||
});
|
||
if (!cands.length) break;
|
||
cands.sort((a, b) => a.rank[0] - b.rank[0] || a.rank[1] - b.rank[1] || a.rank[2] - b.rank[2]);
|
||
addFollower(battle, unit, cands[0].rr, cands[0].cc);
|
||
placed++;
|
||
}
|
||
return placed;
|
||
};
|
||
/** S3 bullets: spend `n` (the skill ends with the last). */
|
||
const spendAmmo = (battle, unit, n) => {
|
||
const sk = unit.skill, w = unit.mem.wang;
|
||
if (!sk || sk.id !== S3 || !sk.active || !(n > 0)) return;
|
||
w.ammo = Math.max(0, w.ammo - n);
|
||
sk.ammoLeft = w.ammo;
|
||
if (battle.hasHook('ammoUsed')) battle.emit('ammoUsed', { unit, left: w.ammo, skill: sk });
|
||
if (w.ammo <= 0) sk.end('ammo');
|
||
};
|
||
const inHerRange = (unit, r, c) => !!unit.rangeKeySet?.has(keyOf(r, c));
|
||
|
||
/** A spent 棋子 comes back on its tile ("原地再部署"); its return drops the 铸子 跟子. */
|
||
const scheduleReturn = (battle, unit, stone) => {
|
||
const at = battle.time + Math.max(0, num(stone.base.respawnTime));
|
||
battle.every(RETRY, (b, sched) => {
|
||
if (stone.alive || stone.removed || b.finished) { sched.cancel(); return; }
|
||
if (b.time + 1e-9 < at || !up(unit) || unit.mem.wang.stock < 1) return; // (one of her stock: the deployment takes it)
|
||
const r = stone.homeR, c = stone.homeC;
|
||
const s3on = unit.skill?.id === S3 && unit.skill.active && inHerRange(unit, r, c);
|
||
if (groundFoeOn(b, r, c) && !s3on) return; // "陷阱无法放置于敌人已在的格子中"
|
||
// (a 跟子 on its tile goes: "跟子所在格进行部署…时,该跟子会随之消失")
|
||
const w = unit.mem.wang;
|
||
const before = w.followers.length;
|
||
w.followers = w.followers.filter((f) => f.r !== r || f.c !== c);
|
||
if (!b.redeploy(stone, { free: false })) { if (w.followers.length !== before) b.fx('disappear', { x: c, y: r, id: unit.id }); return; }
|
||
sched.cancel();
|
||
if (s3on) spendAmmo(b, unit, dropFollowers(b, unit, r, c, Math.min(S3_EXTRA, w.ammo)));
|
||
else dropFollowers(b, unit, r, c, 1);
|
||
}, { owner: unit });
|
||
};
|
||
|
||
/** The 棋子's kit: no attack, untargetable, its node data per deployment, its return when spent. */
|
||
const stoneKit = (owner) => ({
|
||
skill: null,
|
||
trait: { noAttack: true },
|
||
install(battle, unit) {
|
||
// [ASSUMED] a trap: no enemy selects it
|
||
battle.addBuff(unit, { key: 'trait:wangStone', flags: { untargetable: true }, persist: true, allowDead: true });
|
||
battle.on('deploy', (ctx) => {
|
||
if (ctx.unit !== unit) return;
|
||
const w = owner.mem.wang;
|
||
unit.mem.wangNode = { seq: ++w.seq, act: false, h: false, v: false, stacks: 0 };
|
||
w.stock = Math.max(0, w.stock - 1); // it occupies her stock (the owner's decision of 2026-10-06)
|
||
}, { owner: unit });
|
||
battle.on('death', (ctx) => {
|
||
if (ctx.unit !== unit || battle.finished) return;
|
||
unit.removed = false; // the piece is kept (Battle.redeploy accepts it; its hooks stay)
|
||
unit.mem.wangNode = null;
|
||
const w = owner.mem.wang;
|
||
w.stock = Math.min(stockCap, w.stock + 1); // its card goes back into her stock (the official summon rule)
|
||
scheduleReturn(battle, owner, unit);
|
||
}, { owner: unit, priority: -10 });
|
||
},
|
||
});
|
||
|
||
return {
|
||
skills: {
|
||
[S1]: {
|
||
kind: 'instant', trigger: { rule: 'SP_FULL' },
|
||
onStart({ unit }) { const w = unit.mem.wang; w.stock = Math.min(stockCap, w.stock + Math.max(0, Math.floor(num(b1.cnt)))); },
|
||
},
|
||
[S2]: {
|
||
kind: 'instant', trigger: { rule: 'SP_FULL' },
|
||
onStart({ unit }) { const w = unit.mem.wang; w.stock = Math.min(stockCap, w.stock + Math.max(0, Math.floor(num(b2.cnt)))); },
|
||
},
|
||
[S3]: {
|
||
kind: 'ammo',
|
||
ammo: Math.max(1, Math.floor(num(b3.trigger_time, 20))),
|
||
targeting: { rangeGrid: s3?.rangeGrid ?? null },
|
||
attack: { noAttack: true },
|
||
onStart({ battle, unit, skill }) {
|
||
const w = unit.mem.wang;
|
||
w.ammo = skill.ammoLeft;
|
||
const total = w.stock + Math.max(0, Math.floor(num(b3.cnt)));
|
||
const over = Math.max(0, total - stockCap);
|
||
w.stock = Math.min(stockCap, total);
|
||
// "将超出上限的棋子优先部署在范围内敌人所在位置": enemies of her (4-12) range first, then other tiles of it
|
||
let n = Math.min(over, followCap - w.followers.length);
|
||
if (!(n > 0)) return;
|
||
const taken = new Set(piecesOf(battle, unit).map((p) => keyOf(p.r, p.c)));
|
||
const foes = sortEnemyTargets(battle, unit, battle.enemiesInKeys(unit.rangeKeys, unit, ANY), unit.profile?.priority ?? null);
|
||
for (const e of foes) {
|
||
if (n <= 0) break;
|
||
const r = Math.round(e.y), c = Math.round(e.x);
|
||
if (!followerFree(battle, unit, r, c, taken)) continue;
|
||
taken.add(keyOf(r, c));
|
||
addFollower(battle, unit, r, c);
|
||
n--;
|
||
}
|
||
if (n <= 0) return;
|
||
const rest = [];
|
||
for (const k of unit.rangeKeys ?? []) {
|
||
const r = Math.floor(k / COLS), c = k % COLS;
|
||
if (!followerFree(battle, unit, r, c, taken)) continue;
|
||
rest.push({ r, c, k, cls: tileClass(battle, r, c), d: Math.max(Math.abs(r - unit.tileR), Math.abs(c - unit.tileC)) });
|
||
}
|
||
rest.sort((a, b) => a.cls - b.cls || a.d - b.d || a.k - b.k);
|
||
for (const t of rest.slice(0, n)) addFollower(battle, unit, t.r, t.c);
|
||
},
|
||
onEnd({ unit, reason }) {
|
||
const w = unit.mem.wang;
|
||
if (reason !== 'ammo' && w.ammo > 0) w.stock = Math.min(stockCap, w.stock + w.ammo); // "剩余的弹药返还为棋子"
|
||
w.ammo = 0;
|
||
},
|
||
},
|
||
},
|
||
talents: [
|
||
// 铸子 (and the 棋子's own kit): her 棋子 pieces run the stone kit — set up here, before the battle starts
|
||
{ install(battle, unit) {
|
||
unit.mem.wang = { stock: stock0, followers: [], seq: 0, ammo: 0 };
|
||
for (const t of battle.allyUnits) {
|
||
if (t.kind !== 'token' || t.defId !== STONE || t.ownerUnit !== unit || t.alive || t.deployed) continue;
|
||
if (t.kit) battle.offOwner(t);
|
||
battle._setupUnit(t, stoneKit(unit));
|
||
}
|
||
} },
|
||
{ install() {} }, // 料敌机先 — the pieces' stacks (kit install)
|
||
],
|
||
install(battle, unit) {
|
||
const sid = unit.skill?.id;
|
||
const tok = battle.tokenDef(STONE, unit);
|
||
const tbb = tok?.skill?.bb ?? {};
|
||
const line = Math.max(0, Math.floor(num(tbb.tile_count, LINE_FALLBACK) / 2));
|
||
const trigGrid = sid === S3 ? (tok?.skill?.rangeGrid ?? X6) : null;
|
||
/** Trigger / damage tiles of a piece: its own tile, or S3's area around it. */
|
||
const trigKeys = (n) => (trigGrid ? absoluteRangeKeys(trigGrid, n.r, n.c, 'RIGHT', 0) : [keyOf(n.r, n.c)].filter((k) => k >= 0));
|
||
/** 连星's lines of a piece: its tile ± `line` along each side it was connected on. */
|
||
const lineKeys = (n) => {
|
||
const out = new Set();
|
||
for (let k = -line; k <= line; k++) {
|
||
if (n.h) { const x = keyOf(n.r, n.c + k); if (x >= 0) out.add(x); }
|
||
if (n.v) { const x = keyOf(n.r + k, n.c); if (x >= 0) out.add(x); }
|
||
}
|
||
return [...out];
|
||
};
|
||
const hitOpts = (n) => ({ type: 'arts', isSkill: true, mul: 1 + per * n.stacks, resIgnoreFlat: pen * n.stacks, tags: ['skill', TAG] });
|
||
/** A piece sets off: its effect (the picked skill's passive) on `foes` (the enemies of its trigger tiles). */
|
||
const fire = (n, foes) => {
|
||
const src = n.stone ?? unit;
|
||
battle.fx('explode', { x: n.c, y: n.r, r: trigGrid ? 2 : 0.5, id: src.id, src: unit.id, dmgType: 'arts', skill: sid === S3 ? 'wang_3' : sid === S2 ? 'wang_2' : 'wang_1' });
|
||
if (sid === S1) {
|
||
const e = sortEnemyTargets(battle, src, foes.slice(), null)[0];
|
||
if (!e) return;
|
||
const dur = num(b1['attack@sluggish']);
|
||
battle.applyStatus(e, 'sluggish', { duration: dur, source: unit });
|
||
// precomputed by 望 at the trigger (her ATK and damage multipliers, the piece's stacks), dealt as 无来源
|
||
const s = unit.s;
|
||
const amount = s.atk * num(b1['attack@atk_scale']) * (1 + per * n.stacks) * s.dmgDealtMul * s.artsDealtMul;
|
||
const rif = pen * n.stacks + s.resIgnoreFlat, rip = s.resIgnorePct;
|
||
if (amount > 0 && dur > 0) {
|
||
battle.addBuff(e, {
|
||
key: DOT_KEY, refresh: 'independent', maxStacks: 999, duration: dur, interval: 1, source: unit, tags: ['skill', 'dot'],
|
||
onTick: ({ unit: t }) => { if (t.alive) battle.dealDamage(unit, t, { amount, type: 'arts', canDodge: false, isSkill: true, sourceless: true, resIgnoreFlat: rif, resIgnorePct: rip, tags: ['skill', 'dot', DOT_KEY] }); },
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
const victims = sid === S2 ? battle.enemiesInKeys(lineKeys(n), src, ANY) : foes;
|
||
const scale = sid === S2 ? num(b2['attack@atk_scale']) : num(b3.atk_scale);
|
||
for (const e of victims) {
|
||
if (!e.alive) continue;
|
||
battle.dealDamage(src, e, { amount: unit.s.atk * scale, ...hitOpts(n) });
|
||
if (sid === S2 && e.alive) addSlow(battle, unit, e, num(b2['attack@move_speed']), num(b2['attack@duration']));
|
||
}
|
||
};
|
||
/** The piece is spent: a 棋子 leaves the field (it comes back on its own), a 跟子 is gone. */
|
||
const spend = (n) => {
|
||
n.gone = true;
|
||
if (n.stone) { if (n.stone.alive) battle.retreat(n.stone, { reason: 'expired', permanent: true }); return; }
|
||
const w = unit.mem.wang;
|
||
w.followers = w.followers.filter((f) => f !== n);
|
||
};
|
||
battle.on('tick', () => {
|
||
if (!up(unit) || battle.finished) return;
|
||
const nodes = piecesOf(battle, unit);
|
||
if (!nodes.length) return;
|
||
const at = new Map(nodes.map((n) => [keyOf(n.r, n.c), n]));
|
||
for (const n of nodes) { // 棋子相连时相互激活 (kept once active)
|
||
for (const [dr, dc] of SIDES) {
|
||
const k = keyOf(n.r + dr, n.c + dc);
|
||
if (k < 0 || !at.has(k)) continue;
|
||
n.act = true;
|
||
if (dr) n.v = true; else n.h = true;
|
||
}
|
||
}
|
||
if (per > 0 || pen > 0) {
|
||
for (const n of nodes) { // 料敌机先: one stack per piece of its unbroken line (≤ the cap)
|
||
if (!n.act) continue;
|
||
for (const [dr, dc] of [[0, 1], [1, 0]]) {
|
||
let run = 1; // (keyOf is −1 off the stage: never a piece)
|
||
for (let k = 1; at.has(keyOf(n.r + dr * k, n.c + dc * k)); k++) run++;
|
||
for (let k = 1; at.has(keyOf(n.r - dr * k, n.c - dc * k)); k++) run++;
|
||
if (run >= 2) n.stacks = Math.max(n.stacks, Math.min(t2cap, run));
|
||
}
|
||
}
|
||
}
|
||
for (const n of nodes) {
|
||
if (!n.act || n.gone) continue;
|
||
const src = n.stone ?? unit;
|
||
const foes = battle.enemiesInKeys(trigKeys(n), src, ANY);
|
||
if (!foes.length) continue;
|
||
fire(n, foes);
|
||
spend(n);
|
||
}
|
||
}, { owner: unit });
|
||
// 阻回 while the stock is full (S1 / S2 备注)
|
||
if (sid === S1 || sid === S2) {
|
||
battle.on('tick', () => {
|
||
const want = up(unit) && unit.mem.wang.stock >= stockCap;
|
||
const has = unit.findBuff('wang:stockFull');
|
||
if (want && !has) battle.addBuff(unit, { key: 'wang:stockFull', flags: { noSp: true }, tags: ['skill'] });
|
||
else if (!want && has) battle.removeBuff(unit, 'wang:stockFull');
|
||
}, { owner: unit });
|
||
}
|
||
// "干员离场后,附属的陷阱随之消失": her 棋子 leave (and come back with her), her 跟子 are gone
|
||
battle.on('death', (ctx) => {
|
||
if (ctx.unit !== unit || battle.finished) return;
|
||
unit.mem.wang.followers = [];
|
||
for (const a of battle.allyUnits) {
|
||
if (a.kind === 'token' && a.defId === STONE && a.ownerUnit === unit && a.alive) battle.retreat(a, { reason: 'expired', permanent: true });
|
||
}
|
||
}, { owner: unit });
|
||
},
|
||
};
|
||
},
|
||
};
|