feedback1 integrate: follow-ups from the workstream notes (official rules)
- an operator's radius area damage skips unblocked 隐匿 and untargetable enemies (Battle.foesInRadius; PRTS 作战机制 AOE伤害判定), the 逐火 embers included - PRTS 溅射半径一览: 投掷手 0.9, 链术师 1.7, 格雷伊 1.0; 迷迭香 S2 radius 1.5 - 阿罗玛's first-attack mark is shared between 阿罗玛 and leaves with its setter (PRTS 备注) - 奥术法阵 on the field: 炎佑's damage silences its target (PRTS 盟约记录 备注) - skills restart from their initial cooldown when a 重生 ends (PRTS 特殊机制) - 高准度伦蒂尼姆城防自行炮: lock in range, 3x3 area, every 0.5 s up to 10 shots, 失衡/控制免疫 meanwhile - 琳琅诗怀雅 S3 coins go to range 2-4 in front and the units she blocks - 阿戈尔 devour is a 物理流失 reduced by the target's DEF
This commit is contained in:
1 parent
4e68100065
commit
75e2f02590
21 files changed
+346
-112
No files matched your search
+13
-10
@@ -259,15 +259,17 @@ D1 audit): radius 1.2, a tick every 0.5 s, one tick per unit per 0.5 s however m
|
||||
enemy alive hides the knock-out from every later `kill` handler, the kill count, kill credit and the bounty — they all
|
||||
wait for the real death, the only one with a `die` event. Every 重生 (`reborn()`, `husk()`, `statue()`) clears what
|
||||
operators put on the enemy — the buffs with an ally source and source-less catalogue statuses (PRTS 特殊机制 §重生
|
||||
"清空自身身上除白名单外所有Buff"; `rebirthCleanse`) — and keeps [ASSUMED: the whitelist] its `persist` talents, what it or
|
||||
"清空自身身上除白名单外所有Buff"; `rebirthCleanse`), restarts its skills from their initial cooldown when the 重生 ends
|
||||
("重生结束时,重置自身的通用技能与当前形态的技能冷却为初始冷却"; `rebirthCooldowns`, since 0.1.1) — and keeps [ASSUMED: the
|
||||
whitelist] its `persist` talents, what it or
|
||||
another enemy gave it (锏's self-applied 抵抗, enemy auras) and source-less field state (terrain, airflow, element burst
|
||||
locks): a 逐火 knocked out while feared is no unblockable ember. 重生 / form changes: `reborn()` (first knock-out → second
|
||||
form; fx forms 'reborn' → 'form2'), `statue()` (守墓石像: melee only while blocked; first knock-out → 10 s unblockable,
|
||||
immobile statue → a flyer with ranged arts attacks that skip flyers; forms 'stone' → 'fly'), `husk()` (talent
|
||||
Revive[Trigger], every knock-out: 1 s 重生 — 无敌, 无法阻挡, immobile — then a hit-count husk that
|
||||
walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted by operators (the
|
||||
engine's radius area damage — `enemiesInRadius`: profession splash, skill circles — still reaches an unblocked one, a
|
||||
known deviation: officially an AoE judges only the enemies it can select, PRTS 作战机制 §AOE伤害判定); 假想敌:再生's 傀儡 is unblockable and, as it begins, shields the other enemies within 1.8; a husk
|
||||
walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted or struck by
|
||||
operators (radius area damage too: `Battle.foesInRadius`, PRTS 作战机制 §AOE伤害判定 — until 0.1.1 it reached an unblocked
|
||||
one); 假想敌:再生's 傀儡 is unblockable and, as it begins, shields the other enemies within 1.8; a husk
|
||||
still standing after `Revive[Trigger].interval` s stands up again with full HP),
|
||||
转译基底·α (its original form cancels every damage instance, and an HP loss stops at 1 HP; the 4th physical / arts
|
||||
instance or a block starts a 2 s change). Each form change goes through `setForm(b, e, form, fxKind, params)`: the
|
||||
@@ -482,7 +484,7 @@ of coverage per 3 s), kept because the current wording no longer says so (feedba
|
||||
| `tremble` (战栗) | 被阻挡后无法进行普通攻击: no normal attack **while blocked** (abilities still fire) | – |
|
||||
| `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed); refused by 麻痹免疫 (data `palsyImmune`) | stacks, default 1 |
|
||||
| `disarm` | no normal attacks | – |
|
||||
| `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it) / cancels stealth. An enemy's b.snap stealth bit is set only while its 隐匿 is on (not blocked, not revealed: drawn solid otherwise); radius splash (`enemiesInRadius`) ignores it [known deviation, PRTS 作战机制 §AOE伤害判定] | – |
|
||||
| `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it) / cancels stealth. An enemy's b.snap stealth bit is set only while its 隐匿 is on (not blocked, not revealed: drawn solid otherwise); an operator's radius area damage (`foesInRadius`) skips it too (PRTS 作战机制 §AOE伤害判定 "对攻击范围内的每个可以被选中的敌人进行判定"; until 0.1.1 the splash still hit it) | – |
|
||||
| `camou` (迷彩) | an ally's camouflage (ba.camou "不阻挡时不成为敌方普通攻击的目标"): like `stealth` for enemy targeting (only the enemy it blocks attacks it) and on screen (b.snap stealth bit, `snapshot.js flagsOf`), but not 隐匿 for 隐匿-conditions (叙拉古, 家族徽章) and under its own buff keys. 忍冬 S3 (key `vulpis:camou`, until her next cast), 寒芒克洛丝 S1 | – |
|
||||
| `invulnerable` | ignores damage | – |
|
||||
| `levitate` (浮空) | stun + unblockable (unblocks enemies) + 失衡免疫 (`noDisplace`); an air unit meanwhile (`isFlying`: melee cannot hit it); **half duration on units with (current) massLevel > 3**; refused on data flyers (`motion` FLY) and units already levitated (PRTS 异常效果 "若单位数据上为飞行单位…或是持有浮空异常则Buff取消") — a 近地悬浮 enemy is WALK in its data, so it can be levitated; 浮空 is not one of the 近地悬浮 enemies' drop triggers | – |
|
||||
@@ -587,7 +589,8 @@ and 扎罗's 溶血骇惧; "受到N真实伤害" over time is damage (PRTS 伤
|
||||
`'dot'` / `'periodic'` (player report D1). The 源石溶剂 drain also ticks on every 敌人类我方单位 of the field (炎佑, a
|
||||
partner's too: PRTS 备注 "全场范围内的所有敌人类我方单位也会获得此装备的…效果…无视目标可选性"), credited to nobody, once per
|
||||
second however many carriers (PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"), while a carrier is on the field
|
||||
[ASSUMED]. On a leader
|
||||
[ASSUMED]. 奥术法阵 has the same 备注 for its rider: while a carrier is on the field, every damage instance of such a unit
|
||||
silences its target for the item's 5 s ("造成伤害时使目标失去特殊能力5秒"; since 0.1.1). On a leader
|
||||
in a boss / hidden battle a loss of ≥ `BOSS_HIT_LIMIT` (a part's 传递, a drone's death) is cancelled like a hit. Every HP-damage kind
|
||||
meets the limit (phys / arts / true / 元素伤害 incl. element bursts, DoT ticks); element 损伤 (the gauge, `type: 'element'`)
|
||||
removes no HP and never does.
|
||||
@@ -651,7 +654,7 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
|
||||
| `isReservedTile(r, c)` | true when a living unit stands there or it is the home tile of an ally piece that has not deployed yet / waits to redeploy — summon tile pickers must skip these (`findTacticalPoint` does) |
|
||||
| `addProjectile({ from, target | to:{x,y}, speed, onHit(ctx), visual, source, hitDead })` | homing; fizzles if the target dies unless `hitDead` |
|
||||
| `allySelectable(ally, by)`, `alliesFor(by, ownerId?)` | may an ability of ally `by` select `ally` — never a 孤立 unit (炎佑, 从不混淆的方向) but `by` itself (PRTS 选择器: "若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"); `allies()` without the 孤立 ones — content picks ally targets (buffs, auras, 全场 talents, heal picks) through these, `alliesInGrid` too |
|
||||
| `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r, centre?)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir`; enemies by their body (§2 hit areas: a huge enemy on every tile it occupies / within `r` of its rectangle; `centre` = splash around a target, a 中点判定 by position) |
|
||||
| `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r, centre?)`, `foesInRadius(x, y, r, centre?)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir`; enemies by their body (§2 hit areas: a huge enemy on every tile it occupies / within `r` of its rectangle; `centre` = splash around a target, a 中点判定 by position; `foesInRadius` = the enemies an operator's area effect can select — no untargetable, no unblocked unrevealed 隐匿 enemy, PRTS 作战机制 §AOE伤害判定 — while enemy-side auras / heals and collisions keep `enemiesInRadius`) |
|
||||
| `enemiesInKeys(keys, attacker, profile)`, `blockedTargets(unit, profile)` | targetable enemies whose body is on the tiles (a huge one listed once); the enemies a unit blocks — always selectable by it, a ranged operator on a melee tile included (§1.2 Blocking) |
|
||||
| `allies(ownerId?)`, `aliveEnemies()`, `unitAt(r, c)`, `unitById(id)`, `tileInfo(r, c)`, `lowestHpAllyInRange(unit)` | |
|
||||
| `addLayers(playerId, bondId, n, reason, {source})`, `addCoins(playerId, n)` | layers are a no-op when `flags.layerGainsEnabled` is false (unite/boss); a gain adds at most the room left under `BOND_LAYER_CAP` (999, shared/constants.js `layerGainRoom`: the client's `AddBondCount` min(L + n, 999)) on the live copy — or, without one, on the battle's own gains — and returns what it added (0 at the cap: no hook, no event) |
|
||||
@@ -994,13 +997,13 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡
|
||||
| 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 (PRTS 溅射半径一览: 扩散术师 1.1; Arknights Terra Wiki, Splash Caster). [OPEN] the same table gives 炮手 1.0 (none in the pool) and 格雷伊 1.0 |
|
||||
| aoesniper / splashcaster | splash 1.1 tiles around the struck target at full damage (PRTS 溅射半径一览: 扩散术师 1.1; Arknights Terra Wiki, Splash Caster); 格雷伊 1.0 (the same table's 特殊 row; TUNE.splashcaster, 1.1 until 0.1.1). The table's 炮手 1.0 has no chess in the pool |
|
||||
| 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 (primary: PRTS 作战机制 §AOE伤害判定 names 伊芙利特's 炎爆 a 锁定攻击范围 AoE, and 炎爆 is her next-attack skill "下次攻击造成…" (PRTS 伊芙利特 S2), so a 轰击术师 normal attack; secondary: Terra Wiki, Blast Caster; supporting: PRTS 溅射半径一览 documents no splash radius for it; 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 |
|
||||
| bombarder | ground-only splash 0.9 (PRTS 溅射半径一览: 投掷手 0.9; 1.0 until 0.1.1) + aftershock(s) at 50 % ATK (bb append_atk_scale / times); 迷迭香's S2 末梢阻断 1.5 (the same table) |
|
||||
| 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. [OPEN] PRTS 溅射半径一览 gives 链术师 1.7 |
|
||||
| chain | chain N (trait text/bb max_target) with −15 % per jump (bb chain.atk_scale), 1.7-tile jumps (constants.js CHAIN_RADIUS, PRTS 溅射半径一览: 链术师 1.7; 1.8 until 0.1.1), sluggish on each hit |
|
||||
| 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 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'`) — "群体法术伤害" (secondary: Terra Wiki, Phalanx Caster: "attacks hit all enemies within their range"; supporting: PRTS 林 S3 备注 "单次普攻最多触发1次效果" — one normal attack can kill several — the same 锁定攻击范围 shape as the 轰击术师, and PRTS 溅射半径一览 documents no splash radius for it (it omits the 撼地者 too, so this is not proof); no primary source states a target cap; 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 备注) |
|
||||
|
||||
@@ -385,7 +385,7 @@ Members (11 chess, 10 in current shop pool; by tier in shop: {'1': 1, '2': 1, '3
|
||||
- Caps: 3 revives per battle
|
||||
- How layers are gained: Devour (tier per devoured unit, every battle); 幽灵鲨 被击倒 +3; 归溟幽灵鲨 被击倒/替身切换 +5 阿戈尔 +5 不屈; 斯卡蒂 each 2 kills +1 阿戈尔/坚守/突袭; 海霓 first kill (enemy or ally) +3 阿戈尔/奥术; 机变 "斯卡蒂的盟誓" +8. Strategy 克莱门莎: an 阿戈尔 knocked out adds layers = its tier.
|
||||
- How it plays: Put cheap/high-tier fodder in front of 阿戈尔 carries: they steal base ATK and block, gain layers, and (5) revive. Tier-6 fodder gives +6 layers per battle.
|
||||
- [ASSUMED] 5000 physical 流失 ignores DEF and shields
|
||||
- 5000 physical 流失: less the target's DEF (PRTS 作战机制: a 物理/法术流失 "会受到目标当前防御力/法术抗性影响而相应衰减"; DEF-free [ASSUMED] until 0.1.1), no shields, dodge or damage multipliers (a 流失)
|
||||
- [ASSUMED] marked allied units are the player's own operators (devour hits allies)
|
||||
- Bond item (with 变形同构体 grants this bond): 阿戈尔重刃 `chess_item_3_07_e` (2 gold: 攻击力+40%,攻击速度-10)
|
||||
- Garrisons that explicitly add layers to this bond: `garrison_38`, `garrison_40`, `garrison_46`, `garrison_131` (see section 4; plus the generic ones)
|
||||
|
||||
@@ -1567,6 +1567,26 @@ export class Battle {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The enemies within `r` (as enemiesInRadius) an ally-side area effect can select — PRTS 作战机制 §AOE伤害判定 "AOE的判定是
|
||||
* 对攻击范围内的每个可以被选中的敌人进行判定", 隐匿 "隐匿状态下的单位一般无法被敌方的索敌机制和Buff选择器选中为目标": no
|
||||
* untargetable enemy and no 隐匿 one unless revealed or blocked (tile selectors — enemiesInKeys / canTargetEnemy — already
|
||||
* skip them). Flying and asleep enemies stay the caller's choice. Enemy-side effects on other enemies (auras, heals) and
|
||||
* physical collisions keep enemiesInRadius. Player report #8 after 0.1.0 (the 逐火 余烬): until 0.1.1 profession splash
|
||||
* and skill circles still reached an unblocked 隐匿 enemy.
|
||||
*/
|
||||
foesInRadius(x, y, r, centre = false) {
|
||||
const out = this.enemiesInRadius(x, y, r, centre);
|
||||
let n = 0;
|
||||
for (const e of out) {
|
||||
const f = e.s.flags;
|
||||
if (f.untargetable || (f.stealth && !f.reveal && !e.blockedBy)) continue;
|
||||
out[n++] = e;
|
||||
}
|
||||
out.length = n;
|
||||
return out;
|
||||
}
|
||||
|
||||
alliesInRadius(x, y, r, ownerId = null, { includeDevices = false } = {}) {
|
||||
const out = [];
|
||||
const r2 = r * r + 1e-9;
|
||||
|
||||
+3
-3
@@ -24,7 +24,7 @@
|
||||
// flutters inside its own tile); an `attract` (诱导) status walks it to the status point instead (moveAttracted);
|
||||
// both re-plan the route when released (恐惧 outranks 诱导).
|
||||
|
||||
import { ATTACK_PAUSE, ALLY_COLLIDER_RADIUS, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, BOOMERANG_RETURN_SPEED, COLS } from './constants.js';
|
||||
import { ATTACK_PAUSE, ALLY_COLLIDER_RADIUS, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, BOOMERANG_RETURN_SPEED, COLS, CHAIN_RADIUS } from './constants.js';
|
||||
import { sortEnemyTargets, sortAllyTargets, canTargetEnemy, canTargetAlly, tileKeyOf } from './targeting.js';
|
||||
import { reduceElement } from './damage.js';
|
||||
import { straightClear } from './grid.js';
|
||||
@@ -235,7 +235,7 @@ export function resolveHit(b, u, prof, target, info, x, y) {
|
||||
if (prof.splashRadius > 0) {
|
||||
const r = prof.splashRadius;
|
||||
const sc = prof.splashScale ?? 1;
|
||||
for (const e of b.enemiesInRadius(x, y, r, true)) {
|
||||
for (const e of b.foesInRadius(x, y, r, true)) {
|
||||
if (e === target) continue;
|
||||
if (prof.groundOnly && e.isFlying) continue;
|
||||
if (!prof.canHitFly && e.isFlying && !prof.splashHitsFly) continue;
|
||||
@@ -252,7 +252,7 @@ export function resolveHit(b, u, prof, target, info, x, y) {
|
||||
const n = Math.max(1, prof.chain.count || 3);
|
||||
for (let k = 1; k < n; k++) {
|
||||
let best = null, bd = Infinity;
|
||||
for (const e of b.enemiesInRadius(prev.x, prev.y, prof.chain.radius || 1.8)) {
|
||||
for (const e of b.enemiesInRadius(prev.x, prev.y, prof.chain.radius || CHAIN_RADIUS)) {
|
||||
if (hit.has(e.id) || !canTargetEnemy(u, e, prof)) continue;
|
||||
const d = Math.hypot(e.x - prev.x, e.y - prev.y);
|
||||
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; }
|
||||
|
||||
@@ -216,3 +216,6 @@ export const BOSS_POOL_MIN_HP = 1;
|
||||
* the base attributes) stay multipliers.
|
||||
*/
|
||||
export const DIRECT_BONUS_STACKING = 'add';
|
||||
|
||||
/** 链术师 jump radius (PRTS 溅射半径一览, 特性: "链术师 … 1.7"; 1.8 until 0.1.1). */
|
||||
export const CHAIN_RADIUS = 1.7;
|
||||
@@ -36,6 +36,7 @@
|
||||
// death 10), layerGain −100 (after 魔王-style modifiers).
|
||||
|
||||
import * as S from '../support/index.js';
|
||||
import { mitigate } from '../../damage.js';
|
||||
import { spawnYanyou } from '../tokens.js';
|
||||
import { kjeragColdWind } from '../devices.js';
|
||||
import * as items from '../items.js';
|
||||
@@ -335,8 +336,10 @@ function installLaterano(battle, pid, bb, members) {
|
||||
* members the tiles in front of
|
||||
* those (chain); never themselves, a unit already marked by them or a unit that marked them. The marker gains the base
|
||||
* ATK (atkFlat) and block count of everything it marked; then each mark makes its target lose damage_value HP as a
|
||||
* 流失 (battle.loseHp: no DEF, shields, dodge or damage multipliers — research "5000-point physical 流失"; the kill is
|
||||
* credited to the marker) in marking order. A unit that died cancels its remaining marks (as target and as marker).
|
||||
* 物理流失 (PRTS 盟约记录: "造成5000点物理流失", 修正 "【吞噬】的物理流失来源为被付与目标自身;单位被【吞噬】击杀时,击杀来源始终为
|
||||
* 对应标记的付与来源"; PRTS 作战机制: a 物理流失 "会受到目标当前防御力…影响而相应衰减") — less the target's DEF as a physical hit
|
||||
* (its own source: no DEF ignore), then battle.loseHp: no shields, dodge or damage multipliers (DEF-free until 0.1.1); the
|
||||
* kill is credited to the marker — in marking order. A unit that died cancels its remaining marks (as target and as marker).
|
||||
* Each devoured operator adds its tier to 阿戈尔 once (IN_BATTLE gain, disabled in 联防 / boss fields).
|
||||
* Tokens / devices / empty tiles are never devoured.
|
||||
*/
|
||||
@@ -382,7 +385,7 @@ function devour(battle, pid, bb, members) {
|
||||
for (const [m, t] of marks) {
|
||||
if (!t.alive || !m.alive) continue;
|
||||
S.fxOn(battle, 'devour', t, 'bond:egirShip', 'devour', { from: m.id });
|
||||
if (amount > 0) battle.loseHp(t, amount, { source: m, tags: ['bond:egir:devour'] });
|
||||
if (amount > 0) battle.loseHp(t, mitigate(amount, 'phys', t.s), { source: m, tags: ['bond:egir:devour'] });
|
||||
if (!layered.has(t)) {
|
||||
layered.add(t);
|
||||
S.gainLayers(battle, { playerId: pid, bonds: 'egirShip', n: S.tierOf(t), source: m, reason: 'bond' });
|
||||
@@ -479,7 +482,7 @@ function installKazimierz(battle, pid, bb, members) {
|
||||
battle.every(iv, () => {
|
||||
for (const m of members) {
|
||||
if (!S.onField(m) || !m.blocking || !m.blocking.length) continue;
|
||||
const hits = battle.enemiesInRadius(m.x, m.y, radius);
|
||||
const hits = battle.foesInRadius(m.x, m.y, radius);
|
||||
if (!hits.length) continue;
|
||||
const amount = scale * m.s.atk;
|
||||
for (const e of hits) {
|
||||
|
||||
@@ -45,10 +45,10 @@
|
||||
// gauge whose burst is the official one (termDescription ba.dt.erosion: "永久降低100点防御力并受到800点物理伤害").
|
||||
// DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit; 毒雾, 燃烧区域),
|
||||
// bleeding (removed by healing), pulsing auras.
|
||||
// INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed). Radius area damage
|
||||
// (Battle.enemiesInRadius: profession splash around a struck target, many skills' circles) still hits
|
||||
// an unblocked one — a known deviation: officially an AoE only judges the enemies it can select (PRTS
|
||||
// 作战机制 §AOE伤害判定; ba.invisible vs ba.camou); tile selectors (enemiesInKeys) already skip it.
|
||||
// INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed). An operator's radius area
|
||||
// damage skips an unblocked one too (Battle.foesInRadius: profession splash around a struck target,
|
||||
// skill circles — PRTS 作战机制 §AOE伤害判定 "对攻击范围内的每个可以被选中的敌人进行判定"; until 0.1.1 it
|
||||
// still hit it); tile selectors (enemiesInKeys) always skipped it.
|
||||
// REFLECTION — 折射 (ba.refraction "生效时,法术抗性+70"): RES +refracting.magic_resistance while NOT silenced
|
||||
// (the ability line is SILENCE-flagged: silencing turns it off); 镜膜 also gets max HP +100 % while on.
|
||||
// SPECIAL — mostly stats; prisoners, 穿刺手, 暴虐兵长, 镜卫, 动力装甲 … below.
|
||||
@@ -68,7 +68,7 @@
|
||||
// hidden tabs (shared/protocol.js fxForm).
|
||||
// Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss.
|
||||
|
||||
import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE, PROJECTILE_SPEEDS } from '../constants.js';
|
||||
import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE, PROJECTILE_SPEEDS, ALLY_COLLIDER_RADIUS } from '../constants.js';
|
||||
import { canTargetAlly, sortAllyTargets, aggroCmp } from '../targeting.js';
|
||||
import { mitigate, periodicDamage } from '../damage.js';
|
||||
|
||||
@@ -123,8 +123,11 @@ const GRAB_RADIUS_SCALE = 2.5, GRAB_MAX_PREY = 3, GRAB_DELAY = 0.5;
|
||||
/** 孽罪奇美拉 污染模式 aura (PRTS 天赋 "自身半径1.2范围内的所有单位…每0.5秒受到50真实持续伤害(同类效果取最高)"): radius,
|
||||
* damage period (s). */
|
||||
const CHIMERA_AURA_RADIUS = 1.2, CHIMERA_AURA_EVERY = 0.5;
|
||||
/** Bombardments per 自行炮 Cannon cast ("数次") [ASSUMED]. */
|
||||
const CANNON_SHOTS = 3;
|
||||
/** 自行炮 Cannon (PRTS 高准度伦蒂尼姆城防自行炮 技能 "以攻击范围内生命上限最高的我方单位为中心,生成9格的炮击区域,6s内每0.5s对炮击
|
||||
* 区域中生命比例最高的我方单位进行攻击…(最多进行10次攻击)"): shot interval (s), shot cap; 3 shots 1 s apart [ASSUMED] until
|
||||
* 0.1.1. 备注: during the skill animation 失衡免疫 + 晕眩 / 冻结 / 浮空 / 沉睡免疫 — held over the bombardment [ASSUMED length]. */
|
||||
const CANNON_EVERY = 0.5, CANNON_SHOTS = 10;
|
||||
const CANNON_IMMUNE = Object.freeze(['stun', 'freeze', 'cold', 'levitate', 'sleep']);
|
||||
/** 枯朽之种 summoned per BornBugs cast ("数个") [ASSUMED]. */
|
||||
const BUGS_PER_CAST = 3;
|
||||
/** Radius of 骸骨拷打者's "周围" death sensing when its talent has no Attack.range_radius [ASSUMED]. */
|
||||
@@ -737,6 +740,14 @@ function freeAllPrisoners(b) {
|
||||
* knocked out while feared (叙拉古 / 妮芙: 恐惧 makes it unblockable) stayed unblockable — so, 隐匿, untargetable — as an
|
||||
* ember until the fear ran out.
|
||||
*/
|
||||
/**
|
||||
* The end of a 重生 (PRTS 特殊机制 §重生 "重生结束时,重置自身的通用技能与当前形态的技能冷却为初始冷却"): every ability with a
|
||||
* cooldown starts again from its initial cooldown (until 0.1.1 the countdowns ran on through the 重生).
|
||||
*/
|
||||
function rebirthCooldowns(e) {
|
||||
for (const s of (e.mem.ab && e.mem.ab.list) || []) if (s.cd != null) s.left = num(s.icd, 0);
|
||||
}
|
||||
|
||||
function rebirthCleanse(b, e) {
|
||||
for (const x of e.buffs.slice()) {
|
||||
if (x.persist || (x.source && x.source.side === 'enemy')) continue;
|
||||
@@ -764,6 +775,7 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu
|
||||
if (invincible > 0) b.addBuff(e, { key: `${key}:inv`, duration: invincible, visible: true, flags: { invulnerable: true } });
|
||||
if (e.route) e.route.pts = null;
|
||||
e.atkCd = 0;
|
||||
rebirthCooldowns(e);
|
||||
setForm(b, e, 'form2', 'revive', { kind: 'reborn' });
|
||||
};
|
||||
return {
|
||||
@@ -900,7 +912,7 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke
|
||||
b.addBuff(e, { key: `${key}:reborn`, duration: HUSK_REBIRTH, flags: { invulnerable: true, untargetable: true, unblockable: true, noMove: true, noDisplace: true } });
|
||||
if (e.route) e.route.pts = null;
|
||||
setForm(b, e, 'husk', 'ember', { hits, dur: HUSK_REBIRTH + delay });
|
||||
if (onHusk) b.after(HUSK_REBIRTH, () => { if (e.alive && a.state === 'husk') onHusk(b, e); }, { owner: e });
|
||||
b.after(HUSK_REBIRTH, () => { if (e.alive && a.state === 'husk') { rebirthCooldowns(e); if (onHusk) onHusk(b, e); } }, { owner: e });
|
||||
b.after(HUSK_REBIRTH + delay, () => revive(b, e, a), { owner: e });
|
||||
return true;
|
||||
},
|
||||
@@ -926,6 +938,7 @@ function statue(ab) {
|
||||
a.done = true;
|
||||
a.noAtk = e.profile.noAttack;
|
||||
rebirthCleanse(b, e); // "进行重生。重生瞬间完成"
|
||||
rebirthCooldowns(e);
|
||||
e.profile.noAttack = true;
|
||||
e.hp = e.s.maxHp;
|
||||
ab2.immune = new Set([...(ab2.immune || []), 'levitate']);
|
||||
@@ -2503,17 +2516,28 @@ export const KITS = Object.freeze({
|
||||
enemy_1026_aghost: () => [unblockable()], // 幽灵组长 · unblockable
|
||||
enemy_1062_rager_2: (ab) => [{ iv: 1, tick(b, e) { const v = T(ab, 'periodic_damage.damage') ?? 0; if (v > 0) b.dealDamage(null, e, periodicDamage(v)); } }], // 狂暴宿主组长 · "自身每秒受到500无来源真实伤害" (damage, not 流失)
|
||||
enemy_1183_mlasrt: (ab) => [ep('erosion', T(ab, 'EpDamage.attack@ep_damage_ratio') ?? 0), nthAttackPower(nthOf(ab.sk.PowerAttack), (ab.sk.PowerAttack && ab.sk.PowerAttack.bb.atk_scale) || 1)], // 无胄盟清扫小队 · erosion; every 4th attack ×1.5
|
||||
enemy_1273_stmgun_2: (ab) => [skill(ab.sk.Cannon, (b, e) => { // 高准度伦蒂尼姆城防自行炮 · locks the highest max-HP unit, bombards the highest HP% around it
|
||||
const lock = allTargets(b, e).sort((p, q) => q.s.maxHp - p.s.maxHp)[0];
|
||||
if (!lock) return;
|
||||
const x = lock.x, y = lock.y;
|
||||
b.fx('telegraph', { x, y, r: 1, dur: CANNON_SHOTS, kind: 'cannon' });
|
||||
for (let i = 1; i <= CANNON_SHOTS; i++) b.after(i, () => {
|
||||
if (!e.alive) return;
|
||||
const t = b.alliesInRadius(x, y, 1).sort((p, q) => q.hpRatio - p.hpRatio)[0];
|
||||
if (t) { b.fx('explode', { x: t.x, y: t.y, r: 0.5, kind: 'cannon' }); hurt(b, e, t, e.s.atk * (ab.sk.Cannon.bb.atk_scale ?? 0), 'arts'); }
|
||||
}, { owner: e });
|
||||
}, { cond: (b, e) => allTargets(b, e).length > 0 })],
|
||||
enemy_1273_stmgun_2: (ab) => { // 高准度伦蒂尼姆城防自行炮 · locks the highest max-HP unit in range, bombards the highest HP% on its 9 tiles
|
||||
// the allies in its range (the engine's ranged reach: radius + the ally collider)
|
||||
const inRange = (b, e) => allTargets(b, e).filter((a) => Math.hypot(a.x - e.x, a.y - e.y) <= (e.base.rangeRadius || 0) + ALLY_COLLIDER_RADIUS + 1e-9);
|
||||
return [skill(ab.sk.Cannon, (b, e) => {
|
||||
const lock = inRange(b, e).sort((p, q) => q.s.maxHp - p.s.maxHp)[0];
|
||||
if (!lock) return;
|
||||
const r0 = Math.round(lock.y), c0 = Math.round(lock.x);
|
||||
const dur = CANNON_SHOTS * CANNON_EVERY;
|
||||
b.fx('telegraph', { x: c0, y: r0, r: 1.5, dur, kind: 'cannon' });
|
||||
b.addBuff(e, { key: 'ab:cannon', duration: dur, flags: { noDisplace: true } });
|
||||
const ab2 = e.mem.ab;
|
||||
const had = new Set(ab2.immune || []);
|
||||
ab2.immune = new Set([...had, ...CANNON_IMMUNE]);
|
||||
for (let i = 1; i <= CANNON_SHOTS; i++) b.after(i * CANNON_EVERY, () => {
|
||||
if (!e.alive) return;
|
||||
if (i === CANNON_SHOTS) ab2.immune = had;
|
||||
const t = b.allies().filter((a) => canTargetAlly(e, a, true) && Math.abs(Math.round(a.y) - r0) <= 1 && Math.abs(Math.round(a.x) - c0) <= 1)
|
||||
.sort((p, q) => q.hpRatio - p.hpRatio)[0];
|
||||
if (t) { b.fx('explode', { x: t.x, y: t.y, r: 0.5, kind: 'cannon' }); hurt(b, e, t, e.s.atk * (ab.sk.Cannon.bb.atk_scale ?? 0), 'arts'); }
|
||||
}, { owner: e });
|
||||
}, { cond: (b, e) => inRange(b, e).length > 0 })];
|
||||
},
|
||||
// 萨卡兹骸骨拷打者 · heals + ATK stack when a unit within Attack.range_radius is knocked out; leaves 2 血珀 on death — they
|
||||
// stay where it fell and wait for a 唤血祭坛 that this mode has none of (inert, not counted)
|
||||
enemy_1364_spnaxe_2: (ab) => [{
|
||||
|
||||
@@ -368,7 +368,7 @@ function installGeneric(spec) {
|
||||
if (!src || src.side !== 'enemy' || !ctx.dmg || !ctx.dmg.isAttack) return;
|
||||
readyAt = battle.time + c.cooldown;
|
||||
const amount = (c.stat === 'def' ? unit.s.def : unit.s.atk) * c.scale;
|
||||
const victims = c.around ? battle.enemiesInRadius(unit.x, unit.y, 1.5).filter((e) => !(c.groundOnly && e.isFlying)) : (src.alive ? [src] : []);
|
||||
const victims = c.around ? battle.foesInRadius(unit.x, unit.y, 1.5).filter((e) => !(c.groundOnly && e.isFlying)) : (src.alive ? [src] : []);
|
||||
for (const e of victims) {
|
||||
const dealt = amount > 0 ? battle.dealDamage(unit, e, { amount, type: c.type, canDodge: false, isSkill: true, tags: ['counter'] }) : 0;
|
||||
if (c.applyElement) c.applyElement(battle, unit, e, dealt);
|
||||
|
||||
@@ -339,10 +339,20 @@ const BY_BUFF = {
|
||||
}
|
||||
} });
|
||||
},
|
||||
// 奥术法阵: 攻击使目标失去特殊能力 silence s
|
||||
// 奥术法阵: 攻击使目标失去特殊能力 silence s. PRTS 盟约记录 备注 (as 源石溶剂's): "携带后,全场范围内的所有敌人类我方单位也会获得
|
||||
// 此装备的“造成伤害时使目标失去特殊能力5秒”效果(该效果的付与为我方阵营索敌,可对空,无视目标可选性)" — the damage of every
|
||||
// enemy-record unit on our side (炎佑, a partner's too) silences its target while a carrier is on the field [ASSUMED: it
|
||||
// ends with its carriers]; one effect per damage instance however many carriers.
|
||||
silence_attachment(battle, u, p, S) {
|
||||
const d = num(p.silence);
|
||||
if (d > 0) onAttackEnemies(S, u, (t) => battle.applyStatus(t, 'silence', { duration: d, source: u }));
|
||||
if (!(d > 0)) return;
|
||||
onAttackEnemies(S, u, (t) => battle.applyStatus(t, 'silence', { duration: d, source: u }));
|
||||
S.on('damaged', (c) => {
|
||||
const a = c.source, t = c.target;
|
||||
if (!a || a === u || a.side !== 'ally' || !ENEMY_RECORD.test(a.defId ?? '') || !t || t.side !== 'enemy' || !t.alive || !onField(u)) return;
|
||||
if (c.dmg) { if (c.dmg.arcaneSilence) return; c.dmg.arcaneSilence = true; }
|
||||
battle.applyStatus(t, 'silence', { duration: d, source: a });
|
||||
});
|
||||
},
|
||||
// 海沟实验体: 伤害减免 value per damage instance (after DEF/RES)
|
||||
halfidle_block_fixed_damage(battle, u, p, S) {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
// counter {id} · crit {id} · dp {n, id} · heal {id} · taunt {id} · summon {id, token} · pull {id} · sonic {radius} ·
|
||||
// shield {id} · overload {id} · takeoff {id} · sleep {id} · buff {id, kind} · reveal {id} · dodge (engine kind).
|
||||
|
||||
import { COLS } from '../../constants.js';
|
||||
import { COLS, CHAIN_RADIUS } from '../../constants.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets } from '../../targeting.js';
|
||||
import { frontOf, offsetTile } from '../../dir.js';
|
||||
import { bodyInKeys, bodyOnTile, bodyTileReach } from '../../body.js';
|
||||
@@ -276,7 +276,7 @@ export function tinmanKit(bb, chess, def) {
|
||||
unit.mem.tinZones = (unit.mem.tinZones ?? 0) + 1;
|
||||
makeZone(battle, unit, {
|
||||
x, y, radius, duration: dur, skill: 'tinman', onPulse(b) {
|
||||
for (const e of b.enemiesInRadius(x, y, radius)) {
|
||||
for (const e of b.foesInRadius(x, y, radius)) {
|
||||
if (e.isFlying || e.s.flags.untargetable) continue;
|
||||
if (wither > 1) b.addBuff(e, { key: witherKey, duration: 1.05, data: { mul: wither }, source: unit });
|
||||
b.dealDamage(unit, e, { amount: atk * dmgScale, type: 'arts', isSkill: true, canDodge: false, tags: ['dot', 'zone'] });
|
||||
@@ -390,7 +390,7 @@ export default {
|
||||
} }],
|
||||
install(battle, unit) {
|
||||
// the chain shape (count / sluggish, elite module included) comes from the resolved chain profile
|
||||
const ch = unit.profile.chain || { count: num(traitBb(chess)['attack@max_target'], 3), radius: 1.8, sluggish: num(traitBb(chess)['attack@sluggish'], 0.5) };
|
||||
const ch = unit.profile.chain || { count: num(traitBb(chess)['attack@max_target'], 3), radius: CHAIN_RADIUS, sluggish: num(traitBb(chess)['attack@sluggish'], 0.5) };
|
||||
if (unit.skill && unit.skill.spec.attack) unit.skill.spec.attack.chain = { ...ch, falloff: 0 };
|
||||
},
|
||||
};
|
||||
@@ -590,7 +590,7 @@ export default {
|
||||
battle.addDp(unit.ownerId, num(bb.cost));
|
||||
battle.fx('dp', { x: unit.x, y: unit.y, n: num(bb.cost), id: unit.id });
|
||||
const grid = def?.skill?.rangeGrid;
|
||||
const foes = grid ? enemiesInGrid(battle, unit, grid) : battle.enemiesInRadius(unit.x, unit.y, RING1).filter((e) => !e.s.flags.untargetable);
|
||||
const foes = grid ? enemiesInGrid(battle, unit, grid) : battle.foesInRadius(unit.x, unit.y, RING1).filter((e) => !e.s.flags.untargetable);
|
||||
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 2, id: unit.id, skill: 'swordRain' });
|
||||
for (const e of foes) {
|
||||
for (let i = 0; i < 2 && e.alive; i++) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill'] });
|
||||
@@ -776,7 +776,7 @@ export default {
|
||||
const atk = unit.s.atk;
|
||||
makeZone(battle, unit, {
|
||||
x, y, radius: SPORE_RADIUS, duration: dur, skill: 'spores', onPulse(b) {
|
||||
for (const e of b.enemiesInRadius(x, y, SPORE_RADIUS)) {
|
||||
for (const e of b.foesInRadius(x, y, SPORE_RADIUS)) {
|
||||
if (e.s.flags.untargetable) continue;
|
||||
b.applyStatus(e, 'sluggish', { duration: 1.05, source: unit });
|
||||
b.applyStatus(e, 'silence', { duration: 1.05, source: unit });
|
||||
|
||||
@@ -681,7 +681,7 @@ export default {
|
||||
for (const a of alliesInGridOf(battle, unit)) {
|
||||
if (a === unit || a.kind !== 'op') continue;
|
||||
battle.fx('sonic', { x: a.x, y: a.y, radius: rad, id: unit.id });
|
||||
for (const e of battle.enemiesInRadius(a.x, a.y, rad)) {
|
||||
for (const e of battle.foesInRadius(a.x, a.y, rad)) {
|
||||
if (!e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'arts', isSkill: true, isSplash: true, tags: ['sonic'] });
|
||||
}
|
||||
}
|
||||
@@ -756,7 +756,7 @@ export default {
|
||||
kind: 'duration', mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, attack: { hitAllBlocked: true },
|
||||
onStart({ battle, unit }) {
|
||||
const grid = def?.skill?.rangeGrid;
|
||||
const foes = grid ? enemiesInGrid(battle, unit, grid, { canHitFly: false, groundOnly: true }) : battle.enemiesInRadius(unit.x, unit.y, 1.5).filter((e) => !e.isFlying);
|
||||
const foes = grid ? enemiesInGrid(battle, unit, grid, { canHitFly: false, groundOnly: true }) : battle.foesInRadius(unit.x, unit.y, 1.5).filter((e) => !e.isFlying);
|
||||
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'resolve' });
|
||||
for (const e of foes) battle.applyStatus(e, 'tremble', { duration: num(bb.not_combat), source: unit });
|
||||
},
|
||||
@@ -840,7 +840,7 @@ export default {
|
||||
battle.fx('zone', { x, y, radius, dur, id: unit.id, skill: 'tinman1' });
|
||||
battle.every(STEP, (b, sc) => {
|
||||
const pulse = i % per === 0;
|
||||
for (const e of b.enemiesInRadius(x, y, radius)) {
|
||||
for (const e of b.foesInRadius(x, y, radius)) {
|
||||
if (e.isFlying || e.s.flags.untargetable) continue;
|
||||
if (weak > 0) b.applyStatus(e, 'weaken', { duration: STEP + 0.05, value: weak, source: unit });
|
||||
if (wither > 1) b.addBuff(e, { key: 'tinman:wither', duration: STEP + 0.05, data: { mul: wither }, source: unit });
|
||||
|
||||
@@ -82,7 +82,7 @@ function profileMul(battle, unit, target) {
|
||||
}
|
||||
/** Targetable enemies within `r` tiles of (x, y), nearest first (spawn order breaks ties), excluding `skip`. */
|
||||
function enemiesAround(battle, unit, x, y, r, skip = null) {
|
||||
const out = battle.enemiesInRadius(x, y, r).filter((e) => !(skip && skip.has(e)) && canTargetEnemy(unit, e, { canHitFly: true }));
|
||||
const out = battle.foesInRadius(x, y, r).filter((e) => !(skip && skip.has(e)) && canTargetEnemy(unit, e, { canHitFly: true }));
|
||||
const d = (e) => bodyDist(e, x, y);
|
||||
return out.sort((a, b) => d(a) - d(b) || (a.spawnSeq ?? a.id) - (b.spawnSeq ?? b.id));
|
||||
}
|
||||
@@ -113,6 +113,8 @@ const gridKeys = (grid, u, ext = 0) => absoluteRangeKeys(grid || [[0, 0]], u.til
|
||||
const fx = (battle, kind, u, extra = {}) => battle.fx(kind, { x: u.x, y: u.y, id: u.id, ...extra });
|
||||
const copyGrid = (g) => (Array.isArray(g) && g.length ? g.map((p) => [p[0], p[1]]) : null);
|
||||
const NINE = [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]];
|
||||
/** 琳琅诗怀雅 S3's coin range (PRTS 备注 "前方范围2-4"; range_table "2-4", facing right). */
|
||||
const SWIRE2_COIN_GRID = Object.freeze([[1, 1], [0, 0], [0, 1], [0, 2], [-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. */
|
||||
@@ -401,11 +403,11 @@ const KITS = {
|
||||
// 大买家: coin at skill start + coin & ATK stack per trait payment; 破财消灾: DP-paid revive (cost doubles)
|
||||
// S1 仗义疏财 (passive, 2 coins): an attack spends a coin to heal the most injured ally (< 70 % HP) of the 8
|
||||
// surrounding tiles for attack@heal_scale × ATK. S3 千金一掷 (持续时间无限): attacks hit twice, kills give a coin;
|
||||
// closing it spends every coin on random ground enemies of the front range (atk_scale phys + a small push, radial
|
||||
// despite the text's 向前 — PRTS 备注 "推开效果为径向推动"; client charpack char_1033_swire2: the RandomGold ability
|
||||
// (Skill_3_End) carries swire2_s_3[knockback] of template knockback[relative]; 地面敌方单位, 弹道不可对空).
|
||||
// Auto-close (the mode casts everything itself; the player's "主动关闭" is not available): once the purse is full
|
||||
// (10) and an enemy stands in range. 精锐 module MER-Y: ATK +4 % per trait payment (≤ 5 stacks).
|
||||
// closing it spends every coin on random ground enemies of range 2-4 in front and those she blocks (atk_scale phys +
|
||||
// a small push, radial despite the text's 向前 — PRTS 备注 "推开效果为径向推动"; client charpack char_1033_swire2:
|
||||
// the RandomGold ability (Skill_3_End) carries swire2_s_3[knockback] of template knockback[relative]; 地面敌方单位,
|
||||
// 弹道不可对空). Auto-close (the mode casts everything itself; the player's "主动关闭" is not available): once the
|
||||
// purse is full (10) and a coin target stands there. 精锐 module MER-Y: ATK +4 % per trait payment (≤ 5 stacks).
|
||||
chess_char_3_04_a: (bb, chess, def) => {
|
||||
const d = defOf(chess, def);
|
||||
const t0 = talentBb(d, 0), t1 = talentBb(d, 1);
|
||||
@@ -476,22 +478,29 @@ const KITS = {
|
||||
[S1]: () => ({ kind: 'passive' }), // (coins → heals: installS1)
|
||||
[S3]: (s) => {
|
||||
const cash = num(s.bb.atk_scale, 1), force = num(s.bb.force, 0), full = num(s.bb.sp, 10);
|
||||
// the 【金币标记】 targets: ground enemies on range 2-4 in front of her (range_table "2-4") and every unit she blocks
|
||||
const coinMarks = (battle, unit) => {
|
||||
const list = enemiesOn(battle, unit, gridKeys(SWIRE2_COIN_GRID, unit), 0, { ...unit.profile, canHitFly: false });
|
||||
for (const e of unit.blocking || []) if (e.alive && !e.isFlying && !list.includes(e)) list.push(e);
|
||||
return list;
|
||||
};
|
||||
return {
|
||||
kind: 'toggle',
|
||||
attack: { hits: 2 },
|
||||
onTick({ battle, unit, skill }) {
|
||||
if ((unit.mem.coins ?? 0) >= full && enemiesOn(battle, unit, unit.rangeKeys, 0, { ...unit.profile, canHitFly: false }).length) skill.end('manual');
|
||||
if ((unit.mem.coins ?? 0) >= full && coinMarks(battle, unit).length) skill.end('manual');
|
||||
},
|
||||
onEnd({ battle, unit, reason }) {
|
||||
if (reason !== 'manual' || !unit.alive) return;
|
||||
const n = unit.mem.coins ?? 0;
|
||||
unit.mem.coins = 0;
|
||||
let spent = 0;
|
||||
// PRTS 备注: 【金币标记】 goes on "前方范围内的地面敌方单位与自身阻挡的所有单位" and the coins are "弹道(不可对空)" —
|
||||
// air units (FLY, 近地悬浮, 浮空) are never paid
|
||||
const ground = { ...unit.profile, canHitFly: false };
|
||||
// PRTS 备注: closing it "立即对前方范围2-4内的地面敌方单位与自身阻挡的所有单位施加【金币标记】", the coins going to
|
||||
// marked units at random — 弹道(不可对空): air units (FLY, 近地悬浮, 浮空) are never paid. Until 0.1.1 the
|
||||
// coins went to her attack range (1-1) instead of range 2-4.
|
||||
const marked = coinMarks(battle, unit);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const e = battle.rng.pick(enemiesOn(battle, unit, unit.rangeKeys, 0, ground));
|
||||
const e = battle.rng.pick(marked.filter((x) => x.alive && !x.isFlying));
|
||||
if (!e) break;
|
||||
spent++;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * cash, type: 'phys', isSkill: true, tags: ['skill', 'swire2Cash'] });
|
||||
|
||||
@@ -43,6 +43,8 @@ const LINE = Object.freeze(Array.from({ length: COLS }, (_, i) => Object.freeze(
|
||||
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';
|
||||
/** 阿罗玛's 非首次标记 (talent 起泡性能测试): one per enemy, shared by every 阿罗玛 (PRTS 备注). */
|
||||
const AROMA_MARK = 'aroma:bubbled';
|
||||
const BILLRO_MARK_MAX = 5;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
@@ -863,14 +865,26 @@ const kits = {
|
||||
}),
|
||||
skill: { kind: 'duration', mods: { atkPct: num(bb.atk) }, onStart({ battle, unit }) { battle.fx('slippery', { x: unit.x, y: unit.y, id: unit.id }); } },
|
||||
talents: [{ install(battle, unit) { // first attack on each enemy: ×1.1 and levitate 2.5 s
|
||||
unit.mem.bubbled = new Set();
|
||||
// PRTS 阿罗玛 备注: "天赋采用施加非首次标记的方式判断是否为'首次进行攻击',自身离场时移除自身已施加的标记,不同阿罗玛之间的
|
||||
// 非首次标记通用" — one mark per enemy (AROMA_MARK, the setter as source) for every 阿罗玛; hers go when she leaves
|
||||
// the field (until 0.1.1 each 阿罗玛 kept her own list, so two of them both triggered on one enemy)
|
||||
unit.mem.bubbled = new Set(); // the enemies she marked
|
||||
battle.on('hit', (c) => {
|
||||
const e = c.target;
|
||||
if (c.source !== unit || !c.dmg.isAttack || e.side !== 'enemy' || unit.mem.bubbled.has(e.id)) return;
|
||||
if (c.source !== unit || !c.dmg.isAttack || e.side !== 'enemy' || e.findBuff(AROMA_MARK)) return;
|
||||
battle.addBuff(e, { key: AROMA_MARK, source: unit });
|
||||
unit.mem.bubbled.add(e.id);
|
||||
c.dmg.mul *= num(t0.damage_scale, 1.1);
|
||||
if (battle.applyStatus(e, 'levitate', { duration: num(t0.levitate_duration, 2.5), source: unit })) battle.fx('levitate', { x: e.x, y: e.y, id: e.id });
|
||||
}, { owner: unit });
|
||||
battle.on('death', (c) => {
|
||||
if (c.unit !== unit) return;
|
||||
for (const id of unit.mem.bubbled) {
|
||||
const e = battle.unitById(id);
|
||||
if (e && e.findBuff(AROMA_MARK)?.source === unit) battle.removeBuff(e, AROMA_MARK);
|
||||
}
|
||||
unit.mem.bubbled.clear();
|
||||
}, { owner: unit });
|
||||
} }],
|
||||
install(battle, unit) {
|
||||
// S2: landing damage for every levitated enemy (any source) that lands inside her range while the skill runs
|
||||
@@ -979,7 +993,7 @@ const kits = {
|
||||
onTick({ battle, unit, dt }) {
|
||||
const T = unit.mem.tornado;
|
||||
if (!T) return;
|
||||
const inside = battle.enemiesInRadius(T.x, T.y, R);
|
||||
const inside = battle.foesInRadius(T.x, T.y, R);
|
||||
for (const e of inside) pulse(battle, e, `glady:slow:${unit.id}`, { moveMul: Math.max(0, 1 + num(bb.move_speed, -0.5)) });
|
||||
T.acc += dt;
|
||||
if (T.acc + 1e-9 < iv) return;
|
||||
@@ -996,7 +1010,7 @@ const kits = {
|
||||
const T = unit.mem.tornado;
|
||||
unit.mem.tornado = null;
|
||||
if (!T || reason === 'death' || !unit.alive) return;
|
||||
for (const e of battle.enemiesInRadius(T.x, T.y, R)) pullSelf(battle, unit, e, force);
|
||||
for (const e of battle.foesInRadius(T.x, T.y, R)) pullSelf(battle, unit, e, force);
|
||||
battle.fx('pull', { x: T.x, y: T.y, id: unit.id });
|
||||
},
|
||||
},
|
||||
@@ -1946,7 +1960,7 @@ const kits = {
|
||||
if (S2 && skillActive(unit)) {
|
||||
// S2: motes orbit wider and hit enemies instead of operators
|
||||
const hitAt = unit.mem.moteHit || (unit.mem.moteHit = new Map());
|
||||
const foes = battle.enemiesInRadius(unit.x, unit.y, num(bb.outside_radius, 2)).filter((e) => canTargetEnemy(unit, e, { canHitFly: true }))
|
||||
const foes = battle.foesInRadius(unit.x, unit.y, num(bb.outside_radius, 2)).filter((e) => canTargetEnemy(unit, e, { canHitFly: true }))
|
||||
.sort((a, b) => a.id - b.id);
|
||||
for (const e of foes) {
|
||||
if ((hitAt.get(e.id) ?? -Infinity) > battle.time + 1e-9) continue;
|
||||
|
||||
@@ -446,7 +446,7 @@ const KITS = {
|
||||
unit.mem.titiWardAcc = 0;
|
||||
for (const a of unit.mem.titiWard || []) {
|
||||
if (!a.alive || !a.deployed || !a.findBuff('titi:ward')) continue;
|
||||
for (const e of battle.enemiesInRadius(a.x, a.y, RING1)) if (e.alive) battle.applyStatus(e, 'sleep', { duration: AURA_DUR, source: unit });
|
||||
for (const e of battle.foesInRadius(a.x, a.y, RING1)) if (e.alive) battle.applyStatus(e, 'sleep', { duration: AURA_DUR, source: unit });
|
||||
}
|
||||
},
|
||||
onEnd({ battle, unit }) {
|
||||
@@ -507,7 +507,7 @@ const KITS = {
|
||||
const asleep = new Map();
|
||||
// PRTS 备注: "向1.5半径内除该单位外仇恨值最高的敌方单位施加沉睡" — highest aggro (taunt) first, then the nearest
|
||||
const sleepOthers = (from) => {
|
||||
const cands = battle.enemiesInRadius(from.x, from.y, radius).filter((o) => o !== from && !o.s.flags.sleep && !o.mem?.candleOwner)
|
||||
const cands = battle.foesInRadius(from.x, from.y, radius).filter((o) => o !== from && !o.s.flags.sleep && !o.mem?.candleOwner)
|
||||
.sort((a, b) => (b.s.taunt || 0) - (a.s.taunt || 0) || dist(a, from) - dist(b, from) || a.spawnSeq - b.spawnSeq);
|
||||
let n = 0;
|
||||
for (const o of cands) {
|
||||
@@ -604,7 +604,7 @@ const KITS = {
|
||||
battle.addBuff(t, {
|
||||
key: `blaze2:aid:${unit.id}`, duration: num(bb.max_duration, 20), interval: Math.max(0.1, num(bb.interval, 1)), visible: true, data: { src: unit },
|
||||
onTick: ({ unit: x }) => {
|
||||
for (const e of battle.enemiesInRadius(x.x, x.y, r)) {
|
||||
for (const e of battle.foesInRadius(x.x, x.y, r)) {
|
||||
const d = battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'blazeAid'] });
|
||||
if (d > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'burn', amount: d * num(bb.element_multiplier), tags: ['skill', 'blazeAid'] });
|
||||
}
|
||||
@@ -676,7 +676,7 @@ const KITS = {
|
||||
if (u.deploySeq !== dep || u.hp < u.s.maxHp - 1e-6) return;
|
||||
battle.removeBuff(u, buff);
|
||||
u.mem.downed = false;
|
||||
for (const e of battle.enemiesInRadius(u.x, u.y, NEARBY_RADIUS)) if (e.alive) battle.applyStatus(e, 'stun', { duration: num(t1.stun), source: u });
|
||||
for (const e of battle.foesInRadius(u.x, u.y, NEARBY_RADIUS)) if (e.alive) battle.applyStatus(e, 'stun', { duration: num(t1.stun), source: u });
|
||||
battle.fx('revive', { x: u.x, y: u.y, id: u.id, r: NEARBY_RADIUS });
|
||||
},
|
||||
});
|
||||
@@ -793,7 +793,7 @@ const KITS = {
|
||||
// for this one: it never lands on or catches air units (FLY, 近地悬浮, 浮空)
|
||||
const main = list.find((e) => !reach(e) && !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.isFlying);
|
||||
if (!main) return;
|
||||
const near = battle.enemiesInRadius(main.x, main.y, RING1).filter((e) => !e.isFlying && !e.s.flags.untargetable && !e.s.flags.sleep)
|
||||
const near = battle.foesInRadius(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 force = num(bb.force, 1);
|
||||
@@ -827,7 +827,7 @@ const KITS = {
|
||||
const [sr, sc] = frontOf(unit.tileR, unit.tileC, unit.dir, stop ?? 0);
|
||||
const fromX = unit.x, fromY = unit.y;
|
||||
battle.fx('anchor', { x: sc, y: sr, id: unit.id, fromX, fromY, r: radius });
|
||||
for (const e of battle.enemiesInRadius(sc, sr, radius)) {
|
||||
for (const e of battle.foesInRadius(sc, sr, radius)) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'anchor'] });
|
||||
if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun), source: unit });
|
||||
}
|
||||
@@ -894,7 +894,7 @@ const KITS = {
|
||||
const hpScale = num(bb['attack@max_hp_scale'], 0.6), defScale = num(bb['attack@def_scale'], 1), resScale = num(bb['attack@magic_resistance_scale'], 1);
|
||||
const nCandles = Math.max(1, num(bb['attack@max_target'], 3));
|
||||
const candleKey = talentRec(chess, 2)?.bbStr?.take_extra_enemy_key ?? CANDLE_KEY;
|
||||
const aroundN = (battle, a) => battle.enemiesInRadius(a.x, a.y, RING1).length;
|
||||
const aroundN = (battle, a) => battle.foesInRadius(a.x, a.y, RING1).length;
|
||||
return {
|
||||
skills: lazySkills({
|
||||
skchr_etlchi_1: () => ({ kind: instantKind(chess, def), attack: { atkScale: num(bb.atk_scale, 1), hits: 2 } }),
|
||||
@@ -917,7 +917,7 @@ const KITS = {
|
||||
// PRTS 备注 "被添加血镰的单位处于起飞时,血镰可对空": a sickle on the ground spares air units (FLY, 近地悬浮, 浮空);
|
||||
// 起飞 = an airborne skywalker (蒂比's skill: off the ground, flag blockFly)
|
||||
const air = !a.ground && !!a.s.flags.blockFly;
|
||||
for (const e of battle.enemiesInRadius(a.x, a.y, RING1)) {
|
||||
for (const e of battle.foesInRadius(a.x, a.y, RING1)) {
|
||||
if (e.isFlying && !air) continue;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'bloodSickle'] });
|
||||
}
|
||||
@@ -1939,7 +1939,7 @@ const KITS = {
|
||||
if (z.anchor && z.anchor.alive && z.anchor.deployed) { z.x = z.anchor.x; z.y = z.anchor.y; }
|
||||
const iv = Math.max(0.1, num(bb.interval, 1));
|
||||
const steps = Math.min(Math.max(0, num(bb.max_stack_cnt, 15)), Math.floor((z.t - AURA_IV + 1e-9) / iv));
|
||||
const foes = battle.enemiesInRadius(z.x, z.y, RING1);
|
||||
const foes = battle.foesInRadius(z.x, z.y, RING1);
|
||||
for (const e of foes) seaHits.set(e, Math.max(seaHits.get(e) ?? -1, steps));
|
||||
if (z.acc >= iv - 1e-9) {
|
||||
z.acc -= iv;
|
||||
@@ -2037,7 +2037,7 @@ const KITS = {
|
||||
z.x = Math.max(R.c0, Math.min(R.c1, z.x + z.vx * AURA_IV));
|
||||
z.y = Math.max(R.r0, Math.min(R.r1, z.y + z.vy * AURA_IV));
|
||||
const r = r0 + grow * z.t;
|
||||
const foes = battle.enemiesInRadius(z.x, z.y, r).filter((e) => !e.isFlying);
|
||||
const foes = battle.foesInRadius(z.x, z.y, r).filter((e) => !e.isFlying);
|
||||
for (const e of foes) battle.addBuff(e, { key: 'thorn2:rot', duration: AURA_DUR, mods: { healingTakenMul: healMul } });
|
||||
if (z.acc >= 1 - 1e-9) {
|
||||
z.acc -= 1;
|
||||
@@ -2252,7 +2252,7 @@ const KITS = {
|
||||
{ install(battle, unit) { // 游侠
|
||||
const dr = num(t0.damage_resistance);
|
||||
whileOn(battle, unit, 0.2, () => {
|
||||
unit.mem.mlyNear = battle.enemiesInRadius(unit.x, unit.y, AROUND_RADIUS).length;
|
||||
unit.mem.mlyNear = battle.foesInRadius(unit.x, unit.y, AROUND_RADIUS).length;
|
||||
if (dr > 0 && unit.mem.mlyNear >= num(t0.cnt, 3)) battle.addBuff(unit, { key: 'mlynar:ranger', duration: 0.3, mods: { dmgTakenMul: 1 - dr } });
|
||||
});
|
||||
} },
|
||||
@@ -2399,7 +2399,7 @@ const KITS = {
|
||||
battle.fx('iceSpike', { x: c, y: r, id: unit.id, r: ICICLE_RADIUS });
|
||||
battle.after(ICICLE_DELAY, () => {
|
||||
const hit = [];
|
||||
for (const e of battle.enemiesInRadius(c, r, ICICLE_RADIUS)) {
|
||||
for (const e of battle.foesInRadius(c, r, ICICLE_RADIUS)) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isAttack: true, isSkill: true, isSplash: true, tags: ['skill', 'iceSpike'] });
|
||||
if (e.alive && cold > 0) battle.applyStatus(e, 'cold', { duration: cold, source: unit });
|
||||
if (e.alive) hit.push(e);
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
|
||||
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy, aggroCmp } from '../../targeting.js';
|
||||
import { aggregateMods } from '../../buffs.js';
|
||||
import { COLS, ROWS, PULL_STOP_RADIUS } from '../../constants.js';
|
||||
import { COLS, ROWS, PULL_STOP_RADIUS, CHAIN_RADIUS } from '../../constants.js';
|
||||
import { rotateOffset } from '../../dir.js';
|
||||
import { bodyDist, bodyInKeys, bodyInRadius, bodyKeys } from '../../body.js';
|
||||
import { hasHp } from '../../damage.js';
|
||||
@@ -449,7 +449,7 @@ function lemuen(bb, chess, def) {
|
||||
};
|
||||
const blast = (battle, unit, atk, x, y) => {
|
||||
battle.fx('bombard', { x, y, id: unit.id, r: d2 });
|
||||
for (const e of battle.enemiesInRadius(x, y, d2)) {
|
||||
for (const e of battle.foesInRadius(x, y, d2)) {
|
||||
if (e.s.flags.untargetable) continue;
|
||||
const d = bodyDist(e, x, y);
|
||||
battle.dealDamage(unit, e, { amount: atk * (d <= d1 + 1e-9 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] });
|
||||
@@ -1078,7 +1078,7 @@ function pasngr(bb, chess, def) {
|
||||
const t0 = tbb(def, 0), t1 = tbb(def, 1), tb = def?.traitBb || {};
|
||||
const skillGrid = def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null;
|
||||
const strike = (battle, unit, first, scale) => {
|
||||
const ch0 = unit.profile?.chain || { count: 4, falloff: 0.15, radius: 1.8 };
|
||||
const ch0 = unit.profile?.chain || { count: 4, falloff: 0.15, radius: CHAIN_RADIUS };
|
||||
// elite module (电磁调节器) upgrades the skill's chain too: skill@chain.atk_scale / skill@sluggish
|
||||
const ch = { ...ch0, falloff: tb['skill@chain.atk_scale'] != null ? 1 - num(tb['skill@chain.atk_scale']) : num(ch0.falloff, 0.15) };
|
||||
const count = Math.max(1, Math.floor(num(bb['chain.max_target'], ch.count || 4)));
|
||||
@@ -1091,7 +1091,7 @@ function pasngr(bb, chess, def) {
|
||||
battle.dealDamage(unit, prev, { amount: unit.s.atk * scale * Math.pow(1 - num(ch.falloff, 0.15), i), type: 'arts', isSkill: true, isAttack: true, tags: ['skill', 'storm'] });
|
||||
if (slug > 0 && prev.alive) battle.applyStatus(prev, 'sluggish', { duration: slug, source: unit });
|
||||
let best = null, bd = Infinity;
|
||||
for (const x of battle.enemiesInRadius(prev.x, prev.y, ch.radius || 1.8)) {
|
||||
for (const x of battle.foesInRadius(prev.x, prev.y, ch.radius || CHAIN_RADIUS)) {
|
||||
if (hit.has(x.id) || !canTargetEnemy(unit, x, ANY)) continue;
|
||||
const d = bodyDist(x, prev.x, prev.y);
|
||||
if (d < bd - 1e-9) { bd = d; best = x; }
|
||||
@@ -1100,7 +1100,7 @@ function pasngr(bb, chess, def) {
|
||||
}
|
||||
};
|
||||
// the chain of her profile (trait / module: bounce count, falloff, 停顿) with skill overrides
|
||||
const chainOf = (unit, o) => ({ ...(unit.profile?.chain || { count: 4, falloff: 0.15, radius: 1.8, sluggish: 0.5 }), ...o });
|
||||
const chainOf = (unit, o) => ({ ...(unit.profile?.chain || { count: 4, falloff: 0.15, radius: CHAIN_RADIUS, sluggish: 0.5 }), ...o });
|
||||
const skills = {
|
||||
// S1 电能之触: next attack at atk_scale × ATK, bouncing over max_target enemies with a sluggish-s 停顿 (the module's
|
||||
// skill@pasngr_s_1.chain.atk_scale sets its falloff)
|
||||
@@ -1140,7 +1140,7 @@ function pasngr(bb, chess, def) {
|
||||
const h = battle.every(iv, () => {
|
||||
if (!live(unit) || unit.deploySeq !== seq) { h.cancel(); return; } // the storm ends when she leaves the field
|
||||
if (++k >= n) h.cancel();
|
||||
const zone = battle.enemiesInRadius(cx, cy, STORM_RADIUS).filter((e) => canTargetEnemy(unit, e, ANY));
|
||||
const zone = battle.foesInRadius(cx, cy, STORM_RADIUS).filter((e) => canTargetEnemy(unit, e, ANY));
|
||||
const e = battle.rng.pick(zone);
|
||||
if (e) strike(battle, unit, e, scale);
|
||||
}, { owner: unit });
|
||||
@@ -1271,7 +1271,7 @@ function pepe(bb, chess, def) {
|
||||
if (ctx.attacker !== unit) return;
|
||||
const t = ctx.targets[0];
|
||||
const r = num(ctx.profile?.splashRadius, 1);
|
||||
unit.mem.pepeBoost = !!t && battle.enemiesInRadius(t.x, t.y, r).length >= cnt;
|
||||
unit.mem.pepeBoost = !!t && battle.foesInRadius(t.x, t.y, r).length >= cnt;
|
||||
}, { owner: unit });
|
||||
battle.on('hit', (ctx) => { if (ctx.source === unit && ctx.dmg.isAttack && unit.mem.pepeBoost) ctx.dmg.mul *= sc; }, { owner: unit });
|
||||
battle.on('attack', (ctx) => { if (ctx.attacker === unit) unit.mem.pepeBoost = false; }, { owner: unit, priority: -100 });
|
||||
@@ -1505,7 +1505,7 @@ function reed2(bb, chess, def) {
|
||||
battle.after(0, () => {
|
||||
if (!live(unit)) return;
|
||||
battle.fx('scorchBurst', { x, y, id: unit.id, r: aoeR });
|
||||
for (const e of battle.enemiesInRadius(x, y, aoeR, true)) { // splash around the victim: 中点判定
|
||||
for (const e of battle.foesInRadius(x, y, aoeR, true)) { // splash around the victim: 中点判定
|
||||
if (!e.alive) continue;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * aoe, type: 'arts', isSkill: true, isSplash: true, tags: ['skill', 'scorch'] });
|
||||
scorch(battle, unit, e);
|
||||
@@ -2024,7 +2024,8 @@ function mlyss(bb, chess, def) {
|
||||
// ------------------------------------------------------------------------------------------------------------------
|
||||
// 迷迭香 chess_char_6_12 (投掷手) — S2 末梢阻断; 歼灭战装备; 感知稳定
|
||||
|
||||
const ROSMON_SPLASH_MUL = 1.3; // [ASSUMED] "溅射范围扩大" (not in data)
|
||||
/** S2 末梢阻断 "溅射范围扩大": radius 1.5 (PRTS 溅射半径一览, 技能: 迷迭香 末梢阻断 1.5; ×1.3 [ASSUMED] until 0.1.1). */
|
||||
const ROSMON_S2_SPLASH = 1.5;
|
||||
|
||||
function rosmon(bb, chess, def) {
|
||||
const t0 = tbb(def, 0), t1 = tbb(def, 1);
|
||||
@@ -2075,7 +2076,7 @@ function rosmon(bb, chess, def) {
|
||||
onStart({ unit }) {
|
||||
const p = unit.profile;
|
||||
unit.mem.rosSaved = { r: p.splashRadius, n: p.shockTimes };
|
||||
p.splashRadius = num(p.splashRadius, 1) * ROSMON_SPLASH_MUL;
|
||||
p.splashRadius = Math.max(num(p.splashRadius, 0.9), ROSMON_S2_SPLASH);
|
||||
p.shockTimes = num(p.shockTimes, 2) + Math.floor(num(bb.add_times));
|
||||
},
|
||||
onEnd({ unit }) {
|
||||
@@ -2176,7 +2177,7 @@ function angel2(bb, chess, def) {
|
||||
if (!c) return;
|
||||
const r = c.tileR, col = c.tileC;
|
||||
battle.fx('airstrike', { x: col, y: r, id: unit.id, r: AIRSTRIKE_RADIUS });
|
||||
for (const e of battle.enemiesInRadius(col, r, AIRSTRIKE_RADIUS)) {
|
||||
for (const e of battle.foesInRadius(col, r, AIRSTRIKE_RADIUS)) {
|
||||
if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb['attack@cannon_atk_scale'], 1), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'delivery'] });
|
||||
}
|
||||
const waiting = battle.allyUnits.filter((a) => a.kind === 'op' && a.ownerId === unit.ownerId && !a.alive && !a.removed && a !== unit
|
||||
@@ -2251,7 +2252,7 @@ function angel2(bb, chess, def) {
|
||||
sortEnemyTargets(battle, u, cands, null);
|
||||
const c = cands[0];
|
||||
battle.fx('airstrike', { x: c.x, y: c.y, id: unit.id, r: AIRSTRIKE_RADIUS });
|
||||
for (const e of battle.enemiesInRadius(c.x, c.y, AIRSTRIKE_RADIUS, true)) { // splash around the target: 中点判定
|
||||
for (const e of battle.foesInRadius(c.x, c.y, AIRSTRIKE_RADIUS, true)) { // splash around the target: 中点判定
|
||||
if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'phys', isSkill: true, isSplash: true, tags: ['talent', 'airstrike'] });
|
||||
}
|
||||
}, { owner: unit });
|
||||
@@ -2420,7 +2421,7 @@ function qiubai(bb, chess, def) {
|
||||
if (!unit.alive || unit.deploySeq !== seq) return;
|
||||
const x = target.x, y = target.y; // (a fallen target keeps its last position)
|
||||
battle.fx('aoe', { x, y, id: unit.id });
|
||||
for (const e of battle.enemiesInRadius(x, y, 1.2, true)) { // splash around the target: 中点判定
|
||||
for (const e of battle.foesInRadius(x, y, 1.2, true)) { // splash around the target: 中点判定
|
||||
if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.aoe_scale, 1), type: 'arts', isSkill: true, isSplash: e !== target, tags: ['skill'] });
|
||||
}
|
||||
}, { owner: unit });
|
||||
@@ -2507,7 +2508,7 @@ function halo2(bb, chess, def) {
|
||||
let prev = target, px = target.x, py = target.y;
|
||||
for (let i = 0; i < jumps; i++) {
|
||||
let best = null, bd = Infinity;
|
||||
for (const e of battle.enemiesInRadius(px, py, R)) {
|
||||
for (const e of battle.foesInRadius(px, py, R)) {
|
||||
if (e === prev || !canTargetEnemy(unit, e, ANY)) continue;
|
||||
const d = bodyDist(e, px, py);
|
||||
if (d < bd - 1e-9) { bd = d; best = e; }
|
||||
@@ -2532,7 +2533,7 @@ function halo2(bb, chess, def) {
|
||||
onHit({ battle, unit, target }) {
|
||||
if (!target) return;
|
||||
const R = num(bb.ability_range_radius, 2), k = Math.max(0, Math.floor(num(bb.max_target, 2)));
|
||||
const near = battle.enemiesInRadius(target.x, target.y, R).filter((e) => e !== target && canTargetEnemy(unit, e, ANY))
|
||||
const near = battle.foesInRadius(target.x, target.y, R).filter((e) => e !== target && canTargetEnemy(unit, e, ANY))
|
||||
.sort((a, b) => bodyDist(a, target.x, target.y) - bodyDist(b, target.x, target.y) || a.spawnSeq - b.spawnSeq)
|
||||
.slice(0, k);
|
||||
for (const e of near) {
|
||||
@@ -2870,7 +2871,7 @@ function whitw2(bb, chess, def) {
|
||||
}
|
||||
}
|
||||
const near = new Set();
|
||||
for (const d of D) for (const e of battle.enemiesInRadius(d.x, d.y, R)) if (ok(e)) near.add(e);
|
||||
for (const d of D) for (const e of battle.foesInRadius(d.x, d.y, R)) if (ok(e)) near.add(e);
|
||||
if (slow) for (const e of near) battle.addBuff(e, { key: 'whitw2:slow', duration: 0.2, refresh: 'replace', mods: { moveMul: Math.max(0, 1 + slow) }, source: unit });
|
||||
unit.mem.droneAcc += dt;
|
||||
if (unit.mem.droneAcc + 1e-9 >= 1) {
|
||||
|
||||
@@ -1106,7 +1106,7 @@ function yanyouKit(bb, raw) {
|
||||
if (f.acc < 1 - 1e-9) return;
|
||||
f.acc -= 1;
|
||||
// centre-point AoE around the locked target, searched again every second (the target itself included)
|
||||
const hit = flameR > 0 ? battle.enemiesInRadius(t.x, t.y, flameR) : [];
|
||||
const hit = flameR > 0 ? battle.foesInRadius(t.x, t.y, flameR) : [];
|
||||
if (!hit.includes(t)) hit.unshift(t);
|
||||
for (const e of hit) battle.dealDamage(unit, e, { amount: unit.s.atk * flameScale, type: 'arts', isSkill: true, tags: ['flame'] });
|
||||
battle.fx('yanyouFlame', { x: t.x, y: t.y, id: unit.id, target: t.id, r: flameR, n: hit.length, dur: 1 });
|
||||
@@ -1152,7 +1152,7 @@ function yanyouKit(bb, raw) {
|
||||
auraAcc += dt;
|
||||
if (fragMul > 1 && auraAcc >= YANYOU_AURA_EVERY - 1e-9) {
|
||||
auraAcc = 0;
|
||||
for (const e of battle.enemiesInRadius(unit.x, unit.y, YANYOU_FRAGILE_RADIUS)) {
|
||||
for (const e of battle.foesInRadius(unit.x, unit.y, YANYOU_FRAGILE_RADIUS)) {
|
||||
battle.applyStatus(e, 'elemFragile', { duration: 2 * YANYOU_AURA_EVERY, value: fragMul - 1, source: unit });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
import { toLocal, frontOf } from './dir.js';
|
||||
import { absoluteRangeKeys } from './targeting.js';
|
||||
import { bodyInKeys, bodyKeys, bodyOnTile } from './body.js';
|
||||
import { COLS } from './constants.js';
|
||||
import { COLS, CHAIN_RADIUS } from './constants.js';
|
||||
|
||||
const P = (o) => Object.freeze(o);
|
||||
const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||||
@@ -276,13 +276,14 @@ export const SUB = Object.freeze({
|
||||
closerange: P({}),
|
||||
longrange: P({ priority: 'lowDef' }),
|
||||
aoesniper: P({ splashRadius: 1.1, projectile: 'bomb' }),
|
||||
bombarder: P({ splashRadius: 1.0, projectile: 'bomb', groundOnly: true, canHitFly: false,
|
||||
// PRTS 溅射半径一览 (特性): 投掷手 0.9, 扩散术师 1.1 (格雷伊 1.0, TUNE below), 链术师 1.7 jumps; 炮手 1.0 (none in the pool)
|
||||
bombarder: P({ splashRadius: 0.9, projectile: 'bomb', groundOnly: true, canHitFly: false,
|
||||
afterHit: (battle, unit, target, info) => {
|
||||
// aftershocks: (times − 1) extra hits at append_atk_scale × ATK (default one hit at 50 %)
|
||||
const n = Math.max(1, (unit.profile.shockTimes ?? 2) - 1);
|
||||
for (let i = 1; i <= n; i++) {
|
||||
battle.after(0.3 * i, () => {
|
||||
for (const e of battle.enemiesInRadius(info.x, info.y, unit.profile.splashRadius || 1, true)) { // splash: 中点判定
|
||||
for (const e of battle.foesInRadius(info.x, info.y, unit.profile.splashRadius || 1, true)) { // splash: 中点判定
|
||||
if (e.isFlying) continue;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * (unit.profile.shockScale ?? 0.5), type: 'phys', isSplash: true, tags: ['aftershock'] });
|
||||
}
|
||||
@@ -310,7 +311,7 @@ export const SUB = Object.freeze({
|
||||
// documents no splash radius for either (supporting only: it omits the 撼地者 too)
|
||||
splashcaster: P({ splashRadius: 1.1 }),
|
||||
blastcaster: P({ rangeAoe: true }),
|
||||
chain: P({ chain: { count: 3, falloff: 0.15, radius: 1.8, sluggish: 0.5 } }),
|
||||
chain: P({ chain: { count: 3, falloff: 0.15, radius: CHAIN_RADIUS, sluggish: 0.5 } }),
|
||||
funnel: P({ projectile: 'drone', install: installFunnel,
|
||||
dmgMul: (battle, unit, target) => {
|
||||
const f = unit.profile.funnel || { init: 0.2, delta: 0.15, max: 1.1 };
|
||||
@@ -397,6 +398,8 @@ export const SUB = Object.freeze({
|
||||
const TUNE = {
|
||||
fastshot: (tb) => ({ flyScale: num(tb.atk_scale, 1) }),
|
||||
bombarder: (tb) => ({ shockScale: num(tb['attack@append_atk_scale'], 0.5), shockTimes: num(tb['attack@times'], 2) }),
|
||||
// PRTS 溅射半径一览 特殊: 格雷伊 1.0 (the branch's 1.1 otherwise)
|
||||
splashcaster: (tb, def) => ((def.charId ?? def.raw?.charId) === 'char_253_greyy' ? { splashRadius: 1.0 } : {}),
|
||||
hunter: (tb) => ({ ammoMax: num(tb.value, 8), ammoScale: num(tb.atk_scale, 1.2) }),
|
||||
reaperrange: (tb, def) => ({ frontScale: num(tb.atk_scale, 1.5), frontGrid: def.raw?.trait?.rangeGrid ?? null }),
|
||||
funnel: (tb) => ({ funnel: { init: num(tb.init_atk_scale, 0.2), delta: num(tb.delta_atk_scale, 0.15), max: num(tb.max_atk_scale, 1.1) } }),
|
||||
|
||||
@@ -305,7 +305,7 @@ test('拉特兰: ammo ×(1.05+0.015L) floored; 6: every ammo used → all member
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 阿戈尔
|
||||
|
||||
test('阿戈尔: HP ×(1.35+0.01L); devour chain (left first): 5000 phys, base ATK + block, layers = devoured tiers', () => {
|
||||
test('阿戈尔: HP ×(1.35+0.01L); devour chain (left first): 5000 物理流失 less the target\'s DEF, base ATK + block, layers = devoured tiers', () => {
|
||||
const list = [
|
||||
['g1_a', ['egirShip']], ['g2_a', ['egirShip']], ['g3_a', ['egirShip']],
|
||||
['fod_a', ['preciShip'], { tier: 4, stats: { atk: 700, blockCnt: 3, maxHp: 20000, def: 900 } }],
|
||||
@@ -319,13 +319,14 @@ test('阿戈尔: HP ×(1.35+0.01L); devour chain (left first): 5000 phys, base A
|
||||
const [g1, g2, g3, f] = ['g1_a', 'g2_a', 'g3_a', 'fod_a'].map((id) => h.unit(id));
|
||||
const devours = tagged(h, 'bond:egir:devour');
|
||||
assert.deepEqual(devours.map((c) => [c.source.defId, c.target.defId]), [['g1_a', 'g2_a'], ['g1_a', 'fod_a'], ['g2_a', 'fod_a']]);
|
||||
for (const c of devours) close(c.amount, 5000);
|
||||
// PRTS 盟约记录 "造成5000点物理流失"; PRTS 作战机制: a 物理流失 is reduced by the target's DEF
|
||||
for (const c of devours) close(c.amount, Math.max(5000 - c.target.s.def, 5000 * 0.05));
|
||||
close(g1.s.atk, 1000 + 1000 + 700, 1e-6, 'g1 gains g2 + fodder base ATK');
|
||||
close(g2.s.atk, 1000 + 700);
|
||||
close(g3.s.atk, 1000);
|
||||
assert.equal(g1.s.blockCnt, 2 + 2 + 3);
|
||||
assert.equal(g2.s.blockCnt, 2 + 3);
|
||||
close(f.hp, 20000 - 10000, 1e-6, '流失: DEF 900 ignored');
|
||||
close(f.hp, 20000 - 2 * (5000 - 900), 1e-6, '物理流失: less DEF 900 (DEF-free until 0.1.1)');
|
||||
const Lnow = L + 1 + 4; // g2 tier 1 + fodder tier 4, once each
|
||||
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, Lnow);
|
||||
h.step(2);
|
||||
@@ -334,7 +335,7 @@ test('阿戈尔: HP ×(1.35+0.01L); devour chain (left first): 5000 phys, base A
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('阿戈尔 devour: 流失 ignores the marker’s damage bonuses and the target’s shields; a dead marker’s marks are cancelled; kill → marker', () => {
|
||||
test('阿戈尔 devour: 物理流失 ignores the marker’s damage bonuses and the target’s shields; a dead marker’s marks are cancelled; kill → marker', () => {
|
||||
const list = [
|
||||
['g1_a', ['egirShip', 'kjeragShip']], ['g2_a', ['egirShip'], { stats: { maxHp: 3000 } }], ['g3_a', ['egirShip']],
|
||||
['fod_a', ['preciShip'], { tier: 2, stats: { maxHp: 20000, def: 500 } }],
|
||||
@@ -353,7 +354,7 @@ test('阿戈尔 devour: 流失 ignores the marker’s damage bonuses and the tar
|
||||
assert.ok(h.hooksOf('kill').some((c) => c.victim === g2 && c.killer === g1), 'kill credited to the marker');
|
||||
const dv = tagged(h, 'bond:egir:devour');
|
||||
assert.deepEqual(dv.map((c) => [c.source.defId, c.target.defId]), [['g1_a', 'g2_a'], ['g1_a', 'fod_a']], 'g2’s own mark on the fodder is cancelled');
|
||||
close(f.hp, 20000 - 5000, 1e-6, 'exactly 5000: no ×1.25, no DEF, shield untouched');
|
||||
close(f.hp, 20000 - (5000 - 500), 1e-6, '5000 less its DEF 500: no ×1.25, shield untouched');
|
||||
assert.equal(f.buffs.find((b) => b.key === 'test:shield')?.shield, 3000);
|
||||
close(g1.s.atk, 1000 + 1000 + 1000, 1e-6, 'the base ATK of everything it marked stays');
|
||||
assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, 1 + 2, 'layers = tiers of the devoured units');
|
||||
|
||||
@@ -1092,15 +1092,27 @@ test(`${nm('enemy_1062_rager_2')}: loses ${tb('enemy_1062_rager_2', 'periodic_da
|
||||
approx(e.s.maxHp - e.hp, 3 * tb('enemy_1062_rager_2', 'periodic_damage.damage'));
|
||||
});
|
||||
|
||||
test(`${nm('enemy_1273_stmgun_2')}: Cannon locks the highest-max-HP unit and bombards (arts ATK×${skb('enemy_1273_stmgun_2', 'Cannon').bb.atk_scale})`, () => {
|
||||
test(`${nm('enemy_1273_stmgun_2')}: Cannon locks the highest-max-HP unit in range, bombards the highest HP% of its 9 tiles every 0.5 s up to 10 times (arts ATK×${skb('enemy_1273_stmgun_2', 'Cannon').bb.atk_scale}), 失衡免疫 + control immunity meanwhile (PRTS)`, () => {
|
||||
const h = arena({ units: [{ chessId: 't_gun', row: 12, col: 3 }, { chessId: 't_wall', row: 11, col: 3 }], chess: { t_wall: WALL('t_wall', { stats: { maxHp: 2e7 } }) } });
|
||||
h.step();
|
||||
const e = put(h, 'enemy_1273_stmgun_2', [10, 9]);
|
||||
e.profile.noAttack = true;
|
||||
const far = arena({ units: [{ chessId: 't_gun', row: 12, col: 3 }] });
|
||||
far.step();
|
||||
const e0 = put(far, 'enemy_1273_stmgun_2', [10, 9]);
|
||||
e0.profile.noAttack = true;
|
||||
const s = skb('enemy_1273_stmgun_2', 'Cannon');
|
||||
h.run(s.initCooldown + 4);
|
||||
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'telegraph' && f[4].kind === 'cannon'));
|
||||
far.run(s.initCooldown + 1);
|
||||
assert.ok(!far.eventsOf('fx').some((f) => f[1] === 'telegraph' && f[4].kind === 'cannon'), 'nobody in its range: no cast');
|
||||
const e = put(h, 'enemy_1273_stmgun_2', [11, 5]);
|
||||
e.profile.noAttack = true;
|
||||
h.run(s.initCooldown + 0.1);
|
||||
const tel = h.eventsOf('fx').find((f) => f[1] === 'telegraph' && f[4].kind === 'cannon');
|
||||
assert.ok(tel && tel[2] === 3 && tel[3] === 11, 'centred on the highest max-HP unit\'s tile');
|
||||
assert.ok(e.findBuff('ab:cannon')?.flags.noDisplace, '失衡免疫 during the bombardment');
|
||||
assert.equal(h.b.applyStatus(e, 'stun', { duration: 1 }), false, '晕眩免疫 during it');
|
||||
h.run(5.5);
|
||||
assert.equal(h.eventsOf('fx').filter((f) => f[1] === 'explode' && f[4].kind === 'cannon').length, 10, '10 shots');
|
||||
assert.ok(h.unit('t_wall').stats.taken + h.unit('t_gun').stats.taken > 0);
|
||||
assert.ok(!e.findBuff('ab:cannon') && h.b.applyStatus(e, 'stun', { duration: 1 }) !== false, 'over afterwards');
|
||||
});
|
||||
|
||||
for (const key of ['enemy_1501_demonk', 'enemy_10018_sgrobh']) {
|
||||
@@ -1818,15 +1830,20 @@ test(`${nm('enemy_1525_blkswb')}: 抵抗; ignores ${tb('enemy_1525_blkswb', 'Def
|
||||
const first = h.hooksOf('damaged').find((c) => c.source === e);
|
||||
approx(first.amount, e.s.atk * skb('enemy_1525_blkswb', 'Blink').bb.atk_scale - 500, 1e-6, '速杀 on its blocker');
|
||||
assert.ok(e.x < 5 - 0.4, `passed through (x ${e.x})`);
|
||||
// form 2 (its 速杀 held back so it keeps fighting the wall)
|
||||
// form 2
|
||||
const CASTER = chessRec({ id: 't_caster', profession: 'CASTER', projectile: 'none', stats: { atk: 1, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: { spCost: 999 } });
|
||||
const h2 = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }, { chessId: 't_caster', row: 10, col: 4 }], chess: { t_wall: WALL('t_wall', { def: 500 }), t_caster: CASTER },
|
||||
captureNoisy: true, hooks: ['damaged', 'statusApplied'] });
|
||||
h2.step();
|
||||
const k = put(h2, 'enemy_1525_blkswb', [9, 5]);
|
||||
for (const a of k.mem.ab.list) if (a.id === 'blink' || a.id === 'blink2') a.left = 999;
|
||||
// its 速杀 held back so it keeps fighting the wall (form 2's starts from its initial cooldown 0 when the 重生 ends)
|
||||
for (const a of k.mem.ab.list) if (a.id === 'blink') a.left = 999; else if (a.id === 'blink2') a.cond = () => false;
|
||||
killed(h2, k, null);
|
||||
h2.run(tb('enemy_1525_blkswb', 'Reborn.duration') + tb('enemy_1525_blkswb', 'Reborn.invincible') + 0.1);
|
||||
assert.ok(h2.runUntil(() => k.form === 'form2', 10), 'the 重生 ends');
|
||||
// PRTS 特殊机制 §重生 "重生结束时,重置自身的通用技能与当前形态的技能冷却为初始冷却"
|
||||
const b1 = k.mem.ab.list.find((a) => a.id === 'blink');
|
||||
assert.ok(b1.left > (b1.icd ?? 0) - 0.04 && b1.left <= (b1.icd ?? 0) + 1e-9, `速杀 (held at 999) back to its initial cooldown (${b1.left} vs ${b1.icd})`);
|
||||
h2.run(tb('enemy_1525_blkswb', 'Reborn.invincible') + 0.1);
|
||||
assert.ok(k.s.flags.stealth, 'second form: 隐匿');
|
||||
const t0 = h2.b.time, a0 = k.stats.attacks;
|
||||
h2.runUntil(() => k.stats.attacks >= a0 + 1, 20);
|
||||
|
||||
@@ -691,7 +691,8 @@ test('6_11 缪尔赛思 S2 生态耦合: +cost DP; melee copies regenerate and t
|
||||
test('6_12 迷迭香 S1 思维膨大: the next attack adds extra_atk_scale × ATK arts to every enemy it hits', () => {
|
||||
for (const id of both('chess_char_6_12')) {
|
||||
const sid = 'skchr_rosmon_1', bb = bbOf(id, sid);
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 7] }, { key: 'e', pos: [10, 8] }] });
|
||||
// the second enemy inside the 投掷手 splash (PRTS 溅射半径一览 0.9) of the first
|
||||
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 7] }, { key: 'e', pos: [10, 7.6] }] });
|
||||
const u = h.unit(id);
|
||||
usesSkill(u, sid);
|
||||
assert.ok(h.runUntil(() => dealt(h, u, (c) => c.type === 'arts').length >= 2, 6));
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Follow-ups of the 0.1.1 integration (the workstreams' cross-workstream notes and last review rounds), each against its
|
||||
// primary source:
|
||||
// * PRTS 作战机制 §AOE伤害判定 "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定" + 隐匿 "无法被敌方的索敌机制和Buff选择器选中":
|
||||
// radius area damage of an operator (profession splash, skill circles — Battle.foesInRadius) skips an unblocked,
|
||||
// unrevealed 隐匿 enemy and the untargetable (player report #8: the 逐火 余烬; until 0.1.1 they were hit);
|
||||
// * PRTS 溅射半径一览: 投掷手 0.9, 链术师 1.7, 扩散术师 1.1 (格雷伊 1.0), 迷迭香 末梢阻断 1.5;
|
||||
// * PRTS 阿罗玛 天赋 备注: the 非首次标记 is shared by every 阿罗玛 and leaves with its setter;
|
||||
// * PRTS 盟约记录 奥术法阵 备注: 炎佑's damage silences its target while a carrier is on the field;
|
||||
// * PRTS 特殊机制 §重生: skills restart from their initial cooldown when a 重生 ends (test/content/enemies_bosses 锏);
|
||||
// * PRTS 高准度伦蒂尼姆城防自行炮 (test/content/enemies_bosses); PRTS 琳琅诗怀雅 S3 备注 range 2-4 (below).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { resolveProfile } from '../../server/sim/professions.js';
|
||||
import { CHAIN_RADIUS } from '../../server/sim/constants.js';
|
||||
import { spawnYanyou } from '../../server/sim/content/tokens.js';
|
||||
|
||||
const ds = getDefaultSource();
|
||||
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e8, atk: 0, speed: 0, ...o });
|
||||
const stealth = (h, e) => h.b.addBuff(e, { key: 'test:stealth', flags: { stealth: true }, persist: true });
|
||||
const dmgTo = (h, src, e) => h.hooksOf('damaged').filter((c) => c.source === src && c.target === e).reduce((s, c) => s + c.amount, 0);
|
||||
|
||||
test('profession splash skips an unblocked 隐匿 enemy and hits it once revealed (PRTS §AOE伤害判定: only enemies that can be selected)', () => {
|
||||
for (const reveal of [false, true]) {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 'chess_char_1_14_a', row: 10, col: 3 }], timeLimit: 60, hooks: ['damaged'], captureNoisy: true, autoFinish: false });
|
||||
h.step();
|
||||
const u = h.unit('chess_char_1_14_a');
|
||||
const main = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||||
const hid = h.spawn('enemy_dummy', { pos: [10, 4.4] }); // 0.6 from the main target: inside 格雷伊's 1.0 splash
|
||||
stealth(h, hid);
|
||||
if (reveal) h.b.addBuff(hid, { key: 'test:reveal', flags: { reveal: true }, persist: true });
|
||||
h.run(6);
|
||||
assert.ok(dmgTo(h, u, main) > 0, 'the main target is struck');
|
||||
if (reveal) assert.ok(dmgTo(h, u, hid) > 0, 'revealed: splashed (and targetable)');
|
||||
else assert.equal(dmgTo(h, u, hid), 0, 'unblocked 隐匿: never splashed (it used to be)');
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('Battle.foesInRadius: no untargetable or unblocked 隐匿 enemy; a blocked or revealed one counts; enemiesInRadius keeps them all', () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [], timeLimit: 30, autoFinish: false });
|
||||
h.step();
|
||||
const [a, b, c, d] = [[10, 5], [10, 5.2], [10, 5.4], [10, 5.6]].map((pos) => h.spawn('enemy_dummy', { pos }));
|
||||
stealth(h, b);
|
||||
h.b.addBuff(c, { key: 'test:untargetable', flags: { untargetable: true }, persist: true });
|
||||
stealth(h, d);
|
||||
h.b.addBuff(d, { key: 'test:reveal', flags: { reveal: true }, persist: true });
|
||||
assert.deepEqual(h.b.foesInRadius(5, 10, 1).map((e) => e.id).sort(), [a.id, d.id].sort());
|
||||
assert.equal(h.b.enemiesInRadius(5, 10, 1).length, 4, 'enemy-side auras and heals still reach them all');
|
||||
});
|
||||
|
||||
test('PRTS 溅射半径一览: 投掷手 0.9, 扩散术师 1.1 but 格雷伊 1.0, 链术师 jumps 1.7', () => {
|
||||
const prof = (id) => resolveProfile(ds.getChess(id));
|
||||
assert.equal(prof('chess_char_6_12_a').splashRadius, 0.9, '迷迭香 (投掷手)');
|
||||
assert.equal(prof('chess_char_1_14_a').splashRadius, 1.0, '格雷伊');
|
||||
assert.equal(prof('chess_char_1_14_b').splashRadius, 1.0, '格雷伊 (elite)');
|
||||
for (const id of ['chess_char_4_02_a', 'chess_char_4_14_a', 'chess_char_5_12_a', 'chess_char_5_21_a']) assert.equal(prof(id).splashRadius, 1.1, id);
|
||||
assert.equal(CHAIN_RADIUS, 1.7);
|
||||
for (const id of ['chess_char_1_03_a', 'chess_char_6_05_a']) assert.equal(prof(id).chain.radius, 1.7, id);
|
||||
});
|
||||
|
||||
test('阿罗玛\'s 非首次标记 is shared (PRTS 备注 "不同阿罗玛之间的非首次标记通用") and leaves with its setter ("自身离场时移除自身已施加的标记")', () => {
|
||||
const h = makeBattle({
|
||||
defs: { enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ uid: 'A', chessId: 'chess_char_4_10_a', row: 10, col: 1 }, { uid: 'B', chessId: 'chess_char_4_10_a', row: 10, col: 2 }],
|
||||
timeLimit: 60, hooks: ['statusApplied', 'damaged'], captureNoisy: true, autoFinish: false,
|
||||
});
|
||||
h.step();
|
||||
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const [A, B] = ['A', 'B'].map((uid) => h.b.allyUnits.find((u) => u.uid === uid));
|
||||
assert.ok(h.runUntil(() => dmgTo(h, A, e) > 0 && dmgTo(h, B, e) > 0, 15), 'both 阿罗玛 strike it');
|
||||
h.run(1);
|
||||
const lev = () => h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'levitate');
|
||||
assert.equal(lev().length, 1, 'one 浮空 for the pair (each used to trigger her own)');
|
||||
const mark = e.findBuff('aroma:bubbled');
|
||||
assert.ok(mark, 'the shared mark');
|
||||
const setter = mark.source, other = setter === A ? B : A;
|
||||
h.b.kill(setter, null);
|
||||
assert.ok(!e.findBuff('aroma:bubbled'), 'its setter left: the mark goes');
|
||||
const n = lev().length;
|
||||
assert.ok(h.runUntil(() => lev().length > n, 10), 'the other 阿罗玛\'s next attack is a first attack again');
|
||||
assert.equal(e.findBuff('aroma:bubbled').source, other);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('奥术法阵 on the field: 炎佑\'s damage silences its target (PRTS 盟约记录 备注 "造成伤害时使目标失去特殊能力5秒"); none without a carrier', () => {
|
||||
const ARC = 'chess_item_3_08_e_a';
|
||||
for (const carried of [true, false]) {
|
||||
const units = [{ chessId: 'chess_char_1_02_a', row: 12, col: 2, ...(carried ? { items: [ARC] } : {}) }];
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units, timeLimit: 60, hooks: ['statusApplied', 'damaged'], captureNoisy: true, autoFinish: false });
|
||||
h.step();
|
||||
const [y] = spawnYanyou(h.b, 'p1', { atk: 400, hp: 50000 });
|
||||
const e = h.spawn('enemy_dummy', { pos: [9, 7] });
|
||||
assert.ok(h.runUntil(() => dmgTo(h, y, e) > 0, 20), '炎佑 deals damage');
|
||||
h.run(0.2);
|
||||
const sil = h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'silence' && c.source === y);
|
||||
if (carried) {
|
||||
assert.ok(sil.length > 0, 'silenced by 炎佑');
|
||||
assert.ok(Math.abs(sil[0].duration - 5) < 1e-9, '5 s');
|
||||
} else assert.equal(sil.length, 0, 'no carrier: no rider');
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('琳琅诗怀雅 S3 pays her coins to the ground enemies of range 2-4 in front (PRTS 备注), not only her 1-1 attack range', () => {
|
||||
const id = 'chess_char_3_04_a';
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3, skillIndex: 2 }], timeLimit: 60, hooks: ['damaged'], captureNoisy: true, autoFinish: false });
|
||||
h.step();
|
||||
const u = h.unit(id);
|
||||
assert.equal(u.skill.id, 'skchr_swire2_3');
|
||||
const near = h.spawn('enemy_dummy', { pos: [10, 4] }); // her attack range (1-1)
|
||||
const far = h.spawn('enemy_dummy', { pos: [10, 5] }); // range 2-4 only (two tiles ahead)
|
||||
const side = h.spawn('enemy_dummy', { pos: [11, 4] }); // range 2-4's diagonal front
|
||||
const out = h.spawn('enemy_dummy', { pos: [10, 6] }); // outside both
|
||||
assert.ok(h.runUntil(() => u.skill.active, 20), 'S3 on');
|
||||
u.mem.coins = 10;
|
||||
const cash = () => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes('swire2Cash'));
|
||||
assert.ok(h.runUntil(() => cash().length >= 10, 10), 'the full purse is spent');
|
||||
const hit = new Set(cash().map((c) => c.target));
|
||||
assert.ok(hit.has(far) || hit.has(side), 'coins reach range 2-4 beyond her attack range');
|
||||
assert.ok(!hit.has(out), 'nothing outside range 2-4');
|
||||
for (const e of hit) assert.ok([near, far, side].includes(e));
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
Reference in new issue
Block a user