feedback5: merge O19 — 自选 kits for 老鲤, 温蒂 (with her 工程蓄水炮) and 望 (with her 棋子 and 跟子); match: a 自选 piece's summon hand count reads the active module's deploy limit (望 TRP-X: 7 棋子); golden: only diy moved

This commit is contained in:
sganggs committed 2026-10-06 14:12:17 +08:00
commit 94eb21115f
12 files changed
+2293 -101

No files matched your search

+2 -1
View File
@@ -184,7 +184,8 @@ both tiers, both forms and both module stages. A 自选 piece has no 特质 and
the kit's business. Summons: `battle.tokenDef(tokenId, unit)` / `battle.spawnToken(unit, …)` resolve the variant of the
pick. A **placeable** summon (data/backups.json `tokens[id].placeable`, made by the pick's skill or a talent — the
variant's `sources`) is a hand piece like any operator's: when the 自选 piece is deployed in prep its player gets one
stack of the variant's `deployLimit` (server/match/player/diy.js `placeableTokens`, PlayerState `grantTokensFor`), places
stack of the variant's `deployLimit` — the picked module's own when its variant has one (望's TRP-X: 7 棋子) —
(server/match/player/diy.js `placeableTokens`, PlayerState `grantTokensFor`), places
it on a legal tile (the token's position / `ownerRange`), loses it with its owner (sold, merged, moved back), and the
match hands every placed piece to the battle as a PlayerBattleInput token unit `{ kind: 'token', tokenId, ownerUid }` —
so a kit finds its pieces before the battle starts as `battle.allyUnits` with `kind === 'token'`, its `defId` and
+3
View File
@@ -133,6 +133,9 @@ export const OPERATOR_KIT_FILES = Object.freeze([
'op-haak.js',
'op-phatom.js',
'op-crosly.js',
'op-lmlee.js',
'op-weedy.js',
'op-wang.js',
]);
/**
+236
View File
@@ -0,0 +1,236 @@
// server/sim/content/kits/ops/op-lmlee.js — 老鲤 (char_322_lmlee) 自选 operator kit: 6★ 行商 (特种), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_322_lmlee, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 老鲤 (有备无患
// 备注 "“费用充足”指的是当前费用≥触发天赋所需要消耗的费用 / 自身已拥有本天赋的抵消异常效果时,额外消耗费用效果不会重复触发。本天赋
// 不会对我方单位造成晕眩"; S2 备注 "标记爆炸半径1,可对空 / 撤退老鲤时,如果老鲤没有阻挡存在标记的目标,则标记会消失且不产生任何
// 效果 / …①老鲤被击倒 ②老鲤在阻挡被标记的目标时撤退 ③在标记消失前再次释放本技能"; S3 备注 "闪避效果以伤害来源当前位置为准,
// 无来源伤害始终被视作“来自范围外”"), PRTS 分支特性信息 行商 ("可以且优先攻击自身阻挡的单位" — the engine's blocker rule).
// - Trait (行商) "在场时每3秒消耗3点部署费用(不足时自动撤退)": the profession default (professions.js installMerchant, trait
// bb interval / cost). Module MER-X “无问吉凶” overrides it with 2 DP (trait bb cost −2). Module MER-Y “但思善恶” adds
// "每次特性消耗费用时攻击力+4%,最多可以叠加5次" (hidden module talent atk / max_stack_cnt): every trait payment +atk ATK,
// up to max_stack_cnt stacks, until he leaves the field. "再部署时间减少" is in the stats; "撤退时不返还部署费用" has no
// battle effect here (no manual retreat).
// - T1 和气生财 "老鲤阻挡目标时,使其攻击速度-14,且自身攻击速度+14;当周围八格内仅存在一个敌人时效果翻倍"
// (lmlee_t_1[enemy] / [self].attack_speed, cnt): while he blocks, the blocked enemy ASPD −14 and his own +14, both ×2
// while exactly `cnt` enemy stands on the 3 × 3 around him (his tile included [ASSUMED: the enemy he blocks stands
// there], flyers too). MER-Y stage 3: ∓20.
// - T2 有备无患 "特性消耗费用时,若费用足够则改为消耗5费用,抵消自身受到的下一次晕眩/冻结,并使攻击来源晕眩3秒" (extra_cost,
// stun): a trait payment while DP ≥ |extra_cost| and no guard held pays |extra_cost| instead (the `merchantPay` hook) and
// gives him the guard; the guard cancels the next 晕眩 / 冻结 on him (a cold-on-cold 冻结 too) and stuns its source for
// `stun` s — an enemy only (PRTS: never an ally). MER-X stage 3: 4 DP, 4 s.
// - S1 小惩大诫 (AUTO, 持续时间无限): ATK +atk, 法术闪避 prob. An AUTO skill acting on himself fires as soon as its SP is
// full (`trigger: 'SP_FULL'`, the owner's AUTO rule, kits/README.md checklist 5); the data's DEFAULT would wait for an
// attack.
// - S2 驱凶辟邪 (MANUAL, data DEFAULT): passive ASPD +attack_speed. Cast as he is about to attack: the target of that
// attack is marked (taunt_level: our units select it first — "使其更易受我方攻击"); paper_duration s later the mark bursts
// for (default_atk_scale + n × factor_atk_scale) × his ATK arts damage on every enemy within MARK_RADIUS of it (air
// units too; 中点判定 around the target, the selectable enemies), n = the damage instances our side dealt to the target
// meanwhile (≤ max_stack_cnt; at the cap, or when the target is knocked out, it bursts at once). PRTS 备注: it also
// bursts when he is knocked out, when he leaves while blocking the marked enemy, and when he casts again; when he leaves
// otherwise it vanishes. Counted: every `damaged` instance of an ally (credit included) on it but 元素损伤 and 流失
// [ASSUMED: a hit absorbed by a shield counts]. The burst after a knock-out uses his ATK of the last tick on the field
// [ASSUMED].
// - S3 贵客盈门 (AUTO, 持续时间无限; SP_FULL as S1): range x-4, ATK / DEF +atk / +def, taunt +taunt_level; each attack pushes
// every other enemy of his range with attack@force (小力 = 0) at attack@prob_knockback (radial from him [ASSUMED: the text
// gives no direction — knockback[relative]]); prob to dodge each physical / arts damage whose source stands outside his
// range at that moment (无来源 damage always counts as from outside).
import { num, talentBb, moduleBb, skillRec, statBuff, up } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js';
import { isHpLoss } from '../../../damage.js';
import { acquireTargets, effectiveProfile } from '../../../ai.js';
const S1 = 'skchr_lmlee_1';
const S2 = 'skchr_lmlee_2';
const S3 = 'skchr_lmlee_3';
/** The 3 × 3 around him (range_table x-4): 和气生财's 周围八格 and S3's range when the data carries no grid. */
const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
/** S2 驱凶辟邪's burst radius (PRTS 备注 "标记爆炸半径1"; no blackboard key). */
export const MARK_RADIUS = 1;
/** 有备无患's guard on him (cancels the next 晕眩 / 冻结). */
export const GUARD_KEY = 'lmlee:guard';
/** Tag of S2's burst (not counted as a damage instance on the mark). */
const BURST_TAG = 'lmlee:burst';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const markKey = (unit) => `lmlee:mark:${unit.id}`;
/** End the mark `m` without a burst (he left without blocking the marked enemy, the target left the field). */
function dropMark(battle, unit, m) {
m.alive = false;
if (m.target) battle.removeBuff(m.target, markKey(unit));
}
/** S2's burst of mark `m`: (scale + n × factor) × ATK arts on the selectable enemies within MARK_RADIUS of the target. */
function burstMark(battle, unit, m, b2, atk = null) {
if (!m.alive) return;
m.alive = false;
const t = m.target;
battle.removeBuff(t, markKey(unit));
const x = t.x, y = t.y;
const amount = (atk ?? unit.s.atk) * (num(b2.default_atk_scale) + m.n * num(b2.factor_atk_scale));
battle.fx('explode', { x, y, r: MARK_RADIUS, id: unit.id, dmgType: 'arts', skill: 'lmlee_2', n: m.n });
if (!(amount > 0)) return;
for (const e of battle.foesInRadius(x, y, MARK_RADIUS, true)) {
if (e.alive) battle.dealDamage(unit, e, { amount, type: 'arts', isSkill: true, tags: ['skill', BURST_TAG] });
}
}
export default {
char_322_lmlee: (bb, chess) => {
const t0 = talentBb(chess, 0); // 和气生财 (MER-Y stage 2+: its change)
const t1 = talentBb(chess, 1); // 有备无患 (MER-X stage 2+: its change)
const hidden = moduleBb(chess); // MER-Y: atk / max_stack_cnt per trait payment
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s3 = skillRec(chess, S3);
const tauntLv = num(b2.taunt_level, 1);
const maxN = Math.max(1, Math.floor(num(b2.max_stack_cnt, 20)));
return {
skills: {
[S1]: { kind: 'toggle', trigger: { rule: 'SP_FULL' }, mods: { atkPct: num(b1.atk), dodgeArts: num(b1.prob) } },
[S2]: {
kind: 'instant',
// "主动开启:标记目标": the target of the attack he is about to make (the basic strategy casts on that attack)
onStart({ battle, unit }) {
const t = acquireTargets(battle, unit, effectiveProfile(unit)).find((e) => e && e.side === 'enemy' && e.alive);
if (!t) return;
const prev = unit.mem.lmleeMark;
if (prev && prev.alive) burstMark(battle, unit, prev, b2); // ③ cast again before the mark ends
const m = { target: t, n: 0, alive: true, atk: unit.s.atk };
unit.mem.lmleeMark = m;
const dur = num(b2.paper_duration, 5);
battle.addBuff(t, { key: markKey(unit), duration: dur + 0.1, mods: tauntLv ? { taunt: tauntLv } : null, source: unit, tags: ['skill'] });
battle.fx('mark', { x: t.x, y: t.y, id: t.id, src: unit.id, skill: 'lmlee_2' });
battle.after(dur, () => burstMark(battle, unit, m, b2), { owner: unit });
},
},
[S3]: {
kind: 'toggle',
trigger: { rule: 'SP_FULL' },
mods: { atkPct: num(b3.atk), defPct: num(b3.def), taunt: num(b3.taunt_level) },
targeting: { rangeGrid: s3?.rangeGrid ?? X4 },
attack: {
// "攻击小力度推开范围内除当前目标外的敌人"
onHit({ battle, unit, target }) {
const p = num(b3['attack@prob_knockback'], 1), force = num(b3['attack@force'], 0);
if (!(p > 0) || (p < 1 && !battle.rng.chance(p))) return;
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) {
if (e !== target && e.alive) battle.push(e, force, { from: unit });
}
},
},
},
},
talents: [
{ install(battle, unit) { // 和气生财: while blocking — the blocked enemy ASPD −, his own +; ×2 with one enemy around
const foe = num(t0['lmlee_t_1[enemy].attack_speed']), self = num(t0['lmlee_t_1[self].attack_speed']);
const alone = Math.max(1, Math.floor(num(t0.cnt, 1)));
if (!foe && !self) return;
const selfKey = 'talent:lmlee:self', foeKey = `talent:lmlee:foe:${unit.id}`;
battle.on('tick', () => {
const blocked = up(unit) ? unit.blocking.filter((e) => e && e.alive) : [];
if (!blocked.length) {
if (unit.findBuff(selfKey)) battle.removeBuff(unit, selfKey);
return;
}
const around = new Set(absoluteRangeKeys(X4, unit.tileR, unit.tileC, unit.dir, 0));
let n = 0;
for (const e of battle.enemies) if (e.alive && !e.hidden && bodyInKeys(e, around)) n++;
const k = n === alone ? 2 : 1;
const cur = unit.findBuff(selfKey);
if (self && (!cur || cur.mods?.aspd !== self * k)) battle.addBuff(unit, { key: selfKey, mods: { aspd: self * k }, tags: ['talent'] });
if (foe) for (const e of blocked) battle.addBuff(e, { key: foeKey, duration: 0.1, mods: { aspd: foe * k }, source: unit, tags: ['talent'] });
}, { owner: unit });
} },
{ install(battle, unit) { // 有备无患: pay |extra_cost| for the guard; the guard cancels the next 晕眩 / 冻结
const pay = Math.abs(num(t1.extra_cost)), stun = num(t1.stun);
if (!(pay > 0)) return;
battle.on('merchantPay', (ctx) => {
if (ctx.unit !== unit || ctx.cancel || unit.findBuff(GUARD_KEY)) return;
const pl = battle.getPlayer(unit.ownerId);
if (!pl || pl.dp + 1e-9 < pay) return; // "费用充足": DP ≥ the talent's cost
ctx.cost = pay;
battle.addBuff(unit, { key: GUARD_KEY, tags: ['talent'] });
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id, kind: 'lmlee' });
}, { owner: unit, priority: 10 });
battle.on('beforeStatus', (ctx) => {
if (ctx.target !== unit || ctx.cancel || (ctx.status !== 'stun' && ctx.status !== 'freeze') || !unit.findBuff(GUARD_KEY)) return;
ctx.cancel = true;
battle.removeBuff(unit, GUARD_KEY);
const src = ctx.source;
if (stun > 0 && src && src.side === 'enemy' && src.alive) battle.applyStatus(src, 'stun', { duration: stun, source: unit });
battle.fx('counter', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit });
} },
],
install(battle, unit) {
// MER-Y “但思善恶”: every trait payment ATK +atk (≤ max_stack_cnt) — counted when the payment goes through (after
// 有备无患 set its cost)
const ma = num(hidden.atk);
if (ma > 0) {
const cap = Math.max(1, Math.floor(num(hidden.max_stack_cnt, 5)));
battle.on('merchantPay', (ctx) => {
if (ctx.unit !== unit || ctx.cancel) return;
const pl = battle.getPlayer(unit.ownerId);
const c = Number.isFinite(ctx.cost) ? Math.max(0, ctx.cost) : 0;
if (!pl || pl.dp < c) return;
battle.addBuff(unit, { key: 'trait:lmlee:merY', refresh: 'stack', stacks: 1, maxStacks: cap, mods: { atkPct: ma }, tags: ['trait'] });
}, { owner: unit, priority: -10 });
}
const sid = unit.skill?.id;
if (sid === S2) {
statBuff(battle, unit, 'skill:lmlee:s2passive', { aspd: num(b2.attack_speed) }); // "被动效果:攻击速度+15"
// the mark: every damage instance of our side on it, its target's knock-out, his leaving the field
battle.on('damaged', (ctx) => {
const m = unit.mem.lmleeMark;
if (!m || !m.alive || ctx.target !== m.target) return;
const who = ctx.source ?? ctx.credit;
const d = ctx.dmg;
if (!who || who.side !== 'ally' || !d || ctx.type === 'element' || isHpLoss(d) || d.tags?.includes(BURST_TAG)) return;
m.n = Math.min(maxN, m.n + 1);
if (m.n >= maxN) burstMark(battle, unit, m, b2);
}, { owner: unit, priority: -20 });
battle.on('tick', () => {
const m = unit.mem.lmleeMark;
if (!m || !m.alive || !up(unit)) return;
m.blocked = m.target.blockedBy === unit; // read when he leaves (his block list is gone by then)
m.atk = unit.s.atk;
}, { owner: unit });
battle.on('death', (ctx) => {
const m = unit.mem.lmleeMark;
if (!m || !m.alive) return;
if (ctx.unit === m.target) {
if (ctx.reason === 'killed') burstMark(battle, unit, m, b2);
else dropMark(battle, unit, m);
} else if (ctx.unit === unit) {
// ① knocked out, ② leaving while blocking the marked enemy ⇒ bursts; else it vanishes
if (ctx.reason === 'killed' || m.blocked) burstMark(battle, unit, m, b2, m.atk);
else dropMark(battle, unit, m);
}
}, { owner: unit, priority: 5 });
}
if (sid === S3) {
// "有55%概率闪避来自范围外的物理或法术伤害" (the source's position now; 无来源 = from outside)
const p = num(b3.prob);
if (p > 0) {
battle.on('hit', (ctx) => {
const d = ctx.dmg;
if (ctx.target !== unit || !unit.skill?.active || !d || d.cancel || !d.canDodge || (d.type !== 'phys' && d.type !== 'arts')) return;
const src = ctx.source;
if (src && bodyInKeys(src, unit.rangeKeySet ?? new Set(unit.rangeKeys ?? []))) return;
if (battle.rng() >= p) return;
d.cancel = true;
battle.fx('dodge', { x: unit.x, y: unit.y, id: unit.id });
if (battle.hasHook('dodge')) battle.emit('dodge', { source: ctx.credit ?? src, target: unit, dmg: d });
}, { owner: unit, priority: -50 });
}
}
},
};
},
};
+403
View File
@@ -0,0 +1,403 @@
// 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 and without using her stock (stock = cnt at the start, at most STOCK_CAP; the skills add to it). 棋子 / 跟子
// 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, while 望 is on the field — its own card returns to the stock as it leaves, so the
// stock does not change [ASSUMED]. 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)) return;
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;
unit.mem.wangNode = { seq: ++owner.mem.wang.seq, act: false, h: false, v: false, stacks: 0 };
}, { 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;
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 });
},
};
},
};
+299
View File
@@ -0,0 +1,299 @@
// server/sim/content/kits/ops/op-weedy.js — 温蒂 (char_400_weedy) 自选 operator kit: 6★ 推击手 (特种), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules (PUS-X 仿生海龙改型, PUS-Y 新型仿生原型) at
// every form, and the kit of her summon 工程蓄水炮 (token_10009_weedy_cannon). Kit contract and the 自选 rules: ../README.md
// ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_400_weedy, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table / token_table (zh_CN, as built into backups.json; the cannon's
// owner-form / module variants) and PRTS 温蒂 (蓄水炮强化 备注 "天赋文本后半段效果(及模组效果)的作用对象为温蒂自身"; S2 "攻击间隔增大
// (+220%)", 备注 "溅射范围为0.9半径"; S3 备注 "蓄水炮以温蒂的攻击力进行发射…技能范围内没有敌人时温蒂/蓄水炮不会对应发射出液氮炮 /
// 自身优先攻击剩余路径最短的敌人,水炮优先攻击距离自身最近的敌人;液氮炮溅射范围半径1.2,均可对空 / “正比于距离的真实伤害”即…
// 移动创伤(1200伤害/格),再次施加…时仅刷新BUFF的持续时间,不改变真实伤害的来源与每格伤害比例"), PRTS 工程蓄水炮 (备注 "持有禁疗 /
// 蓄水炮的技能和攻击均优先选择离自身最近的敌人作为目标 / 普通攻击…该位移效果仅对前方三格内的地面敌方单位生效 / 部署后20秒自动撤退";
// 技能备注 "蓄水炮进行的发射除基本力度外均以本体的数值为准"), PRTS 特殊机制 移动创伤 (every 0.066 s and once at the end: (distance
// moved since the last update) × 每格伤害 预计算的真实持续伤害, nothing when the difference is > 4 tiles), PRTS 卫戍协议/帮助 (a
// placed summon deploys with the board; "若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个"; a summon's manual skill is
// force-opened).
// - Trait (推击手) "同时攻击阻挡的所有敌人 / 可以放置于远程位": the profession default (pusher `hitAllBlocked`, block 2; melee,
// ground-only — PRTS 分支特性信息 推击手) and the placement rule of the prep (shared/highGround.js: her trait line lets the
// match put her on a 高台, where she attacks and blocks nothing). Module PUS-X "可以放置于远程位,并返还该次部署费用的一半"
// (trait bb value): a redeployment on a ranged (高台) tile gives value × her cost back (as 见行者's PUS-X; the battle-start
// deployment costs nothing). Module PUS-Y "阻挡2个及以上敌人时自身推力增加一级" (trait bb cnt / base_force_level): her
// pushes +base_force_level while she blocks ≥ cnt enemies.
// - T1 工程蓄水炮 (the cannon, a hand piece the player places — "部署位置=全部位"): deploys with the board, leaves 20 s after
// each deployment (its talent `duration`) and comes back on its tile after its redeploy time (35 s; PUS-Y stage 3 "再部署
// 时间减少8秒": the module variant's 27 s), paying its 5 DP, while she is on the field [ASSUMED, as 鸿雪's 打字机]. The
// cannon: untargetable ("不会受到攻击"), 禁疗, blocks nothing, 3-2 range, ranged physical, hits air, the nearest enemy to
// itself first; each attack pushes its target 小力 + 蓄水炮强化's base_force_level along its facing — a ground enemy on the
// three tiles ahead only. Its skill (the copy of S3 the data gives, sktok_weedy_token, MANUAL, 0 SP) is force-opened by the
// 卫戍 strategy: free, once per deployment, by the data's DEFAULT (cast on its attack) — and it fires with every S3 of hers
// while it stands on one of the 4 tiles around her. It fires with her numbers (her ATK, her S3 level; the damage counts
// as hers [ASSUMED: "均以本体的数值为准"]) but its own force (+1 by 蓄水炮强化).
// - T2 蓄水炮强化 (the cannon's talent, x-5): while the cannon stands on one of the 4 tiles around her she gains `sp` SP every
// `interval` s of it (3 s; PUS-X stage 2+: 2.5 / 2 s — the module's token variant) [ASSUMED: the clock runs while it is
// there] and, PUS-X stage 2+, ATK +atk (the module's hidden token talent, 15 % / 20 %; PRTS 备注: on 温蒂 herself).
// - S1 炮管敲击 (AUTO, data DEFAULT — a "next attack"): that attack (every enemy she blocks) at atk_scale × ATK, each target
// pushed along her facing with `force` (中力) and 晕眩 `stun` s.
// - S2 水炮模式 (AUTO, 持续时间无限; SP_FULL — a mode switch on herself, the owner's AUTO rule): attack interval ×(1 +
// base_attack_time) (PRTS lists it as a ratio, "+220%"), ATK +atk, range +ability_range_forward_extend, a ranged attack
// — one shot with a 0.9-tile splash (PRTS 备注), air units too [ASSUMED: a ranged attack reaches air] — every enemy it hits
// pushed with base_force_level (小力) along her facing.
// - S3 液氮大炮 (MANUAL, data SKILL_RANGE on its 4-1): at once, the enemy of the 4-1 with the shortest remaining path and
// every enemy within 1.2 of it (air units too) take atk_scale × ATK arts, the 移动创伤 (`value` × `dist` per tile moved,
// true damage, `duration` s) and a push of `force` along her facing; the cannon fires too (above). The shot is instant
// [ASSUMED: no travel time]. Pushes go along her facing (knockback[dir], as every 推击手 push) [ASSUMED for S2 / S3,
// whose text gives no direction].
import { num, traitBb, skillRec, up, giveSp } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_weedy_1';
const S2 = 'skchr_weedy_2';
const S3 = 'skchr_weedy_3';
export const CANNON = 'token_10009_weedy_cannon';
/** S3's / the cannon skill's 4-1 when the data carries no grid. */
const GRID_4_1 = Object.freeze([[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]]);
/** PRTS S3 备注 "液氮炮溅射范围半径1.2". */
export const NITRO_SPLASH = 1.2;
/** PRTS S2 备注 "溅射范围为0.9半径". */
export const WATER_SPLASH = 0.9;
/** The 移动创伤 buff on an enemy (one per enemy whoever applies it — PRTS: a new one only refreshes its duration). */
export const RUPTURE_KEY = 'weedy:rupture';
/** PRTS 特殊机制 移动创伤: an update whose distance difference is above this deals nothing. */
const RUPTURE_MAX_STEP = 4;
/** 工程蓄水炮's life when the token carries no `duration` talent (E2: 20 s). */
const LIFE_FALLBACK = 20;
/** Seconds between two tries of a cannon to come back (tile busy, DP short, 温蒂 off the field). */
const RETRY = 0.25;
const TAG_NITRO = 'weedy:nitro';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** First talent of a normalised token def whose blackboard holds `key` (token talents keep no index). */
const tokTalent = (def, key, not = null) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null && (not == null || t.bb[not] == null)) ?? null;
/** Push direction along a unit's facing (knockback[dir]). */
const along = (u) => ({ x: u.fwd[1], y: u.fwd[0] });
/** On one of the 4 tiles around (周围4格). */
const beside = (a, b) => Math.abs(a.tileR - b.tileR) + Math.abs(a.tileC - b.tileC) === 1;
/**
* 移动创伤 (PRTS 特殊机制) on enemy `e`: for `duration` s every `interval` s — and once more at the end — the distance it
* moved since the last update × `perTile` as true 持续伤害 from `src` (none when that difference is > 4 tiles). A new
* application while one runs only refreshes its duration: the source and the per-tile damage stay the first one's.
*/
export function rupture(battle, src, e, { duration, perTile, interval }) {
if (!e || !e.alive || !(duration > 0) || !(perTile > 0)) return;
const cur = e.findBuff(RUPTURE_KEY);
if (cur) {
cur.timeLeft = Math.max(cur.timeLeft, duration);
cur.duration = Math.max(cur.duration, duration);
return;
}
const st = { x: e.x, y: e.y, moved: 0, acc: 0 };
const step = (u) => { st.moved += Math.hypot(u.x - st.x, u.y - st.y); st.x = u.x; st.y = u.y; };
const flush = (u) => {
const m = st.moved;
st.moved = 0;
if (!(m > 1e-9) || m > RUPTURE_MAX_STEP || !u.alive) return;
battle.dealDamage(src, u, { amount: m * perTile, type: 'true', canDodge: false, isSkill: true, tags: ['skill', 'dot', RUPTURE_KEY] });
};
const iv = interval > 0 ? interval : 0.066;
battle.addBuff(e, {
key: RUPTURE_KEY, duration, source: src, tags: ['skill'], data: st,
onTick: ({ unit, dt }) => {
step(unit);
st.acc += dt;
if (st.acc + 1e-9 >= iv) { st.acc -= iv; flush(unit); }
},
onExpire: ({ unit }) => { step(unit); flush(unit); },
});
}
/**
* One 液氮炮 of `shooter` (温蒂 or her cannon): the target of the skill range (`grid`, the shooter's facing) — `pick`
* 'path' = the shortest remaining path (温蒂), 'nearest' = nearest to the shooter (the cannon) — and every enemy within
* NITRO_SPLASH of it: atk_scale × 温蒂's ATK arts from `src`, the 移动创伤, a push of `force` along the shooter's facing.
* Returns false when no enemy can be selected there (nothing fires).
*/
export function liquidNitrogen(battle, { shooter, src, atk, sbb, grid, force, pick }) {
const keys = absoluteRangeKeys(grid ?? GRID_4_1, shooter.tileR, shooter.tileC, shooter.dir, 0);
const cands = battle.enemiesInKeys(keys, shooter, { canHitFly: true });
if (!cands.length) return false;
const key = pick === 'nearest' ? (e) => Math.hypot(e.x - shooter.x, e.y - shooter.y) : (e) => battle.remainingDistance(e);
let target = null, best = Infinity;
for (const e of cands) {
const v = key(e);
if (v < best - 1e-9 || (Math.abs(v - best) <= 1e-9 && target && e.spawnSeq < target.spawnSeq)) { best = v; target = e; }
}
const x = target.x, y = target.y;
battle.fx('frostNova', { x, y, r: NITRO_SPLASH, id: shooter.id, src: shooter.id, target: target.id, dmgType: 'arts', skill: 'weedy_3' });
const amount = atk * num(sbb.atk_scale, 1);
const rup = { duration: num(sbb.duration), perTile: num(sbb.value) * num(sbb.dist, 1), interval: num(sbb.interval, 0.066) };
const dir = along(shooter);
for (const e of battle.foesInRadius(x, y, NITRO_SPLASH, true)) {
if (!e.alive) continue;
battle.dealDamage(src, e, { amount, type: 'arts', isSkill: true, tags: ['skill', TAG_NITRO] });
if (!e.alive) continue;
rupture(battle, src, e, rup); // before the push: the push's displacement counts
battle.push(e, force, { from: shooter, dir });
}
return true;
}
/** 温蒂's deployed cannons. */
const cannonsOf = (battle, unit) => battle.allyUnits.filter((a) => a.kind === 'token' && a.defId === CANNON && a.ownerUnit === unit && a.alive && a.deployed);
/** The cannon's kit: its attack push, its skill, 蓄水炮强化 on 温蒂, its life and its redeploys. `owner` = 温蒂. */
function cannonKit(t, owner) {
const def = t.def;
const life = num(tokTalent(def, 'duration')?.bb?.duration, LIFE_FALLBACK);
const boost = tokTalent(def, 'base_force_level')?.bb ?? {}; // 蓄水炮强化: base_force_level, interval, sp
const forceUp = num(boost.base_force_level);
const every = num(boost.interval), sp = num(boost.sp);
const atkUp = num(tokTalent(def, 'atk', 'base_force_level')?.bb?.atk); // PUS-X stage 2+: 温蒂 ATK +atk beside it
const sk = def.skill ?? null;
const sbb = sk?.bb ?? {};
const AHEAD = [[0, 1], [0, 2], [0, 3]];
return {
skill: sk ? {
kind: 'instant',
onStart({ battle, unit }) {
liquidNitrogen(battle, { shooter: unit, src: owner, atk: owner.s.atk, sbb, grid: sk.rangeGrid ?? GRID_4_1, force: num(sbb.force) + forceUp, pick: 'nearest' });
},
} : null,
trait: {
priority: 'nearest',
// "能攻击单个敌人并将其小力度地推开,该位移效果仅对前方三格内的地面敌方单位生效"
afterHit(battle, unit, target) {
if (!target || !target.alive || target.side !== 'enemy' || target.isFlying) return;
const ahead = new Set(absoluteRangeKeys(AHEAD, unit.tileR, unit.tileC, unit.dir, 0));
if (!ahead.has(Math.round(target.y) * COLS + Math.round(target.x))) return;
battle.push(target, forceUp, { from: unit, dir: along(unit) });
},
},
install(battle, unit) {
battle.addBuff(unit, { key: 'trait:weedyCannon', flags: { noHeal: true }, persist: true, allowDead: true }); // 持有禁疗
// 部署后20秒自动撤退
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit || !(life > 0)) return;
const seq = unit.deploySeq;
battle.after(life, () => { if (unit.alive && unit.deploySeq === seq) battle.retreat(unit, { reason: 'expired', permanent: true }); }, { owner: unit });
}, { owner: unit });
// back on its tile after its redeploy time, paying its cost, while 温蒂 is on the field (the piece is kept)
battle.on('death', (ctx) => {
if (ctx.unit !== unit || battle.finished) return;
unit.removed = false;
const at = battle.time + Math.max(0, num(unit.base.respawnTime));
battle.every(RETRY, (b, sched) => {
if (unit.alive || unit.removed) { sched.cancel(); return; }
if (b.time + 1e-9 < at || !up(owner)) return;
if (b.redeploy(unit, { free: false })) sched.cancel();
}, { owner: unit });
}, { owner: unit, priority: -10 });
// 蓄水炮强化 on 温蒂: +sp SP every `interval` s beside her; PUS-X stage 2+: her ATK +atk meanwhile
let acc = 0;
const atkKey = `talent:weedy:cannonAtk:${unit.id}`;
battle.on('tick', ({ dt }) => {
const on = up(unit) && up(owner) && beside(unit, owner);
if (!on) {
acc = 0;
if (atkUp && owner.findBuff(atkKey)) battle.removeBuff(owner, atkKey);
return;
}
if (atkUp && !owner.findBuff(atkKey)) battle.addBuff(owner, { key: atkKey, mods: { atkPct: atkUp }, source: unit, tags: ['talent'] });
if (!(every > 0) || !(sp > 0)) return;
acc += dt;
while (acc + 1e-9 >= every) { acc -= every; giveSp(owner, sp); }
}, { owner: unit });
},
};
}
export default {
char_400_weedy: (bb, chess) => {
const tb = traitBb(chess); // PUS-X: value; PUS-Y: cnt / base_force_level
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s3 = skillRec(chess, S3);
const blockCnt = num(tb.cnt), blockForce = num(tb.base_force_level);
/**
* PUS-Y: +base_force_level while she blocks ≥ cnt enemies — counted when the attack starts (`weedyBlocked`, set at
* `beforeAttack`: the first push of an attack frees an enemy before the second is pushed) or when S3 is cast.
*/
const bonus = (u, n = u.mem.weedyBlocked ?? u.blocking.length) => (blockForce && blockCnt > 0 && n >= blockCnt ? blockForce : 0);
return {
skills: {
[S1]: {
kind: 'instant',
attack: {
atkScale: num(b1.atk_scale, 1),
onHit({ battle, unit, target }) {
if (!target || !target.alive || target.side !== 'enemy') return;
battle.applyStatus(target, 'stun', { duration: num(b1.stun), source: unit });
battle.push(target, num(b1.force) + bonus(unit), { from: unit, dir: along(unit) });
},
},
},
[S2]: {
kind: 'toggle',
trigger: { rule: 'SP_FULL' },
mods: { atkPct: num(b2.atk), batPct: num(b2.base_attack_time) },
targeting: { rangeExtend: num(b2.ability_range_forward_extend) },
attack: {
attack: 'ranged', projectile: 'bomb', splashRadius: WATER_SPLASH, canHitFly: true, hitAllBlocked: false, maxTargets: 1,
// every enemy the shot hits is pushed once the hit is resolved (a push of the main target inside the hit would
// move the splash centre: ai.js resolveHit centres it on the target after the main hit's callbacks)
onEachHit({ unit, target }) {
if (target && target.side === 'enemy') (unit.mem.weedyWet ??= []).push(target);
},
onHit({ battle, unit }) {
const list = unit.mem.weedyWet ?? [];
unit.mem.weedyWet = [];
const f = num(b2.base_force_level) + bonus(unit);
for (const e of list) if (e.alive) battle.push(e, f, { from: unit, dir: along(unit) });
},
},
},
[S3]: {
kind: 'instant',
onStart({ battle, unit }) {
liquidNitrogen(battle, { shooter: unit, src: unit, atk: unit.s.atk, sbb: b3, grid: s3?.rangeGrid ?? GRID_4_1, force: num(b3.force) + bonus(unit, unit.blocking.length), pick: 'path' });
// "如果蓄水炮在周围4格内的话也会同样进行发射" (its own skill numbers and force, her ATK)
for (const c of cannonsOf(battle, unit)) {
if (!beside(c, unit)) continue;
const cs = c.def.skill ?? null;
const cbb = cs?.bb ?? b3;
const up1 = num(tokTalent(c.def, 'base_force_level')?.bb?.base_force_level);
liquidNitrogen(battle, { shooter: c, src: unit, atk: unit.s.atk, sbb: cbb, grid: cs?.rangeGrid ?? GRID_4_1, force: num(cbb.force) + up1, pick: 'nearest' });
}
},
},
},
talents: [
// 工程蓄水炮 (and 蓄水炮强化, the cannon's own talent): her cannon pieces run the cannon's kit — set up here, before the
// battle starts (Battle._setupUnit with a kit, as op-bgsnow.js does for its 打字机)
{ install(battle, unit) {
for (const t of battle.allyUnits) {
if (t.kind !== 'token' || t.defId !== CANNON || t.ownerUnit !== unit || t.alive || t.deployed) continue;
if (t.kit) battle.offOwner(t);
battle._setupUnit(t, cannonKit(t, unit));
}
} },
{ install() {} }, // 蓄水炮强化 — the cannon's talent (cannonKit)
],
install(battle, unit) {
if (blockForce) battle.on('beforeAttack', (ctx) => { if (ctx.attacker === unit) unit.mem.weedyBlocked = unit.blocking.length; }, { owner: unit, priority: 100 });
// PUS-X: "可以放置于远程位,并返还该次部署费用的一半" — a redeployment on a ranged tile (unit.ground false)
const back = num(tb.value);
if (back > 0) {
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit || ctx.initial || unit.ground) return;
const n = unit.base.cost * back;
battle.addDp(unit.ownerId, n);
battle.fx('dp', { x: unit.x, y: unit.y, n, id: unit.id });
}, { owner: unit });
}
},
};
},
};