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:
1 parent
e6628f0cfe
commit
6464e4b46a
138 files changed
+7946
-1379
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
} }],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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); },
|
||||
}),
|
||||
|
||||
@@ -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 } }); });
|
||||
} },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in new issue
Block a user