feedback1e WR: 卡涅利安 charged S1 never attacks, S3 mark before the damage; review fixes
Review of the E3 fix (阵法术师 / 轰击术师 strike every enemy in range): - 卡涅利安 S1 沙暴守卫 charged: PRTS 备注 "应用蓄力时:应用技能未开启时的特性" keeps the whole skill-off trait in force, 不攻击 included. A kit trait canAttack now stops her attacks while a charged S1 runs; the guard stays. The E3 test casts S1 uncharged and a new test pins both casts. - 卡涅利安 S3 食噬之印 charged: the mark stacks before the attack's damage (PRTS 备注 "于攻击造成伤害前生效"): x1.2 on the first hit, x2.0 from the 5th (was one attack late). One mark per enemy: another 卡涅利安 only adds stacks, the bonus stays the setter's (PRTS 备注 popup). - rangeAoe is applied after every override (kit trait included) and sets allInRange itself; SUB.phalanx / blastcaster carry rangeAoe only. - 流形 copies (tokens.js and the 缪尔赛思 kit) turn a copied 'beam' into a plain bolt: the copy takes no attack shape. - fx.js: the dead blastcaster shell colour is gone (and its test). - SIM.md / comments cite PRTS 溅射半径一览 (no splash row for these branches), note that stealthed enemies are not struck unless revealed or blocked, and list the table's other radii as open deviations.
This commit is contained in:
1 parent
27181d88bd
commit
f3a73fec6b
9 files changed
+207
-57
No files matched your search
+7
-7
@@ -890,24 +890,24 @@ PRTS 选择器, is not in the data): "地面敌人" / "不可对空" ⇒ `!e.isF
|
||||
without a projectile — `rangeAoe` profiles: "在攻击前摇结束时选取范围内的全体目标,同时造成伤害", PRTS 作战机制). Kit-settable profile flags beyond the
|
||||
table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡的敌人"), `onEachHit(b, u, victim, hctx)`, `dmgMul`,
|
||||
`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority`, `blockFly`, `noHeal`, `boomerang` (the projectile stays
|
||||
`'boomerang'` whatever the data's generic ranged projectile says), `rangeAoe` (likewise `'beam'`, with `allInRange`)
|
||||
(see the header of professions.js).
|
||||
`'boomerang'` whatever the data's generic ranged projectile says), `rangeAoe` (applied after every override: sets
|
||||
`allInRange` and, on a ranged profile, the instant `'beam'`) (see the header of professions.js).
|
||||
|
||||
| sub | behaviour |
|
||||
|---|---|
|
||||
| fastshot | FLY first; module `atk_scale` vs FLY |
|
||||
| closerange, underminer, primcaster, corecaster, ritualist, summoner, counsellor, pioneer, fearless, fighter, protector, guardian, primprotector, executor, duelist | plain profile (numbers from data; skills/talents via kits). underminer module: weaken 10 % ATK 2 s on hit |
|
||||
| longrange | lowest DEF first |
|
||||
| aoesniper / splashcaster | splash 1.1 tiles around the struck target at full damage (Arknights Terra Wiki, Splash Caster: "a radius of 1.1 tiles centered on the target") |
|
||||
| blastcaster | `rangeAoe`: every enemy on its line at once, the same damage near and far, instant (`'beam'`) — "超远距离的群体法术伤害" is the whole line, not a splash (Terra Wiki, Blast Caster; PRTS 作战机制 §AOE伤害判定: 伊芙利特's 炎爆 is a 锁定攻击范围 AoE; community report E3) |
|
||||
| bombarder | ground-only splash 1.0 + aftershock(s) at 50 % ATK (bb append_atk_scale / times) |
|
||||
| aoesniper / splashcaster | splash 1.1 tiles around the struck target at full damage (PRTS 溅射半径一览: 扩散术师 1.1; Arknights Terra Wiki, Splash Caster). [OPEN] the same table gives 炮手 1.0 (none in the pool) and 格雷伊 1.0 |
|
||||
| blastcaster | `rangeAoe`: every selectable enemy on its line at once, the same damage near and far, instant (`'beam'`) — "超远距离的群体法术伤害" is the whole line, not a splash (PRTS 溅射半径一览 lists no splash for it; Terra Wiki, Blast Caster; PRTS 作战机制 §AOE伤害判定: 伊芙利特's 炎爆 is a 锁定攻击范围 AoE; community report E3). A stealthed enemy is not struck unless it is revealed or blocked (a 锁定范围 AoE cannot hit a 隐匿 unit, PRTS 作战机制) |
|
||||
| bombarder | ground-only splash 1.0 + aftershock(s) at 50 % ATK (bb append_atk_scale / times). [OPEN] PRTS 溅射半径一览 gives 投掷手 0.9 |
|
||||
| hunter | 8 bullets (bb value), ×1.2 ATK (bb atk_scale), reloads 1/s after 1 s without attacking; can't attack when empty |
|
||||
| loopshooter | 回环射手 (user playtest #3): every attack throws a boomerang (`ai.js throwBoomerang`, projectile `'boomerang'`) out to the target at 15 tiles/s — it hits on arrival — and back to the thrower's current position at 3.75 tiles/s without damage (PRTS 跃跃 "投射物飞行速度15,返回时飞行速度3.75"); attacks only while holding it (every boomerang thrown caught — "必须回收全部回旋投掷物才可以进行下一次攻击", `unit.trait.boomerangsOut`) and with the attack cooldown ready, so the real interval is the longer of the two; a target dead mid-flight is not hit (it still flies to the last position and back); knocked out / withdrawn ⇒ lost, a redeployed thrower holds a fresh one; 跃跃 S2's extra boomerangs share the one flight (cnt hits) |
|
||||
| reaperrange | hits every enemy in range; ×1.5 (bb atk_scale) on the trait front grid (or its own line ahead) — both along its direction |
|
||||
| chain | chain N (trait text/bb max_target) with −15 % per jump (bb chain.atk_scale), 1.8-tile jumps, sluggish on each hit |
|
||||
| chain | chain N (trait text/bb max_target) with −15 % per jump (bb chain.atk_scale), 1.8-tile jumps, sluggish on each hit. [OPEN] PRTS 溅射半径一览 gives 链术师 1.7 |
|
||||
| funnel | drone damage 20 % → +15 %/hit on the same target → 110 % (bb init/delta/max) |
|
||||
| mystic | stores up to 3 (bb times) attacks while idle, fires them all at once |
|
||||
| phalanx | no attack & DEF +200 %, RES +20 (bb) while the skill is off; while on, `rangeAoe`: each attack strikes every enemy on its range at once (blocked enemies included), the same damage near and far, instant (`'beam'`) — "群体法术伤害" (Terra Wiki, Phalanx Caster: "attacks hit all enemies within their range"; PRTS 林 S3 备注 "单次普攻最多触发1次效果"; community report E3: it used to be one bolt + a 1.1 splash) |
|
||||
| phalanx | no attack & DEF +200 %, RES +20 (bb) while the skill is off; while on, `rangeAoe`: each attack strikes every selectable enemy on its range at once (blocked enemies included; a stealthed one only when revealed or blocked), the same damage near and far, instant (`'beam'`) — "群体法术伤害" (PRTS 溅射半径一览 lists no splash for it; Terra Wiki, Phalanx Caster: "attacks hit all enemies within their range"; PRTS 林 S3 备注 "单次普攻最多触发1次效果"; community report E3: it used to be one bolt + a 1.1 splash). 卡涅利安's charged S1 keeps the skill-off trait, 不攻击 included (kit `canAttack`, PRTS 备注) |
|
||||
| physician | heal the lowest HP% injured ally in range (a skill `targeting.maxTargets` widens any heal profile) |
|
||||
| ringhealer | heal 3 allies |
|
||||
| chainhealer | heal bounces 3× (−25 %, bb chain.*) within 2.5 tiles |
|
||||
|
||||
@@ -111,8 +111,6 @@ const FLAME_TINTS = Object.freeze([0xffd27a, 0xffa94d, 0xff8a3d, 0xff5a2a]);
|
||||
const SHELL_RISE = 0.34, SHELL_UP = 5.5;
|
||||
/** 蕾缪安 S3 shell (fx 'bombardShell'); `look` 'mortar' is its own flight (_stepMortar). */
|
||||
const BOMBARD_SHELL = Object.freeze({ look: 'mortar', tint: 0xfff2d8, glow: 0xff9c33, trail: 0xffb35c, smoke: 0x3a3430, len: 1.3, width: 0.3, head: 0.56 });
|
||||
/** Sub-professions whose shells are arts (purple blast) — the rest explode orange. */
|
||||
const ARTS_SHELLS = new Set(['blastcaster']);
|
||||
/**
|
||||
* An fx anchored on a unit (extra.id) is drawn at that unit's rendered position while the event's own (x, y) is within
|
||||
* this many tiles of it (it happens on the unit); farther away the fx happens at (x, y) — the sim puts the caster in
|
||||
@@ -482,15 +480,14 @@ export class FxSystem {
|
||||
const ux = dist > 1e-6 ? dx / dist : (src.facing || 1) >= 0 ? 1 : -1, uy = dist > 1e-6 ? dy / dist : 0;
|
||||
const hand = Math.min(0.28, dist * 0.3); // the weapon is in front of the body
|
||||
const look = spec.look;
|
||||
const arts = look === 'shell' && ARTS_SHELLS.has(this.ctx.subProfOf ? this.ctx.subProfOf(src.info?.defId) : null);
|
||||
pr.kind = kind; pr.spec = spec; pr.src = src; pr.tgt = tgt; pr.rise = 0;
|
||||
pr.x0 = src.x + ux * hand; pr.y0 = src.y + uy * hand; pr.z0 = chestZ(src);
|
||||
pr.tx = tgt.x; pr.ty = tgt.y; pr.tz = look === 'shell' ? feetZ(tgt) : chestZ(tgt);
|
||||
pr.t = 0; pr.fade = 0; pr.hit = false; pr.emit = Math.random(); pr.ang = Math.atan2(-uy, ux); // ≈ on screen (rows run up)
|
||||
pr.dur = clamp(dist / projSpeed(kind) / this._ts(), 0.04, 1.5);
|
||||
pr.arc = spec.arc ? spec.arc * clamp(0.45 + dist * 0.18, 0.6, 1.8) : 0;
|
||||
pr.glow = arts ? 0xb36bff : spec.glow;
|
||||
pr.trailTint = arts ? 0xc77dff : spec.trail ?? spec.glow;
|
||||
pr.glow = spec.glow;
|
||||
pr.trailTint = spec.trail ?? spec.glow;
|
||||
// boomerang legs: kinematic, from (bx, by, bz) at constant speed towards the target, then back to the thrower
|
||||
pr.phase = 0; pr.bx = pr.x0; pr.by = pr.y0; pr.bz = pr.z0; pr.trav = 0; pr.d0 = Math.max(0.1, dist); pr.ux = ux; pr.uy = uy;
|
||||
pr.spin = Math.random() * 6;
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
// groupId `abyssal` (data/chess.json has no groupId: charIds from docs/research/03-operators.json).
|
||||
// - "友方干员" effects touch operators only (summons/devices excluded); SP gifts skip units whose timed skill runs
|
||||
// (AK: no SP gain during a skill — the engine's gainSp enforces it too; the kits skip such units when picking).
|
||||
// - On-hit statuses of 卡涅利安's attacks apply to every enemy the attack damages — the 阵法术师 trait strikes every
|
||||
// enemy on her range at once (professions.js `allInRange`, community report E3) — via a `damaged` hook.
|
||||
// - On-hit effects of 卡涅利安's attacks land on every enemy the attack strikes — the 阵法术师 trait strikes every enemy
|
||||
// on her range at once (professions.js `allInRange` / `rangeAoe`, community report E3): S2's 停顿 / 束缚 via a
|
||||
// `damaged` hook, the charged S3 mark via a `hit` hook (it applies before the damage, PRTS 备注).
|
||||
// - Dodge from a skill that adds to other dodge sources (焰尾 S3) is rolled independently in a `hit` hook, so the
|
||||
// sources combine as 1 − Π(1 − p) (the same rule the engine now applies to stacked dodge mods).
|
||||
// - Operator loadouts (DESIGN §16): every selectable non-default skill of the 22 visible chess is authored in the kit's
|
||||
@@ -39,6 +40,9 @@ const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', '
|
||||
const LINE = Object.freeze(Array.from({ length: COLS }, (_, i) => Object.freeze([0, i])));
|
||||
/** 卡涅利安 S3 食噬之印: the ATK bonus climbs in 1 s steps over 20 s (PRTS 备注; the blackboard has no ramp time). */
|
||||
const BILLRO_S3_RAMP = 20;
|
||||
/** 卡涅利安 S3 charged mark: one buff per enemy, shared by every 卡涅利安 (PRTS 备注), ≤ 5 stacks (skill text). */
|
||||
const BILLRO_MARK = 'billro:mark';
|
||||
const BILLRO_MARK_MAX = 5;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// helpers
|
||||
@@ -1716,10 +1720,15 @@ const kits = {
|
||||
|
||||
// ===== 卡涅利安 (phalanx) S2 沙缚镣锁 — faster attacks on every enemy in range (the trait's 群体法术伤害), 0.3 s
|
||||
// sluggish on each; charged: ATK +10 % and bind; talents
|
||||
// S1 沙暴守卫 (SEARCH: ATK/DEF up; charged — cast with every charge stored — the trait's DEF/RES guard stays on);
|
||||
// S3 食噬之印 (wider range, ATK +0 % → +140 %/+200 % in 1 s steps over 20 s — PRTS 备注; charged: every hit marks
|
||||
// its target, +20 % damage from her per mark, ≤ 5, until the skill ends). Talent 生命之餐 heals on every skill;
|
||||
// module PLX-X keeps part of the guard during any skill, PLX-Y (乡音): +3 % damage per enemy in range (≤ 5)
|
||||
// S1 沙暴守卫 (SEARCH: ATK/DEF up; charged — cast with every charge stored — "特性效果在技能期间继续生效", PRTS 备注
|
||||
// "应用蓄力时:应用技能未开启时的特性": the skill-off trait stays in force, the DEF/RES guard AND 不攻击 — she makes
|
||||
// no attack until the skill ends, a pure guard);
|
||||
// S3 食噬之印 (wider range, ATK +0 % → +140 %/+200 % in 1 s steps over 20 s — PRTS 备注; charged: each attack adds a
|
||||
// stack of the mark BEFORE its damage — PRTS 备注 "于攻击造成伤害前生效,多层效果之间加算叠加" — +20 % damage from
|
||||
// her per stack, ≤ 5, so ×1.2 on the first hit and ×2.0 from the 5th, until the skill ends; one mark per enemy:
|
||||
// another 卡涅利安 only adds stacks to it, the bonus is the setter's — PRTS 备注 popup). Talent 生命之餐 heals on
|
||||
// every skill; module PLX-X keeps part of the guard during any skill, PLX-Y (乡音): +3 % damage per enemy in range
|
||||
// (≤ 5)
|
||||
chess_char_4_24_a: (bb, chess, def) => {
|
||||
const t0 = tbb(def, 0), t1 = tbb(def, 1), mb = moduleBb(def);
|
||||
const tb = def.traitBb || {};
|
||||
@@ -1727,15 +1736,17 @@ const kits = {
|
||||
const S1 = isSel(def, 'skchr_billro_1'), S2 = isSel(def, 'skchr_billro_2'), S3 = isSel(def, 'skchr_billro_3');
|
||||
const g = grid(def.skill?.rangeGrid);
|
||||
const isCharged = (skill) => skill.maxCharges > 1 && skill.charges + 1 >= skill.maxCharges; // cast with every charge stored
|
||||
const markKey = (unit) => `billro:mark:${unit.id}`;
|
||||
return {
|
||||
// charged S1: the skill-off trait's 不攻击 stays in force while the skill lasts (PRTS S1 备注) — no attack at all
|
||||
trait: S1 ? { canAttack: (battle, u) => !(skillActive(u) && u.mem.billroCharged) } : null,
|
||||
skills: alt(def, {
|
||||
skchr_billro_1: () => ({
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(bb.atk), defPct: num(bb.def) },
|
||||
onStart({ battle, unit, skill }) {
|
||||
unit.mem.billroCharged = isCharged(skill);
|
||||
// 蓄力额外效果:特性效果在技能期间继续生效 (the profession removes its own guard buff when a skill starts)
|
||||
// 蓄力额外效果:特性效果在技能期间继续生效 (the profession removes its own guard buff when a skill starts; the
|
||||
// kit trait's canAttack keeps the trait's 不攻击)
|
||||
if (unit.mem.billroCharged) battle.addBuff(unit, { key: 'billro:s1guard', mods: { defPct: num(unit.profile?.guardDef, 2), resFlat: num(unit.profile?.guardRes, 20) } });
|
||||
battle.fx('shell', { x: unit.x, y: unit.y, id: unit.id });
|
||||
},
|
||||
@@ -1759,7 +1770,11 @@ const kits = {
|
||||
},
|
||||
onEnd({ battle, unit }) {
|
||||
battle.removeBuff(unit, 'billro:s3atk');
|
||||
for (const id of unit.mem.billroMarked || []) { const e = battle.unitById(id); if (e) battle.removeBuff(e, markKey(unit)); }
|
||||
// "持续至技能结束": the marks she set end with her skill (stacks another 卡涅利安 added to them included)
|
||||
for (const id of unit.mem.billroMarked || []) {
|
||||
const e = battle.unitById(id);
|
||||
if (e && e.findBuff(BILLRO_MARK)?.source === unit) battle.removeBuff(e, BILLRO_MARK);
|
||||
}
|
||||
unit.mem.billroMarked = null;
|
||||
unit.mem.billroCharged = false;
|
||||
},
|
||||
@@ -1807,17 +1822,22 @@ const kits = {
|
||||
}, { owner: unit });
|
||||
}
|
||||
if (S3) {
|
||||
// charged S3: each hit adds a mark (≤ 5) — +20 % damage taken from her per mark until the skill ends
|
||||
// charged S3 食噬之印: each attack adds a stack BEFORE its damage (PRTS 备注 "于攻击造成伤害前生效"); stacks add up,
|
||||
// +20 % damage taken from the mark's setter per stack. One mark per enemy (PRTS 备注 popup: "存在则为已有的BUFF叠加
|
||||
// 一次加成,不存在则给与敌人仅对此卡涅利安的伤害生效的一个食噬之印BUFF"): another 卡涅利安 stacks it without the bonus
|
||||
const per = num(bb['attack@damage_scale'], 0.2);
|
||||
battle.on('hit', (c) => {
|
||||
if (c.source !== unit) return;
|
||||
const m = c.target.findBuff?.(markKey(unit));
|
||||
if (m) c.dmg.mul *= 1 + num(bb['attack@damage_scale'], 0.2) * (m.stacks || 1);
|
||||
}, { owner: unit });
|
||||
battle.on('damaged', (c) => {
|
||||
const e = c.target;
|
||||
if (c.source !== unit || !c.dmg?.isAttack || e.side !== 'enemy' || !e.alive || !skillActive(unit) || !unit.mem.billroCharged) return;
|
||||
battle.addBuff(e, { key: markKey(unit), refresh: 'stack', maxStacks: 5, duration: Math.max(0.1, (unit.skill?.timeLeft ?? 0) + 0.1), source: unit, visible: true });
|
||||
unit.mem.billroMarked?.add(e.id);
|
||||
if (c.source !== unit || e.side !== 'enemy') return;
|
||||
let m = e.findBuff?.(BILLRO_MARK) || null;
|
||||
if (c.dmg.isAttack && skillActive(unit) && unit.mem.billroCharged) {
|
||||
if (m) battle.addBuff(e, { key: BILLRO_MARK, refresh: 'stack', maxStacks: BILLRO_MARK_MAX, duration: m.timeLeft });
|
||||
else {
|
||||
m = battle.addBuff(e, { key: BILLRO_MARK, refresh: 'stack', maxStacks: BILLRO_MARK_MAX, duration: Math.max(0.1, (unit.skill?.timeLeft ?? 0) + 0.1), source: unit, visible: true });
|
||||
if (m) unit.mem.billroMarked?.add(e.id);
|
||||
}
|
||||
}
|
||||
if (m && m.source === unit) c.dmg.mul *= 1 + per * (m.stacks || 1);
|
||||
}, { owner: unit });
|
||||
}
|
||||
if (!S2) return;
|
||||
|
||||
@@ -1749,7 +1749,8 @@ function copyInto(battle, t, src, scale, ranged) {
|
||||
const p = t.profile;
|
||||
if (p) {
|
||||
p.attack = ranged ? 'ranged' : 'melee';
|
||||
p.projectile = ranged ? (sp.projectile && sp.projectile !== 'none' && sp.projectile !== 'orb' ? sp.projectile : 'bolt') : 'none';
|
||||
// (a 阵法术师 / 轰击术师's instant 'beam' is their every-enemy-in-range shape: the copy fires a plain bolt)
|
||||
p.projectile = ranged ? (sp.projectile && sp.projectile !== 'none' && sp.projectile !== 'orb' && sp.projectile !== 'beam' ? sp.projectile : 'bolt') : 'none';
|
||||
p.canHitFly = ranged ? true : !!sp.canHitFly;
|
||||
if (!(sp.dmgType === 'heal' || sp.dmgType === 'none' || sp.noAttack || sp.noAttackUnlessSkill)) p.dmgType = sp.dmgType;
|
||||
p.heal = null;
|
||||
|
||||
@@ -728,7 +728,9 @@ function applyManifoldCopy(battle, unit, target, scale) {
|
||||
const p = unit.profile;
|
||||
if (p) {
|
||||
p.attack = ranged ? 'ranged' : 'melee';
|
||||
p.projectile = ranged ? (tp.projectile && tp.projectile !== 'none' && tp.projectile !== 'orb' ? tp.projectile : 'bolt') : 'none';
|
||||
// the talent's list of copied attributes (生命上限 … 伤害类型) names no attack shape: a 阵法术师 / 轰击术师's instant
|
||||
// 'beam' (rangeAoe: every enemy in range) becomes a plain single-target bolt, like a healer's orb
|
||||
p.projectile = ranged ? (tp.projectile && tp.projectile !== 'none' && tp.projectile !== 'orb' && tp.projectile !== 'beam' ? tp.projectile : 'bolt') : 'none';
|
||||
p.canHitFly = ranged ? true : !!tp.canHitFly;
|
||||
// 初始伤害类型(不攻击、治疗类型则不继承)
|
||||
if (!(tp.dmgType === 'heal' || tp.dmgType === 'none' || tp.noAttack || tp.noAttackUnlessSkill)) p.dmgType = tp.dmgType;
|
||||
|
||||
+16
-11
@@ -10,7 +10,9 @@
|
||||
// boomerang bool (回环射手: keeps 'boomerang')
|
||||
// canHitFly bool maxTargets n (≥1) hitAllBlocked bool (attack every blocked enemy)
|
||||
// allInRange bool (every enemy on the range at once) splashRadius tiles (around the struck target)
|
||||
// rangeAoe bool (a 锁定攻击范围 AoE without a projectile: allInRange, instant 'beam' hits)
|
||||
// rangeAoe bool (a 锁定攻击范围 AoE without a projectile — SUB table or kit trait, applied by resolveProfile after
|
||||
// every override: allInRange + instant 'beam' hits on a ranged profile; only selectable enemies are
|
||||
// struck — a stealthed one is not, unless revealed or blocked; PRTS 作战机制 §AOE伤害判定)
|
||||
// splashScale (× damage for splash victims)
|
||||
// splashOthersOnly bool groundOnly bool hits n (damage instances per attack)
|
||||
// chain {count, falloff, radius, sluggish} heal {mode:'single'|'multi'|'chain', count, falloff, farMul, elementHealRatio}
|
||||
@@ -300,12 +302,12 @@ export const SUB = Object.freeze({
|
||||
return front ? (unit.profile.frontScale ?? 1.5) : 1;
|
||||
} }),
|
||||
// --- CASTER
|
||||
// "群体法术伤害" names two shapes: the 扩散术师 splash 1.1 tiles around the struck target (Arknights Terra Wiki, Splash
|
||||
// Caster), while the 轰击术师 ("超远距离的群体法术伤害") and the 阵法术师 strike every enemy inside the attack range at
|
||||
// once, the same damage near and far (Terra Wiki, Blast / Phalanx Caster; PRTS 作战机制 §AOE伤害判定: 伊芙利特's 炎爆 is a
|
||||
// 锁定攻击范围 AoE) — community report E3 after 0.1.0
|
||||
// "群体法术伤害" names two shapes: the 扩散术师 splash 1.1 tiles around the struck target (PRTS 溅射半径一览 — which lists
|
||||
// no splash for the 阵法术师 / 轰击术师 — and Arknights Terra Wiki, Splash Caster), while the 轰击术师 ("超远距离的群体法术
|
||||
// 伤害") and the 阵法术师 strike every enemy inside the attack range at once, the same damage near and far (Terra Wiki,
|
||||
// Blast / Phalanx Caster; PRTS 作战机制 §AOE伤害判定: 伊芙利特's 炎爆 is a 锁定攻击范围 AoE) — community report E3 after 0.1.0
|
||||
splashcaster: P({ splashRadius: 1.1 }),
|
||||
blastcaster: P({ allInRange: true, rangeAoe: true }),
|
||||
blastcaster: P({ rangeAoe: true }),
|
||||
chain: P({ chain: { count: 3, falloff: 0.15, radius: 1.8, sluggish: 0.5 } }),
|
||||
funnel: P({ projectile: 'drone', install: installFunnel,
|
||||
dmgMul: (battle, unit, target) => {
|
||||
@@ -316,7 +318,7 @@ export const SUB = Object.freeze({
|
||||
} }),
|
||||
mystic: P({ install: installMystic,
|
||||
hitsFn: (battle, unit) => { const n = 1 + (unit.trait.stored ?? 0); unit.trait.stored = 0; return n; } }),
|
||||
phalanx: P({ noAttackUnlessSkill: true, allInRange: true, rangeAoe: true, install: installPhalanx }),
|
||||
phalanx: P({ noAttackUnlessSkill: true, rangeAoe: true, install: installPhalanx }),
|
||||
primcaster: P({}),
|
||||
corecaster: P({}),
|
||||
// --- MEDIC
|
||||
@@ -483,10 +485,6 @@ export function resolveProfile(def, kitTrait = null) {
|
||||
// a boomerang thrower (回环射手) always throws its boomerang: the data's generic ranged projectile ('arrow', a
|
||||
// build-data default) would turn the out-and-back flight into a plain shot
|
||||
if (p.boomerang && p.attack === 'ranged') p.projectile = 'boomerang';
|
||||
// a 锁定攻击范围 AoE (阵法术师, 轰击术师) has no projectile: every enemy on the range is struck at the same moment
|
||||
// ('beam' = instant hits, drawn as a line to each victim — PRTS 作战机制 "在攻击前摇结束时选取范围内的全体目标,同时
|
||||
// 造成伤害"); the data's generic ranged projectile ('bolt') would land them one by one
|
||||
if (p.rangeAoe && p.attack === 'ranged') p.projectile = 'beam';
|
||||
if (def.type === 'token') {
|
||||
if (p.dmgType === 'heal') p.heal = p.heal || { mode: 'single' };
|
||||
if (def.stats.atk <= 0) p.noAttack = true;
|
||||
@@ -495,6 +493,13 @@ export function resolveProfile(def, kitTrait = null) {
|
||||
if (p.dmgType !== 'heal' && p.heal) p.heal = null;
|
||||
if (p.dmgType === 'none') p.noAttack = true;
|
||||
if (kitTrait) Object.assign(p, kitTrait);
|
||||
// a 锁定攻击范围 AoE (阵法术师, 轰击术师) strikes every enemy on its range and has no projectile: they are struck at the
|
||||
// same moment ('beam' = instant hits, drawn as a line to each victim — PRTS 作战机制 "在攻击前摇结束时选取范围内的全体
|
||||
// 目标,同时造成伤害"); the data's generic ranged projectile ('bolt') would land them one by one
|
||||
if (p.rangeAoe) {
|
||||
p.allInRange = true;
|
||||
if (p.attack === 'ranged') p.projectile = 'beam';
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
|
||||
@@ -1053,13 +1053,13 @@ test('卡涅利安 S3 食噬之印: wider range, ATK ramps to +atk over the skil
|
||||
assert.ok(u.rangeKeys.length > n0);
|
||||
h.run(sk.duration / 2);
|
||||
approx(u.findBuff('billro:s3atk').mods.atkPct, bb.atk * 0.5, 0.02, `${id}: half-way ramp`);
|
||||
const m = e.findBuff(`billro:mark:${u.id}`);
|
||||
assert.ok(m && m.stacks === 5, `${id}: marks capped at 5 (${m?.stacks})`);
|
||||
const m = e.findBuff('billro:mark');
|
||||
assert.ok(m && m.stacks === 5 && m.source === u, `${id}: marks capped at 5 (${m?.stacks})`);
|
||||
const hits = dmgBy(h, u, (c) => c.dmg.isAttack && c.target === e);
|
||||
const last = hits[hits.length - 1];
|
||||
approx(last.amount / (u.s.atk), 1 + bb['attack@damage_scale'] * 5, 0.05, '+100 % with 5 marks');
|
||||
h.runUntil(() => !u.skill.active, sk.duration);
|
||||
assert.ok(!e.findBuff(`billro:mark:${u.id}`), 'marks end with the skill');
|
||||
assert.ok(!e.findBuff('billro:mark'), 'marks end with the skill');
|
||||
assert.ok(!u.findBuff('billro:s3atk'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -199,14 +199,6 @@ describe('shots', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("an arts blastcaster's bomb explodes purple", () => {
|
||||
const a = unit(1, 3, 10, { info: { defId: 'char_blast' } }), b = unit(2, 6, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [a, b] });
|
||||
fx.ctx.subProfOf = (id) => (id === 'char_blast' ? 'blastcaster' : null);
|
||||
fx.attack(a, b, 'bomb');
|
||||
assert.equal(fx.projs[0].glow, 0xb36bff);
|
||||
});
|
||||
|
||||
test('chain / beam kinds are beams, melee shots are no projectile', () => {
|
||||
const a = unit(1, 3, 10), b = unit(2, 5, 10, { isEnemy: true });
|
||||
const { fx } = makeFx({ views: [a, b] });
|
||||
|
||||
@@ -4,13 +4,18 @@
|
||||
// "对攻击范围内的每个可以被选中的敌人进行判定"; PRTS 林 S3 备注 "单次普攻最多触发1次效果" — one normal attack can kill
|
||||
// several), not one target plus a 1.1-tile splash. The same holds for the 轰击术师 line ("超远距离的群体法术伤害": every
|
||||
// enemy on the line — Terra Wiki Blast Caster; PRTS 作战机制: 伊芙利特's 炎爆 is a 锁定攻击范围 AoE), while the 扩散术师
|
||||
// "群体法术伤害" stays a splash of 1.1 tiles around the struck target (Terra Wiki Splash Caster). Real battles with the
|
||||
// real chess (every selectable attacking skill, normal + elite), counting the enemies each attack damages.
|
||||
// "群体法术伤害" stays a splash of 1.1 tiles around the struck target (PRTS 溅射半径一览 — no row for the 阵法术师 /
|
||||
// 轰击术师 — and Terra Wiki Splash Caster). Real battles with the real chess (every selectable attacking skill, normal +
|
||||
// elite), counting the enemies each attack damages. Her kit per PRTS 卡涅利安 备注: a charged S1 keeps the skill-off trait
|
||||
// (不攻击 + the guard), the charged S3 mark stacks before the damage and is one buff per enemy (the setter's bonus); a
|
||||
// 流形 copy takes no attack shape ('beam' → bolt); `rangeAoe` from any source means every enemy in range, instant.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource, spawnsFromTemplate } from '../../server/sim/simdata.js';
|
||||
import { TOKEN_IDS } from '../../server/sim/content/tokens.js';
|
||||
import { effectiveProfile } from '../../server/sim/ai.js';
|
||||
import { resolveProfile } from '../../server/sim/professions.js';
|
||||
import { getData } from '../../server/data.js';
|
||||
|
||||
const ds = getDefaultSource();
|
||||
@@ -41,7 +46,7 @@ function hitsAll(id, skillIndex, pos, { equal = true, row = 10, col = 5 } = {})
|
||||
const u = h.unit(id);
|
||||
const es = pos.map((p) => h.spawn('enemy_dummy', { pos: p }));
|
||||
if (u.profile.noAttackUnlessSkill) {
|
||||
u.skill.gainSp(u.skill.spCost * Math.max(1, u.skill.maxCharges || 1));
|
||||
u.skill.gainSp(u.skill.spCost); // one charge: an uncharged cast (a charged 卡涅利安 S1 keeps the trait's 不攻击)
|
||||
assert.ok(h.runUntil(() => u.skill.active, 10), `${id}/${skillIndex}: skill on`);
|
||||
}
|
||||
for (const e of es) assert.ok(u.rangeKeys.includes(keyOf(e)), `${id}/${skillIndex}: enemy at ${e.y},${e.x} inside the range`);
|
||||
@@ -180,3 +185,131 @@ test('E3 卡涅利安 S3 食噬之印 values: ATK climbs from +0 % in 1 s steps
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('E3 卡涅利安 S1 沙暴守卫 charged: the skill-off trait stays in force — the DEF/RES guard AND 不攻击 (PRTS 备注 "应用蓄力时:应用技能未开启时的特性"); uncharged she strikes every enemy in range', () => {
|
||||
for (const id of ['chess_char_4_24_a', 'chess_char_4_24_b']) {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 5, skillIndex: 0 }], timeLimit: 120, hooks: ['damaged'], captureNoisy: true });
|
||||
h.step();
|
||||
const u = h.unit(id);
|
||||
const es = SPREAD.map((p) => h.spawn('enemy_dummy', { pos: p }));
|
||||
assert.equal(u.skill.maxCharges, 2, `${id}: S1 stores 2 charges`);
|
||||
u.skill.gainSp(u.skill.spCost * u.skill.maxCharges); // every charge stored: the SEARCH cast is a charged one
|
||||
assert.ok(h.runUntil(() => u.skill.active, 10), `${id}: charged S1 on`);
|
||||
u.skill.charges = 0; // (the spare charge would re-cast as soon as it ends)
|
||||
u.skill.sp = 0;
|
||||
assert.ok(u.findBuff('billro:s1guard'), `${id}: charged — the trait guard stays`);
|
||||
const t0 = h.b.time, dur = u.skill.timeLeft;
|
||||
assert.ok(h.runUntil(() => !u.skill.active, dur + 1), `${id}: the charged S1 ends`);
|
||||
assert.ok(h.b.time - t0 > dur - 0.1, `${id}: ran its ${dur} s`);
|
||||
assert.equal(perAttack(h, u).size, 0, `${id}: no attack during a charged S1`);
|
||||
// uncharged: the guard goes, every attack strikes all five enemies
|
||||
u.skill.gainSp(u.skill.spCost);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 10), `${id}: uncharged S1 on`);
|
||||
assert.ok(!u.findBuff('billro:s1guard'), `${id}: uncharged — no trait guard`);
|
||||
h.run(5);
|
||||
const atks = [...perAttack(h, u).values()];
|
||||
assert.ok(atks.length >= 2, `${id}: uncharged S1 attacks (${atks.length})`);
|
||||
for (const m of atks) assert.equal(m.size, es.length, `${id}: uncharged S1 strikes every enemy in range (${m.size}/${es.length})`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
/** `damage / ATK` of every normal attack `u` lands on `e` (0-RES dummy, ATK read at the hit). */
|
||||
function ratios(h, u, e) {
|
||||
const out = [];
|
||||
h.b.on('damaged', (c) => { if (c.source === u && c.target === e && c.dmg?.isAttack) out.push(c.amount / u.s.atk); }, { priority: -1000 });
|
||||
return out;
|
||||
}
|
||||
|
||||
test('E3 卡涅利安 S3 食噬之印 charged: each attack stacks the mark BEFORE its damage — ×1.2 on the first hit, ×2.0 from the 5th (PRTS 备注), normal + elite', () => {
|
||||
for (const id of ['chess_char_4_24_a', 'chess_char_4_24_b']) {
|
||||
const per = ds.getChess(id, { skillIndex: 2 }).skill.bb['attack@damage_scale'];
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy({ res: 0 }) } }, units: [{ chessId: id, row: 10, col: 5, skillIndex: 2 }], timeLimit: 120, hooks: [], captureNoisy: true });
|
||||
h.step();
|
||||
const u = h.unit(id);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const r = ratios(h, u, e);
|
||||
u.skill.addCharge(2);
|
||||
assert.ok(u.skill.activate('test'));
|
||||
u.skill.charges = 0;
|
||||
u.skill.sp = 0;
|
||||
assert.ok(h.runUntil(() => r.length >= 7, 20), `${id}: 7 hits (${r.length})`);
|
||||
const want = [1, 2, 3, 4, 5, 5, 5].map((n) => 1 + per * n);
|
||||
for (let i = 0; i < want.length; i++) assert.ok(Math.abs(r[i] - want[i]) < 1e-6, `${id}: hit ${i + 1} ×${r[i].toFixed(4)}, want ×${want[i].toFixed(2)}`);
|
||||
assert.equal(e.findBuff('billro:mark').stacks, 5);
|
||||
h.runUntil(() => !u.skill.active, 25);
|
||||
assert.ok(!e.findBuff('billro:mark'), `${id}: the mark ends with her skill`);
|
||||
// uncharged: no mark, no bonus
|
||||
u.skill.gainSp(u.skill.spCost);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 10));
|
||||
const n0 = r.length;
|
||||
assert.ok(h.runUntil(() => r.length >= n0 + 2, 10));
|
||||
for (const x of r.slice(n0)) assert.ok(Math.abs(x - 1) < 1e-6, `${id}: uncharged ×${x}`);
|
||||
assert.ok(!e.findBuff('billro:mark'));
|
||||
}
|
||||
});
|
||||
|
||||
test('E3 卡涅利安 S3 charged: one mark per enemy — a second 卡涅利安 adds stacks without the bonus, the setter keeps it (PRTS 备注 popup)', () => {
|
||||
const A = 'chess_char_4_24_a', B = 'chess_char_4_24_b';
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy({ res: 0 }) } }, timeLimit: 120, hooks: [], captureNoisy: true,
|
||||
units: [{ chessId: A, row: 10, col: 5, skillIndex: 2 }, { chessId: B, row: 10, col: 7, skillIndex: 2 }],
|
||||
});
|
||||
h.step();
|
||||
const a = h.unit(A), b = h.unit(B);
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] }); // on both ranges
|
||||
const ra = ratios(h, a, e), rb = ratios(h, b, e);
|
||||
const charge = (u) => { u.skill.addCharge(2); assert.ok(u.skill.activate('test')); u.skill.charges = 0; u.skill.sp = 0; };
|
||||
charge(a);
|
||||
assert.ok(h.runUntil(() => ra.length >= 1, 5));
|
||||
assert.equal(e.findBuff('billro:mark').source, a, 'set by the first');
|
||||
charge(b);
|
||||
assert.ok(h.runUntil(() => rb.length >= 4 && ra.length >= 4, 15));
|
||||
const m = e.findBuff('billro:mark');
|
||||
assert.equal(e.buffs.filter((x) => x.key === 'billro:mark').length, 1, 'one mark');
|
||||
assert.equal(m.source, a, 'still the setter\'s');
|
||||
assert.equal(m.stacks, 5, 'both stacked it');
|
||||
for (const x of rb) assert.ok(Math.abs(x - 1) < 1e-6, `the second 卡涅利安 gets no bonus (×${x})`);
|
||||
assert.ok(Math.abs(ra[ra.length - 1] - 2) < 1e-6, `the setter ×${ra[ra.length - 1]} with 5 stacks`);
|
||||
// the setter's skill ends: her mark goes; the next charged hit of the other sets a mark of its own
|
||||
a.skill.stop();
|
||||
assert.ok(!e.findBuff('billro:mark'), 'the mark ends with the setter\'s skill');
|
||||
const n0 = rb.length;
|
||||
assert.ok(h.runUntil(() => rb.length > n0, 5));
|
||||
assert.equal(e.findBuff('billro:mark').source, b);
|
||||
assert.ok(Math.abs(rb[n0] - 1.2) < 1e-6, `its own mark ×${rb[n0]}`);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('E3 audit: a 流形 copying a 阵法术师 / 轰击术师 fires a single-target bolt — the copy takes no attack shape (tokens.js and the 缪尔赛思 kit)', () => {
|
||||
const MF = TOKEN_IDS.manifold;
|
||||
for (const kit of ['managed', 'token']) {
|
||||
for (const src of ['chess_char_4_10_a', 'chess_char_4_24_a']) {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy() } }, timeLimit: 60, autoFinish: false, hooks: ['damaged'], captureNoisy: true,
|
||||
...(kit === 'token' ? { kits: { chess_char_6_11_a: () => ({ skill: null, talents: [], generic: true }) } } : {}),
|
||||
units: [{ chessId: 'chess_char_6_11_a', row: 12, col: 2, uid: 1 }, { chessId: src, row: 12, col: 6, uid: 2 }, { kind: 'token', tokenId: MF, row: 9, col: 6, uid: 3, ownerUid: 1 }],
|
||||
});
|
||||
h.step();
|
||||
const m = h.b.allyUnits.find((u) => u.defId === MF && u.alive);
|
||||
assert.ok(h.runUntil(() => m.mem.mlyss || m.mem.copy, 10), `${kit}/${src}: copied`);
|
||||
assert.equal((m.mem.mlyss || m.mem.copy).from ?? m.mem.copy?.id, h.unit(src).id);
|
||||
assert.equal(m.profile.projectile, 'bolt', `${kit}/${src}: a bolt, not the instant 'beam'`);
|
||||
assert.ok(!m.profile.allInRange && !m.profile.rangeAoe, `${kit}/${src}: single target`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('E3 profile: rangeAoe is applied after every override — from a kit trait too it means every enemy in range with instant hits', () => {
|
||||
const def = { profession: 'CASTER', subProf: 'corecaster', attackKind: 'ranged', projectile: 'bolt', dmgType: 'arts', stats: { atk: 100 } };
|
||||
const plain = resolveProfile(def);
|
||||
assert.equal(plain.allInRange, false);
|
||||
assert.equal(plain.projectile, 'bolt');
|
||||
const aoe = resolveProfile(def, { rangeAoe: true });
|
||||
assert.equal(aoe.allInRange, true);
|
||||
assert.equal(aoe.projectile, 'beam');
|
||||
for (const sub of ['phalanx', 'blastcaster']) {
|
||||
const p = resolveProfile({ ...def, subProf: sub, attackKind: sub === 'phalanx' ? 'none' : 'ranged', projectile: sub === 'phalanx' ? 'none' : 'bolt' });
|
||||
assert.deepEqual([p.rangeAoe, p.allInRange, p.projectile, p.splashRadius], [true, true, 'beam', 0], sub);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user