Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar

- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
This commit is contained in:
sganggs committed 2026-10-01 19:08:23 +08:00
1 parent e6628f0cfe
commit 6464e4b46a
138 files changed
+7946 -1379

No files matched your search

+58 -21
View File
@@ -38,7 +38,8 @@
// create those units (death spawns, embers, blades, 再生).
// ELEMENT — element damage on hit = ATK × ep_damage_ratio into the ally's gauge. 侵蚀 (erosion) is an engine
// gauge whose burst is the official one (termDescription ba.dt.erosion: "永久降低100点防御力并受到800点物理伤害").
// DOT — HP-loss / arts damage zones (污染秽蚀, 毒雾, 燃烧区域), bleeding (removed by healing), pulsing auras.
// DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit; 毒雾, 燃烧区域),
// bleeding (removed by healing), pulsing auras.
// INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed; AoE still hits).
// REFLECTION — 折射 (ba.refraction "生效时,法术抗性+70"): RES +refracting.magic_resistance while NOT silenced
// (the ability line is SILENCE-flagged: silencing turns it off); 镜膜 also gets max HP +100 % while on.
@@ -70,8 +71,11 @@ export const EROSION_BURST = Object.freeze({ defDown: ELEMENT.erosion.ally.defDo
const PARASITE_SPREAD = 1000;
/** Death-explosion radius when the enemy has no official radius [ASSUMED]. */
const BOOM_RADIUS = 1.25;
/** Pollution zone radius of 萨卡兹枯朽战车's ranged 污染秽蚀 [ASSUMED]. */
const TANK_ZONE_RADIUS = 1;
/** 萨卡兹枯朽战车 秽蚀轰击: radius of the 【污染秽蚀】 it leaves at its target (PRTS 萨卡兹枯朽战车 技能 "在目标位置生成半径1.7,
* 持续10s的【污染秽蚀】" — the skill blackboard's range_radius 2.2 is its trigger range; the zone's radius is in no table). */
const TANK_ZONE_RADIUS = 1.7;
/** 【污染秽蚀】 ticks once per second; "同名效果不叠加": a unit inside several zones takes one tick per second. */
const POLLUTION_INTERVAL = 1;
/** "数个目标" of 假想敌:骨刺 while stealthed [ASSUMED]. */
const ACBUNN_TARGETS = 3;
/** 假想敌:再生 shield aura radius ("周围一定距离") [ASSUMED]. */
@@ -625,7 +629,12 @@ const float = () => ({ spawn(b, e) { setFloat(b, e, true); } });
/** The enemies whose kit spawns them hovering (float()): the match's bot counts them as air units (bot.js fieldModel). */
export const HOVER_KEYS = Object.freeze(['enemy_2025_syufo', 'enemy_10045_parrot']);
/** "生命值首次降至一半以下时,在数秒内陷入恐惧" (SelfFear). */
/**
* "生命值首次降至一半以下时,在数秒内陷入恐惧" (SelfFear; PRTS “萨科塔之翼/之眼/昂首” "生命值首次低于50%时,对自身施加持续5s的恐惧,
* 在5s内移动速度最终提升至150%"). The fear's source is the enemy itself, so it has no 恐惧可达地块: for SelfFear.fear s it
* flies to random points of its own tile (±0.25) at ×move_speed — the engine's 恐惧 movement (fear.js; user playtest #6
* item 13, "小范围乱飞") — then flies on along its route.
*/
const selfFear = (ab) => ({
taken(c, b, e, a) {
if (a.done || !(e.hpRatio < 0.5)) return;
@@ -875,10 +884,20 @@ function bleed(ab) {
};
}
/** 污染秽蚀 zone: HP loss per second, `low` on ground tiles, `high` on elevated tiles. */
/**
* 【污染秽蚀】 zone (PRTS 萨卡兹枯朽战士 / 萨卡兹枯朽战车: "范围内位于低地/高地的我方干员和召唤物每秒受到50/25点真实普通伤害
* (可对空,无视无法选择、迷彩;同名效果不叠加)"): `low` true damage per second on low ground, `high` on high ground, to every
* ally inside (flyers, stealthed and untargetable ones included); a unit inside several zones takes one tick per second
* (`mem.pollutedAt`: the last tick it took).
*/
function pollution(b, src, x, y, r, life, low, high) {
zone(b, { x, y, r, life, iv: 1, kind: 'pollution', tick(units) {
for (const u of units) { const v = u.ground ? low : high; if (v > 0) b.loseHp(u, v, { source: src }); }
zone(b, { x, y, r, life, iv: POLLUTION_INTERVAL, kind: 'pollution', tick(units) {
for (const u of units) {
const v = u.ground ? low : high;
if (!(v > 0) || b.time - (u.mem.pollutedAt ?? -Infinity) < POLLUTION_INTERVAL - 1e-6) continue;
u.mem.pollutedAt = b.time;
hurt(b, src, u, v, 'true', { tags: ['pollution'] });
}
} });
}
@@ -1104,19 +1123,37 @@ function kitParrot(ab) {
}];
}
function kitTank(ab) {
const every = (T(ab, 'Empty.sp') ?? 2) + 1, melee = T(ab, 'Empty.attack@chuang_atk_scale') ?? 1;
const s = ab.sk.PollutedRangedAtk ? ab.sk.PollutedRangedAtk.bb : {};
/**
* 萨卡兹枯朽战车 / 尖端 (PRTS; enemy_database spData "初始技力 2 / 技力上限 2 / 攻击回复"): an attack made with full SP is the
* skill 秽蚀轰击 — "对目标造成100%物理普通伤害,同时在目标位置生成半径1.7,持续10s的【污染秽蚀】" — and empties the SP; every
* other attack gains 1. Initial SP = max, so the FIRST attack is the skill, then every 3rd (attacks 1, 4, 7 …). Talent:
* "普通攻击只攻击位于低地的我方单位,且不会攻击飞行单位" (a candidate filter, ai.js enemyAttack: a FLY ally such as the 炎佑 dragon on a
* low tile is no target either); "被阻挡时进行近战攻击,造成攻击力200%的伤害" — normal attacks on its blocker ×chuang_atk_scale.
* [ASSUMED] the skill also fires on its blocker and deals the skill's own 100 % there (PRTS names no blocked exception:
* enemy SP skills fire as soon as they are ready and its condition — a low-ground non-flying ally within 2.2 — holds for
* the blocker), so a blocked tank hits 100 / 200 / 200 % … (v2.4.1: 200 % on every attack, the skill included).
* Reach: the 2.2 range circle takes an ally whose 0.25 collider touches it (ai.js enemyAttack, constants.js
* ALLY_COLLIDER_RADIUS; PRTS 作战机制 §碰撞体积) — 2.45 from the ally's centre, 21 tiles around it instead of 13 (user
* playtest #6 follow-up: the user remembers a long-reaching, hard-hitting ranged attack; the data scale stays 100 %).
*/
function kitTank(ab, e) {
const melee = T(ab, 'Empty.attack@chuang_atk_scale') ?? 1;
const sk = ab.sk.PollutedRangedAtk || null;
const s = sk ? sk.bb : {};
const spd = (e.def.raw && e.def.raw.sp) || null;
const max = Math.max(1, num(spd && spd.maxSp, 0) || (sk && sk.sp) || (T(ab, 'Empty.sp') ?? 2));
const init = Math.min(max, Math.max(0, num(spd && spd.initSp, 0)));
return [{
// 只攻击地面单位 — PRTS 萨卡兹枯朽战车 天赋 "普通攻击只攻击位于低地的我方单位,且不会攻击飞行单位" (a candidate filter,
// ai.js enemyAttack: a FLY ally such as the 炎佑 dragon on a low tile is no target either)
spawn(b, e, a) { a.n = 0; e.profile.canTarget = (u) => !!u.ground && !u.isFlying; },
hitOut(c, b, e) { if (c.dmg.isAttack && c.target === e.blockedBy) c.dmg.amount *= melee; }, // 近战攻击造成更高伤害
attack(c, b, e, a) {
a.n++;
if (a.n % every) return;
spawn(b, e2, a) { a.sp = init; a.skill = false; e2.profile.canTarget = (u) => !!u.ground && !u.isFlying; },
// SP full: this attack is 秽蚀轰击 (a blocked hit lands at once, inside this attack, while `a.skill` is set)
before(c, b, e2, a) { a.skill = a.sp >= max; },
hitOut(c, b, e2, a) { if (c.dmg.isAttack && !a.skill && c.target === e2.blockedBy) c.dmg.amount *= melee; },
attack(c, b, e2, a) {
if (!a.skill) { a.sp = Math.min(max, a.sp + 1); return; }
a.skill = false;
a.sp = 0;
const t = c.targets[0];
if (t) pollution(b, e, t.x, t.y, TANK_ZONE_RADIUS, s.projectile_life_time ?? 0, s.polluted_damage_low ?? 0, s.polluted_damage_high ?? 0);
if (t) pollution(b, e2, t.x, t.y, TANK_ZONE_RADIUS, s.projectile_life_time ?? 0, s.polluted_damage_low ?? 0, s.polluted_damage_high ?? 0);
},
}];
}
@@ -2147,11 +2184,11 @@ export const KITS = Object.freeze({
// --- DOT 持续
enemy_1234_dsubrl: kitDsubrl, // 深溟巢涌者 · pulse hits every ally in range + neural; 抵抗, immune 停顿
enemy_1234_dsubrl_2: kitDsubrl, // 富营养的巢涌者 · same
enemy_1267_nhpbr: kitPolluted, // 萨卡兹枯朽战士 · death: 污染秽蚀 HP-loss zone
enemy_1267_nhpbr: kitPolluted, // 萨卡兹枯朽战士 · death: 污染秽蚀 zone (50 / 25 true per second)
enemy_1267_nhpbr_2: kitPolluted, // 萨卡兹枯朽战士组长 · same
enemy_1270_nhstlk: (ab) => [bleed(ab)], // 逐腐兽 · bleeding (arts/s, cleared by healing)
enemy_1270_nhstlk_2: (ab) => [bleed(ab)], // 疯狂的逐腐兽 · same
enemy_1272_nhtank: kitTank, // 萨卡兹枯朽战车 · ground targets only, melee ×2, every 3rd attack a pollution zone
enemy_1272_nhtank: kitTank, // 萨卡兹枯朽战车 · ground targets only, melee ×2, 秽蚀轰击 on attacks 1, 4, 7 …
enemy_1272_nhtank_2: kitTank, // 尖端萨卡兹枯朽战车 · same
enemy_9006_actoxi: (ab) => [{ // 假想敌:蚀裂 · death: poison cloud on its killer
sil: true,
@@ -2201,7 +2238,7 @@ export const KITS = Object.freeze({
enemy_1355_mrfly_2: (ab, e) => [enemyAura(e.base.rangeRadius || 2.5, 'ab:mrfly', { resFlat: T(ab, 'magdef_add.magic_resistance') ?? 0 })], // 护障·P · RES aura
enemy_1042_frostd: (ab) => [allyAura(T(ab, 'defup.range_radius') ?? 2.5, 'ab:frost', { aspd: (T(ab, 'atkSpeedDown.attack_speed') ?? 0) * 100 })], // 寒霜 · ASPD −50 aura on operators
enemy_1040_bombd: kitBombd, // 暴鸰 · no normal attack: ONE bomb (target + 8 tiles), then ×2 speed
enemy_10083_hlbird: kitSelfFear, // “萨科塔之翼” · fear + flee below half HP
enemy_10083_hlbird: kitSelfFear, // “萨科塔之翼” · below half HP: 5 s self-fear, flutters in its tile ×1.5
enemy_10084_hlegle: kitSteal, // “萨科塔之眼” · fear below half; steals 1 ammo instead of hitting
enemy_10085_hllevi_2: kitRoar, // “萨科塔昂首” · fear below half; 祈祷邀约 global ASPD −30
enemy_1407_hummbd: kitExposeOnDeath, // 远眺 · death: exposes operators around (damage taken ×1.2)
+12 -8
View File
@@ -30,10 +30,14 @@
// "立即流失N%当前生命" + hp_ratio ⇒ self HP loss at start (宴, 风丸); hp_ratio + "恢复/回复…生命" ⇒ self heal at start.
// Passive skills only apply stat mods (for bb.duration s when the text says "N秒内": 宴) and the self/counter effects
// above — their scales describe procs (bombs, sword rain, counters) that need a hand-authored kit.
// force→onHit displacement (pull when the text says 拖拽 or for hookmasters, push otherwise; 0.5+0.5·force tiles).
// force→onHit displacement with the official 力度 − 重量 rules (Battle.push / pullToFront): a pull "至面前" when the text says
// 拖拽 or for hookmasters, else a push — along the unit's direction when the text says 朝部署方向 / 向前 / 身前方向 or for
// 推击手 (directional), otherwise away from the unit (radial); a skill of constants.js PUSH_EFFECT_SKILLS (见行者 S1) pushes
// by PRTS 推与拉's 特效 column.
import { normalizeSkill } from '../simdata.js';
import { sortEnemyTargets } from '../targeting.js';
import { PUSH_EFFECT_SKILLS } from '../constants.js';
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v !== '' && Number.isFinite(+v) ? +v : undefined));
@@ -212,8 +216,10 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
// ---- displacement
const forceRaw = g('force');
const hasForce = !passive && forceRaw !== undefined;
const force = Math.max(0, forceRaw ?? 0);
const force = forceRaw ?? 0; // 力度 (微小力 −1 … 特大力 5)
const pull = /拖拽/.test(desc) || (!/推开|击退/.test(desc) && def && def.subProf === 'hookmaster');
const directional = !pull && (/朝部署方向|向前|身前方向/.test(desc) || (def && def.subProf === 'pusher'));
const effectPush = PUSH_EFFECT_SKILLS.has(sk.id);
const hitElement = element && !counterText;
const healAlly = (battle, unit) => {
@@ -223,7 +229,7 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
}
let best = null;
for (const a of battle.injuredAlliesInKeys(unit.rangeKeys, unit)) if (!(allyHealOthersOnly && a === unit)) { best = a; break; }
if (!best) best = battle.alliesInRadius(unit.x, unit.y, 1.5, null).filter((a) => a.hp < a.s.maxHp && !(allyHealOthersOnly && a === unit)).sort((a, b) => a.hpRatio - b.hpRatio)[0] ?? null;
if (!best) best = battle.alliesInRadius(unit.x, unit.y, 1.5, null).filter((a) => a.hp < a.s.maxHp && !(allyHealOthersOnly && a === unit) && (a === unit || !(a.s.flags.noHeal || a.profile?.noHeal))).sort((a, b) => a.hpRatio - b.hpRatio)[0] ?? null;
if (best) battle.heal(unit, best, unit.s.atk * allyHealScale);
};
const onHit = hitStatuses.length || hasForce || hitElement || allyHeal
@@ -235,11 +241,9 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
for (const s of hitStatuses) ctx.battle.applyStatus(ctx.target, s.key, { duration: s.duration, source: ctx.unit });
}
if (hasForce && ctx.target.alive && ctx.target.side === 'enemy') {
const dx = ctx.target.x - ctx.unit.x, dy = ctx.target.y - ctx.unit.y;
const len = Math.hypot(dx, dy) || 1;
const dir = pull ? { x: -dx / len, y: -dy / len } : { x: dx / len, y: dy / len };
const dist = pull ? Math.max(0, Math.min(len - 0.6, 0.5 + 0.5 * force)) : 0.5 + 0.5 * force;
if (dist > 0) ctx.battle.displace(ctx.target, dir, dist, { force: force + 1 });
const u = ctx.unit;
if (pull) ctx.battle.pullToFront(ctx.target, u, force);
else ctx.battle.push(ctx.target, force, { from: u, dir: directional && u.fwd ? { x: u.fwd[1], y: u.fwd[0] } : null, effect: effectPush });
}
}
: null;
+25 -14
View File
@@ -10,14 +10,20 @@
// golden stays in the hand (the built-in equipped the merged golden on the copy, and its live loop over
// the target's items skipped the item after a merge)
// 紧急调度券 a shop operator leaves its slot only when it was actually granted (built-in cleared the slot first)
// 寻呼模块 the special refresh shows `refresh_cnt` DIFFERENT operators when the pool allows it
// 寻呼模块 the special refresh shows `refresh_cnt` DIFFERENT operators (fewer when the pool has no more)
// 突变细胞 after the battle the carrier becomes a NORMAL random tier+1 operator; its other equipment goes back to
// the hand (built-in kept it on the new operator)
// 教鞭 / “神秘顾客” trap_create_self_choice {choice_event: hunter_band_1}: the bounty comes from the band bounty
// family `enemyeffect_b_*` (research 04 §7: "offer 3 random enemyeffect_b_* bounties … adds 1 enemy to your
// next battle, killer gets `coin` funds"); the built-in drew any tier ≤ II kill bounty (incl. 2-round and
// faction bounties). [ASSUMED simplification, engine: no PERSONAL_CHOOSE overlay] 3 are drawn and one of
// them is taken at random. Falls back to the built-in when the family is empty.
// 教鞭 trap_create_self_choice {choice_event: hunter_band_1}: the bounty is a 战术特训 card (choices.json
// cards.bounty payout `perfect`, e.g. 战术特训·飞行I "若各自行动阶段就达成完美作战,获得1资金") — PRTS
// 卫戍协议:盟约 下半/PRTS盟约记录 §法术 教鞭 "于3个战术特训的悬赏任务中选择一项", §机变阶段 "※以下悬赏任务仅由
// 法术教鞭生成", and 杜宾 加练! "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金" (user
// playtest #6 item 4 review; the built-in drew any tier ≤ II kill bounty, and research 04 §7 [ASSUMED]
// the band family `enemyeffect_b_*`).
// “神秘顾客” the same trap_create_self_choice ("选择一项特殊悬赏任务"; no band grants it in act2, research 04): a band
// bounty `enemyeffect_b_*` (research 04 §7 [ASSUMED]: "adds 1 enemy to your next battle, killer gets `coin`
// funds").
// Both: [ASSUMED simplification, engine: no PERSONAL_CHOOSE overlay] 3 cards whose enemy can appear in the
// mode are drawn and one of them is taken at random; the built-in runs when the family is empty.
// “神秘顾客” trap_disney_special: when actively destroyed, +count funds and the Art passes to the next alive player
// (seat order, cyclic)
// 天师古鼎 equip_with_another_gain_coin_when_gain_char: a 【炎】 carrier also holding 炎国短刀 (either quality)
@@ -42,14 +48,18 @@ function pieceIsMember(ctx, piece, bondId) {
return bonds.includes('maniShip') && isCoreBond(bondId) && ctx.bondActive('maniShip') && ctx.bondActive(bondId);
}
/** Offer size of the personal bounty choice (research 04 §7, [ASSUMED]). */
/** Offer size of the personal bounty choice (PRTS 法术 教鞭 "于3个战术特训的悬赏任务中选择一项"). */
const OFFER_SIZE = 3;
/** Bounty cards of the band bounty family `enemyeffect_b_*` whose enemy can appear in this mode. */
function bandBounties(ctx) {
/** cards.bounty entries matching `test` whose enemy can appear in this mode. */
function bountyCards(ctx, test) {
const all = ctx.data && ctx.data.choices && ctx.data.choices.cards && Array.isArray(ctx.data.choices.cards.bounty) ? ctx.data.choices.cards.bounty : [];
const inactive = ctx.gd.inactiveEnemies instanceof Set ? ctx.gd.inactiveEnemies : new Set();
return all.filter((c) => c && /^enemyeffect_b_\d+$/.test(String(c.effectId)) && ctx.gd.enemy(c.enemyKey) && !inactive.has(c.enemyKey));
return all.filter((c) => c && test(c) && ctx.gd.enemy(c.enemyKey) && !inactive.has(c.enemyKey));
}
/** The 战术特训 cards (perfect payout: the chooser's own phase perfect) — what 教鞭 offers. */
const isTraining = (c) => c.payout === 'perfect';
/** The band bounty family `enemyeffect_b_*` — what “神秘顾客” offers [ASSUMED]. */
const isBandBounty = (c) => /^enemyeffect_b_\d+$/.test(String(c.effectId));
function wrap(registry, key, hooks) {
const base = registry.get(`item:${key}`) || {};
@@ -85,7 +95,8 @@ export function registerMeta(registry) {
const shares = (id) => { const c = ctx.gd.chess(id); return !!(c && Array.isArray(c.bonds) && c.bonds.some((b) => bonds.has(b))); };
const ids = [];
for (let k = 0; k < n; k++) {
const id = ctx.rollChess({ maxTier, filter: (x) => shares(x) && !ids.includes(x) }) || ctx.rollChess({ maxTier, filter: shares });
// never the same operator twice (user playtest #6 item 19): fewer cards when the pool has no other one
const id = ctx.rollChess({ maxTier, filter: (x) => shares(x) && !ids.includes(x) });
if (id) ids.push(id);
}
if (ids.length) ctx.offerChess(ids, { source: 'item' });
@@ -109,11 +120,11 @@ export function registerMeta(registry) {
},
}));
// 教鞭 / “神秘顾客” — trap_create_self_choice {choice_event}: a band bounty (enemyeffect_b_*) for the next battle
for (const key of ['chess_item_6_03_m', 'chess_item_6_01_m']) {
// 教鞭 / “神秘顾客” — trap_create_self_choice {choice_event}: 教鞭 a 战术特训 card, “神秘顾客” a band bounty (enemyeffect_b_*)
for (const [key, family] of [['chess_item_6_03_m', isTraining], ['chess_item_6_01_m', isBandBounty]]) {
wrap(registry, key, (base) => ({
onArt(ctx, ev) {
const cards = bandBounties(ctx);
const cards = bountyCards(ctx, family);
if (!cards.length) { if (typeof base.onArt === 'function') base.onArt.call(base, ctx, ev); return; }
const offer = ctx.rng.shuffle(cards.slice()).slice(0, OFFER_SIZE);
const card = ctx.rng.pick(offer);
+43 -37
View File
@@ -12,7 +12,8 @@
// Operator loadouts (DESIGN §16): every selectable non-default skill of the 16 visible chess is authored in the kit's
// `skills: { [skillId]: SkillSpec }` map from its own SkillRecord (skillRec / skillBbOf — Lv4 normal, Lv7 elite);
// talents / traits read the resolved record, so a module choice ('none' ⇒ traitBase / talentsBase, module.active
// false) is honoured. Per-skill triggers come from data (TANK S1 ⇒ TAKE_DAMAGE); the few spec overrides are documented
// false) is honoured. Per-skill triggers come from data (the official 技能策略: every MANUAL 重装 skill ⇒ TAKE_DAMAGE, a
// MANUAL skill with its own 技能范围 ⇒ SKILL_RANGE, AUTO skills keep their own rule); the few spec overrides are documented
// at the skill (冲锋号令 AUTO ⇒ SP_FULL, 花香疗法 heal-type DEFAULT). Tests: test/content/kits_alt_t1.test.js.
//
// fx kinds emitted (battle.fx(kind, {x, y, …})): aoe {radius, id, skill} · zone {radius, dur, id, skill} ·
@@ -88,10 +89,13 @@ export function enemiesInGrid(battle, unit, grid, { n = 0, priority = null, canH
sortEnemyTargets(battle, unit, list, priority ?? unit.profile?.priority ?? null);
return n > 0 && list.length > n ? list.slice(0, n) : list;
}
/** Deployed allies (no devices) whose tile is inside `grid` relative to `unit` (null ⇒ current range). */
/**
* Deployed allies (no devices) whose tile is inside `grid` relative to `unit` (null ⇒ current range) — never a 孤立 unit
* (炎佑: Battle.allySelectable), as Battle.alliesInGrid.
*/
export function alliesInGridOf(battle, unit, grid = null) {
const set = grid ? new Set(absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)) : (unit.rangeKeySet || new Set(unit.rangeKeys || []));
return battle.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC));
return battle.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC) && battle.allySelectable(a, unit));
}
/** Any targetable enemy in the unit's current range. */
export const enemyInRange = (battle, unit) => battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length > 0;
@@ -133,7 +137,7 @@ export function toggleBuff(battle, unit, key, cond, mods, extra = {}) {
export function installAura(battle, unit, { key, select, mods, value = 0, interval = 0.5 }) {
battle.every(interval, () => {
if (!up(unit)) return;
for (const a of battle.allies()) {
for (const a of battle.alliesFor(unit)) {
if (!select(a)) continue;
const cur = a.findBuff(key);
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > value && cur.timeLeft > 0.05) continue;
@@ -356,7 +360,7 @@ export default {
// ---------------------------------------------------------------------------------------------------------------
// 1_02 角峰 抗寒体质: HP +max_hp, DEF +def, RES ×(1+magic_resistance). 雪原卫士: RES +magic_resistance (flat).
// Alternate S1 体能强化 (TANK S1 ⇒ TAKE_DAMAGE trigger from data): HP +max_hp, +hp_recovery_per_sec HP per second
// Alternate S1 体能强化 (重装 ⇒ TAKE_DAMAGE trigger from data): HP +max_hp, +hp_recovery_per_sec HP per second
// (the 生命回复速度 attribute). Elite module PRO-Y (block 4) is a stat of the module (attr blockCnt).
chess_char_1_02_a: (bb, chess) => {
const s1 = skillBbOf(chess, 'skchr_yak_1');
@@ -392,7 +396,7 @@ export default {
// attack@max_target enemies on the line and cause attack@sluggish s of 停顿.
// 细胞活性抑制剂: attacks inflict `damage` arts per `interval` s for `duration` s (damage_seamonster vs 【海怪】).
// Elite module (SPT-X): stealth of enemies inside the range is cancelled.
// Alternate S1 侵袭破坏应对 (TANK S1 ⇒ TAKE_DAMAGE trigger from data): ATK +atk, DEF +def.
// Alternate S1 侵袭破坏应对 (重装 ⇒ TAKE_DAMAGE trigger from data): ATK +atk, DEF +def.
chess_char_1_04_a: (bb, chess, def) => {
const t = talentBb(chess, 0);
const s1 = skillBbOf(chess, 'skchr_udflow_1');
@@ -622,14 +626,18 @@ export default {
},
// ---------------------------------------------------------------------------------------------------------------
// 1_10 古米 备用军粮 (TAKE_DAMAGE, ct charges): the next attack heals the most injured ally nearby (skill grid)
// for heal_scale × ATK. Elite module (GUA-X): targets below hp_ratio HP get ×heal_scale more.
// 1_10 古米 备用军粮 (自动触发, ct charges — an AUTO skill: no 技能策略 applies, it keeps its own rule; PRTS 备注 "此技能
// 在存在生命值不满的可治疗角色时可触发;技能触发后古米将切换至治疗模式(普通攻击改为治疗技能范围内的一名友方单位),直至古米
// 完成一次普通攻击的治疗"): cast as soon as a healable ally of the skill range (x-4, herself included) is injured —
// no enemy needed (user playtest #6: it waited until she engaged) — then her next normal attack heals the most
// injured ally of the skill range for heal_scale × ATK instead of hitting an enemy; she makes no normal attack until
// that heal is done. Elite module (GUA-X): targets below hp_ratio HP get ×heal_scale more.
// 平底锅专精: each attack prob for ATK ×atk_scale and a `stun` s stun (only when that hit lands).
// Alternate S2 食粮烹制 (PRTS notes: 10 s 缴械 at the start, the real duration is disarm + 30 s, attacks become heals
// with the attack interval +130 %): cooking for `disarm` s — no attacks, DEF +def; then for the skill's `duration` s
// she heals the most injured ally inside the skill grid (x-4) for ATK per attack, ATK +atk, base attack time
// ×(1 + base_attack_time). The GUA-X heal bonus (below hp_ratio: ×heal_scale) applies to these heals too.
// Trigger: DEFAULT (data) with her own profile — an enemy in her initial range (the tile she blocks on).
// Trigger: TAKE_DAMAGE (data: the 重装 strategy "不受技能范围影响,受到伤害时释放技能").
chess_char_1_10_a: (bb, chess, def) => {
const t = talentBb(chess, 0);
const tb = traitBb(chess);
@@ -638,6 +646,7 @@ export default {
const b2 = r2?.bb ?? {};
const cook = Math.max(0, num(b2.disarm, 10));
const cookKey = 'sunbr:cook', serveKey = 'sunbr:serve';
const healGrid = def?.skill?.rangeGrid ?? null;
return {
skills: {
[S2]: {
@@ -661,9 +670,9 @@ export default {
},
},
install(battle, unit) {
if (unit.skill?.id !== S2 || tb.hp_ratio == null || num(tb.heal_scale, 1) === 1) return;
// (S1 applies the module bonus inside its own heal): her S2 heals — attack heals while it runs (herself included,
// she stands in the grid), never her natural / self regeneration
if (tb.hp_ratio == null || num(tb.heal_scale, 1) === 1) return;
// GUA-X on her skill heals (S1's heal-mode attack, S2's attack heals while it runs — herself included, she stands
// in the grid), never her natural / self regeneration
battle.on('heal', (ctx) => {
if (ctx.source !== unit || !unit.skill?.active || ctx.opts?.regen || ctx.opts?.self) return;
if (ctx.target.hpRatio < num(tb.hp_ratio)) ctx.amount *= num(tb.heal_scale, 1);
@@ -671,18 +680,11 @@ export default {
},
skill: {
kind: instantKind(def),
attack: {
onHit({ battle, unit }) {
const grid = def?.skill?.rangeGrid;
const keys = grid ? absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0) : unit.rangeKeys;
const ally = battle.injuredAlliesInKeys(keys, unit)[0];
if (!ally) return;
let amount = unit.s.atk * num(bb.heal_scale, 1);
if (tb.hp_ratio != null && ally.hpRatio < num(tb.hp_ratio)) amount *= num(tb.heal_scale, 1);
battle.heal(unit, ally, amount, { tags: ['skill'] });
battle.fx('heal', { x: ally.x, y: ally.y, id: ally.id });
},
},
heal: true,
...(healGrid ? { trigger: { rule: 'SKILL_RANGE', grid: healGrid, allies: true }, targeting: { rangeGrid: healGrid } } : {}),
// the heal-mode attack: the most injured ally of the skill range (acquireTargets, heal profile)
attack: { dmgType: 'heal', heal: { mode: 'single' }, healScale: num(bb.heal_scale, 1), projectile: 'none' },
onHit({ battle, target }) { if (target) battle.fx('heal', { x: target.x, y: target.y, id: target.id }); },
},
talents: [{ install(battle, unit) {
// the proc is rolled per attack hit; the stun only follows a hit that landed (not dodged / cancelled)
@@ -757,7 +759,7 @@ export default {
if (unit.skill?.id !== S1) return;
battle.on('tick', () => {
const sk = unit.skill;
if (!up(unit) || !unit.canAct || !sk.ready || sk.active || unit.s.flags.silence) return;
if (!up(unit) || !unit.canAct || !sk.ready || sk.active || sk.opCooling || unit.s.flags.silence) return;
if (battle.injuredAlliesInKeys(unit.baseRangeKeys || unit.rangeKeys, unit).length) sk.activate('DEFAULT');
}, { owner: unit });
},
@@ -832,9 +834,10 @@ export default {
},
},
trait: {
// with only bound enemies in range she holds her fire (the mystic trait stores the energy meanwhile)
// with only bound enemies in range (or blocked by her — always her targets, Battle.blockedTargets) she holds her
// fire (the mystic trait stores the energy meanwhile)
canAttack(battle, u) {
const ok = battle.enemiesInKeys(u.rangeKeys, u, u.profile).some((e) => !bound(e));
const ok = battle.enemiesInKeys(u.rangeKeys, u, u.profile).some((e) => !bound(e)) || battle.blockedTargets(u, u.profile).some((e) => !bound(e));
if (!ok) u.trait.hadTarget = false;
return ok;
},
@@ -861,8 +864,11 @@ export default {
talents: [{ install(battle, unit) {
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !ctx.targets.some(bound)) return;
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile || unit.profile).filter((e) => !bound(e));
sortEnemyTargets(battle, unit, cands, (ctx.profile || unit.profile).priority);
const prof = ctx.profile || unit.profile;
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof);
for (const e of battle.blockedTargets(unit, prof)) if (!cands.includes(e)) cands.push(e);
for (let i = cands.length - 1; i >= 0; i--) if (bound(cands[i])) cands.splice(i, 1);
sortEnemyTargets(battle, unit, cands, prof.priority);
ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length));
}, { owner: unit });
} }],
@@ -921,7 +927,9 @@ export default {
},
// ---------------------------------------------------------------------------------------------------------------
// 1_19 野鬃 夹枪冲锋: wider range (skill grid), ATK +atk, hits push the target away (attack@force, 中等力度).
// 1_19 野鬃 夹枪冲锋: wider range (skill grid), ATK +atk, hits push the target away (attack@force 1 = 中力; radial — away
// from her centre, PRTS 推与拉: every push but the 推击手' is radial) by the official 力度 − 重量 distance (Battle.push:
// weight 0 → 2.14 tiles, 1 → 1.7, 2 → 0.44, 3 → 0.12, ≥ 4 → none; user playtest #6 item 14).
// 一致向前: after deploying (normal flag 0: first deployment only; elite flag 1: every deployment) every undeployed
// 【近卫】 operator of the player costs `value` less DP to deploy (≤ max_stack_cnt per operator until it deploys).
// Alternate S1 骑枪刺击 (PASSIVE, ON_DEPLOY): for `duration` s after every deployment ASPD +attack_speed.
@@ -946,9 +954,7 @@ export default {
attack: {
onHit({ battle, unit, target }) {
if (!target || !target.alive || target.side !== 'enemy') return;
let dx = target.x - unit.x, dy = target.y - unit.y;
if (Math.hypot(dx, dy) < 1e-6) { dx = unit.fwd[1]; dy = unit.fwd[0]; }
battle.displace(target, { x: dx, y: dy }, 0.5 + 0.5 * force, { force: force + 1 });
battle.push(target, force, { from: unit });
},
},
},
@@ -981,9 +987,9 @@ export default {
// attack@buff_prob to stun attack@stun s; afterwards 雷蛇 is stunned `stun` s.
// 战术防御: when attacked, +sp SP to herself and to one random ally in the talent grid. Elite 雷抗: RES +magic_resistance.
// Elite module (SPT-X): stealth of enemies inside the range is cancelled.
// Alternate S1 充能防御 (AUTO, hurt SP; TANK S1 ⇒ TAKE_DAMAGE from data — the hit that fills SP sets it off): PRTS
// "技能拥有8s的持续时间": for bb.duration s DEF +def, and the next damage instance taken is blocked (cancelled — a
// blocked hit "导致第一天赋无法触发": no 战术防御 SP, no hurt SP).
// Alternate S1 充能防御 (AUTO, hurt SP, "技能自动开启" — an AUTO skill takes no 技能策略: SP_FULL, so the hit that fills SP
// sets it off): PRTS "技能拥有8s的持续时间": for bb.duration s DEF +def, and the next damage instance taken is blocked
// (cancelled — a blocked hit "导致第一天赋无法触发": no 战术防御 SP, no hurt SP).
chess_char_1_20_a: (bb, chess) => {
const t = talentBb(chess, 0);
const grid = talentGrid(chess, 0);
@@ -993,7 +999,7 @@ export default {
return {
skills: {
[S1]: {
kind: 'duration', duration: num(b1.duration, 8), mods: { defPct: num(b1.def) },
kind: 'duration', duration: num(b1.duration, 8), mods: { defPct: num(b1.def) }, trigger: 'SP_FULL',
onStart({ battle, unit, skill }) {
battle.addBuff(unit, { key: blockKey, duration: skill.duration + 0.05, tags: ['skill'], visible: true });
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id });
+14 -55
View File
@@ -18,36 +18,10 @@ import { canTargetEnemy } from '../../targeting.js';
import { bodyInKeys } from '../../body.js';
import {
num, talentBb, moduleBb, traitBb, up, cheb, byEnemyAttack, onHitBy, onHitOn, onDamagedOn, enemiesInGrid,
alliesInGridOf, enemyInRange, statBuff, toggleBuff, installAura, spTimeBonus, freeTileAround, summonTileFree, instantKind,
alliesInGridOf, enemyInRange, statBuff, toggleBuff, installAura, spTimeBonus, freeTileAround, instantKind,
tinmanKit, batMod,
} from './tier1.js';
/**
* The tokens.json variant a summon of `owner` gets — its chess (else the normal `_a` sibling, else the first variant)
* with the owner's selected skill / module merged, as simdata getToken does — for fields normalizeToken drops (the
* withdraw skill's duration). Exact when two players of one field give the same chess different loadouts (DESIGN §16).
*/
function ownTokenVariant(battle, tokId, owner) {
const vs = battle.tokenDef(tokId, owner)?.raw?.variants;
if (!vs || typeof vs !== 'object') return null;
const oid = String(owner?.defId ?? '');
let v = vs[oid] ?? vs[oid.replace(/_b$/, '_a')] ?? Object.values(vs)[0] ?? null;
const lo = owner?.def?.loadout;
if (v && lo && !lo.isDefault) {
if (!lo.skillIsDefault && v.bySkill?.[lo.skillIndex]) v = { ...v, ...v.bySkill[lo.skillIndex] };
if (!lo.moduleIsDefault && v.byModule?.[lo.moduleId]) v = { ...v, ...v.byModule[lo.moduleId] };
}
return v;
}
/** Lifetime (s) of a token `owner` summons: its own withdraw skill duration, else "N秒后自动销毁" in the owner's skill text. */
function tokenLifetime(battle, tokId, owner, desc) {
const raw = battle.tokenDef(tokId, owner)?.raw;
const v = num(ownTokenVariant(battle, tokId, owner)?.skill?.duration, num(raw?.skill?.duration));
if (v > 0) return v;
const m = String(desc || '').match(/(\d+(?:\.\d+)?)秒后自动销毁/);
return m ? +m[1] : 0;
}
import { releaseSkillSummon } from '../tokens.js';
/** Largest element gauge of a unit (every element — 侵蚀 included: the bosses' attacks fill it on operators). */
const elemLoad = (a) => Math.max(a.elem.burn, a.elem.neural, a.elem.necrosis, a.elem.apoptosis, a.elem.erosion ?? 0);
@@ -92,10 +66,12 @@ export default {
}),
// ---------------------------------------------------------------------------------------------------------------
// 2_02 赫默 医疗无人机 (AUTO, heal trigger): deploy a medical drone (overrideTokenKey, stats from tokens.json variants)
// next to the most injured ally in range; it heals around itself and self-destructs after its lifetime; at most `cnt`
// drones at once. 强化注射: every 【医疗】 operator on the field ASPD +attack_speed. Elite module (PHY-Y): heals on
// ground units ×heal_scale (module 'none': no bonus).
// 2_02 赫默 医疗无人机 (AUTO, heal trigger): "获得一个医疗无人机 / 最多可库存1个无人机;无人机投入战场后治疗周围友军,
// 10秒后自动销毁" — the 医疗探机 is a hand piece the player places (user playtest #6; PRTS 卫戍协议/帮助): each cast gives
// one (stock ≤ cnt) and the placed piece takes the field on its own tile (tokens.js releaseSkillSummon: not at the
// battle start — user playtest #4; not placed ⇒ no drone); it heals around itself and self-destructs after 10 s (token
// kit). 强化注射: every 【医疗】 operator on the field ASPD +attack_speed. Elite module (PHY-Y): heals on ground units
// ×heal_scale (module 'none': no bonus).
// S1 治疗强化·γ型 (alt): ATK +atk for its duration (no drone).
chess_char_2_02_a: (bb, chess) => {
const tokId = chess?.skill?.overrideTokenKey ?? (chess?.tokens ?? [])[0] ?? 'token_10000_silent_healrb';
@@ -105,23 +81,7 @@ export default {
return {
skill: {
kind: 'instant', heal: true,
onStart({ battle, unit }) {
const focus = battle.injuredAlliesInKeys(unit.rangeKeys, unit)[0] ?? unit;
let best = null, bd = Infinity;
for (const k of unit.rangeKeys || []) {
const r = (k / COLS) | 0, c = k % COLS;
if (!summonTileFree(battle, r, c) || !battle.grid.canStand(r, c, { ranged: true })) continue;
const d = Math.max(Math.abs(r - focus.tileR), Math.abs(c - focus.tileC)) + 0.01 * (Math.abs(r - unit.tileR) + Math.abs(c - unit.tileC));
if (d < bd) { bd = d; best = [r, c]; }
}
if (!best) return;
const drones = (unit.mem.drones ?? []).filter((d) => d.alive);
while (drones.length >= cnt) battle.retreat(drones.shift(), { reason: 'expired', permanent: true });
const life = tokenLifetime(battle, tokId, unit, chess?.skill?.desc);
const d = battle.spawnToken(unit, tokId, best[0], best[1], life > 0 ? { duration: life } : {});
if (d) { drones.push(d); battle.fx('summon', { x: d.x, y: d.y, id: d.id, token: tokId }); }
unit.mem.drones = drones;
},
onStart({ battle, unit }) { releaseSkillSummon(battle, unit, tokId, { cap: cnt }); },
},
skills: { 'skcom_heal_up[3]': { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } } },
talents: [{ install(battle, unit) {
@@ -135,8 +95,9 @@ export default {
},
// ---------------------------------------------------------------------------------------------------------------
// 2_03 崖心 (hidden) 束缚链: up to max_target enemies in the large front grid are dragged (force 1 = 中等) to the tile in front
// of her, take atk_scale × ATK true damage and are stunned `stun` s. 雪境猎手: ATK/DEF +atk/+def while not blocking.
// 2_03 崖心 (hidden) 束缚链: up to max_target enemies in the large front grid are dragged (force 1 = 中力; Battle.pullToFront:
// the official 力度 − 重量 pull — weight ≤ 1 all the way in front of her, 2 a third of the way, 3 barely, ≥ 4 not at all),
// take atk_scale × ATK true damage and are stunned `stun` s. 雪境猎手: ATK/DEF +atk/+def while not blocking.
// Elite module (HOK-X, trait value/dist): dragged enemies take `value` arts per `dist` tiles travelled.
chess_char_2_03_a: (bb, chess, def) => {
const t = talentBb(chess, 0);
@@ -147,11 +108,9 @@ export default {
kind: 'instant',
onStart({ battle, unit }) {
const foes = enemiesInGrid(battle, unit, def?.skill?.rangeGrid ?? null, { n: Math.max(1, Math.floor(num(bb.max_target, 1))) });
const fx0 = unit.tileC + unit.fwd[1], fy0 = unit.tileR + unit.fwd[0];
for (const e of foes) {
const sx = e.x, sy = e.y;
const dx = fx0 - e.x, dy = fy0 - e.y, dist = Math.hypot(dx, dy);
const moved = dist > 0.05 ? battle.displace(e, { x: dx, y: dy }, dist, { force: force + 1 }) : 0; // same force mapping as generic.js
const moved = battle.pullToFront(e, unit, force);
battle.fx('pull', { x: e.x, y: e.y, id: e.id, fromX: sx, fromY: sy });
if (moved > 0 && num(tb.value) > 0) {
battle.dealDamage(unit, e, { amount: num(tb.value) * moved / Math.max(0.01, num(tb.dist, 1)), type: 'arts', isSkill: true, canDodge: false, tags: ['drag'] });
@@ -698,7 +657,7 @@ export default {
battle.every(1, () => {
if (!up(unit)) return;
const amt = unit.s.atk * r;
for (const a of battle.allies()) if (a.hp < a.s.maxHp) battle.heal(unit, a, amt, { aura: true, tags: ['talent'] });
for (const a of battle.alliesFor(unit)) if (a.hp < a.s.maxHp) battle.heal(unit, a, amt, { aura: true, tags: ['talent'] });
}, { owner: unit });
} }],
};
+54 -67
View File
@@ -7,8 +7,9 @@
// Profession defaults (server/sim/professions.js) are kept and extended: fastshot fly bonus, instructor, reaperrange,
// hunter ammo, funnel ramp, phalanx guard, merchant drain, tactician reinforcement, underminer module weaken.
// Summons: the match hands 伺夜's 狼群 and 巫恋's 诅咒娃娃 to the player as placeable board pieces (tokens.js kits);
// these kits adopt/replace such pieces instead of adding a second pack/doll, and every summon tile avoids the home
// tile of an ally that has not (re)deployed yet (freeTile). Offensive skills whose own range differs from the attack
// 伺夜's kit adopts his pack piece instead of adding a second pack, 巫恋's S2 brings her placed doll onto its tile
// (tokens.js releaseSkillSummon), and every summon tile avoids the home tile of an ally that has not (re)deployed yet
// (freeTile). Offensive skills whose own range differs from the attack
// range trigger on that skill range (CUSTOM_RANGE: 松果 shortened range, 见行者 push row — research 03 §1.4).
//
// Client VFX (battle.fx kinds used here, all `{x, y, id, …extra}`): 'aoe' (radius, dmgType), 'strike' (special single
@@ -20,18 +21,19 @@
// selectable skill of the visible chess, each built from its OWN record at the chess's level (skillData: Lv4 normal /
// Lv7 elite blackboard, range, charges) — the loader picks the selected one. Talents / trait / install are shared: the
// parts that belong to one skill only (琳琅诗怀雅 bombs, 菲莱 counters, 雪猎 special bullets…) check the selected skill id.
// Triggers come from the data record of each skill (skills[i].trigger), except offensive skills with their own range
// (CUSTOM_RANGE on that range, research 03 §1.4) and 薄绿 S1 (阵法术师 技能1 row SEARCH = "初始攻击范围内出现敌人后自动释放",
// research 03 C1 ⇒ DEFAULT). Non-default modules: 能天使 MAR-Y (ASPD vs ground), 琳琅诗怀雅 MER-Y (ATK per payment),
// Triggers come from the data record of each skill (skills[i].trigger: tools/build-data.mjs resolveTrigger — the official
// 技能策略 incl. SKILL_RANGE for a MANUAL skill's own 技能范围 and the class rows for every MANUAL skill), except
// 薄绿 S1 (the 阵法术师 row SEARCH = "在初始攻击范围内存在敌人时" ⇒ the engine's DEFAULT for a phalanx, checked every
// tick) and the kits' own automatic casts (雪猎 special bullets). Non-default modules: 能天使 MAR-Y (ASPD vs ground), 琳琅诗怀雅 MER-Y (ATK per payment),
// 斯卡蒂 DRE-X (× vs blocked), 瑕光 GUA-X (heal × under 50 %), 伺夜 TAC-Y (×165 % trait, pack-blocked enemies taunt +1),
// 空弦 MAR-X (fly ×, profession layer). No in-battle effect here: 忍冬 SOL-Y "首次部署时部署费用-4" (the initial
// deployment is free) and the 集成战略-only ISW-A modules of 琳琅诗怀雅 / 空弦 (their stats and trait cost still apply).
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../../targeting.js';
import { COLS } from '../../constants.js';
import { bodyDist, bodyInKeys, bodyOnTile, bodyTileReach } from '../../body.js';
import { COLS, PUSH_DIRECTIONAL_MIN_DIST } from '../../constants.js';
import { bodyDist, bodyInKeys, bodyOnTile } from '../../body.js';
import { normalizeChess, normalizeSkill } from '../../simdata.js';
import { tacticalPoint as sharedTacticalPoint } from '../tokens.js';
import { tacticalPoint as sharedTacticalPoint, releaseSkillSummon } from '../tokens.js';
// ---------------------------------------------------------------------------------------------------------------
// helpers
@@ -113,6 +115,8 @@ const copyGrid = (g) => (Array.isArray(g) && g.length ? g.map((p) => [p[0], p[1]
const NINE = [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]];
/** Two enemy bodies touch within this distance (tiles) — 见行者 collision stun. */
const COLLIDE = 0.6;
/** 忍冬 S3's 迷彩 (until her next cast): its own buff key, never merged with another unit status. */
const CAMOU_KEY = 'vulpis:camou';
const CN_NUM = { 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 };
/** Number captured by `re` (arabic or a single chinese numeral) in `text`, else `fallback`. */
function textNum(text, re, fallback) {
@@ -174,6 +178,7 @@ function aura(battle, unit, o) {
const list = o.side === 'ally' ? battle.allyUnits : battle.enemies;
for (const x of list) {
if (!x.alive || !x.deployed || x.hidden || x.kind === 'device') continue;
if (o.side === 'ally' && !battle.allySelectable(x, unit)) continue; // never a 孤立 unit (炎佑)
if (!onTiles(x, set) || (o.filter && !o.filter(x))) continue;
const mods = typeof o.mods === 'function' ? o.mods(x) : o.mods;
if (!mods) continue;
@@ -447,6 +452,7 @@ const KITS = {
let best = null;
for (const a of battle.allyUnits) {
if (a === unit || !alive(a) || a.hidden || a.kind === 'device' || a.hpRatio >= healRatio) continue;
if (a.s.flags.noHeal || a.profile?.noHeal) continue; // 禁疗 / 孤立: never a heal target
if (Math.max(Math.abs(a.tileR - unit.tileR), Math.abs(a.tileC - unit.tileC)) !== 1) continue; // 周围八格
if (!best || a.hpRatio < best.hpRatio || (a.hpRatio === best.hpRatio && a.deploySeq < best.deploySeq)) best = a;
}
@@ -486,7 +492,8 @@ const KITS = {
if (!e) break;
spent++;
battle.dealDamage(unit, e, { amount: unit.s.atk * cash, type: 'phys', isSkill: true, tags: ['skill', 'swire2Cash'] });
if (e.alive) battle.displace(e, { x: unit.fwd[1], y: unit.fwd[0] }, 0.5 + 0.5 * force, { force: force + 1 });
// "将目标小力地向前推开": a directional push along her direction (Battle.push, official 力度 − 重量 distance)
if (e.alive) battle.push(e, force, { from: unit, dir: { x: unit.fwd[1], y: unit.fwd[0] } });
}
fx(battle, 'coin', unit, { n: 0, spent, skill: 'swire2_3' });
},
@@ -719,10 +726,14 @@ const KITS = {
const keys = new Set(gridKeys(skillGrid ?? unit.rangeGrid, unit));
const victims = battle.enemies.filter((e) => e.alive && !e.hidden && !e.s.flags.untargetable && onTiles(e, keys));
const pushed = new Set(victims);
const dist = 0.5 + 0.5 * force; // engine displacement convention (docs/SIM.md §12)
for (const e of victims) {
const expect = e.isBoss ? 0 : dist * Math.max(0, Math.min(1, 1 - 0.25 * Math.max(0, e.s.massLevel - (force + 1))));
const moved = battle.displace(e, { x: unit.fwd[1], y: unit.fwd[0] }, dist, { force: force + 1 });
// "往身前方向…推开": a directional push whose angle is fixed — PRTS 备注 "不会因为角度过大而改变推动的方向或削减力度"
// (only the angle: a target nearer than 0.25 tile still turns radial at 受力等级 −2) — by the official
// 力度 − 重量 distance of a 特效 push (PRTS 推与拉 names 见行者's skills 特效类; Battle.push / pushDistance);
// stopped short of it ⇒ it hit a wall
const near = Math.hypot(e.x - unit.x, e.y - unit.y) < PUSH_DIRECTIONAL_MIN_DIST;
const expect = battle.pushDistance(e, near ? force - 2 : force, { effect: true });
const moved = battle.push(e, force, { from: unit, dir: { x: unit.fwd[1], y: unit.fwd[0] }, fixedAngle: true, effect: true });
const wall = expect > 0 && moved + 0.05 < expect;
battle.applyStatus(e, 'stun', { duration: wall ? sWall : sDirect, source: unit });
}
@@ -735,9 +746,8 @@ const KITS = {
}
fx(battle, 'push', unit, { skill: 'forcer_2', n: victims.length });
},
// "立即将范围内所有敌人…": fires once an enemy is inside the skill range (research 03 §1.4), not only when one
// stands on her 2-tile attack range
...(skillGrid ? { trigger: { rule: 'CUSTOM_RANGE', grid: skillGrid } } : {}),
// "立即将范围内所有敌人…": the data rule SKILL_RANGE fires it once an enemy is inside the skill range (PRTS 技能
// 策略 "仅在技能范围内存在敌人(无视其不可选中)时释放技能"), not only when one stands on her 2-tile attack range
},
talents: [{ install(battle, unit) {
battle.on('hit', (ctx) => {
@@ -758,14 +768,14 @@ const KITS = {
return kit;
},
// ---- 3_08 薄绿 · 阵法术师 — S2 聚能涡旋: each hit pulls the target in front of her (splash arts), end-of-skill burst on
// ---- 3_08 薄绿 · 阵法术师 — S2 聚能涡旋: each hit pushes the target towards her (splash arts), end-of-skill burst on
// every enemy in range; 地质学者: DEF aura (skill off) / less likely targeted (skill on);
// 精锐 module PLX-X: keeps part of the guard (DEF/RES) while the skill runs
// S1 风语: wider range (skill grid), attacks at attack@atk_scale (群体 arts splash of the trait). Auto-cast: the data
// rule SEARCH is the 阵法术师 技能1 row, officially "初始攻击范围内出现敌人后自动释放" (research 03 Addendum C1,
// BWIKI) = an enemy inside her INITIAL range — the engine's DEFAULT rule, checked every tick for a unit that does
// not attack while its skill is off — not any enemy on the field (the sim's generic SEARCH, research 03 §1.4
// [ASSUMED]): she would burn the skill on enemies that just spawned.
// rule SEARCH is the 阵法术师 row (PRTS 卫戍协议/帮助 "不受基础策略影响,在初始攻击范围内存在敌人时释放技能"; it
// covers every MANUAL skill of the class — user playtest #6) = an enemy inside her INITIAL range — the engine's
// DEFAULT rule, checked every tick for a unit that does not attack while its skill is off — not any enemy on the
// field: she would burn the skill on enemies that just spawned.
chess_char_3_08_a: (bb, chess, def) => {
const d = defOf(chess, def);
const t0 = talentBb(d, 0);
@@ -805,10 +815,9 @@ const KITS = {
atkScale: num(bb['attack@atk_scale'], 1),
onHit({ battle, unit, target }) {
if (!target || !target.alive || target.side !== 'enemy') return;
const dx = unit.tileC + unit.fwd[1] - target.x, dy = unit.tileR + unit.fwd[0] - target.y;
const len = Math.hypot(dx, dy);
if (len < 0.05) return;
if (battle.displace(target, { x: dx, y: dy }, Math.min(len, 0.5 + 0.5 * pullForce), { force: pullForce + 1 }) > 0) fx(battle, 'pull', target, { src: unit.id });
// PRTS 备注: "此技能的“拖拽”机制实际为反方向(指向薄绿方向)的推开" — a radial push towards her by the
// official 力度 − 重量 push distance (小力 vs weight 1: 0.44 tiles), never past her (Battle.push inward)
if (battle.push(target, pullForce, { from: unit, inward: true }) > 0) fx(battle, 'pull', target, { src: unit.id });
},
},
onStart: guardOn,
@@ -976,7 +985,7 @@ const KITS = {
if (sel === S2 || sel == null) {
battle.on('tick', () => {
const sk = unit.skill;
if (!alive(unit) || !unit.canAct || !sk || !sk.ready || unit.s.flags.silence || (unit.trait.ammo ?? 1) > 0) return;
if (!alive(unit) || !unit.canAct || !sk || !sk.ready || sk.opCooling || unit.s.flags.silence || (unit.trait.ammo ?? 1) > 0) return;
if (battle.enemiesInKeys(unit.baseRangeKeys || unit.rangeKeys, unit, unit.profile).length) sk.activate('DEFAULT');
}, { owner: unit });
}
@@ -994,7 +1003,7 @@ const KITS = {
// ---- 3_12 瑕光 · 守护者 — S3 先贤化身: ATK/DEF +, bonus arts per hit, heals another nearby ally per attack;
// 剑盾骑士: hurt-SP skills of the team also gain SP on attack; 仁慈: attacks sleeping enemies (first, ×atk_scale);
// 精锐 module GUA-Y: damage taken −15 %
// S1 光芒涌动 (TAKE_DAMAGE, charges): next attack ×atk_scale phys + heals the most injured ally of the 3×3 (herself
// S1 光芒涌动 (自动触发 ⇒ DEFAULT, charges): next attack ×atk_scale phys + heals the most injured ally of the 3×3 (herself
// included) for heal_scale × ATK; S2 慑敌辉光: ATK +, puts every ground enemy on her own tile and every enemy she
// blocks to sleep (PRTS 备注; for the skill's duration: no own value in the data) and heals every ally of the
// skill range by ATK × ratio each second;
@@ -1176,47 +1185,18 @@ const KITS = {
return kit;
},
// ---- 3_15 巫恋 · 削弱者 (hidden) — S2 诅咒娃娃: places the doll (auto-placed on the tile of the range covering most
// enemies; replaces any doll of hers still standing — the doll is never a prep hand card) whose 3×3 aura lowers
// enemy ATK/DEF, gone after 15 s; 溃败暗示: low-HP enemies fragile
// ---- 3_15 巫恋 · 削弱者 (hidden) — S2 诅咒娃娃 "获得一个诅咒娃娃(最多可库存1个)": the doll is a hand piece the player
// places (user playtest #6; PRTS 卫戍协议/帮助); each cast gives one and the placed piece takes the field on its
// own tile (tokens.js releaseSkillSummon: not at the battle start; not placed ⇒ no doll); its token kit keeps the
// 3×3 ATK/DEF aura (the token skill's bb) for 15 s; 溃败暗示: low-HP enemies fragile
chess_char_3_15_a: (bb, chess, def) => {
const d = defOf(chess, def);
const t0 = talentBb(d, 0);
const tokenId = chess?.skill?.overrideTokenKey ?? (d.tokens || []).find((t) => /doll/.test(String(t))) ?? 'token_10006_vodfox_doll';
const debuff = { atkPct: num(bb.atk), defPct: num(bb.def) };
const dollKit = () => ({
skill: null,
trait: { noAttack: true },
install(battle, doll) {
aura(battle, doll, { key: 'skill:vodfox_doll', side: 'enemy', interval: 0.1, tiles: () => doll.rangeKeySet, mods: debuff });
},
});
return {
skill: {
kind: 'instant',
onStart({ battle, unit }) {
const tok = battle.tokenDef(tokenId, unit); // the owner's skill / module variant (DESIGN §16)
const life = num(tok?.skill?.duration, 0) > 0 ? tok.skill.duration : textNum(d.skill?.description, /(\d+)秒后自动销毁/, 15);
const grid = tok?.rangeGrid ?? NINE;
const reach = Math.max(1, ...grid.map((p) => Math.max(Math.abs(p[0]), Math.abs(p[1]))));
// "最多可库存1个" / deploy limit 1: the new doll replaces any doll of hers still standing — withdrawn first
// so its tile is available again
for (const t of tokensOf(battle, unit, tokenId)) if (t.alive) battle.retreat(t, { reason: 'expired', permanent: true });
const foes = battle.aliveEnemies().filter((e) => !e.hidden);
let best = null, bs = -1, bd = Infinity;
for (const k of unit.rangeKeys || []) {
const r = (k / COLS) | 0, c = k % COLS;
if (!freeTile(battle, r, c) || !battle.grid.canStand(r, c, { ranged: true })) continue;
let s = 0;
for (const e of foes) if (bodyTileReach(e, r, c) <= reach) s++;
const dd = Math.abs(r - unit.tileR) + Math.abs(c - unit.tileC);
if (s > bs || (s === bs && dd < bd)) { best = [r, c]; bs = s; bd = dd; }
}
if (!best) return;
const doll = battle.spawnToken(unit, tokenId, best[0], best[1], { duration: life, untargetable: true, kit: dollKit() });
unit.mem.doll = doll;
if (doll) fx(battle, 'summon', doll, { src: unit.id, token: tokenId });
},
onStart({ battle, unit }) { releaseSkillSummon(battle, unit, tokenId); },
},
talents: [{ install(battle, unit) {
aura(battle, unit, {
@@ -1283,10 +1263,15 @@ const KITS = {
},
// ---- 3_18 忍冬 · 尖兵 — S3 隐狐之艺: +DP, range +1, ATK +, decaying ASPD, hits all blocked, 0.2 s stun per hit,
// camouflage after a kill until the next cast; 追凶: bonus arts on marked enemies; 蓄势: DP regen + out-of-combat
// after a kill during the skill 迷彩 from its end until the next cast ("进入迷彩状态,直至下一次开启技能") — the
// `camou` status under its own key (ba.camou "不阻挡时不成为敌方普通攻击的目标": only the enemy she blocks attacks
// her, as with 隐匿; it used to be the generic `stealth` = 隐匿 under the key `stealth`, shared with 伪装服's timed
// 隐匿, which the unending one then made permanent, and it counted as 隐匿 for 叙拉古 / 家族徽章 — user playtest #6
// "叙拉古阵营隐身不会结束": the bond's 隐匿 ends on time, this 迷彩 officially lasts); 追凶: bonus arts on marked
// enemies; 蓄势: DP regen + out-of-combat
// regen; 精锐 module SOL-X: ATK/DEF + while blocking
// S1 小施惩戒 (charges): next attack + extra_damage_ratio × ATK arts and +cost DP; S2 坠刃拷问 (charges, fires
// once an enemy is inside its own range 3-12): +cost DP, ≤ max_target enemies of that range take atk_scale arts and
// S1 小施惩戒 (charges): next attack + extra_damage_ratio × ATK arts and +cost DP; S2 坠刃拷问 (charges, the data rule
// SKILL_RANGE: fires once an enemy is inside its own range 3-12, charges 3 s apart): +cost DP, ≤ max_target enemies of that range take atk_scale arts and
// 停顿 — the ones already 停顿 are also stunned. (SOL-Y "首次部署时部署费用-4": the initial deployment is free.)
chess_char_3_18_a: (bb, chess, def) => {
const d = defOf(chess, def);
@@ -1300,7 +1285,7 @@ const KITS = {
mods: { atkPct: num(bb.atk) },
attack: { hitAllBlocked: true, onHitStatus: { key: 'stun', duration: num(bb['attack@stun'], 0.2) } },
onStart({ battle, unit, skill }) {
battle.removeStatus(unit, 'stealth');
battle.removeBuff(unit, CAMOU_KEY);
unit.mem.vulpisKill = false;
const dp = num(bb.cost, 0);
if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); }
@@ -1315,7 +1300,7 @@ const KITS = {
onEnd({ battle, unit, reason }) {
battle.removeBuff(unit, 'skill:vulpis_aspd');
if (reason !== 'death' && unit.alive && unit.mem.vulpisKill) {
battle.applyStatus(unit, 'stealth', { source: unit });
battle.addBuff(unit, { key: CAMOU_KEY, flags: { camou: true }, status: 'camou', source: unit });
fx(battle, 'camouflage', unit);
}
},
@@ -1329,12 +1314,11 @@ const KITS = {
if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); }
} },
}),
skchr_vulpis_2: (s) => { // 坠刃拷问 — air units too (PRTS 备注 "※可对空")
skchr_vulpis_2: (s) => { // 坠刃拷问 — air units too (PRTS 备注 "※可对空"); data trigger SKILL_RANGE (its 3-12 range)
const grid = copyGrid(s.rangeGrid);
const n = Math.max(1, Math.floor(num(s.bb.max_target, 6)));
return {
kind: instantKindOf(s),
...(grid ? { trigger: { rule: 'CUSTOM_RANGE', grid } } : {}),
onStart({ battle, unit }) {
const dp = num(s.bb.cost, 0);
if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); }
@@ -1575,8 +1559,11 @@ const KITS = {
},
},
skills: altSkills(chess, d, bb, {
// (自动触发: an AUTO skill takes no 技能策略 — the 战术家 row is for MANUAL skills — and this DP skill fires as soon
// as it is ready, as before)
skchr_vigil_1: (s) => ({
kind: 'instant',
trigger: 'SP_FULL',
onStart({ battle, unit }) {
dpGain(battle, unit, num(s.bb.cost, 0));
const w = wolfOf(unit);
+54 -144
View File
@@ -3,7 +3,7 @@
// (精锐 `_b`) id: `bb` is the Lv4 / Lv7 skill blackboard, talent/trait blackboards come from `def` (module talent and
// trait upgrades of elites are already merged into the data), `def.raw.module.active` tells whether the module runs.
// Every number comes from a blackboard (skill `bb`, `def.talents[i].bb`, `def.traitBb`, token data); literals below
// are only fall-backs for missing keys or documented [ASSUMED] shapes (tornado radius, device placement …).
// are only fall-backs for missing keys or documented [ASSUMED] shapes (tornado radius …).
//
// Notes shared by several kits:
// - `base_attack_time` in skill blackboards is a FLAT change of the base attack time in seconds (白面鸮 −1.8 on 2.85 s
@@ -30,7 +30,7 @@ import { bodyDist, bodyInKeys } from '../../body.js';
import { absoluteRangeKeys, canTargetEnemy, sortEnemyTargets } from '../../targeting.js';
import { normalizeChess } from '../../simdata.js';
import { aggregateMods } from '../../buffs.js';
import { dirFromDelta, frontOf, offsetTile } from '../../dir.js';
import { CAT_SHIELD_KEY } from '../tokens.js';
const TICK_EPS = 0.01; // minimal status duration (s)
const AURA = 0.2; // aura refresh period (s)
@@ -53,24 +53,6 @@ const nationOf = (u) => u?.def?.raw?.nationId ?? null;
const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId ?? u?.def?.raw?.charId);
const enemyHasTag = (e, tag) => !!e && e.side === 'enemy' && Array.isArray(e.def?.tags) && e.def.tags.includes(tag);
const keySet = (unit) => unit.rangeKeySet || new Set(unit.rangeKeys || []);
/**
* The tokens.json variant a summon of `owner` gets — its chess (else the normal `_a` sibling, else the first variant)
* with the owner's selected skill / module merged, as simdata getToken does — for fields normalizeToken drops
* (stats.deployLimit). Exact when two players of one field give the same chess different loadouts (DESIGN §16).
*/
function ownTokenVariant(battle, tokenId, owner) {
const vs = battle.tokenDef(tokenId, owner)?.raw?.variants;
if (!vs || typeof vs !== 'object') return null;
const oid = String(owner?.defId ?? '');
let v = vs[oid] ?? vs[oid.replace(/_b$/, '_a')] ?? Object.values(vs)[0] ?? null;
const lo = owner?.def?.loadout;
if (v && lo && !lo.isDefault) {
if (!lo.skillIsDefault && v.bySkill?.[lo.skillIndex]) v = { ...v, ...v.bySkill[lo.skillIndex] };
if (!lo.moduleIsDefault && v.byModule?.[lo.moduleId]) v = { ...v, ...v.byModule[lo.moduleId] };
}
return v;
}
/** Every living, visible enemy standing on the unit's range (ignores stealth/untargetable: auras, reveals). */
function enemiesOnRange(battle, unit, keys = null) {
const set = keys || keySet(unit);
@@ -102,7 +84,7 @@ function toggleBuff(battle, unit, key, on, mods) {
/** "技力光环(同类效果取最高)": shared key, highest value wins. */
function spAura(battle, source, value, filter) {
whileDeployed(battle, source, AURA, () => {
for (const a of battle.allies(source.ownerId)) {
for (const a of battle.alliesFor(source, source.ownerId)) {
if (!filter(a)) continue;
const cur = a.findBuff('aura:spRecovery');
if (cur && cur.source !== source && cur.source?.alive && (cur.data?.v ?? 0) > value) continue;
@@ -128,16 +110,12 @@ function inspire(battle, target, val, src, stat = 'atk') {
}
/** Reveal stealthed enemies on the given tiles. */
const reveal = (battle, enemies) => { for (const e of enemies) if (e.s.flags.stealth) pulse(battle, e, 'aura:reveal', null, { flags: { reveal: true } }); };
/** Knock-back / pull (0.5 + 0.5·force tiles, like generic.js). `toward` = {x,y} point for pulls. */
function shove(battle, e, from, force, { toward = null, maxDist = Infinity } = {}) {
if (!e.alive) return 0;
const f = Math.max(0, num(force, 0));
const dx = toward ? toward.x - e.x : e.x - from.x, dy = toward ? toward.y - e.y : e.y - from.y;
const len = Math.hypot(dx, dy);
if (!(len > 1e-6)) return 0;
const dist = Math.min(0.5 + 0.5 * f, maxDist);
if (!(dist > 0)) return 0;
return battle.displace(e, { x: dx, y: dy }, dist, { force: f + 1 });
/**
* Knock-back of 力度 `force` away from `from` (a radial push, Battle.push: the official 力度 − 重量 distance — PRTS 推与拉;
* user playtest #6 item 14). Returns the tiles moved.
*/
function shove(battle, e, from, force) {
return e && e.alive ? battle.push(e, num(force, 0), { from }) : 0;
}
/** Free tile for a summon/device: in the rect, standable, empty and not the home tile of any ally (dead ones redeploy there). */
function freeTile(battle, r, c, { ranged = false, ground = false } = {}) {
@@ -203,12 +181,12 @@ function moduleRangeGrid(def) {
return null;
}
const applyModuleRange = (battle, unit, def) => { const g = moduleRangeGrid(def); if (g) { unit.rangeGrid = g; battle.refreshRange(unit); } };
/** Pull `e` towards the unit, to the tile in front of it ("拖拽至面前"); returns the distance moved. */
/**
* Pull `e` "至面前" of the unit with 力度 `force` (Battle.pullToFront: the official 拉力起点 half a tile ahead, 急停 0.6708
* around it, 力度 − 重量 — weight ≤ force all the way, one heavier a third of the way, …); returns the distance moved.
*/
function pullToFront(battle, unit, e, force) {
if (!e || !e.alive) return 0;
const d = Math.hypot(unit.x - e.x, unit.y - e.y);
if (!(d > 1 + 1e-6)) return 0;
return shove(battle, e, unit, force, { toward: { x: unit.x, y: unit.y }, maxDist: d - 1 });
return e && e.alive ? battle.pullToFront(e, unit, num(force, 0)) : 0;
}
// ---------------------------------------------------------------------------------------------------------------
@@ -216,7 +194,7 @@ function pullToFront(battle, unit, e, force) {
const kits = {
// ===== 信仰搅拌机 (shotprotector) S3 退休前布道 — ammo 30 counters; talents 扫射迎宾仪礼 / 架盾送客仪礼; module: reveal
// S1 铳骑主考官 (TAKE_DAMAGE: next attack ×3 hits + reload an adjacent 拉特兰 ammo skill), S2 八臂电锯侠 (ammo;
// S1 铳骑主考官 (自动触发 ⇒ DEFAULT: next attack ×3 hits + reload an adjacent 拉特兰 ammo skill), S2 八臂电锯侠 (ammo;
// a fatal hit is blocked for `ammo_cost` bullets); module SPT-Y 老朋友: range +1
chess_char_4_01_a: (bb, chess, def) => {
const t0 = tbb(def, 0), t1 = tbb(def, 1);
@@ -655,8 +633,7 @@ const kits = {
},
// ===== 寒芒克洛丝 (fastshot) S2 封喉 — double shot → 4 shots after 40 hits; talent 中的 (20 % ×1.5 + 0.2 s stun)
// S1 无痕 (ATK up, double shot, 迷彩 = the engine's ally `stealth`: ranged enemies cannot target her unless she
// blocks them)
// S1 无痕 (ATK up, double shot, 迷彩 = the engine's `camou` flag: only an enemy she blocks targets her)
chess_char_4_06_a: (bb, chess, def) => {
const t0 = tbb(def, 0);
const need = num(bb['attack@max_stack_count'], 40);
@@ -666,7 +643,7 @@ const kits = {
skchr_kroos2_1: () => ({
kind: 'duration',
mods: { atkPct: num(bb.atk) },
flags: { stealth: true },
flags: { camou: true },
attack: { hits: 2 },
onStart({ battle, unit }) { battle.fx('camouflage', { x: unit.x, y: unit.y, id: unit.id }); },
}),
@@ -902,108 +879,35 @@ const kits = {
},
// ===== 凯瑟琳 (craftsman) S2 战火淬炼 — stops attacking, HP/DEF up, devices give 6 %/s shields; talent 定向支援信号
// [ASSUMED] the devices are not board pieces in this mode (data `placeable: false`): at battle start (and when she
// redeploys, while stock lasts) she auto-places up to deployLimit of them on a free tile orthogonally next to her best
// blockers, each pointing at that operator's tile.
// "携带3个支援装置(最多部署2个)": the 爬行号·防护单元 are hand pieces (2 = the deploy limit) the player places and
// turns towards an operator (user playtest #6, PRTS 卫戍协议/帮助 §战斗部署); they deploy with the board and the
// token kit (tokens.js catShield) gives the shields — S2 overwrite_ratio per second while this skill runs — so
// this kit places nothing (none placed ⇒ no device).
// S1 岁月锻打 (passive: she and every other operator holding a device shield ATK/DEF +8 %/+11 %); module CRA-X
// carries one more device (talent cnt, data)
chess_char_4_11_a: (bb, chess, def) => {
const t0 = tbb(def, 0);
const tokenId = (def.talents || []).find((t) => t.tokenKey)?.tokenKey ?? (def.tokens || [])[0] ?? 'token_10041_cathy_catsld';
const carried = Math.max(0, Math.floor(num(t0.cnt, 3)));
const overwrite = num(bb.overwrite_ratio, 0.06);
const S2 = isSel(def, 'skchr_cathy_2');
return {
skills: alt(def, {
skchr_cathy_1: () => ({
kind: 'passive',
mods: { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) },
onStart({ battle, unit }) {
unit.mem.forgeAura?.cancel();
unit.mem.forgeAura = whileDeployed(battle, unit, AURA, () => {
for (const a of battle.allies(unit.ownerId)) {
if (a === unit || a.kind !== 'op' || !a.findBuff('cathy:shield')) continue;
pulse(battle, a, `cathy:forge:${unit.id}`, { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) });
}
});
},
}),
// carries one more device (talent cnt, data — a spare the hand never shows)
chess_char_4_11_a: (bb, chess, def) => ({
skills: alt(def, {
skchr_cathy_1: () => ({
kind: 'passive',
mods: { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) },
onStart({ battle, unit }) {
unit.mem.forgeAura?.cancel();
unit.mem.forgeAura = whileDeployed(battle, unit, AURA, () => {
for (const a of battle.allies(unit.ownerId)) {
if (a === unit || a.kind !== 'op' || !a.findBuff(CAT_SHIELD_KEY)) continue;
pulse(battle, a, `cathy:forge:${unit.id}`, { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) });
}
});
},
}),
skill: {
kind: 'duration',
mods: { hpPct: num(bb.max_hp), defPct: num(bb.def) },
attack: { noAttack: true },
onStart({ battle, unit }) { battle.fx('overclock', { x: unit.x, y: unit.y, id: unit.id }); },
},
install(battle, unit) {
const tok = battle.tokenDef(tokenId, unit); // the owner's skill / module variant (DESIGN §16)
const tb = tok?.talents?.[0]?.bb || {};
const cap = num(tb.max_shield_ratio, 0.2), each = num(tb.shield_ratio_each_trigger, 0.06);
const idle = num(tb.interval, 5), period = Math.max(0.1, num(tb['catsld_t_1[timer][interval].interval'], num(bb.interval, 1)));
const limit = Math.max(1, Math.floor(num(ownTokenVariant(battle, tokenId, unit)?.stats?.deployLimit, num(tok?.raw?.deployLimit, 2))));
unit.mem.devicesLeft = carried;
unit.mem.devices = [];
// a device points at ONE tile (AK: range [0,0],[0,1] turned towards the chosen operator) and shields whichever
// of her operators stands there — the same operator again after it redeploys on its tile ("装置效果不叠加":
// one device per operator)
const covered = (a) => unit.mem.devices.some((d) => d.alive && d.mem.tr === a.tileR && d.mem.tc === a.tileC);
const giveShield = (target, amount, capAmt) => {
const cur = target.findBuff('cathy:shield');
const v = Math.min(capAmt, (cur ? cur.shield : 0) + amount);
if (!cur || v > cur.shield + 1e-9) battle.addBuff(target, { key: 'cathy:shield', shield: v, visible: true, source: unit });
};
const deviceKit = (tr, tc) => ({
skill: null, talents: [], trait: { noAttack: true },
install(b, dev) {
dev.mem.tr = tr; dev.mem.tc = tc;
dev.mem.target = null;
// token trait "不会受到攻击": untargetable (spawn option) AND immune — enemy AoE abilities that sweep every ally
// in a radius would otherwise destroy the 100-HP device and silently end its shields
b.addBuff(dev, { key: 'cathy:device', flags: { invulnerable: true }, persist: true, allowDead: true });
b.every(period, () => {
if (!dev.alive) return;
const o = b.unitAt(tr, tc);
const t = o && o.kind === 'op' && o.ownerId === dev.ownerId && o.deployed ? o : null;
if (t !== dev.mem.target) { // newly connected operator: full talent shield at once
dev.mem.target = t;
if (t) { giveShield(t, unit.s.maxHp * cap, unit.s.maxHp * cap); b.fx('shield', { x: t.x, y: t.y, id: t.id }); }
return;
}
if (!t) return;
const capAmt = unit.s.maxHp * cap;
if (S2 && unit.alive && skillActive(unit)) giveShield(t, unit.s.maxHp * overwrite, capAmt);
else if (b.time - t.lastHitAt >= idle - 1e-9) giveShield(t, unit.s.maxHp * each, capAmt);
}, { owner: dev, immediate: true });
},
});
const place = () => {
if (!unit.alive || !unit.deployed) return;
unit.mem.devices = unit.mem.devices.filter((d) => d.alive);
// front-most on the player's own board first (a board position; the mirrored Final Assault side counts from
// the field's right)
const boardCol = (x) => (x.player && x.player.mirror ? -x.tileC : x.tileC);
const cands = battle.allies(unit.ownerId).filter((a) => a.kind === 'op' && !covered(a))
.sort((a, b) => (b.s.blockCnt > 0) - (a.s.blockCnt > 0) || b.s.taunt - a.s.taunt || boardCol(b) - boardCol(a) || a.id - b.id);
for (const a of cands) {
if (unit.mem.devices.length >= limit || unit.mem.devicesLeft <= 0) break;
const r = a.tileR, c = a.tileC;
// orthogonal neighbours only (a diagonal device could never have the operator in its range): behind the
// operator first (along its direction), then its two sides, then in front; each device faces the operator
const tiles = [frontOf(r, c, a.dir, -1), offsetTile(r, c, -1, 0, a.dir), offsetTile(r, c, 1, 0, a.dir), frontOf(r, c, a.dir, 1)];
const t = tiles.find(([rr, cc]) => freeTile(battle, rr, cc, { ranged: true }));
if (!t) continue;
const dev = battle.spawnToken(unit, tokenId, t[0], t[1], { kit: deviceKit(r, c), untargetable: true, dir: dirFromDelta(r - t[0], c - t[1]) });
if (!dev) continue;
unit.mem.devicesLeft--;
unit.mem.devices.push(dev);
battle.fx('device', { x: dev.x, y: dev.y, id: dev.id, target: a.id });
}
};
battle.on('battleStart', place, { owner: unit });
battle.on('deploy', (c) => { if (c.unit === unit && !c.initial) place(); }, { owner: unit });
},
};
},
}),
skill: {
kind: 'duration',
mods: { hpPct: num(bb.max_hp), defPct: num(bb.def) },
attack: { noAttack: true },
onStart({ battle, unit }) { battle.fx('overclock', { x: unit.x, y: unit.y, id: unit.id }); },
},
}),
// ===== 歌蕾蒂娅 (hookmaster) S3 缺水的碎漩狂舞 — bind a far target, tornado: slow, 85 % arts pulses + pull, final pull
// S1 缺水的大洋裂断 (charges: next attack pulls the target to her front, 150 %/180 %); S2 缺水的掌握怒海 (BAT +0.5 s,
@@ -1060,8 +964,8 @@ const kits = {
for (const e of inside) {
if (!e.alive || e.s.flags.untargetable) continue;
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 0.85), type: 'arts', isSkill: true, tags: ['skill', 'tornado'] });
const d = Math.hypot(T.x - e.x, T.y - e.y);
if (e.alive && d > 0.05) dragDmg(battle, unit, e, shove(battle, e, unit, force, { toward: T, maxDist: d }));
// "小力地拖拽至中心": a pull to the marked point (PRTS 推与拉: its 6 tornado pulls stop 0.05 from it)
if (e.alive) dragDmg(battle, unit, e, battle.pull(e, force, { to: T, stop: 0.05 }));
}
battle.fx('tornadoPulse', { x: T.x, y: T.y, id: unit.id, r: R });
},
@@ -1141,6 +1045,7 @@ const kits = {
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit || !skillActive(unit)) return;
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile);
for (const e of battle.blockedTargets(unit, c.profile)) if (!cands.includes(e)) cands.push(e); // DESIGN §20.3
if (cands.length <= 1) return;
sortEnemyTargets(battle, unit, cands, c.profile?.priority ?? null);
const n = Math.max(1, Math.floor((c.profile?.maxTargets || 1) + unit.s.maxTargets));
@@ -1506,8 +1411,10 @@ const kits = {
const g = grid(def.skill?.rangeGrid);
return {
skills: alt(def, {
// (自动触发: an AUTO skill takes no 技能策略 — an AUTO DP skill fires as soon as it is ready, like 伺夜 S1)
skchr_flamtl_1: () => ({
kind: 'instant',
trigger: 'SP_FULL',
onStart({ battle, unit }) {
battle.addDp(unit.ownerId, num(bb.cost, 6));
battle.addBuff(unit, { key: 'flamtl:evade', visible: true });
@@ -1526,7 +1433,7 @@ const kits = {
if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun, 0.5), source: unit });
}
for (const a of battle.unitsInGrid(unit, area, { side: 'ally' })) {
if (a.kind === 'device') continue;
if (a.kind === 'device' || !battle.allySelectable(a, unit)) continue;
battle.addBuff(a, { key: 'flamtl:redPine', duration: num(bb['flamtl_s_2.duration'], 10), mods: { dodgePhys: num(bb['flamtl_s_2.prob'], 0.4) }, visible: true, source: unit });
}
battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id });
@@ -1651,7 +1558,9 @@ const kits = {
const own = new Set(absoluteRangeKeys(unit.rangeGrid, unit.tileR, unit.tileC, unit.dir, unit.s.rangeExtend));
const ok = (e) => bodyInKeys(e, own) || !!(e.blockedBy && e.blockedBy.side === 'ally' && e.blockedBy.alive);
if (c.targets.every(ok)) return;
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile).filter(ok);
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile);
for (const e of battle.blockedTargets(unit, c.profile)) if (!cands.includes(e)) cands.push(e); // DESIGN §20.3
for (let i = cands.length - 1; i >= 0; i--) if (!ok(cands[i])) cands.splice(i, 1);
sortEnemyTargets(battle, unit, cands, c.profile?.priority ?? null);
c.targets = cands.slice(0, Math.max(1, c.targets.length));
}, { owner: unit, priority: 5 });
@@ -1935,6 +1844,7 @@ const kits = {
skills: alt(def, {
skchr_cetsyr_1: () => ({
kind: 'toggle',
trigger: 'SP_FULL', // 自动触发 (no 技能策略: the 吟游者 row is for MANUAL skills): on as soon as it is ready
onStart({ battle, unit }) { setTrait(unit, bb['attack@atk_to_hp_recovery_ratio']); battle.fx('mote', { x: unit.x, y: unit.y, id: unit.id }); },
onEnd({ unit }) { resetTrait(unit); },
}),
+40 -55
View File
@@ -19,10 +19,13 @@
// - Non-stacking auras refresh a short buff with a fixed key every 0.25 s while the source is on the field.
// SP auras ("同类效果取最高") share the buff key `aura:spRecovery` (mods.spRecoveryFlat): the highest value wins.
// - Skills whose auto-cast needs a condition the engine rules can't express use the NEVER trigger (CUSTOM_RANGE with an
// empty grid) plus their own activation — 塞雷娅 (`autoCast`: an injured ally in the heal area of the skill; her
// initial range is her own tile), 华法琳 (heal target below half HP, checked right before the heal), 号角 S1 / 塞雷娅 S1
// (the data TAKE_DAMAGE rule, `hurtCast`: a hit arms one charge only while none is waiting for its attack). Every other kit
// keeps the data rule (DEFAULT = about to attack + an enemy in the INITIAL range, research 03 Addendum C1).
// empty grid) plus their own activation — 塞雷娅 S2 (`autoCast`: an injured ally in the heal area of the skill; her
// initial range is her own tile), 华法琳 (heal target below half HP, checked right before the heal). 塞雷娅 S1 (自动触发)
// uses the engine's DEFAULT rule with `allies` / `hpAtMost` (about to attack + an ally of the skill area at ≤ half HP:
// the heal replaces that attack). Every other
// kit keeps the data rule (tools/build-data.mjs resolveTrigger, the official 技能策略: DEFAULT = about to attack + an
// enemy in the INITIAL range; SKILL_RANGE for a MANUAL skill's own 技能范围; the class rows — 重装 TAKE_DAMAGE … — for
// every MANUAL skill; AUTO skills never take a class row).
// - fx kinds emitted (battle.fx(kind, {x, y, id, …})): 'aoe' {r, skill}, 'healAoe' {r}, 'summon' {token}, 'anchor'
// {fromX, fromY, r}, 'teleport', 'zone' {r, duration}, 'iceSpike' {r}, 'extraAttack', 'downed', 'revive' {r},
// 'overload', 'ember', 'bloodBattle', 'reborn', 'candle', 'wake' {scale}, 'mote', 'hpShare', 'knockout', 'crit',
@@ -35,7 +38,7 @@ import { frontOf, rotateOffset, toLocal } from '../../dir.js';
import { mitigate, hasHp } from '../../damage.js';
// ---- text-only constants (the official blackboards carry no key for these) --------------------------------------
/** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 山 module "生命值高于50%时". */
/** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 塞雷娅 S1 "血量小于等于一半"; 山 module "生命值高于50%时". */
const HALF_HP = 0.5;
/** 夕 S1 "下一次攻击溅射范围扩大" — expanded splash radius (tiles; the splash-caster default is 1.1). PRTS: "溅射半径扩大至1.7". */
const DUSK_SPLASH_RADIUS = 1.7;
@@ -118,8 +121,6 @@ const skillRange = (chess, def, extra = null) => {
const g = skillGrid(chess, def);
return g || extra ? { ...(g ? { rangeGrid: g } : {}), ...(extra || {}) } : undefined;
};
/** Push distance of an AK force level (same mapping as content/generic.js: 中等力度 = 1 → 1 tile). */
const pushTiles = (force) => 0.5 + 0.5 * force;
/** "攻击范围内存在N名及以上敌人时攻击速度+X" (modules REA-Y). */
function crowdAspd(battle, unit, key, aspd, cnt) {
if (!aspd || !(cnt > 0)) return;
@@ -179,21 +180,6 @@ function autoCast(battle, unit, cond) {
}, { owner: unit });
}
/**
* TAKE_DAMAGE auto-cast of a "下次攻击" skill with charges (号角 S1, 塞雷娅 S1 — spec trigger NEVER): a hit taken arms
* the skill (the engine rule's conditions: not an element-gauge hit, not `noSp`, able to act, not silenced) only while no
* armed charge is waiting for its attack. The engine rule re-activates a pending instant skill on every hit, so two hits
* before her next attack would spend both charges of the elite on that one attack.
*/
function hurtCast(battle, unit) {
battle.on('damaged', (c) => {
if (c.target !== unit || !c.dmg || c.dmg.noSp || c.dmg.type === 'element' || !on(unit)) return;
const sk = unit.skill;
if (!sk || sk.noSkill || sk.pending || !sk.ready || (sk.active && sk.isTimed) || !unit.canAct || unit.s.flags.silence) return;
sk.activate('TAKE_DAMAGE');
}, { owner: unit });
}
/** Periodic check while the unit is on the field. */
function whileOn(battle, unit, sec, fn) {
battle.every(sec, () => { if (on(unit)) fn(); }, { owner: unit });
@@ -203,7 +189,7 @@ function whileOn(battle, unit, sec, fn) {
function spAura(battle, unit, value, filter) {
if (!(value > 0)) return;
whileOn(battle, unit, AURA_IV, () => {
for (const a of battle.allies()) {
for (const a of battle.alliesFor(unit)) {
if (!filter(a)) continue;
const cur = a.findBuff('aura:spRecovery');
if (cur && num(cur.mods?.spRecoveryFlat) > value + 1e-9) continue;
@@ -788,12 +774,12 @@ const KITS = {
const near = battle.enemiesInRadius(main.x, main.y, RING1).filter((e) => !e.isFlying && !e.s.flags.untargetable && !e.s.flags.sleep)
.sort((a, b) => (a === main ? -1 : b === main ? 1 : 0) || dist(a, main) - dist(b, main) || a.spawnSeq - b.spawnSeq)
.slice(0, Math.max(1, num(bb.max_target, 2)));
const fr = unit.tileR + unit.fwd[0], fc = unit.tileC + unit.fwd[1];
const force = num(bb.force, 1);
battle.fx('anchor', { x: main.x, y: main.y, id: unit.id, fromX: unit.x, fromY: unit.y, r: RING1 });
for (const e of near) {
const dx = fc - e.x, dy = fr - e.y, d = Math.hypot(dx, dy);
if (d > 0.1) battle.displace(e, { x: dx, y: dy }, d, { force: force + 1 });
// "中等力度地拖拽至面前": the 捕网's pull uses the 拖拽 rules (PRTS 推与拉 §捕网 "力的大小:同拖拽") —
// Battle.pullToFront, the official 力度 − 重量 pull
battle.pullToFront(e, unit, force);
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'anchorPull'] });
}
},
@@ -893,7 +879,7 @@ const KITS = {
skchr_etlchi_2: () => ({
kind: 'duration', attack: { noAttack: true },
onStart({ battle, unit }) {
const other = battle.allies().filter((a) => a !== unit && a.ground && a.hp > 0)
const other = battle.alliesFor(unit).filter((a) => a !== unit && a.ground && a.hp > 0)
.sort((a, b) => aroundN(battle, b) - aroundN(battle, a) || dist(a, unit) - dist(b, unit) || a.id - b.id)[0] ?? null;
unit.mem.sickles = other ? [unit, other] : [unit];
unit.mem.sickleAcc = 0;
@@ -1136,7 +1122,7 @@ const KITS = {
// max HP/s at the end. T1 军事要塞: all Defenders ATK +20 % while she is on the field.
// T2 血战: once per deployment, lethal damage → full heal, max HP −50 %, ASPD +18, DEF +18 %.
// Module (elite): ×1.1 vs blocked enemies.
// S1 照明榴弹 (instant / 2 charges elite, TAKE_DAMAGE): next attack atk_scale × ATK; a ranged (unblocked) one also
// S1 照明榴弹 (instant / 2 charges elite, 自动触发 ⇒ DEFAULT): next attack atk_scale × ATK; a ranged (unblocked) one also
// splashes projectile_range and lights the impact for projectile_delay_time s (enemies within projectile_range lose
// 隐匿). S2 暴风号令 (ammo 10): every attack attack@s2.atk_scale × ATK phys splash; 过载 for the second half of the
// ammo [ASSUMED like S3]: + attack@s2.magic_atk_scale × ATK arts to every enemy hit (manual close never happens in the
@@ -1155,7 +1141,7 @@ const KITS = {
return {
skills: lazySkills({
skchr_horn_1: () => ({
kind: instantKind(chess, def), trigger: NEVER, // TAKE_DAMAGE (data) via hurtCast: one armed charge at a time
kind: instantKind(chess, def), // 自动触发 "下次攻击": the data rule DEFAULT (an AUTO skill takes no 技能策略)
attack: {
atkScale: num(bb.atk_scale, 1),
onHit({ battle, unit, x, y }) {
@@ -1221,7 +1207,6 @@ const KITS = {
const aspd = num(tm.attack_speed); // FOR-Y: 不阻挡敌人时…攻击速度+10
if (aspd) whileOn(battle, unit, 0.2, () => { if (!unit.blocking.length) battle.addBuff(unit, { key: 'horn:moduleY', duration: 0.3, mods: { aspd } }); });
if (sid === 'skchr_horn_1') {
hurtCast(battle, unit);
// the pending shot: a ranged (unblocked) attack widens its splash and lights the impact
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit || !unit.skill?.pending || !c.profile) return;
@@ -1436,8 +1421,9 @@ const KITS = {
// 塞雷娅 — S2 药物配置 (instant, time SP): heals every ally in the skill range for heal_scale × ATK (cast when one is
// injured). T1 莱茵充能护服: every 20 s on the field ATK +5 % / DEF +4 % (×5). T2 精神回复: +1 SP to every ally she heals.
// Module (elite): heals on allies below 50 % ×1.15.
// S1 急救 (instant / 2 charges elite, TAKE_DAMAGE): her next attack heals the ally below half HP with the lowest HP
// ratio in the skill area (周围) for heal_scale × ATK. S3 钙质化 (duration): allies in the skill area heal
// S1 急救 (instant / 2 charges elite, 自动触发: cast at her attack when an ally of the skill area 周围 is at ≤ half HP):
// that attack is instead a heal of the lowest such ally for heal_scale × ATK.
// S3 钙质化 (duration; the 重装 strategy TAKE_DAMAGE): allies in the skill area heal
// attack@heal_scale × ATK per second; enemies there take arts ×demkni_s_3.damage_scale and move −60 %.
// Module GUA-Y (elite): damage taken −15 %.
chess_char_5_11_a: (bb, chess, def) => {
@@ -1454,16 +1440,15 @@ const KITS = {
return {
skills: lazySkills({
skchr_demkni_1: () => ({
kind: instantKind(chess, def), trigger: NEVER, // TAKE_DAMAGE (data) via hurtCast: one armed charge at a time
attack: {
onHit({ battle, unit }) {
const t = battle.unitsInGrid(unit, grid, { side: 'ally' }).filter((a) => healable(a, unit) && a.hpRatio < HALF_HP)
.sort((a, b) => a.hpRatio - b.hpRatio || a.id - b.id)[0];
if (!t) return;
battle.heal(unit, t, unit.s.atk * num(bb.heal_scale, 1));
battle.fx('healAoe', { x: t.x, y: t.y, id: t.id, r: 0.5 });
},
},
// 自动触发: its own rule, no 技能策略 — PRTS 备注 "此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次
// 攻击" and the corrected text "血量小于等于一半": checked when she is about to attack (an enemy target, the basic
// rule), an ally of the skill area at ≤ half HP casts it and that attack becomes the heal (unlike 古米's 备用军粮,
// no heal mode is left waiting: the engine withdraws a cast whose ally was healed before the attack)
kind: instantKind(chess, def),
trigger: { rule: 'DEFAULT', grid, allies: true, hpAtMost: HALF_HP },
targeting: { rangeGrid: grid },
attack: { dmgType: 'heal', heal: { mode: 'single', hpAtMost: HALF_HP }, healScale: num(bb.heal_scale, 1), projectile: 'none' },
onHit({ battle, target }) { if (target) battle.fx('healAoe', { x: target.x, y: target.y, id: target.id, r: 0.5 }); },
}),
skchr_demkni_3: () => ({
kind: 'duration',
@@ -1512,7 +1497,6 @@ const KITS = {
install(battle, unit) {
// S2 only: the NEVER-trigger skill casts itself when an ally in its area is injured
if (!sid || sid === 'skchr_demkni_2') autoCast(battle, unit, () => zone(battle, unit).some((a) => a.hp < a.s.maxHp - 1e-6));
if (sid === 'skchr_demkni_1') hurtCast(battle, unit);
lowHpHealUp(battle, unit, tb);
if (num(tb.damage_resistance) > 0) permBuff(battle, unit, 'saria:moduleY', { dmgTakenMul: 1 - num(tb.damage_resistance) });
},
@@ -1761,8 +1745,10 @@ const KITS = {
};
return {
skills: lazySkills({
// (自动触发: an AUTO skill takes no 技能策略 — an AUTO DP skill fires as soon as it is ready, like 伺夜 S1)
skchr_svash2_1: () => ({
kind: instantKind(chess, def),
trigger: 'SP_FULL',
onStart({ battle, unit }) {
battle.addDp(unit.ownerId, num(bb.cost));
const { pick } = waitingArea(battle, unit);
@@ -1916,7 +1902,7 @@ const KITS = {
const tickSpecial = (battle, unit, z, seaHits) => {
if (z.type === 'guard') {
const inZone = (a) => Math.abs(a.tileR - z.r) <= 1 && Math.abs(a.tileC - z.c) <= 1;
const allies = battle.allies().filter(inZone);
const allies = battle.alliesFor(unit).filter(inZone);
for (const a of allies) battle.addBuff(a, { key: 'thorn2:bastion', duration: AURA_DUR, mods: mods({ defFlat: num(bb.def) }) });
if (z.acc >= 1 - 1e-9) {
z.acc -= 1;
@@ -1997,7 +1983,7 @@ const KITS = {
const cnt = num(t1.cnt, 6);
const ally = num(t1.attack_speed_ally), allyX = num(t1.attack_speed_ally_extra), foe = num(t1.attack_speed_enemy), foeX = num(t1.attack_speed_enemy_extra);
whileOn(battle, unit, AURA_IV, () => {
for (const a of battle.allies()) {
for (const a of battle.alliesFor(unit)) {
const v = ally + (straightRun(battle, a.tileR, a.tileC) >= cnt ? allyX : 0);
if (v) battle.addBuff(a, { key: 'thorn2:vision', duration: AURA_DUR, mods: { aspd: v } });
}
@@ -2097,10 +2083,9 @@ const KITS = {
hits: 2,
onEachHit({ battle, unit, target, kind }) {
if (kind !== 'main' || !target || !target.alive || target.side !== 'enemy') return;
const force = num(bb['attack@force'], 1);
const dx = target.x - unit.x, dy = target.y - unit.y;
const dir = Math.hypot(dx, dy) > 0.05 ? { x: dx, y: dy } : { x: unit.fwd[1], y: unit.fwd[0] };
battle.displace(target, dir, pushTiles(force), { force: force + 1 });
// "中等力度地推动" (PRTS 备注: on the 2nd hit of each attack — onEachHit runs after both): a radial push by
// the official 力度 − 重量 distance (Battle.push)
battle.push(target, num(bb['attack@force'], 1), { from: unit });
},
},
}),
@@ -2155,9 +2140,7 @@ const KITS = {
attack: {
onHit({ battle, unit, target }) {
if (!target || !target.alive || target.side !== 'enemy' || target.blockedBy) return;
const fx0 = unit.tileC + unit.fwd[1], fy0 = unit.tileR + unit.fwd[0];
const dx = fx0 - target.x, dy = fy0 - target.y, d = Math.hypot(dx, dy);
if (d > 0.1) battle.displace(target, { x: dx, y: dy }, d, { force: force + 1 });
battle.pullToFront(target, unit, force); // the official 力度 − 重量 pull (Battle.pullToFront)
},
},
},
@@ -2311,12 +2294,14 @@ const KITS = {
talents: [
{ install(battle, unit) { // 加速力场
const aspd = num(t0.attack_speed);
if (aspd) whileOn(battle, unit, AURA_IV, () => { for (const a of battle.allies()) battle.addBuff(a, { key: 'aglina:field', duration: AURA_DUR, mods: { aspd } }); });
if (aspd) whileOn(battle, unit, AURA_IV, () => { for (const a of battle.alliesFor(unit)) battle.addBuff(a, { key: 'aglina:field', duration: AURA_DUR, mods: { aspd } }); });
} },
{ install(battle, unit) { // 兼职工作
const hp = num(t1.hp_recovery_per_sec);
// PRTS 备注: an HP-regeneration attribute ("生命回复速度"), not given to allies under 禁疗 (the noHeal status)
if (hp > 0) whileOn(battle, unit, AURA_IV, () => { if (!unit.skill?.active) for (const a of battle.allies()) if (!a.s.flags.noHeal) battle.addBuff(a, { key: 'aglina:parttime', duration: AURA_DUR, mods: { hpRegen: hp } }); });
// an HP-regeneration attribute, so 禁疗 does not stop it — PRTS 备注 "生命回复的提供方式为增加目标的'生命回复速度'属性,
// 不受治疗加成和禁疗影响" (异常效果 禁疗: "增减生命回复速度…的效果不会被识别为治疗类能力"); a 孤立 unit (炎佑) is not
// selected (Battle.alliesFor)
if (hp > 0) whileOn(battle, unit, AURA_IV, () => { if (!unit.skill?.active) for (const a of battle.alliesFor(unit)) battle.addBuff(a, { key: 'aglina:parttime', duration: AURA_DUR, mods: { hpRegen: hp } }); });
} },
],
};
+60 -29
View File
@@ -43,7 +43,7 @@
// can be copied. "被击败后25秒后自动刷新": one pending respawn at a time, only while she is on the field
// and no 流形 of hers stands (her knock-out cancels it; her redeploy re-summons it as her 援军).
// 6_12 迷迭香 "溅射范围扩大" ×1.3 (not in data); 感知稳定 picks among the owner's deployed casters (none ⇒ no buff).
// 6_13 新约能天使 steal target = ally op in range with the highest ASPD; bombardment radius 1 tile (not in data).
// 6_13 新约能天使 bombardment radius 1 tile (not in data).
// 6_14 流明 S3 heals an abnormal ally even at full HP (forced heal); 抵抗 = the engine `resist` status.
// 6_15 仇白 入隙 reads the target's sluggish/bind statuses; module adds 10 % ATK arts per hit.
// 6_16 溯光星源 link transfers the pre-mitigation arts amount × share to the other locked target(s); 能源解析 = the
@@ -52,7 +52,8 @@
// 6_18 荒芜拉普兰德 S3 drones are virtual (fx events), move at projectile_move_speed tiles/s, she stops attacking
// while they roam; 头狼 stage 2 "特殊能力失效" = silence; stage 3 = +1 drone (normal attacks hit once
// more, S3 releases one more drone). Base drone count 1.
// 6_19 锏 10 slashes every d_hit_interval, pulls every p_hit_interval, final blow (skill range) at the end.
// 6_19 锏 10 slashes every d_hit_interval, pulls every p_hit_interval, final blow (skill range) at the end;
// S3 slashes and pulls air units too (PRTS 备注 "可对空").
// 6_20 纯烬艾雅法拉 5 shots are padded by cycling targets when fewer injured allies exist.
//
// Operator loadouts (DESIGN §16): every visible chess also authors its selectable NON-default skills in `skills`
@@ -98,9 +99,9 @@
// `skill.addTriggerRange`; element-type damage skips the `hit` hook, so element (损伤) amplification uses the
// `elementHit` hook (`onElementHit`: × dmg.mul before the gauge fill).
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../../targeting.js';
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy, aggroCmp } from '../../targeting.js';
import { aggregateMods } from '../../buffs.js';
import { COLS, ROWS } from '../../constants.js';
import { COLS, ROWS, PULL_STOP_RADIUS } from '../../constants.js';
import { rotateOffset } from '../../dir.js';
import { bodyDist, bodyInKeys, bodyInRadius, bodyKeys } from '../../body.js';
import { hasHp } from '../../damage.js';
@@ -311,13 +312,15 @@ function ensureDeployTracker(battle) {
return S.lastOp;
}
/** Pull `e` toward `unit` (0.5 + 0.5·force tiles, never past `minDist`). */
function pullToward(battle, unit, e, force, minDist = 0.3) {
if (!e || !e.alive) return;
const dx = unit.x - e.x, dy = unit.y - e.y, len = Math.hypot(dx, dy);
if (len <= minDist) return;
const dist = Math.min(len - minDist, 0.5 + 0.5 * Math.max(0, force));
if (dist > 0) battle.displace(e, { x: dx, y: dy }, dist, { force: force + 1 });
/**
* Pull `e` with 力度 `force` towards the point / unit `unit` ("向自己中心…拖拽", "拉向目标所在位置"), stopping `stop` tiles
* from it — Battle.pull: the official 力度 − 重量 pull (PRTS 推与拉; user playtest #6 item 14). The default stop is the
* 急停 radius 0.6708 around a pulling unit. A pulling ally is also the pull's `center`, so an enemy it blocks itself
* stays where it is held (as in Battle.pullToFront) — e.g. the 流形 S3 pulse then still stuns it.
*/
function pullToward(battle, unit, e, force, stop = PULL_STOP_RADIUS) {
if (!e || !e.alive || !unit) return 0;
return battle.pull(e, num(force), { to: { x: unit.x, y: unit.y }, center: unit.side === 'ally' ? unit : null, stop });
}
/** Stat-aura pulse: short buffs refreshed every `period` s on `targets()`. */
@@ -712,14 +715,14 @@ function sbell2(bb, chess, def) {
}
const skills = {
// S1 铃音吹雪 (SEARCH trigger, 2 charges): atk_scale × ATK arts + `cold` s of 寒冷 on every enemy in range, pushed
// (force 1 = 中等) along her direction; then one snow layer spreads forward over the ground (≤ trig_cnt tiles)
// (force 1 = 中力) along her direction — PRTS 备注 "固定方向推动(不会因角度过大或距离过近而变化方向与力度),且仅对地面
// 单位产生推力" (Battle.push fixed, official 力度 − 重量 distance); then one snow layer spreads forward over the ground
// (≤ trig_cnt tiles)
skchr_sbell2_1: {
kind: instantKind(def),
// data trigger SEARCH (the 阵法术师 profession row for S1): the engine reads it as "an enemy anywhere on the field",
// which dumped both charges on an enemy spawning at the gate, far outside her range (the burst hits "范围内所有
// 敌人"). SEARCH exists for units that never attack (DEFAULT fires on an attack; CUSTOM_RANGE_SEARCH_ENEMY is its
// custom-range twin) [ASSUMED]: an enemy inside her range, checked every tick — the engine's DEFAULT for a
// phalanx (noAttackUnlessSkill)
// data trigger SEARCH (the 阵法术师 row, every MANUAL skill of the class: "在初始攻击范围内存在敌人时释放技能") =
// an enemy inside her initial range, checked every tick — what the engine's DEFAULT does for a phalanx
// (noAttackUnlessSkill); an enemy anywhere on the field would dump both charges on an enemy spawning at the gate
trigger: 'DEFAULT',
onStart({ battle, unit }) {
const [fr, fc] = unit.fwd;
@@ -728,7 +731,7 @@ function sbell2(bb, chess, def) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] });
if (!e.alive) continue;
if (num(bb.cold) > 0) battle.applyStatus(e, 'cold', { duration: num(bb.cold), source: unit });
battle.displace(e, { x: fc, y: fr }, 0.5 + 0.5 * force, { force: force + 1 });
if (!e.isFlying) battle.push(e, force, { from: unit, dir: { x: fc, y: fr }, fixed: true });
}
const n = Math.max(0, Math.floor(num(bb.trig_cnt, 5)));
let laid = 0;
@@ -1210,9 +1213,10 @@ function pepe(bb, chess, def) {
}, { owner: unit });
}
if (sid === 'skchr_pepe_2') {
battle.on('beforeAttack', (ctx) => { // 随机攻击范围内的目标
battle.on('beforeAttack', (ctx) => { // 随机攻击范围内的目标 (and the enemies she blocks — Battle.blockedTargets)
if (ctx.attacker !== unit || !unit.skill?.active) return;
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
for (const e of battle.blockedTargets(unit, ctx.profile)) if (!c.includes(e)) c.push(e);
if (c.length) ctx.targets = [battle.rng.pick(c)];
}, { owner: unit });
}
@@ -1311,7 +1315,7 @@ function siege2(bb, chess, def) {
if (sid === 'skchr_siege2_2') {
const need = Math.max(1, Math.floor(num(bb.buff_stack_cnt, 2))), spr = num(bb.sp_recovery_per_sec);
if (spr > 0) aura(battle, unit, 0.25, () => {
const n = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a !== unit && a.kind !== 'device').length;
const n = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a !== unit && a.kind !== 'device' && battle.allySelectable(a, unit)).length;
if (n >= need) battle.addBuff(unit, { key: 'siege2:homeland', mods: { spRecoveryFlat: spr }, duration: 0.4, refresh: 'replace' });
});
}
@@ -1367,7 +1371,7 @@ function siege2(bb, chess, def) {
{ install(battle, unit) { // 诸王的叹息
const dr = num(t0.damage_resistance), atk = num(t0.atk);
aura(battle, unit, 0.25, () => {
const allies = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a.kind !== 'device');
const allies = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a.kind !== 'device' && battle.allySelectable(a, unit));
if (!allies.includes(unit)) allies.push(unit);
if (dr > 0) for (const a of allies) battle.addBuff(a, { key: 'siege2:sigh', mods: { physTakenMul: 1 - dr }, duration: 0.4, refresh: 'replace' });
const n = allies.filter((a) => a !== unit).length;
@@ -1875,7 +1879,7 @@ function mlyss(bb, chess, def) {
if (!unit.skill?.active) return;
for (const t of battle.allyUnits) {
if (!isTok(t, tokId, unit) || !live(t) || !t.mem.mlyss || t.mem.mlyss.ranged) continue;
for (const e of battle.unitsInGrid(t, AROUND8, { side: 'enemy' })) pullToward(battle, t, e, num(t.def?.skill?.bb?.force), 0.2);
for (const e of battle.unitsInGrid(t, AROUND8, { side: 'enemy' })) pullToward(battle, t, e, num(t.def?.skill?.bb?.force));
for (const e of t.blocking) if (e.alive) battle.applyStatus(e, 'stun', { duration: pulseIv + 0.1, source: unit });
battle.fx('pulse', { x: t.x, y: t.y, id: t.id });
}
@@ -1915,7 +1919,7 @@ function mlyss(bb, chess, def) {
if (unit.skill) unit.skill.addTriggerRange(() => battle.allyUnits.filter((t) => mine(t) && live(t) && t.mem.mlyss));
battle.on('tick', () => { // without a dedicated token kit the copy fires once ready and someone can be copied
for (const t of battle.allyUnits) {
if (!mine(t) || !live(t) || t.mem.mlyssClone || !t.kit?.generic || !t.skill || t.skill.active || !t.skill.ready) continue;
if (!mine(t) || !live(t) || t.mem.mlyssClone || !t.kit?.generic || !t.skill || t.skill.active || !t.skill.ready || t.skill.opCooling) continue;
if (pickCopyTarget(battle, unit, t)) t.skill.activate('MLYSS_WTRMAN');
}
}, { owner: unit });
@@ -2051,7 +2055,10 @@ function rosmon(bb, chess, def) {
if (sid !== 'skchr_rosmon_3') return;
battle.on('beforeAttack', (ctx) => { // "仅选择被阻挡的敌人作为目标"
if (ctx.attacker !== unit || !unit.skill?.active) return;
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile).filter((e) => !!e.blockedBy);
// her range, plus the enemies she blocks herself — always her targets (Battle.blockedTargets, DESIGN §20.3)
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
for (const e of battle.blockedTargets(unit, ctx.profile)) if (!c.includes(e)) c.push(e);
for (let i = c.length - 1; i >= 0; i--) if (!c[i].blockedBy) c.splice(i, 1);
sortEnemyTargets(battle, unit, c, ctx.profile?.priority ?? null);
ctx.targets = c.slice(0, Math.max(1, Math.floor(num(ctx.profile?.maxTargets, 2))));
}, { owner: unit });
@@ -2104,6 +2111,12 @@ function rosmon(bb, chess, def) {
// ------------------------------------------------------------------------------------------------------------------
// 新约能天使 chess_char_6_13 (怪杰) — S2 开火成瘾症; 火力电台; 铳弹协约; module 新朋友圣城生活套组
// S2 (PRTS 备注): the ally is the friendly operator of her attack range with the highest 仇恨值 ("选择的友方干员为攻击范围内
// 仇恨值最高的我方干员": highest taunt level, then the latest deployed — targeting.js aggroCmp, the order enemies attack
// in; it used to be the highest ASPD, so the shield often went to a back-row shooter instead of the operator in front);
// both barriers are shield_max_hp_ratio × the holder's OWN max HP ("获得的屏障均以自身生命上限为标准计算"), losing
// initial/shield_max_duration per second ("屏障每秒衰减量为:初始屏障量/30"); a new one replaces the old one ("重复获得此
// 屏障时,重置屏障量与衰减速度").
const AIRSTRIKE_RADIUS = 1; // [ASSUMED] bombardment splash radius (not in data)
@@ -2201,7 +2214,7 @@ function angel2(bb, chess, def) {
attack: { atkScale: num(bb['attack@atk_scale'], 1) },
onStart({ battle, unit, skill }) {
const cands = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op' && live(a));
cands.sort((a, b) => b.s.aspd - a.s.aspd || a.deploySeq - b.deploySeq);
cands.sort(aggroCmp);
const victim = steal > 0 ? cands[0] ?? null : null;
unit.mem.angelVictim = victim;
decayingShield(battle, unit, 'angel2:barrier', unit.s.maxHp * shieldRatio, shieldDur);
@@ -2278,7 +2291,7 @@ function lumen(bb, chess, def) {
if (!(sc > 0) || !(life > 0)) return;
battle.fx('healField', { x: target.x, y: target.y, id: unit.id });
for (const a of battle.alliesInRadius(target.x, target.y, 1.5, target.ownerId)) {
if (a.kind === 'device') continue;
if (a.kind === 'device' || a.s.flags.noHeal || a.profile?.noHeal) continue; // a heal: never on 禁疗 / 孤立
battle.addBuff(a, {
key: `lumen:rain:${unit.id}`, duration: life, interval: iv, refresh: 'replace', visible: true, source: unit,
onTick: ({ unit: x }) => { if (x.hp < x.s.maxHp) battle.heal(unit, x, unit.s.atk * sc, { hot: true }); },
@@ -2798,7 +2811,9 @@ function whitw2(bb, chess, def) {
battle.on('beforeAttack', (ctx) => {
const m = unit.mem.hunt;
if (ctx.attacker !== unit || !unit.skill?.active || !m) return;
// the skill range, plus the enemies she blocks (always her targets, Battle.blockedTargets — DESIGN §20.3)
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
for (const e of battle.blockedTargets(unit, ctx.profile)) if (!c.includes(e)) c.push(e);
if (!c.length) { ctx.targets = []; return; }
const n = 1 + Math.floor(num(bb['attack@cnt'])) + ((unit.mem.wolfStage || 0) >= 3 ? 1 : 0);
m.locks = m.locks.filter((e) => e.alive && c.includes(e)).slice(0, n);
@@ -2923,8 +2938,10 @@ function blkkgt(bb, chess, def) {
// the skill range (x-1): her range while S3 runs — also for the finisher, since onEnd runs before the engine
// removes the skill's range
const skillKeys = (unit) => unit.rangeKeys || [];
// S3 hits and pulls air units too — PRTS 锏 S3 备注 "※可对空。不会拖拽自身中心半径0.6708范围内的敌人" (her attacks and S1 /
// S2 stay ground-only: "地面敌人")
const victims = (battle, unit) => {
const c = battle.enemiesInKeys(skillKeys(unit), unit, unit.profile);
const c = battle.enemiesInKeys(skillKeys(unit), unit, { ...unit.profile, canHitFly: true, groundOnly: false });
sortEnemyTargets(battle, unit, c, null);
return c.slice(0, maxT);
};
@@ -2933,7 +2950,13 @@ function blkkgt(bb, chess, def) {
if (v.length) battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length });
for (const e of v) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'slash'] });
};
const pull = (battle, unit, force) => { for (const e of enemiesIn(battle, unit, skillKeys(unit))) if (!e.isFlying) pullToward(battle, unit, e, force); };
// "持续将敌人中等力度地拖拽至自身中心,之后…较大力地拖拽至自身": the official 力度 − 重量 pulls (PRTS 推与拉: the last one
// aims at her own tile centre, the others at the usual 拉力起点 half a tile ahead; 急停 0.6708 around her)
const pull = (battle, unit, force, last = false) => {
for (const e of enemiesIn(battle, unit, skillKeys(unit))) {
if (last) pullToward(battle, unit, e, force); else battle.pullToFront(e, unit, force);
}
};
const skills = {
// S1 纯粹的武力 (attack SP): the next attack hits up to max_target ground enemies of the 3×3 around her, each twice
// (her trait) at atk_scale_s1 × ATK
@@ -2966,6 +2989,10 @@ function blkkgt(bb, chess, def) {
skills,
install(battle, unit) {
if (penFlat > 0 || legendAtk) battle.addBuff(unit, { key: 'blkkgt:contract', mods: { defIgnoreFlat: penFlat, atkPct: legendAtk }, persist: true, allowDead: true });
// S3 slashes: 晕眩免疫, 冻结免疫 (PRTS 备注)
battle.on('beforeStatus', (c) => {
if (c.target === unit && unit.mem.dgb && (c.status === 'stun' || c.status === 'freeze')) c.cancel = true;
}, { owner: unit });
if (legendTremble > 0) {
const seen = new WeakSet();
battle.on('tick', () => {
@@ -2983,8 +3010,11 @@ function blkkgt(bb, chess, def) {
duration: slashes * iv,
...(skillGrid ? { targeting: { rangeGrid: skillGrid } } : {}),
attack: { noAttack: true },
// PRTS 备注 "※多段斩击期间,自身获得无敌,晕眩免疫,冻结免疫": invulnerable while the slashes run (removed in onEnd,
// before the finisher); stun / freeze refused by her `beforeStatus` hook (install) while `mem.dgb` is set
onStart({ battle, unit }) {
unit.mem.dgb = { n: 1, acc: 0, pacc: 0 };
battle.addBuff(unit, { key: 'blkkgt:slashes', duration: slashes * iv + 1, flags: { invulnerable: true }, visible: true, source: unit });
slash(battle, unit, num(bb.d_atk_scale, 1));
},
onTick({ battle, unit, dt }) {
@@ -2997,11 +3027,12 @@ function blkkgt(bb, chess, def) {
},
onEnd({ battle, unit, reason }) {
unit.mem.dgb = null;
battle.removeBuff(unit, 'blkkgt:slashes');
if (reason === 'death' || !unit.alive) return;
battle.fx('finale', { x: unit.x, y: unit.y, id: unit.id });
unit.mem.dgbFinale = true; // the finisher is part of the skill (talent at 100 %, module +10 %)
try { slash(battle, unit, num(bb.e_atk_scale_end, 1)); } finally { unit.mem.dgbFinale = false; }
pull(battle, unit, num(bb.e_force));
pull(battle, unit, num(bb.e_force), true);
},
},
talents: [
+153 -49
View File
@@ -6,7 +6,8 @@
// Every tokenId of data/tokens.json has a kit, so `battle.spawnToken(owner, tokenId, r, c)` works with data defaults
// even when the summoner's kit passes no options:
// 医疗探机 heal profile (data), untargetable, self-destructs after its withdraw skill time (10 s)
// 诅咒娃娃 no attack, aura: enemies in its range ATK/DEF + bb.atk/bb.def (−25 %/−30 %), 15 s (skill duration)
// 诅咒娃娃 no attack, aura: enemies in its range ATK/DEF + bb.atk/bb.def (−25 %/−30 %), 15 s (skill duration);
// leaves when 巫恋 leaves (PRTS 备注) — the drone stays when 赫默 leaves (PRTS 医疗探机 备注)
// 沙之碑 on appear: owner ATK × atk_scale arts + stun (skill range 3×3), blocks 3 (no attack), talent duration 20 s
// 战术装备 on appear: stun around (bb.stun), blocked enemies DEF + talent def (−160), talent duration 25 s
// “小自在” arts melee blocker, talent duration 25 s; kills emit `summonKill` (夕's 化境 is the 夕 kit's job)
@@ -31,9 +32,11 @@
// its blast fx carries `consumed: true` so clients play the explosion, not a death sound)
// 从不混淆的方向 untargetable marker; when the owner's skill ends it vanishes and the owner returns to its tile
// 黄金盟誓 attacks deal true damage (trait); lasts while the owner's skill runs
// 防护单元 untargetable device: shield = 凯瑟琳 max HP × max_shield_ratio on one operator in its range (effects
// do not stack), refilled by shield_ratio_each_trigger /s when the target was not hit for `interval`
// s (always while 凯瑟琳's skill runs, owner bb overwrite_ratio)
// 防护单元 untargetable, invulnerable device placed by the player (a hand piece, user playtest #6): shield =
// 凯瑟琳 max HP × max_shield_ratio on the operator in its range (range 1-1: the tile it faces; effects do
// not stack — `cathy:shield`, read by 凯瑟琳 S1 岁月锻打), in full whenever it takes a new operator,
// refilled by shield_ratio_each_trigger /s when the target was not hit for `interval` s (always while
// 凯瑟琳's timed skill runs, owner bb overwrite_ratio)
// 投递坐标 / 风雪之眼 / 保护目标 inert pieces (no attack; 风雪之眼 untargetable) — their effects belong to the owner kit
// 炎佑 (enemy_9012_acloon) flying ally: flies after the highest-aggro enemy of the field and hovers over it, stays
// put when there is none; 3-target arts + burn on every hit, 元素脆弱 aura, 祛恶之焰 channel (yanyouKit)
@@ -48,15 +51,28 @@
// sees its 援军 standing; with the engine's default reinforcement the talent token replaces the generic 援军. Without
// a board piece the token takes a tactical point: a free walkable tile of the owner's range on an enemy ground path
// first (`tacticalPoint`), then the one nearest to the owner.
// Placed summons (PRTS 卫戍协议/帮助 §作战阶段; user playtest #6): every board summon piece marks the tile its summon
// deploys on. The talent ones the owner holds from the start (狼群, 海嗣, 流形, 凯瑟琳's devices) deploy with the
// board, after the operators. A skill's summon (赫默's 医疗探机, 巫恋's 诅咒娃娃: "获得一个…") also deploys once with the
// board, for free and ignoring the holding ("在赫默的技能未开启前未持有该召唤物,但作战开始时无视持有状态自动部署1个";
// settled by the user after playtest #6 — shared/constants.js SKILL_SUMMON_START_DEPLOY, re-exported here; false =
// the playtest #4 reading, `deferDeploy`: off the field until the skill). Then it is docked on its tile
// (`dockSkillSummons`; the tile stays reserved) and takes the field there each time the owner's skill gives one
// (`releaseSkillSummon`; PRTS "若战场区初始部署有召唤物,若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个"): one
// in stock at most ("最多可库存1个"), after the token's redeploy time once it left, free [ASSUMED: no DP], never while
// its owner is off the field — a stocked one deploys as soon as the owner is back. A skill's summon or a device
// (凯瑟琳) that was not placed never appears (the hidden 待部署区 deploys nothing by itself), nor does 海嗣; only the
// tacticians' 狼群 / 流形 still come as 援军 on a tactical point (above).
// Fallbacks (only while the summoner still uses the generic kit — a hand-authored kit takes over): skill summons
// (赫默/巫恋/蜜蜡/风丸/维娜/耀骑士临光/迷迭香 S3) spawn at skill start — also under a hand-authored kit when only the
// SELECTED skill runs the generic spec (a non-default skill the kit has no `skills` entry for) — 夕 spawns 小自在 on
// its first attack, 凯瑟琳 deploys her devices at battle start.
// (赫默/巫恋 through their placed pieces as above; 蜜蜡/风丸/维娜/耀骑士临光/迷迭香 S3 on a tile of their own) spawn at
// skill start — also under a hand-authored kit when only the SELECTED skill runs the generic spec (a non-default skill
// the kit has no `skills` entry for) — and 夕 spawns 小自在 on its first attack.
// Operator loadouts (DESIGN §16): variants, `sources` and counts are those of the owner's selected skill / module
// (variantOf / tokenSources: getToken(id, owner.defId, owner.def.loadout)); a skill summon runs only when that skill
// makes the token (Battle.spawnToken also refuses summons the owner's loadout does not produce).
//
// Exports for other content: spawnYanyou, spawnMapChar, findSummonTile, summonToken, tacticalPoint, wolfShadows, TOKEN_IDS.
// Exports for other content: spawnYanyou, spawnMapChar, findSummonTile, summonToken, tacticalPoint, wolfShadows,
// releaseSkillSummon, SKILL_SUMMON_START_DEPLOY, CAT_SHIELD_KEY, TOKEN_IDS.
import { COLS, ROWS, MOVE_SCALE } from '../constants.js';
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../targeting.js';
@@ -64,10 +80,13 @@ import { bodyInKeys, bodyOnTile } from '../body.js';
import { hasHp } from '../damage.js';
import { genericKit } from './generic.js';
import { normDir, localOrder } from '../dir.js';
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
const GRID_3X3 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
const GRID_PLUS = Object.freeze([[1, 0], [0, -1], [0, 0], [0, 1], [-1, 0]]);
/** Buff key of the 爬行号·防护单元 shield on an operator (凯瑟琳 S1 岁月锻打 buffs the operators holding one). */
export const CAT_SHIELD_KEY = 'cathy:shield';
export const TOKEN_IDS = Object.freeze({
healDrone: 'token_10000_silent_healrb',
@@ -236,6 +255,93 @@ function bindToOwnerSkill(battle, unit) {
}, { owner: unit });
}
// ---------------------------------------------------------------------------------------------------------------
// placed skill summons (user playtest #6; see the header "Placed summons")
// SKILL_SUMMON_START_DEPLOY (shared/constants.js — also read by the summon card's hint): true = the PRTS reading the
// user settled after playtest #6, the placed piece of a skill's summon also deploys once, for free, at the battle
// start; false = the playtest #4 reading (it comes with the skill). Re-exported for content and tests.
export { SKILL_SUMMON_START_DEPLOY };
/** Seconds between two tries of a docked piece whose tile is busy / whose redeploy time is still running. */
const DOCK_RETRY = 0.5;
/**
* How the owner's loadout makes a board summon piece of a manually deployable summon (owner-loadout `sources`):
* 'skill' (only its skill makes it), 'none' (the owner's skill / module makes none — 赫默 on S1 with a drone piece; the
* match never hands such a card out), else null (a talent summon, or not a placed hand piece).
*/
function pieceSource(battle, u) {
if (!u || u.kind !== 'token' || u.uid == null || !u.ownerUnit || u.ownerUnit.kind !== 'op') return null;
if (u.def?.raw?.placeable !== true) return null;
const src = tokenSources(battle, u.defId, u.ownerUnit);
if (src.includes('talent')) return null;
if (src.includes('skill')) return 'skill';
return src.length ? 'none' : null;
}
/**
* Dock the board pieces of skill summons (before the start): off the field with the board (`deferDeploy`, unless
* SKILL_SUMMON_START_DEPLOY), and whenever one leaves (10 s drone, 15 s doll) it stays its tile's piece — not removed,
* its hooks kept, Battle.isReservedTile — ready again after the token's redeploy time (data respawnTime, 5 s). A piece
* its owner's loadout does not make never deploys.
*/
function dockSkillSummons(battle) {
for (const u of battle.allyUnits) {
const how = pieceSource(battle, u);
if (how === 'none') { u.deferDeploy = true; continue; }
if (how !== 'skill') continue;
u.mem.docked = true;
u.mem.readyAt = -Infinity;
if (!SKILL_SUMMON_START_DEPLOY) u.deferDeploy = true;
battle.on('death', (ctx) => {
if (ctx.unit !== u || battle.finished) return;
u.removed = false;
u.mem.readyAt = battle.time + Math.max(0, num(u.base.respawnTime, 0));
deployDocked(battle, u);
}, { owner: u, priority: -10 });
// the owner back on the field with one in stock: the condition of the PRTS redeploy is met at once
battle.on('deploy', (ctx) => {
if (ctx.unit === u.ownerUnit && !battle.finished) deployDocked(battle, u);
}, { owner: u });
}
}
/**
* Deploy a docked piece on its tile when its owner holds one and it is ready; retried while it waits. Not while the
* owner is off the field [ASSUMED: as in the base game, a summon is deployed only while its owner stands] — the stock
* stays, and the piece deploys as soon as the owner is back (its `deploy` hook, dockSkillSummons).
*/
function deployDocked(battle, u) {
const stock = u.ownerUnit?.mem.summonStock;
if (!stock || !(stock[u.defId] > 0) || u.alive || u.removed || battle.finished || !u.ownerUnit.alive) return false;
const wait = u.mem.readyAt - battle.time;
if (wait > 1e-9 || !battle.redeploy(u, { free: true })) {
if (!u.mem.dockRetry) {
u.mem.dockRetry = true;
battle.after(Math.max(wait, DOCK_RETRY), () => { u.mem.dockRetry = false; deployDocked(battle, u); }, { owner: u });
}
return false;
}
stock[u.defId]--;
battle.fx('summon', { x: u.x, y: u.y, id: u.id, token: u.defId });
return true;
}
/**
* The owner's skill gives it one `tokenId` ("获得一个医疗无人机"; at most `cap` in stock — "最多可库存1个"): its placed
* piece takes the field on its own tile now, or as soon as it is ready (the previous one still up / just gone).
* Returns the piece deployed now, else null — also when the player placed none (the summon then never appears).
*/
export function releaseSkillSummon(battle, owner, tokenId, { cap = 1 } = {}) {
if (!owner) return null;
const pieces = battle.allyUnits.filter((t) => t.kind === 'token' && t.defId === tokenId && t.ownerUnit === owner && t.mem.docked);
if (!pieces.length) return null;
const stock = owner.mem.summonStock || (owner.mem.summonStock = {});
stock[tokenId] = Math.min(Math.max(1, Math.floor(num(cap, 1))), (stock[tokenId] || 0) + 1);
for (const p of pieces) if (deployDocked(battle, p)) return p;
return null;
}
function inRectTile(battle, r, c) { return Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c); }
/**
@@ -264,8 +370,10 @@ function countNear(list, r, c, rad = 1) {
/**
* Pick a summon tile for `owner` (null when none). placement:
* 'ally' — any standable tile in the owner's range covering the most injured allies (医疗探机)
* 'enemy' — any standable tile in range covering the most enemies (诅咒娃娃)
* 'ally' — any standable tile in the owner's range covering the most injured allies (a healing skill summon that
* is no hand piece; 医疗探机 itself is placed by the player — releaseSkillSummon)
* 'enemy' — any standable tile in range covering the most enemies (a debuff skill summon that is no hand piece;
* 诅咒娃娃 itself is placed by the player)
* 'melee' — melee-deployable tile in range, on the enemy path and near the enemies first (沙之碑)
* 'adjacent' — melee-deployable tile among the 8 surrounding tiles (纸偶, 黄金盟誓)
* 'plus' — melee-deployable tile among the 4 neighbours (“耀阳”)
@@ -282,7 +390,10 @@ export function findSummonTile(battle, owner, placement = 'melee', opts = {}) {
const melee = opts.melee ?? (placement === 'melee' || placement === 'adjacent' || placement === 'plus');
const enemies = battle.enemies.filter((e) => e.alive && !e.hidden);
const focus = opts.at ?? mostAdvancedEnemy(battle) ?? owner;
const injured = placement === 'ally' ? battle.allies(owner.ownerId).filter((a) => a.hp < a.s.maxHp - 1e-6) : null;
// injured allies a heal could reach: never 孤立 (Battle.alliesFor) nor 禁疗 units (炎佑, the 禁疗 summons)
const injured = placement === 'ally'
? battle.alliesFor(owner, owner.ownerId).filter((a) => a.hp < a.s.maxHp - 1e-6 && !(a.s.flags.noHeal || a.profile?.noHeal))
: null;
let best = null, bestS = null;
for (const k of keys) {
const r = (k / COLS) | 0, c = k % COLS;
@@ -359,7 +470,11 @@ function curseDoll(bb, raw, def) {
},
},
trait: { noAttack: true },
install(battle, unit) { onDeploy(battle, unit, () => scheduleLifetime(battle, unit, life)); },
install(battle, unit) {
onDeploy(battle, unit, () => scheduleLifetime(battle, unit, life));
// PRTS 诅咒娃娃 备注: "巫恋退场时强制撤退场上的诅咒娃娃" (knocked out or withdrawn)
battle.on('death', (ctx) => { if (ctx.unit === unit.ownerUnit && unit.alive) battle.retreat(unit, { reason: 'expired', permanent: true }); }, { owner: unit });
},
};
}
@@ -738,7 +853,7 @@ function manifold(bb, raw, def) {
if (!unit.mem.noCopy) {
battle.on('tick', () => {
const sk = unit.skill;
if (!sk || !unit.alive || !unit.deployed || sk.active || !sk.ready || !unit.canAct || unit.s.flags.silence) return;
if (!sk || !unit.alive || !unit.deployed || sk.active || !sk.ready || sk.opCooling || !unit.canAct || unit.s.flags.silence) return;
if (pickCopyTarget(battle, unit)) sk.activate('MLYSS_WTRMAN');
}, { owner: unit });
}
@@ -818,19 +933,25 @@ function goldenOath(bb, raw, def) {
};
}
/** 爬行号·防护单元 (凯瑟琳 talent 定向支援信号). */
/**
* 爬行号·防护单元 (凯瑟琳 talent 定向支援信号; a hand piece the player places and turns, user playtest #6): "使一名干员
* 获得相当于凯瑟琳生命上限20%的屏障(若目标最近5秒内未受攻击,则每秒补充…不超过初始上限),装置效果不叠加" — the operator
* on its range (1-1: its tile + the tile it faces) gets the full shield when the device takes it (deploy, or a new /
* redeployed operator there), then the refill; untargetable ("不会受到攻击") and invulnerable (enemy AoE sweeping every
* ally in a radius would otherwise destroy the 100-HP device). The buff key is 凯瑟琳 S1 岁月锻打's test.
*/
function catShield(bb, raw, def) {
const t = talentBb(def, 'max_shield_ratio');
const idle = num(t.interval, 0);
const every = num(t['catsld_t_1[timer][interval].interval'], 1);
const maxRatio = num(t.max_shield_ratio, 0);
const refill = num(t.shield_ratio_each_trigger, 0);
const KEY = 'catsld:shield';
return {
skill: null,
trait: { noAttack: true },
install(battle, unit) {
const st = stateOf(battle);
battle.addBuff(unit, { key: 'token:catDevice', flags: { invulnerable: true }, persist: true, allowDead: true });
const ownerHp = () => { const o = ownerOf(unit); return o ? o.s.maxHp : unit.s.maxHp; };
const valid = (a) => a && a.alive && a.deployed && a.kind === 'op';
const pick = () => {
@@ -851,18 +972,19 @@ function catShield(bb, raw, def) {
const give = (a, amount) => {
const cap = ownerHp() * maxRatio;
if (!(cap > 0) || !(amount > 0)) return;
const b = a.findBuff(KEY);
if (b) { b.shield = Math.min(cap, (b.shield || 0) + amount); a.markDirty(); } else battle.addBuff(a, { key: KEY, shield: Math.min(cap, amount), duration: Infinity, source: unit });
const b = a.findBuff(CAT_SHIELD_KEY);
if (b) { b.shield = Math.min(cap, (b.shield || 0) + amount); a.markDirty(); } else battle.addBuff(a, { key: CAT_SHIELD_KEY, shield: Math.min(cap, amount), duration: Infinity, visible: true, source: unit });
};
onDeploy(battle, unit, () => {
const connect = () => {
const a = pick();
if (a) { give(a, ownerHp() * maxRatio); battle.fx('catShield', { x: a.x, y: a.y, id: a.id, from: unit.id }); }
});
return a;
};
onDeploy(battle, unit, connect);
battle.every(every, () => {
if (!unit.alive || !unit.deployed) return;
let a = unit.mem.target;
if (!valid(a)) a = pick();
if (!a) return;
const a = unit.mem.target;
if (!valid(a)) { connect(); return; }
const o = ownerOf(unit);
const skillOn = !!(o && o.alive && o.skill && o.skill.active && o.skill.isTimed);
const rate = skillOn ? num(o.skill.bb?.overwrite_ratio, refill) : refill;
@@ -1170,7 +1292,7 @@ function touchKit(bb, raw, def) {
unit.mem.touchExtraAt = n;
let best = target.hp < target.s.maxHp - 1e-6 ? target : null;
for (const a of battle.alliesInRadius(target.x, target.y, 1, unit.ownerId)) {
if (a === target || a.hp >= a.s.maxHp - 1e-6) continue;
if (a === target || a.hp >= a.s.maxHp - 1e-6 || a.s.flags.noHeal || a.profile?.noHeal) continue; // (禁疗 / 孤立)
if (Math.abs(a.tileR - target.tileR) + Math.abs(a.tileC - target.tileC) !== 1) continue;
if (!best || a.hpRatio < best.hpRatio) best = a;
}
@@ -1260,7 +1382,9 @@ export default kits;
* hand-authored kit without a spec for a non-default selected skill (content/index.js selectSkillSpec, `skillSource:
* 'generic'`) — and the data says that skill makes the token (owner-loadout `sources` has 'skill'). Placement per
* token; 迷迭香 S3 “如你所愿”: "立即在攻击范围内的近战位部署两个战术装备" (the token's deployLimit — 2, 20 with
* module uniequip_003_rosmon — only caps how many stand; SKILL_SUMMON_PER_CAST).
* module uniequip_003_rosmon — only caps how many stand; SKILL_SUMMON_PER_CAST). 医疗探机 / 诅咒娃娃 are hand pieces
* (data `placeable`): their entries only mark them as skill summons — releaseSkillSummon deploys the player's piece on
* its own tile, and their 'ally' / 'enemy' placement would apply only to data without such a piece.
*/
const SKILL_SUMMONS = Object.freeze({
[TOKEN_IDS.healDrone]: 'ally',
@@ -1326,6 +1450,9 @@ function ensureReinforcement(battle, owner, tokenId) {
export function install(battle) {
const st = stateOf(battle);
// the placed pieces of skill summons wait on their tiles for the skill (user playtest #6, see the header)
dockSkillSummons(battle);
// last deployed operator per player (“耀阳”: 上一名部署干员势力为【卡西米尔】) — tokens are not operators
battle.on('deploy', (ctx) => {
const u = ctx.unit;
@@ -1383,6 +1510,8 @@ export function install(battle) {
battle.on('skillStart', (ctx) => {
if (ctx.unit !== owner) return;
for (const t of skillSummons) {
// a hand piece (医疗探机 / 诅咒娃娃) deploys where the player placed it — nowhere when not placed
if (battle.data.rawToken?.(t)?.placeable === true) { releaseSkillSummon(battle, owner, t); continue; }
for (let i = 0; i < (SKILL_SUMMON_PER_CAST[t] ?? 1); i++) if (!summonToken(battle, owner, t, SKILL_SUMMONS[t])) break;
}
}, { owner });
@@ -1403,31 +1532,6 @@ export function install(battle) {
if (d) battle.fx('duskDragon', { x: d.x, y: d.y, id: d.id });
}, { owner });
}
if (toks.includes(TOKEN_IDS.catShield)) {
const deployDevices = () => {
if (!owner.alive || !owner.deployed) return;
const probe = battle.data.getToken?.(TOKEN_IDS.catShield, owner.defId, owner.def?.loadout ?? null);
const v = ownVariant(battle.data.rawToken?.(TOKEN_IDS.catShield), owner);
const limit = Math.max(1, Math.floor(num(v?.stats?.deployLimit, 1)));
const stock = Math.max(1, Math.floor(num(v?.count ?? v?.stats?.deckStack, limit)));
const grid = probe?.rangeGrid ?? [[0, 0], [0, 1]];
let placed = battle.allyUnits.filter((t) => t.alive && t.defId === TOKEN_IDS.catShield && t.ownerUnit === owner).length;
const used = new Set();
for (const k of owner.rangeKeys || []) {
if (placed >= Math.min(limit, stock)) break;
const r = (k / COLS) | 0, c = k % COLS;
if (!tileFree(battle, r, c) || !battle.grid.canStand(r, c, { ranged: true })) continue;
// the device helps the operator in its own range (the tile in front)
const keys = absoluteRangeKeys(grid, r, c, owner.dir, 0);
const ally = keys.map((kk) => battle.unitAt((kk / COLS) | 0, kk % COLS)).find((a) => a && a.kind === 'op' && a.ownerId === owner.ownerId && !used.has(a.id));
if (!ally) continue;
if (battle.spawnToken(owner, TOKEN_IDS.catShield, r, c)) { placed++; used.add(ally.id); }
}
};
// initial deployment: wait until every board unit stands (the devices need the operator in front of them)
battle.on('battleStart', deployDevices, { owner });
battle.on('deploy', (ctx) => { if (ctx.unit === owner && battle.started && !ctx.initial) deployDevices(); }, { owner, priority: -20 });
}
}
}