diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index b8df9954..92d8ecf0 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,7 +1,11 @@ -# Commits that only move code. Blame across them with -# git blame -C -C --ignore-revs-file .git-blame-ignore-revs -# (or once: git config blame.ignoreRevsFile .git-blame-ignore-revs; -C -C follows lines into the new files of a split). -# Pure moves only (0.2.0 refactor plan, rule 3): a commit listed here changes nothing but where the code lives. +# Commits that only move code between files (0.2.0 refactor plan, rule 3: a commit listed here changes nothing but where +# the code lives). Blame skips them when asked to: +# git config blame.ignoreRevsFile .git-blame-ignore-revs (GitHub's blame view reads this file by itself) +# For lines that moved into a new file, add -C -C (git blame -C -C ) to follow them to their old file. # feedback5 R1: server/index.js top-level code → server/http/ (config, common, files, static, media, buildTag, websocket, boot) b48a67caec5ba0699097e21f3db4d4dbb35a2aed +# feedback5 R3: server/sim/Battle.js → server/sim/battle/*.js (the Battle methods, verbatim) +13224f9483d94f65089e289ff2be63f50a69b1cb +# feedback5 R3: server/sim/content/enemies.js → server/sim/content/enemies/*.js (helpers, archetypes, kit families) +560328cacc863c26d07780096527e255ebf62bc4 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 2909813b..38d2b2e9 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -69,7 +69,9 @@ server/ results.js settlement stats, titles (评语) bot.js AI player (plays prep phases; also auto-play for disconnected humans) sim/ - Battle.js one field simulation (normal / unite / boss); public API §5 + Battle.js one field simulation (normal / unite / boss); public API §5 — the class: constructor + method install + battle/ Battle's methods by concern (players, lifecycle, hooks, spawns, deploy, blocking, status, combat, + queries, summons, tiles, displacement, economy, events), installed on Battle.prototype constants.js TICK, conversions, tuning knobs rng.js seeded PRNG (mulberry32) + helpers grid.js field grid, tile queries, passability, official 4-direction SPFA flow field + line-of-sight smoothing, road-over-floor preference only where it crosses fewer floor tiles (§21.15); DEPLOY_REFUSED_TILES (深水区, §21.3) @@ -91,7 +93,9 @@ server/ garrisons.js battle side (IN_BATTLE) garrison effect keys items.js battle side of equipment + Arts bands.js battle side of band (strategy) effects - enemies.js enemy ability specs + generic special-type behaviours + enemies.js enemy ability specs + generic special-type behaviours: dispatch hooks, KITS, STATS_ONLY + enemies/ helpers.js, archetypes.js, one kit file per special type (invisible, times, element, dot, + reflection, fly, special) and leaders.js — each with its part of KITS bosses.js boss scripts (boss_1 … boss_10) devices.js stage devices & special terrain shared/ diff --git a/docs/SIM.md b/docs/SIM.md index 65c4c99a..70e5ca60 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -7,7 +7,10 @@ the test harness. Everything here is deterministic: the only randomness is `batt ``` server/sim/ - Battle.js one field (normal / unite / boss / hidden) — public API, hook bus, helpers + Battle.js one field (normal / unite / boss / hidden) — the class: constructor, method install, public API + battle/ Battle's methods by concern: players, lifecycle (start / step / end), hooks (bus, timers, callback + isolation), spawns, deploy, blocking, status (buffs, statuses), combat, queries (+ ranges), summons, + tiles (relocation, bodies, tactical points), displacement, economy (DP, layers, coins), events constants.js TICK, MOVE_SCALE, ATTACK_PAUSE, element numbers, tuning knobs rng.js mulberry32 PRNG (+ int/range/chance/pick/shuffle/weighted) grid.js stage grid, tile semantics, 8-dir A* (no corner cutting), obstacles @@ -25,6 +28,7 @@ server/sim/ snapshot.js wire format (UnitInfo, snapshot tuples, flags, anim codes) simdata.js data access + normalisation (data/*.json, research fallback) content/index.js installContent / setupUnitKit / registerAllMeta + content/enemies/ helpers.js, archetypes.js, one kit file per special type + leaders.js (content/enemies.js builds KITS) content/generic.js generic kit from skill blackboards content/kits/tier1..6.js, content/{tokens,bonds,garrisons,items,bands,enemies,bosses,devices,choices}.js (content phase) ``` @@ -137,7 +141,7 @@ passage must follow; test/ui/playtest6_summons.test.js checks). "左右两格" = `offsetTile(r, c, ±1, 0, dir)` (`support.sideTiles`); kit pushes / pulls use `unit.fwd`; blowers compare directions (same ⇒ `equal`, reverse ⇒ `opposite`, perpendicular ⇒ `vertical`); board-position rules ("更靠左", "最右边", "同一行最右边") stay board positions in the player's own frame (mirrored FA side: counted from the field's other end), -independent of the units' directions. Enemies keep their own horizontal facing logic (content/enemies.js frontGuard). +independent of the units' directions. Enemies keep their own horizontal facing logic (content/enemies/archetypes.js frontGuard). "First tile" tie-breaks relative to a unit (tactical points `findTacticalPoint`, summon tiles `findSummonTile`, the 突袭 landing tile) compare offsets in the unit's facing-RIGHT frame (`localOrder` / `localBefore`; for a RIGHT unit exactly the old tile-key order), so a rotated layout plays the same (test/sim/facing-invariance.test.js: every chess × 4 @@ -267,7 +271,7 @@ a stealthed ally (隐匿, 排气格栅) only for the enemy it blocks — our ope (attacks, skill picks, cast conditions, a normal attack on every operator in range — 斩胄之剑 / 破胄之锤's hover attack, “灵幛”). **Enemy area effects** — splash, death and self blasts, area skills and statuses, pulses, the zones an enemy leaves, chain / bounce jumps, 周围四格 additions, whole-column / whole-field skills — select with `targeting.js -areaSelectable` (`content/enemies.js areaAllies` / `areaAlliesInTiles` / `fieldAllies`; PRTS 作战机制 §AOE伤害判定 +areaSelectable` (`content/enemies/helpers.js areaAllies` / `areaAlliesInTiles` / `fieldAllies`; PRTS 作战机制 §AOE伤害判定 "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定"; PRTS 异常效果 §无法选择: 隐匿, 不可选中, 无敌 and 对地规避 make "常见的、来自不同阵营的“选择”行为" skip a unit unless the ability "无视可选性"): no 隐匿 ally, the one blocking that enemy included (GitHub #97, owner 2026-10-04; the 0.1.2 [ASSUMED] that the blocker's area also hit is withdrawn — the blocked enemy's attack still lands), no untargetable or sleeping one, no @@ -291,7 +295,7 @@ DISAPPEAR / APPEAR legs still happen, hiding ends the stand; GitHub #58 — then never attack — unless content arms them through `enemy.profile` (`noAttack: false`, `melee`, `dmgType`, `maxTargets`: 转译基底·α's 寻仇者 / 特战术师 forms, which then attack like any enemy); `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius instead. A `noMove` enemy stands (not `moving`, drawn idle). Content can take over an enemy's attack: `enemy.profile.deferHit` = the engine makes the attack (target, timing, the `'atk'` event) but deals no -damage — the content's `attack` handler resolves it (帝国炮火先兆者's shells landing 3 s later, `content/enemies.js +damage — the content's `attack` handler resolves it (帝国炮火先兆者's shells landing 3 s later, `content/enemies/fly.js kitShell`); `enemy.profile.shot` = the `'atk'` event's projectile kind (`'mortar'`: no projectile drawn). 暴鸰 (`kitBombd`, no normal attack) drops its one bomb as a projectile: the cast (trigger: an ally within its range 2; the drone hovers through it) releases it `BOMBD_RELEASE` (8 ticks, the Attack clip's OnAttack on frame 8) later — `'atk'` kind @@ -306,7 +310,7 @@ present when data/stages.json says `active: true` (this wins over the level file research stages without `active` use `!hidden`. Active platforms/mounds (射击台, act1 m03) [ASSUMED, DATA §15.11] are ground obstacles, and an operator standing on one is elevated (`unit.ground = false`: never blocks). -**Enemy damage zones** (`content/enemies.js zone` / `dmgZone` / `pollution`): a zone ticks on the allies inside it +**Enemy damage zones** (`content/enemies/helpers.js zone`, `archetypes.js dmgZone` / `pollution`): a zone ticks on the allies inside it that it selects — an enemy's zone through its area selection (`areaAllies`, above; the sourceless 毒雾 with no selecting enemy: 隐匿 kept out, 起飞 not), 【污染秽蚀】 on every ally inside (flyers, stealthed and untargetable ones included; `alliesInRadius`) — through `dealDamage`, so shields absorb a damage @@ -323,7 +327,7 @@ one; the sourceless 毒雾 of 假想敌:蚀裂 skips a 隐匿 one but still re kind of damage — 无来源 true (like the terrain it stands for [ASSUMED]), the chimera credited — not a 流失 (player report D1 audit): radius 1.2, a tick every 0.5 s, one tick per unit per 0.5 s however many chimeras reach it (`mem.chimeraAt`). -**Knock-outs that are not deaths** (`content/enemies.js`; player reports after 0.1.0): a `killed` ability that keeps the +**Knock-outs that are not deaths** (`content/enemies/archetypes.js`; player reports after 0.1.0): a `killed` ability that keeps the 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 特殊机制 §重生 @@ -598,7 +602,7 @@ gain is amount × `dmg.mul` × `elemTakenMul` (元素损伤倍率: "受到的元 改为目标的损伤抵抗即可"); `elementIntake(unit)` in damage.js returns that factor, applied after `elementHit`). 元素脆弱 never scales it, no gauge decays (EP_RECOVERY_PER_SEC 0), and every enemy in data/enemies.json has 损伤抵抗 0 except 转译基底·α (10); operators have none. -Enemies deal ATK × their talent's `ep_damage_ratio` per hit (content/enemies.js `ep`). A full gauge bursts with the +Enemies deal ATK × their talent's `ep_damage_ratio` per hit (content/enemies/archetypes.js `ep`). A full gauge bursts with the official effects, which depend on the side hit (constants.js `ELEMENT`); burst damage is **无来源** (DamageInfo `sourceless`, PRTS 伤害分类 "无法被追溯伤害来源": no damage-dealt multiplier or penetration of the unit that filled the gauge, and the `hit` / `damaged` / `fatal` hooks see `source: null` — no attacker-keyed content applies — while the ctx's @@ -1175,9 +1179,9 @@ Unknown subprofessions fall back to the profession default (test `professions.te e.g. `mortar` for 帝国炮火先兆者, which the renderer does not draw — its fx `bombardShell` is the shell), `['dmg', tgt, amount, type]` (`phys|arts|true|burn|neural|necrosis|apoptosis`), `['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`, `['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing; - `extra.form` = the unit's model form from then on — an enemy's `content/enemies.js setForm`, a 傀儡师's 替身 — `shared/protocol.js fxForm`), + `extra.form` = the unit's model form from then on — an enemy's `content/enemies/helpers.js setForm`, a 傀儡师's 替身 — `shared/protocol.js fxForm`), `['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`. -- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips) +- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies/helpers.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips) (`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping) (`spine`/`avatar` are asset ids from data). - flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed and not within 3 s of a block's end) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512); diff --git a/docs/research/08-waves-official.md b/docs/research/08-waves-official.md index fc4fc0b4..0ed9f843 100644 --- a/docs/research/08-waves-official.md +++ b/docs/research/08-waves-official.md @@ -554,7 +554,7 @@ Everything in `docs/BALANCE.md` §4 (the tuned tables) and the `tools/balance.mj - `config.bossHpScale`: co-op pool = `bloodPoint[difficulty]` with no alive-player factor; solo stays a flagged config value. (2026-10-01: × alive / 4 available behind `aliveScaling`, off, see §6 #7.) - Keep `enemyScale` (PRTS table) and the 终极 speed ×1.15 from R3. - Re-run `tools/balance.mjs --tuning off` only to report, not to tune. -3. **Pathing** (`server/sim/grid.js`, `server/sim/ai.js`, `server/sim/Battle.js`): +3. **Pathing** (`server/sim/grid.js`, `server/sim/ai.js`, `server/sim/Battle.js` — since 0.2.0 `server/sim/battle/spawns.js` / `tiles.js`): - Add `Grid.flowField(dest)` as in §3.4, cached per `(dest, version)`. - `planLeg` for WALK: follow the smoothed parents from the enemy's tile to the leg target and re-plan on version change. That is the same trigger as today. - Crates: `setObstacle` marks a cost-1000 obstacle-like tile, not an impassable one. The existing "blocked enemy attacks the crate" logic stays. diff --git a/public/js/render/interp.js b/public/js/render/interp.js index 1ab8cc28..a3d99c8a 100644 --- a/public/js/render/interp.js +++ b/public/js/render/interp.js @@ -21,7 +21,7 @@ // included — are always delivered, and a full queue sheds only cosmetic ones. // // Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Two optional lists ride along -// (server/sim/Battle.js snapshot, user playtest #4 items 8 / 9): +// (server/sim/battle/events.js snapshot, user playtest #4 items 8 / 9): // * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's // normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older // snapshot, like flags); diff --git a/public/js/render/style.js b/public/js/render/style.js index 5bfbdaa4..4f53e9ad 100644 --- a/public/js/render/style.js +++ b/public/js/render/style.js @@ -113,7 +113,7 @@ export const PROJ = Object.freeze({ drone: { look: 'dart', speed: 16, tint: 0xe4fbff, glow: 0x57c9ff, len: 0.7, width: 0.16, head: 0.3, trail: 0x57c9ff, hit: 'zap' }, enemy: { look: 'orb', speed: 10, tint: 0xffe2da, glow: 0xff3b30, len: 0.55, width: 0.28, head: 0.44, trail: 0xff4a3a, muzzle: 0xff6a5a, hit: 'enemy' }, boomerang: { look: 'boomerang', speed: 15, back: 3.75, tint: 0xfff4d6, glow: 0x9ff0dc, len: 0.4, width: 0.3, head: 0.5, trail: 0x9ff0dc, hit: 'spark' }, - // 暴鸰's bomb (sim content/enemies.js kitBombd; official projectile_bombd, speed 5): dropped from the drone, it falls + // 暴鸰's bomb (sim content/enemies/fly.js kitBombd; official projectile_bombd, speed 5): dropped from the drone, it falls // onto its target with a low arc and bursts where the sim's 'explode' blast goes off droneBomb: { look: 'shell', speed: 5, once: true, tint: 0xffe2c8, glow: 0xff5a3a, len: 0.6, width: 0.3, head: 0.5, trail: 0xff7a4a, arc: 0.35, smoke: 0x2e2824, hit: 'boom' }, }); @@ -143,7 +143,7 @@ const STATUS_GUESS = [ ]; /** - * The 折射 icon is not drawn while the unit is silenced: the RES bonus is already off (enemies.js refraction) + * The 折射 icon is not drawn while the unit is silenced: the RES bonus is already off (sim content/enemies/archetypes.js refraction) * and the status must not keep looking active. Other icons stay. * @param {string} key a b.ev status key * @param {Set|string[]|null} statuses diff --git a/server/sim/Battle.js b/server/sim/Battle.js index fc232337..ba54c1cf 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -24,56 +24,52 @@ // `abs: true` on a unit (or player `coords: 'field'`) = field tiles (and field directions). // Robustness: every content callback and every step phase is wrapped; errors are logged once per key and the // battle continues. After MAX_INTERNAL_ERRORS the battle force-ends as a timeout. +// +// Code layout: this file keeps the constructor (fields, players, spawn queue, content install); the methods live in +// server/sim/battle/, one module per concern, and are installed on Battle.prototype below in a fixed order, with the +// descriptors of class methods (non-enumerable; `this` is the battle, subclasses and `super` calls work as before): +// players.js board → field tiles / directions, ally creation, kit / profile / skill setup +// lifecycle.js start (the battle-start deployment), step (the tick order above), end checks, BattleResult +// hooks.js hook bus (on / off / emit), timers (after / every), content-callback isolation and errors +// spawns.js spawn queue, routes, spawnEnemy; hidden enemies, boss pool sync, field clamp, list compaction +// deploy.js deploy, kill, retreat, removal bookkeeping, redeploys, leaks +// blocking.js contact blocking, block release, the 隐匿 switch-off after a block +// status.js buffs, catalogue statuses (抵抗, 寒冷 → 冻结, 诱导, 恐惧 …), "同名效果取最高", the buff tick +// combat.js damage / heal / HP loss (damage.js pipeline), projectiles, forced attacks +// queries.js enemy tile index, unit queries (tiles, grid, radius, selectability), players, range rebuilds +// summons.js tokens (summons), devices, stage devices +// tiles.js obstacles, relocation / 【移动】, knocked-out bodies, reserved tiles, tactical points, ground paths +// displacement.js push / pull / displace (受力等级 = force − weight); pushTiles +// economy.js DP, IN_BATTLE bond layers, bounty coins +// events.js client events, snapshot, field meta, fx, the sim log +// util.js fin / clone -import { TICK, ROWS, COLS, BLOCK_RADIUS_SQ, DP_DEFAULTS, DOWN_STATE, FORCED_EXIT, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST, AUTO_OP_COOLDOWN, STEALTH_RESTORE } from './constants.js'; -import { GEO, layerGainRoom } from '../../shared/constants.js'; +import { TICK, ROWS, COLS, DP_DEFAULTS, MAX_HOOK_DEPTH, AUTO_OP_COOLDOWN } from './constants.js'; +import { GEO } from '../../shared/constants.js'; import { createRng } from './rng.js'; import { Grid } from './grid.js'; -import { Unit } from './units.js'; -import { makeBuff, STATUS, RESIST_STATUSES } from './buffs.js'; -import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff, elementView, leaderHitCancelled } from './damage.js'; -import { absoluteRangeKeys, canTargetEnemy, extendedGrid, evadesGround, enemyStealthed, stealthOffKey } from './targeting.js'; -import { bodyKeys, bodyInKeys, bodyInRadius } from './body.js'; -import { normDir, mirrorDir, localOrder, localBefore } from './dir.js'; import { ProjectileSystem } from './projectiles.js'; -import { stampFear } from './fear.js'; -import { SkillRuntime } from './skills.js'; -import { updateAlly, updateEnemy, compileRoute, remainingDistance, effectiveProfile, performAttack, acquireTargets } from './ai.js'; -import { resolveProfile } from './professions.js'; -import { unitInfo, snapshotUnits } from './snapshot.js'; -import { toDataSource, normalizeRoute, normalizeStage, normalizeToken, normalizeEnemy } from './simdata.js'; -import { installContent, setupUnitKit } from './content/index.js'; +import { remainingDistance } from './ai.js'; +import { toDataSource, normalizeRoute, normalizeStage } from './simdata.js'; +import { installContent } from './content/index.js'; +import { BattlePlayers } from './battle/players.js'; +import { BattleLifecycle } from './battle/lifecycle.js'; +import { BattleHooks } from './battle/hooks.js'; +import { BattleSpawns } from './battle/spawns.js'; +import { BattleDeploy } from './battle/deploy.js'; +import { BattleBlocking } from './battle/blocking.js'; +import { BattleStatus } from './battle/status.js'; +import { BattleCombat } from './battle/combat.js'; +import { BattleQueries } from './battle/queries.js'; +import { BattleSummons } from './battle/summons.js'; +import { BattleTiles } from './battle/tiles.js'; +import { BattleDisplacement } from './battle/displacement.js'; +import { BattleEconomy } from './battle/economy.js'; +import { BattleEvents } from './battle/events.js'; + +export { pushTiles } from './battle/displacement.js'; const DEFAULT_RECTS = { normal: GEO.NORMAL_RECT, unite: GEO.UNITE_RECT, boss: GEO.BOSS_RECT, hidden: GEO.BOSS_RECT }; -let hookSeq = 0; -let schedSeq = 0; - -/** Finite number or the default (content may pass undefined/NaN/Infinity/strings to helpers). */ -const fin = (v, d) => { const n = typeof v === 'number' ? v : (v == null || v === '' ? NaN : Number(v)); return Number.isFinite(n) ? n : d; }; -/** - * Official push distance (tiles) of a 受力等级 (constants.js PUSH_TILES: ≤ −3 → 0, ≥ 3 → the 3 value); `effect` = a 特效 - * push (PRTS 推与拉's 特效 column, PUSH_TILES_EFFECT: 见行者), else the 弹道 column. - */ -export function pushTiles(level, effect = false) { - const l = Math.round(fin(level, -99)); - if (l <= -3) return 0; - return (effect ? PUSH_TILES_EFFECT : PUSH_TILES)[Math.min(3, l)]; -} - -function clone(v) { - if (v == null || typeof v !== 'object') return v; - if (Array.isArray(v)) return v.map(clone); - const o = {}; - for (const k of Object.keys(v)) o[k] = clone(v[k]); - return o; -} - -const FALLBACK_ENEMY = Object.freeze({ - type: 'enemy', id: 'enemy_unknown', key: 'enemy_unknown', name: '未知敌人', rank: 'NORMAL', maxHp: 2000, atk: 200, def: 100, res: 0, - aspd: 100, bat: 2, rangeRadius: 0, moveSpeed: 1, massLevel: 1, lpr: 1, motion: 'WALK', applyWay: 'MELEE', dmgType: 'phys', - immune: new Set(), tauntLevel: 0, blockCnt: 1, epResistance: 0, epDamageResistance: 0, hpRecoveryPerSec: 0, notCountInTotal: false, - tags: [], abilities: [], skills: [], talent: {}, spine: 'unknown', avatar: 'unknown', raw: {}, -}); export class Battle { constructor(opts = {}) { @@ -125,11 +121,11 @@ export class Battle { this.reason = null; this.started = false; this._startDeploying = false; - /** @type {Unit[]} every unit ever created */ + /** @type {import('./units.js').Unit[]} every unit ever created */ this.units = []; - /** @type {Unit[]} alive enemies (compacted each tick) */ + /** @type {import('./units.js').Unit[]} alive enemies (compacted each tick) */ this.enemies = []; - /** @type {Unit[]} every ally unit (ops, tokens, devices; dead included) */ + /** @type {import('./units.js').Unit[]} every ally unit (ops, tokens, devices; dead included) */ this.allyUnits = []; this._hooks = Object.create(null); this._emitDepth = 0; @@ -176,2287 +172,17 @@ export class Battle { if (typeof opts.setup === 'function') this._safe(() => opts.setup(this), 'opts.setup'); } - // ============================================================================================================= - // players & units - - _addPlayer(p) { - const bossLike = this.kind === 'boss' || this.kind === 'hidden'; - const side = p.side === 'R' ? 'R' : 'L'; - const mirror = bossLike && side === 'R'; - const ps = { - playerId: p.playerId, - seat: p.seat ?? this.players.length, - side, - colOffset: Number(p.colOffset) || 0, - rowOffset: p.rowOffset, - coords: p.coords ?? 'board', - mirror, - facing: mirror ? -1 : 1, - // default direction of this player's units on this field (the FA right side is mirrored: RIGHT ↔ LEFT) - dir: mirror ? 'LEFT' : 'RIGHT', - half: mirror || (Number(p.colOffset) || 0) >= 8 ? 'R' : 'L', - bonds: clone(p.bonds ?? {}), - bandId: p.bandId ?? null, - playerEffects: clone(p.playerEffects ?? []), - lpForBoss: p.lpForBoss ?? null, - dp: this.flags.dpInit, - units: [], - input: p, - }; - this.players.push(ps); - this._perPlayer[ps.playerId] = { - killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, - damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [], - }; - const late = []; - for (const u of p.units ?? []) { - try { - const unit = this._createAllyFromInput(ps, u); - if (unit && unit.kind === 'token' && !unit.ownerUnit && u.ownerUid != null) late.push([unit, u]); - } catch (e) { this._internalError('createUnit', e); } - } - // a token piece listed before its owner (board order is top→bottom) is linked once every unit exists, and takes - // the owner-level variant (elite owners: `_b` stats) of the owner's loadout (DESIGN §16) - for (const [t, inp] of late) { - const owner = ps.units.find((x) => x.kind === 'op' && x.uid === inp.ownerUid); - if (!owner) continue; - t.ownerUnit = owner; - const def = inp.def ? null : this._tokenDef(inp.tokenId ?? inp.chessId, owner, null); - if (!def || def === t.def) continue; - const st = def.stats; - t.def = def; t.defId = def.id; t.name = def.name; t.rangeGrid = def.rangeGrid; - Object.assign(t.base, { - maxHp: st.maxHp, atk: st.atk, def: st.def, res: st.res, aspd: st.aspd, bat: st.bat, blockCnt: st.blockCnt, spRecovery: st.spRecovery, - tauntLevel: st.tauntLevel, massLevel: st.massLevel, hpRecoveryPerSec: st.hpRecoveryPerSec, cost: st.cost, respawnTime: st.respawnTime, - }); - t.markDirty(); - t.hp = t.s.maxHp; - } - } - - /** Board → field tile for a player (see header). */ - mapTile(ps, row, col, abs = false) { - if (abs || ps.coords === 'field') return [row, col]; - const bossLike = this.kind === 'boss' || this.kind === 'hidden'; - let r = row; - if (ps.rowOffset != null) r = row + ps.rowOffset; - else if (bossLike && row >= 7) r = row + BOSS_ROW_OFFSET; - const c = ps.mirror ? (col <= 10 ? COLS - 1 - col : col) : col + ps.colOffset; - return [r, c]; - } - - /** - * Board direction → field direction for a player (DESIGN §3): as given (default RIGHT) except on the mirrored Final - * Assault right side, where RIGHT ↔ LEFT (UP / DOWN unchanged). `abs` / field coordinates are taken as they are. - */ - mapDir(ps, dir, abs = false) { - const d = normDir(dir); - return ps && ps.mirror && !abs && ps.coords !== 'field' ? mirrorDir(d) : d; - } - - _createAllyFromInput(ps, inp) { - // coerce (a string row would otherwise concatenate: "10" + -7 → "10-7"); non-integers never deploy - const row = Number(inp.row), col = Number(inp.col); - if (!Number.isInteger(row) || !Number.isInteger(col)) { this.log(`bad tile ${inp.row},${inp.col} for ${inp.chessId ?? inp.tokenId}`); return null; } - const [r, c] = this.mapTile(ps, row, col, !!inp.abs); - const dir = this.mapDir(ps, inp.dir, !!inp.abs); - if (inp.kind === 'token') { - const owner = inp.ownerUid != null ? ps.units.find((x) => x.uid === inp.ownerUid) : null; - const def = this._tokenDef(inp.tokenId ?? inp.chessId, owner, inp.def); - if (!def) { this.log(`unknown token ${inp.tokenId}`); return null; } - const u = this._makeAlly(ps, def, 'token', r, c, { uid: inp.uid, ownerUnit: owner, dir }); - u.carry = inp.carryState ?? null; - return u; - } - // the unit's own loadout (DESIGN §16): an entry without loadout fields is the DEFAULT — never another player's - // choice for the same chess id in a multi-player field (the per-battle data view maps id-only lookups) - const def = this.data.getChess(inp.chessId, { skillIndex: inp.skillIndex ?? null, moduleId: inp.moduleId ?? null }); - if (!def) { this.log(`unknown chess ${inp.chessId}`); return null; } - const u = this._makeAlly(ps, def, 'op', r, c, { uid: inp.uid, dir }); - u.items = [...(inp.items ?? [])]; - u.carry = inp.carryState ?? null; - return u; - } - - /** - * Token def: an inline def (PlayerBattleInput `def`, spawnToken `opts.def`) as given, else the data record's variant - * for the owner — `owner` = the owning ally unit (its chess and its selected skill / module: getToken(id, - * owner.defId, owner.def.loadout), DESIGN §16), a chess id, or null. - */ - _tokenDef(tokenId, owner, inline) { - if (inline) return normalizeToken(tokenId, inline); - if (!this.data.getToken) return null; - if (owner && typeof owner === 'object') return this.data.getToken(tokenId, owner.defId ?? null, owner.def?.loadout ?? null); - return this.data.getToken(tokenId, owner ?? null); - } - - _makeAlly(ps, def, kind, r, c, extra = {}) { - const st = def.stats; - const u = new Unit({ - id: ++this._idSeq, side: 'ally', kind, def, defId: def.id, name: def.name, ownerId: ps ? ps.playerId : null, - uid: extra.uid ?? null, ownerUnit: extra.ownerUnit ?? null, x: c, y: r, tileR: r, tileC: c, - dir: extra.dir != null ? normDir(extra.dir) : extra.facing != null ? normDir(extra.facing) : ps ? ps.dir : 'RIGHT', - base: { - maxHp: st.maxHp, atk: st.atk, def: st.def, res: st.res, aspd: st.aspd, bat: st.bat, blockCnt: st.blockCnt, - moveSpeed: 0, spRecovery: st.spRecovery, tauntLevel: st.tauntLevel, massLevel: st.massLevel, - hpRecoveryPerSec: st.hpRecoveryPerSec, cost: st.cost, respawnTime: st.respawnTime, - }, - }); - u.alive = false; - u.deployed = false; - u.items = []; - u.rangeGrid = def.rangeGrid; - u.player = ps; - this.units.push(u); - this.allyUnits.push(u); - if (ps) ps.units.push(u); - return u; - } - - /** Resolve kit/profile/skill for a unit (called by content/index.js; safe to call again). */ - _setupUnit(u, kit = null) { - if (!kit) { - try { kit = setupUnitKit(this, u, this.contentMode); } catch (e) { this._internalError('setupUnitKit', e); kit = null; } - } - u.kit = kit || {}; - u.profile = resolveProfile(u.def, u.kit.trait || null); - if (u.def.untargetable) this.addBuff(u, { key: 'trait:untargetable', flags: { untargetable: true }, persist: true, allowDead: true }); - // abnormal effects a summon holds (tokens.json `abnormal`, PRTS; user playtest #6 item 18): 禁疗 — no heal reaches it; - // 孤立 ("无法被同阵营选中") — no ally heal or ally selection (auras over a range) reaches it - const ab = u.def.abnormal; - if (Array.isArray(ab) && ab.length) { - const flags = {}; - if (ab.includes('healFree')) flags.noHeal = true; - if (ab.includes('isolated')) { flags.isolated = true; flags.noHeal = true; } - if (Object.keys(flags).length) this.addBuff(u, { key: 'trait:abnormal', flags, persist: true, allowDead: true }); - } - const spec = u.kit.skill || null; - u.skill = new SkillRuntime(this, u, u.def.skill, spec, u.def.skill?.bb ?? {}); - if (u.profile.install && !u._profInstalled) { - u._profInstalled = true; - this._safe(() => u.profile.install(this, u), 'profile.install', u); - } - for (const t of u.kit.talents || []) { - if (t && typeof t.install === 'function') this._safe(() => t.install(this, u), 'talent.install', u); - } - if (typeof u.kit.install === 'function') this._safe(() => u.kit.install(this, u), 'kit.install', u); - return u.kit; - } - - // ============================================================================================================= - // lifecycle - - start() { - if (this.started) return; - this.started = true; - this._safe(() => this._spawnStageDevices(), 'stageDevices'); - // PRTS 卫戍协议/帮助 §作战阶段: "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物(如果有)", noted "自卫戍协议: - // 盟约 下半(2026/3/14)起,部署顺序由“从左到右>从下到上”更改为“从上到下>从左到右”" — a scanning order: down each column - // (top first), the columns from left to right; act 1's along each row, the rows from the bottom. It is the order PRTS - // gives the 阿戈尔 devour ("从最先部署(更靠左和靠上的)的【阿戈尔】干员开始", content/bonds/core.js egirOrder) and the one - // act-2 videos show. Per player the operators, left column first, top to bottom within a column (the mirrored right - // boss side from its own left, i.e. the highest field column — PRTS "部署顺序…左右镜像", research 01 §4.3), then the - // summon pieces in the same order. Until 0.1.3 the top row came first (row-major). [ASSUMED] On a shared field (联防, - // boss) the players' fields deploy at the same time (PRTS: one unit after another with a fixed delay, from the battle - // start), so the i-th operators of all players come in together (in `players` order), then the summons likewise - const seq0 = this._deploySeq; - const lists = this.players.map((ps) => ps.units.filter((u) => u.kind === 'op' || u.kind === 'token').slice().sort((a, b) => - (ps.mirror ? b.homeC - a.homeC : a.homeC - b.homeC) || b.homeR - a.homeR || a.id - b.id)); - // a summon piece flagged `deferDeploy` by content (one its owner's loadout does not make — 赫默 on S1 with a drone - // piece —, or a skill's summon when shared/constants.js SKILL_SUMMON_START_DEPLOY is off: content/tokens.js - // dockSkillSummons) stays off the field, its tile reserved (isReservedTile), until content deploys it - // (_startDeploying: a board piece content brings in during this deployment — a tactician's 流形 / 狼群 comes with - // its owner's deploy — takes its 联防 carry on that first deployment too) - this._startDeploying = true; - for (const kind of ['op', 'token']) { - const per = lists.map((l) => l.filter((u) => u.kind === kind && !u.deferDeploy)); - const n = Math.max(0, ...per.map((l) => l.length)); - for (let i = 0; i < n; i++) for (const l of per) if (l[i]) this._safe(() => this._deploy(l[i], { initial: true }), 'initialDeploy', l[i]); - } - this._startDeploying = false; - // 仇恨 (targeting.js sortAllyTargets: the later deployed is attacked first): every summon that came in during the - // initial deployment — also one an operator's deploy brought along (a tactician's 援军, a start-of-battle summon) - // — ranks after all the operators (of every player on the field [ASSUMED]), in the order it came - const summons = this.allyUnits.filter((u) => u.kind === 'token' && u.alive && u.deployed && u.deploySeq > seq0).sort((a, b) => a.deploySeq - b.deploySeq); - for (const t of summons) t.aggroSeq = ++this._deploySeq; - // 联防 (PRTS 卫戍协议/帮助 "部署完成后…上一阶段为退场状态的干员强制退场"): an operator knocked out at the end of its - // own combat is withdrawn right after the deployment — down on its tile, its redeploy timer starting now (re-read - // after battleStart below; DESIGN §5.5). Its HP ratio is the end-of-phase one (0, as after kill()) until it - // redeploys at full HP. - for (const u of this.allyUnits) { - if (u.kind === 'op' && u.alive && u.carry && u.carry.down === true) { - this._safe(() => { u.hp = 0; this.retreat(u, { reason: FORCED_EXIT }); }, 'forcedExit', u); - } - } - this.emit('battleStart', {}); - // redeploy-time effects that start with the battle (机变 征召 "所有干员的再部署时间-50%", added by a battleStart - // handler) cover the operators forced out above too: their timer is re-read with them, as a later knock-out's is - for (const u of this.allyUnits) { - if (u.kind !== 'op' || u.alive || u.removed || u.removeReason !== FORCED_EXIT) continue; - u.respawnAt = u.deathAt + Math.max(0, u.base.respawnTime * u.persist.redeployMul * u.s.redeployMul); - } - } - - step() { - if (this.finished || this._stepping) return; // re-entrant step() from a hook is a no-op - // forceEnd() requested while stepping (content hook, repeated engine errors) is deferred to the end of the - // current phase: the remaining phases are skipped and the result is built once, so nothing mutates it later. - this._stepping = true; - try { - if (!this.started) this._phase('start', () => this.start()); - const dt = this.dt; - this._phase('scheduled', () => this._runScheduled()); - this._phase('spawns', () => this._processSpawns()); - this._phase('dp', () => { - for (const ps of this.players) ps.dp = Math.min(this.flags.dpMax, ps.dp + this.flags.dpPerSec * dt); - }); - this._phase('buffs', () => this._tickBuffs(dt)); - this._phase('enemies', () => { - // enemies spawned by hooks during this loop start moving next tick (a leak hook that spawns an enemy which - // leaks at once would otherwise loop forever inside a single tick) - const list = this.enemies; - for (let i = 0, n = list.length; i < n && !this._endReq; i++) { - const e = list[i]; - if (!e.alive) continue; - try { updateEnemy(this, e, dt); } catch (err) { this._internalError('updateEnemy', err); } - this._clampPos(e); - } - }); - this._compactEnemies(); - this._phase('enemyIndex', () => this._buildEnemyIndex()); - this._phase('allies', () => { - const list = this.allyUnits; - for (let i = 0; i < list.length && !this._endReq; i++) { - const u = list[i]; - if (!u.alive || !u.deployed) continue; - try { - if (u.skill) u.skill.tick(dt); - if (u.alive && u.kind !== 'device') updateAlly(this, u, dt); - } catch (err) { this._internalError('updateAlly', err); } - } - }); - this._phase('projectiles', () => this.projectiles.update(dt)); - this._phase('redeploy', () => this._checkRedeploys()); - this._phase('boss', () => this._bossSync()); - if (this._hooks.tick) this._phase('tickHook', () => this.emit('tick', { dt })); - this._compactEnemies(); - if (this._toRelease.length) this._releaseRemoved(); - if (!this._endReq && !this.finished) { - this.tickCount++; - this.time = this.tickCount * dt; // no floating drift over long battles - this._phase('endCheck', () => this._checkEnd()); - } - } finally { - this._stepping = false; - } - if (this._endReq && !this.finished) { - try { this.forceEnd(this._endReq); } catch (e) { this._handlerError('internal:forceEnd', null, e); this._hardFinish(this._endReq); } - } - } - - /** Last-resort finish when building the normal result threw (never throws itself). */ - _hardFinish(reason) { - if (this.finished) return; - this.finished = true; - this.reason = reason === 'timeout' ? 'timeout' : 'forced'; - this._endReq = null; - try { this._result = this._buildResult(); } catch { this._result = { time: this.time, reason: this.reason, perPlayer: {}, killed: this.killed, total: this.total, errors: this.errorCount }; } - this.projectiles.clear(); - } - - /** Run until finished or `maxSeconds` of game time elapsed. Returns the result when finished. */ - runToEnd(maxSeconds = MAX_BATTLE_TIME) { - const limit = this.time + maxSeconds; - while (!this.finished && this.time < limit) this.step(); - return this.finished ? this.result() : null; - } - - _phase(name, fn) { - if (this.finished || this._endReq) return; - try { fn(); } catch (e) { this._internalError(name, e); } - } - - _checkEnd() { - if (this.finished) return; - if (this.sharedBoss && this.sharedBoss.hp <= 0) { this._finish('cleared'); return; } - // A boss / hidden field with a shared pool ends only when the pool is empty or the match forces an end (DESIGN - // §5.5) — never merely because it emptied: the h07_04 pair leader (official route: no wait) can walk into the - // objective, and the field then idles while another field may still empty the pool or the overtime drain ends it. - const poolHolds = !!this.sharedBoss && (this.kind === 'boss' || this.kind === 'hidden'); - if (this.autoFinish && !poolHolds && !this._pending.length && !this.enemies.some((e) => e.alive) && this.started) { - if (!this._hasPendingEnemySchedules()) { this._finish('cleared'); return; } - } - if (this.time >= this.timeLimit - 1e-9) { this._timeout(); return; } - if (this.time >= MAX_BATTLE_TIME) { this._timeout(); return; } - } - - _hasPendingEnemySchedules() { - for (const s of this._sched) if (!s.cancelled && s.holdsBattle) return true; - return false; - } - - _timeout() { - // Remaining non-boss enemies on the field count as leaked (DESIGN §5.5). Spawns that never happened before the - // limit are dropped: they are removed from `total` and reported in `result().unspawned` (not leaks). A content-made - // neutral (`mem.noLeak`: 隐德来希's 心烛, which follows its original) is never a leak — it is in no spawn schedule, so - // a client result listing it would be rejected (fields.js validateClientResult 'leak key'). - for (const e of this.enemies) { - if (!e.alive || e.isBoss || e.mem.noLeak) continue; - this._recordLeak(e, true); - } - this.unspawned = []; - for (const p of this._pending) { - if (p.precounted) { - this.total--; - const pp = this._pp(p.ownerPlayerId ?? this._ownerForTile(p.pos ?? this._routeFor(p.routeIndex, p.route)?.start)); - if (pp) pp.total--; - } - this.unspawned.push({ enemyKey: p.enemyKey, time: p.time, tag: p.tag ?? null, sourcePlayerId: p.sourcePlayerId ?? null }); - } - this._pending = []; - this._finish('timeout'); - } - - forceEnd(reason = 'forced') { - if (this.finished) return; - if (this._stepping) { if (!this._endReq) this._endReq = reason; return; } // finalised when step() unwinds - this._endReq = null; - if (!this.started) this.start(); - if (this.finished) return; - if (reason === 'timeout') this._timeout(); - else this._finish('forced'); - } - - _finish(reason) { - if (this.finished) return; - this.finished = true; - this.reason = reason; - this._endReq = null; - const res = this._buildResult(); - this._result = res; - this.emit('battleEnd', { result: res }); - // re-sync fields that battleEnd handlers may have changed (layer gains / coins live in perPlayer objects) - for (const pid of Object.keys(res.perPlayer)) { - const pp = res.perPlayer[pid]; - pp.perfect = !pp.leaked.some((l) => l.counted !== false); - } - this.projectiles.clear(); - } - - _buildResult() { - const perPlayer = {}; - for (const ps of this.players) { - const pp = this._perPlayer[ps.playerId]; - pp.perfect = !pp.leaked.some((l) => l.counted !== false); - // the operators and the board's summon pieces (a board uid): 联防 carries an operator's HP ratio and SP, a summon's - // SP only (match/unite.js). `sp` is the official 技力 — stored charges included (PRTS 技能 "可充能X次…当前技力上限等于该 - // 技能技力需求的X倍"); a running skill spent its SP at activation, so it reports what was left (0 for one charge). - // `skillActive` is reported, never carried. - pp.unitsEnd = ps.units.filter((u) => u.kind === 'op' || (u.kind === 'token' && u.uid != null)).map((u) => ({ - uid: u.uid, id: u.id, defId: u.defId, - hpPct: u.alive ? Math.max(0, Math.min(1, u.hp / u.s.maxHp)) : 0, - sp: u.skill && !u.skill.noSkill ? Math.round(u.skill.spTotal * 100) / 100 : 0, - skillActive: !!(u.skill && u.skill.active && u.skill.kind !== 'passive'), - alive: !!u.alive, - })); - pp.unitStats = ps.units.map((u) => ({ - id: u.id, uid: u.uid, defId: u.defId, name: u.name, kind: u.kind, - dmg: Math.round(u.stats.dmg), kills: u.stats.kills, heal: Math.round(u.stats.heal), taken: Math.round(u.stats.taken), attacks: u.stats.attacks, - })); - perPlayer[ps.playerId] = pp; - } - const res = { time: Math.round(this.time * 1000) / 1000, reason: this.reason, perPlayer, killed: this.killed, total: this.total, errors: this.errorCount }; - if (this.unspawned && this.unspawned.length) res.unspawned = this.unspawned; - if (this.sharedBoss) res.bossHpLeft = Math.max(0, this.sharedBoss.hp); - return res; - } - - /** BattleResult (valid once finished; a provisional result before). */ - result() { - if (this._result) return this._result; - return this._buildResult(); - } - - // ============================================================================================================= - // hook bus - - on(name, fn, opts = {}) { - if (typeof fn !== 'function') return null; - const h = { name, fn, priority: Number(opts.priority) || 0, owner: opts.owner ?? null, seq: ++hookSeq, removed: false, once: !!opts.once }; - const list = this._hooks[name] ?? (this._hooks[name] = []); - let i = list.length; - while (i > 0 && list[i - 1].priority < h.priority) i--; - list.splice(i, 0, h); - return h; - } - - off(nameOrHandle, fn) { - if (nameOrHandle && typeof nameOrHandle === 'object') { - const h = nameOrHandle; - h.removed = true; - const list = this._hooks[h.name]; - if (list) { - const i = list.indexOf(h); - if (i >= 0) list.splice(i, 1); - if (!list.length) delete this._hooks[h.name]; - } - return; - } - const list = this._hooks[nameOrHandle]; - if (!list) return; - for (let i = list.length - 1; i >= 0; i--) if (!fn || list[i].fn === fn) { list[i].removed = true; list.splice(i, 1); } - if (!list.length) delete this._hooks[nameOrHandle]; - } - - /** Remove every hook and scheduled callback registered with `owner`. */ - offOwner(owner) { - for (const name of Object.keys(this._hooks)) { - const list = this._hooks[name]; - for (let i = list.length - 1; i >= 0; i--) if (list[i].owner === owner) { list[i].removed = true; list.splice(i, 1); } - if (!list.length) delete this._hooks[name]; - } - for (const s of this._sched) if (s.owner === owner) s.cancelled = true; - } - - hasHook(name) { return !!this._hooks[name]; } - - emit(name, ctx) { - const list = this._hooks[name]; - if (!list) return ctx; - // Recursion guard: handlers that re-trigger their own event (damaged → dealDamage → damaged …) would otherwise - // recurse until a stack overflow, whose depth (and thus the outcome) depends on the machine — not deterministic. - if (this._emitDepth >= MAX_HOOK_DEPTH) { - this._handlerError(`hookDepth:${name}`, null, new Error(`hook nesting deeper than ${MAX_HOOK_DEPTH}; handlers skipped — ${this._chain(name)}`)); - return ctx; - } - const arr = list.length === 1 ? [list[0]] : list.slice(); - const d = this._emitDepth++; - this._frameCtx[d] = ctx; - try { - for (const h of arr) { - if (h.removed) continue; - this._frameName[d] = name; - this._frameOwner[d] = h.owner; - try { h.fn(ctx, this); } catch (e) { this._handlerError(`hook:${name}`, h.owner, e); } - if (h.once) this.off(h); - if (ctx && ctx.stopPropagation) break; - } - } finally { - this._emitDepth--; - } - return ctx; - } - - /** - * The open hook/callback frames (outermost first) — names the loop when the nesting guard trips, e.g. - * "chain: hit(enemy_1430_lrrook) > damaged(chess_char_3_18_a) > hit(enemy_1430_lrrook) > … > damaged". - */ - _chain(last) { - const id = (u) => (u && typeof u === 'object' ? (u.defId ?? u.name ?? '?') : u == null ? '' : String(u)); - const lab = (i) => { - const o = this._frameOwner[i]; - let who = id(o); - const c = this._frameCtx[i]; - if (!who && c && typeof c === 'object') { // global handler: show the units of the event instead - const a = c.source ?? c.attacker ?? c.killer ?? null, t = c.target ?? c.unit ?? c.victim ?? c.enemy ?? null; - who = a || t ? `${id(a)}→${id(t)}` : ''; - } - return `${this._frameName[i]}${who ? `(${who})` : ''}`; - }; - const n = this._emitDepth; - const parts = []; - for (let i = 0; i < Math.min(n, 4); i++) parts.push(lab(i)); - if (n > 12) parts.push('…'); - for (let i = Math.max(4, n - 8); i < n; i++) parts.push(lab(i)); - parts.push(last); - return `chain: ${parts.join(' > ')}`; - } - - // ============================================================================================================= - // scheduling - - after(seconds, fn, opts = {}) { - const s = { due: this.time + Math.max(0, Number(seconds) || 0), fn, owner: opts.owner ?? null, interval: 0, seq: ++schedSeq, cancelled: false, holdsBattle: !!opts.holdsBattle }; - s.cancel = () => { s.cancelled = true; }; - this._sched.push(s); - return s; - } - - every(seconds, fn, opts = {}) { - const iv = Math.max(TICK, Number(seconds) || TICK); - const s = { due: this.time + (opts.immediate ? 0 : iv), fn, owner: opts.owner ?? null, interval: iv, seq: ++schedSeq, cancelled: false, count: 0, holdsBattle: false }; - s.cancel = () => { s.cancelled = true; }; - this._sched.push(s); - return s; - } - - _runScheduled() { - if (!this._sched.length) return; - const now = this.time + 1e-9; - let due = this._sched.filter((s) => !s.cancelled && s.due <= now); - if (!due.length) { if (this._sched.some((s) => s.cancelled)) this._sched = this._sched.filter((s) => !s.cancelled); return; } - due.sort((a, b) => a.due - b.due || a.seq - b.seq); - for (const s of due) { - if (s.cancelled) continue; - if (s.interval > 0) { - let n = 0; - while (s.due <= now && !s.cancelled && n++ < 8) { - s.count++; - this._safe(() => s.fn(this, s), 'every', s.owner); - s.due += s.interval; - } - if (s.due <= now) s.due = this.time + s.interval; - } else { - s.cancelled = true; - this._safe(() => s.fn(this, s), 'after', s.owner); - } - } - this._sched = this._sched.filter((s) => !s.cancelled); - } - - // ============================================================================================================= - // spawns - - _queueSpawn(s, precount) { - if (Number(s.time) === Infinity) return; // "never" — not scheduled, not counted - // capped: a bogus count (Infinity, 1e9) would otherwise hang the constructor / exhaust memory - const count = Math.min(MAX_ALIVE_ENEMIES, Math.max(1, Math.floor(fin(s.count ?? 1, 1)) || 1)); - const interval = Math.max(0, fin(s.interval, 0)); - const def = this.data.getEnemy(s.enemyKey); - for (let i = 0; i < count; i++) { - const p = { - time: Math.max(0, fin(s.time, 0)) + i * interval, enemyKey: s.enemyKey, routeIndex: s.routeIndex ?? 0, route: s.route ?? null, - mods: s.mods ?? null, sourcePlayerId: s.sourcePlayerId ?? null, bounty: s.bounty ?? null, tag: s.tag ?? null, - ownerPlayerId: s.ownerPlayerId ?? null, pos: s.pos ?? null, seq: ++this._spawnSeq, countInTotal: s.countInTotal, - }; - p.counted = p.countInTotal ?? (!(def && def.notCountInTotal) && p.tag !== 'boss' && p.tag !== 'part'); - if (precount && p.counted) { - this.total++; - const owner = p.ownerPlayerId ?? this._ownerForTile(p.pos ?? this._routeFor(p.routeIndex, p.route)?.start); - const pp = this._pp(owner); - if (pp) pp.total++; - p.precounted = true; - } - this._pending.push(p); - } - } - - _processSpawns() { - const now = this.time + 1e-9; - while (this._pending.length && this._pending[0].time <= now) { - const p = this._pending.shift(); - this._safe(() => this.spawnEnemy(p.enemyKey, { ...p, _precounted: p.precounted }), 'spawnEnemy'); - } - } - - _routeFor(idx, explicit = null) { - if (explicit) return normalizeRoute(explicit); - return this.routes[idx] ?? this.routes[0] ?? this._defaultRoute(); - } - - _defaultRoute() { - if (this._defRoute !== undefined) return this._defRoute; - const starts = this.grid.specialTiles('start'); - const ends = this.grid.specialTiles('end'); - this._defRoute = starts.length && ends.length ? { motion: 'WALK', start: starts[0], end: ends[0], checkpoints: [] } : null; - return this._defRoute; - } - - _ownerForTile(tile) { - if (!this.players.length) return null; - if (this.players.length === 1 || !tile) return this.players[0].playerId; - const c = Array.isArray(tile) ? tile[1] : tile.c; - const half = c >= 11 ? 'R' : 'L'; - return (this.players.find((p) => p.half === half) ?? this.players[0]).playerId; - } - - /** - * Spawn an enemy now. opts: { routeIndex, route, pos:[r,c], mods:{hpMul,atkMul,defMul,resMul,speedMul}, tag, - * sourcePlayerId, ownerPlayerId, bounty:{coins,ownerPlayerId}, countInTotal } - */ - spawnEnemy(enemyKey, opts = {}) { - if (this.enemies.length >= MAX_ALIVE_ENEMIES && this.aliveEnemies().length >= MAX_ALIVE_ENEMIES) { - this._handlerError('spawnEnemy', null, new Error(`more than ${MAX_ALIVE_ENEMIES} living enemies; spawn of ${enemyKey} refused`)); - return null; - } - // opts.def: an inline record (data/enemies.json shape, or an already normalised EnemyDef) for keys absent from data - let def = null; - if (opts.def && typeof opts.def === 'object') { - try { def = opts.def.type === 'enemy' && opts.def.immune instanceof Set ? opts.def : normalizeEnemy(enemyKey, opts.def); } catch (e) { this._handlerError('spawnEnemy.def', null, e); } - } - if (!def) def = this.data.getEnemy(enemyKey); - if (!def) { this.log(`unknown enemy ${enemyKey}`); def = { ...FALLBACK_ENEMY, id: enemyKey, key: enemyKey }; } - const ov = this.enemyOverrides[def.key] ?? this.enemyOverrides[enemyKey]; - if (ov && ov.stats) { - const st = ov.stats; - def = { ...def, - maxHp: Math.max(1, fin(st.maxHp ?? st.hp, def.maxHp)), atk: Math.max(0, fin(st.atk, def.atk)), def: Math.max(0, fin(st.def, def.def)), - res: Math.max(0, fin(st.res, def.res)), moveSpeed: Math.max(0, fin(st.moveSpeed, def.moveSpeed)), - bat: Math.max(0.1, fin(st.bat, def.bat)), aspd: fin(st.aspd, def.aspd) || def.aspd, - rangeRadius: Math.max(0, fin(st.rangeRadius, def.rangeRadius)), massLevel: fin(st.massLevel, def.massLevel), - lpr: fin(st.lpr, def.lpr), blockCnt: Math.max(1, fin(st.blockCnt, def.blockCnt)) }; - } - const route = opts.route ? normalizeRoute(opts.route) : this._routeFor(opts.routeIndex ?? 0); - let start = opts.pos ?? route?.start ?? [this.rect.r0, this.rect.c1]; - if (!Array.isArray(start) || !Number.isFinite(start[0]) || !Number.isFinite(start[1])) start = route?.start ?? [this.rect.r0, this.rect.c1]; - const R = this.rect; // spawn inside the field (same bounds as _clampPos) - start = [Math.max(R.r0 - 0.5, Math.min(R.r1 + 0.5, start[0])), Math.max(R.c0 - 0.5, Math.min(R.c1 + 0.5, start[1]))]; - // multipliers: finite and ≥ 0 (hp > 0), anything else counts as 1 - const mm = (v, pos = false) => { const n = fin(v, 1); return n < 0 || (pos && n <= 0) ? 1 : n; }; - const m0 = opts.mods || {}; - const m = { hpMul: mm(m0.hpMul, true), atkMul: mm(m0.atkMul), defMul: mm(m0.defMul), resMul: mm(m0.resMul), speedMul: mm(m0.speedMul) }; - const e = new Unit({ - id: ++this._idSeq, side: 'enemy', kind: 'enemy', def, defId: def.key ?? enemyKey, name: def.name, - x: start[1], y: start[0], motion: def.motion, - base: { - maxHp: def.maxHp * (m.hpMul ?? 1), atk: def.atk * (m.atkMul ?? 1), def: def.def * (m.defMul ?? 1), res: def.res * (m.resMul ?? 1), - aspd: def.aspd, bat: def.bat, blockCnt: 0, moveSpeed: def.moveSpeed * (m.speedMul ?? 1), spRecovery: 0, - tauntLevel: def.tauntLevel, massLevel: def.massLevel, hpRecoveryPerSec: def.hpRecoveryPerSec, rangeRadius: def.rangeRadius, - }, - }); - e.alive = true; - e.deployed = true; - e.blockWeight = def.blockCnt ?? 1; - e.hitArea = def.hitArea ?? null; // 巨型单位 hit rectangle (body.js); null = a point - e.lpr = def.lpr; - e.mods = opts.mods ?? null; - e.tag = opts.tag ?? null; - e.bounty = opts.bounty ?? null; - e.sourcePlayerId = opts.sourcePlayerId ?? null; - e.ownerId = opts.ownerPlayerId ?? this._ownerForTile(start); - e.spawnX = e.x; e.spawnY = e.y; - e.spawnSeq = ++this._spawnSeq; - e.deploySeq = ++this._deploySeq; - e.deployedAt = this.time; - e.atkCd = 0; - e.pauseUntil = -Infinity; // content holds (暴鸰's drop) - e.atkStandUntil = -Infinity; // standing for its attack clip (ai.js attackStand) - // every enemy profile starts with the same fields (stable object shapes keep the hot loop's property reads fast); - // `dmgType` null = the data's (content may arm a data-unarmed enemy: ai.js enemyAttack) - e.profile = { noAttack: def.dmgType === 'none', maxTargets: 1, atkScale: 1, dmgType: null }; - e.route = { legs: route ? compileRoute(route, this.rect) : [], legIdx: 0, pts: null, ptIdx: 0, suffix: null, version: -1, waitLeft: null }; - if (!e.route.legs.length) { - const end = this.grid.specialTiles('end')[0]; - if (end) e.route.legs.push({ t: 'move', r: end[0], c: end[1], final: true }); - } - e.counted = opts.countInTotal ?? (!def.notCountInTotal && e.tag !== 'boss' && e.tag !== 'part'); - if (e.counted && !opts._precounted) { - this.total++; - const pp = this._pp(e.ownerId); - if (pp) pp.total++; - } - if (e.tag === 'boss') { - e.isBoss = true; - if (this.sharedBoss) { e.bossPool = this.sharedBoss; this._syncBossHp(e); } - } - e.hp = e.bossPool ? e.hp : e.s.maxHp; - this.units.push(e); - this.enemies.push(e); - this._ev(['spawn', unitInfo(e)]); - if (this._hooks.deploy) this.emit('deploy', { unit: e, initial: false }); - if (this._hooks.enemySpawn) this.emit('enemySpawn', { enemy: e }); - return e; - } - - // ============================================================================================================= - // deployment / death / redeploy - - /** - * Deploy `u` on its home tile, or on `tile` ([r, c]: a one-off landing tile — the home stays the board tile; an - * operator that left the field comes back on its body tile, restTile). `keepSp` = { sp, charges } - * restored right after the skill reset, before the `deploy` hook fires. - */ - _deploy(u, { initial = false, carry = null, tile = null, keepSp = null } = {}) { - if ((u.alive && u.deployed) || u._removing) return false; - const R0 = tile ? tile[0] : u.homeR, C0 = tile ? tile[1] : u.homeC; - if (!this.grid.inRect(R0, C0)) { - this.log(`${u} at ${R0},${C0} is outside the field rect; not deployed`); - if (!tile) u.removed = true; - return false; - } - const k = R0 * COLS + C0; - const occ = this._occ[k]; - if (occ && occ !== u && occ.alive && occ.deployed) { this.log(`tile ${R0},${C0} occupied; ${u} not deployed`); return false; } - // "倒地干员所在地块视为可部署,但所有我方单位在此处的部署行为将被阻止" (PRTS 卫戍协议/帮助 §作战阶段 单位部署) - if (this.downOn(R0, C0, u)) { this.log(`a knocked-out operator lies on ${R0},${C0}; ${u} not deployed`); return false; } - const first = u.deploySeq === 0; - u.alive = true; - u.deployed = true; - u.removed = false; - u.hidden = false; - u.body = null; - u.x = C0; u.y = R0; u.tileR = R0; u.tileC = C0; - u.blocking = []; - u.deploySeq = ++this._deploySeq; - u.aggroSeq = u.deploySeq; - u.deployedAt = this.time; - u.atkCd = 0; - u.lastAttackAt = -Infinity; - u.elem.burn = u.elem.neural = u.elem.necrosis = u.elem.apoptosis = u.elem.erosion = 0; - u.ground = this.grid.isLow(R0, C0) && !this._elevated?.has(k); - u.markDirty(); - u.hp = u.s.maxHp; - // 联防 carry (PRTS 卫戍协议/帮助 §联防阶段 "将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力…)"): - // an operator's HP ratio here, the SP in skill.reset and again after the `deploy` hook (below) — a summon's SP only - const cs = initial || (first && this._startDeploying) ? (carry ?? u.carry) : null; - if (cs && u.kind === 'op' && Number.isFinite(cs.hpPct)) u.hp = Math.max(1, u.s.maxHp * Math.max(0.01, Math.min(1, cs.hpPct))); - this._occ[k] = u; - this._refreshRange(u); // also rebuilds baseRangeKeys (initial range incl. permanent rangeExtend) - if (!u.skill) this._setupUnit(u); - u.skill.reset(cs); - const sk = u.skill; - // "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却" (PRTS 卫戍协议/帮助; skills.js) - if (initial) sk.opReadyAt = this.time + this.flags.startOpCooldown; - if (keepSp && !sk.noSkill && sk.kind !== 'passive' && !sk.active) { - sk.charges = Math.max(0, Math.min(sk.maxCharges, Math.floor(fin(keepSp.charges, 0)))); - sk.sp = Math.max(0, Math.min(sk.spCost, fin(keepSp.sp, 0))); - if (sk.charges >= sk.maxCharges) sk.sp = sk.spCost; - } - if (first) this._ev(['spawn', unitInfo(u)]); - this._ev(['deploy', u.id]); - if (this._hooks.deploy) this.emit('deploy', { unit: u, initial }); - // 联防: "部署完成后,将对应单位的…技力修改至与上一阶段结束时相同" — the carried SP is set again once the deployment is - // done, so a deploy-time SP gift (独行, 黄沙罗盘 …) does not come on top of it; later redeploys keep those gifts - if (cs && Number.isFinite(cs.sp) && u.alive && u.skill) u.skill.setSpTotal(cs.sp); - return true; - } - - /** Mark `unit` dead (hp reached 0). Fires `kill` then `death`. */ - kill(unit, killer = null) { - if (!unit || !unit.alive) return; - unit.hp = 0; - if (this._hooks.kill) this.emit('kill', { killer, victim: unit }); - // a nested kill/retreat inside the handlers already removed it: a later handler's hp write must not stick - if (!unit.alive) { if (!unit.bossPool) unit.hp = 0; return; } - if (unit.hp > 0 && !unit.bossPool) { // revived by a kill handler (clamped: a handler may write any number) - unit.hp = Math.min(unit.hp, unit.s.maxHp); - return; - } - unit.hp = 0; // NaN / negative writes from handlers - this._remove(unit, 'killed', killer); - } - - /** - * Withdraw an ally without a kill (it may redeploy after its respawn time): an operator lies down where it stood and - * comes back there (isDown, GitHub #60) — unless `permanent`, or the 突袭 retreat ('raid') that redeploys it at once. - */ - retreat(unit, { reason = 'retreat', permanent = false } = {}) { - if (!unit || !unit.alive || unit.side !== 'ally') return; - this._remove(unit, reason, null, permanent); - } - - _remove(unit, reason, killer = null, permanent = false) { - unit.alive = false; - unit.removeReason = reason; - unit.deployed = false; - unit.deathAt = this.time; - // while the removal bookkeeping runs, a skill onEnd handler must not redeploy the unit (it would come back - // alive but without its tile in _occ, its buffs wiped and a respawn timer pending) — redeploy from `death` instead - unit._removing = true; - if (unit.skill && unit.skill.active) { - this._safe(() => unit.skill.end('death'), 'skill.end', unit); - unit.skill.active = false; - } - unit._removing = false; - if (unit.side === 'ally') { - this.releaseBlocked(unit); - const k = unit.tileR * COLS + unit.tileC; - if (this._occ[k] === unit) this._occ[k] = null; - // keep persistent buffs only - const kept = []; - for (const b of unit.buffs) { - if (b.persist) kept.push(b); - else if (b.visible || b.status) this._ev(['status', unit.id, b.status ?? b.key, 0]); - } - unit.buffs = kept; - unit.markDirty(); - if (unit.kind === 'op' && !permanent) { - if (reason === 'killed') { const pp = this._pp(unit.ownerId); if (pp) pp.deaths++; } - const mul = unit.persist.redeployMul * unit.s.redeployMul; - unit.respawnAt = this.time + Math.max(0, unit.base.respawnTime * mul); - if (this.isDown(unit)) this._layBody(unit); // before `die` / `death`: the body's tile is final for them - } else { - unit.removed = true; - } - if (unit.kind === 'device' && unit.obstacle) this.grid.setObstacle(unit.tileR, unit.tileC, false, unit.obstacleKind); - } else { - unit.removed = true; - this._unblock(unit); - this._enemiesDirty = true; - if (reason === 'killed') { - if (unit.counted) { - this.killed++; - const pp = this._pp(unit.ownerId); - if (pp) pp.killed++; - } - if (killer) killer.stats.kills++; - if (unit.bounty && unit.bounty.coins > 0) { - const payee = killer && killer.side === 'ally' && killer.ownerId != null ? killer.ownerId : (unit.bounty.ownerPlayerId ?? unit.ownerId); - this.addCoins(payee, unit.bounty.coins); - } - } - } - // the reason ('killed' | 'retreat' | 'expired' | …) lets the client keep the knock-down sound for real knock-outs - if (reason !== 'leak') this._ev(['die', unit.id, reason]); - if (this._hooks.death) this.emit('death', { unit, reason, killer }); - if (unit.removed) this._toRelease.push(unit); - } - - /** - * Drop hooks and periodic timers (`every`) owned by permanently removed units (killed/leaked enemies, expired - * tokens, destroyed devices). Runs at the end of the step, so the unit's own death/leak handlers still fire; - * one-shot `after` callbacks are kept (they run once and are gone). Without this, per-enemy content hooks pile up - * and every emit walks the handlers of every enemy that ever spawned. - */ - _releaseRemoved() { - const set = new Set(); - for (const u of this._toRelease) if (u.removed && !u.alive) set.add(u); - this._toRelease = []; - if (!set.size) return; - for (const name of Object.keys(this._hooks)) { - const list = this._hooks[name]; - let w = 0; - for (let i = 0; i < list.length; i++) { - const h = list[i]; - if (h.owner != null && set.has(h.owner)) h.removed = true; - else list[w++] = h; - } - list.length = w; - if (!w) delete this._hooks[name]; - } - for (const sc of this._sched) if (sc.interval > 0 && sc.owner != null && set.has(sc.owner)) sc.cancelled = true; - } - - /** - * Immediately redeploy a dead (or retreated) ally on its rest tile (restTile: where an operator that left the field - * lies, else its home tile). opts: - * free=true no DP cost (false: pays `base.cost`, refused when the player lacks the DP) - * tile=[r,c] land on this in-rect tile instead (the home stays the board tile); refused (false) when the tile is - * outside the rect, a living unit stands there or another knocked-out operator lies there — no fallback - * keepSp keep the SP / charges the unit had (保留技力): restored before the `deploy` hook fires - * Returns true when the unit was deployed (full HP, `deploy {initial:false}` fires). - */ - redeploy(unit, { free = true, tile = null, keepSp = false } = {}) { - if (!unit || unit.side !== 'ally' || unit.alive || unit.removed) return false; - let at = null; - if (tile != null) { - if (!Array.isArray(tile) || !Number.isInteger(tile[0]) || !Number.isInteger(tile[1]) || !this.grid.inRect(tile[0], tile[1])) return false; - at = [tile[0], tile[1]]; - } else { - const rest = this.restTile(unit); - if (rest[0] !== unit.homeR || rest[1] !== unit.homeC) at = rest; - } - const k = at ? at[0] * COLS + at[1] : unit.homeR * COLS + unit.homeC; - const occ = this._occ[k]; - if (occ && occ.alive && occ !== unit) return false; - if (at && this.downOn(at[0], at[1], unit)) return false; - const ps = this.getPlayer(unit.ownerId); - const cost = unit.base.cost; - let paid = 0; - if (!free) { - if (!ps || ps.dp + 1e-9 < cost) return false; - paid = Math.min(ps.dp, cost); - ps.dp = Math.max(0, ps.dp - cost); // paid before `deploy` fires (handlers see the new DP) - } - const sk = unit.skill; - const keep = keepSp && sk && !sk.noSkill && sk.kind !== 'passive' ? { sp: sk.sp, charges: sk.charges } : null; - if (this._deploy(unit, { initial: false, tile: at, keepSp: keep })) return true; - if (paid > 0 && ps) ps.dp = Math.min(this.flags.dpMax, ps.dp + paid); - return false; - } - - /** - * Automatic redeploys (DESIGN §5.5): an operator that left the field and whose timer is done comes back on its rest - * tile — where it lies ("满足再部署条件时,移除场上的该倒地干员并自动部署至该位置", PRTS 卫戍协议/帮助) — when that tile - * is free and its player has the DP. - */ - _checkRedeploys() { - for (const u of this.allyUnits) { - if (u.alive || u.removed || u.kind !== 'op') continue; - if (this.time + 1e-9 < u.respawnAt) continue; - // the DP first: an operator past its timer mostly waits for DP, and the tile checks scan every ally - const ps = this.getPlayer(u.ownerId); - const cost = u.base.cost; - if (!ps || ps.dp + 1e-9 < cost) continue; - const [r, c] = this.restTile(u); - const occ = this._occ[r * COLS + c]; - if (occ && occ.alive && occ !== u) continue; - if (this.downOn(r, c, u)) continue; - ps.dp = Math.max(0, ps.dp - cost); - this._deploy(u, { initial: false, tile: r === u.homeR && c === u.homeC ? null : [r, c] }); - } - } - - /** Leak: an enemy reached its goal. */ - leak(e) { - if (!e || !e.alive || e.side !== 'enemy') return; - this._recordLeak(e, false); - this._remove(e, 'leak'); - this._ev(['leak', e.id]); - if (this._hooks.enemyLeak) this.emit('enemyLeak', { enemy: e }); - } - - _recordLeak(e, timeout) { - const owner = timeout ? e.ownerId : this._ownerForTile([Math.round(e.y), Math.round(e.x)]) ?? e.ownerId; - const pp = this._pp(owner) ?? this._pp(e.ownerId); - if (pp) { - pp.leaked.push({ enemyKey: e.defId, mods: e.mods ?? null, lpr: e.lpr ?? 1, sourcePlayerId: e.sourcePlayerId ?? e.ownerId, tag: e.tag ?? null, counted: !!e.counted || e.isBoss, boss: e.isBoss || undefined, spawned: true }); - if (e.counted || e.isBoss) pp.perfect = false; - } - if (e.counted) this.leakedCount++; - } - - // ============================================================================================================= - // blocking - - /** - * Try to block enemy `e` where it stands. Returns true if it is (now) blocked. Official contact rule (constants.js - * BLOCK_RADIUS, PRTS 游戏数据基础 §阻挡半径): the enemy's position within the blocker's block radius of the blocker's - * centre (ground 0.7071 — the tile's circumscribed circle, reaching 0.21 tile into the side neighbours; air 0.8944; - * devices 0.4472), and the blocker has free capacity for the enemy's block weight (and may block it at all: a unit on - * a fenced tile blocks no ground enemy, _blockerFor). Checked every tick for every - * unblocked enemy, moving or not, so an enemy overlapping an operator is taken over as soon as its blocker is gone or - * the operator's capacity frees up (user playtest #5 item 4). Several blockers in contact → the nearest [ASSUMED], - * ties → the first in row-then-column scan order. - */ - _checkBlock(e) { - if (e.blockedBy || e.hidden || !e.alive) return !!e.blockedBy; - const f = e.s.flags; - if (f.unblockable || f.levitate || f.fear) return false; - const r0 = Math.round(e.y), c0 = Math.round(e.x); - const w = e.blockWeight ?? 1; - let u = null, bd = Infinity; - const fly = e.isFlying; - for (let r = r0 - 1; r <= r0 + 1; r++) { - if (r < 0 || r >= ROWS) continue; - for (let c = c0 - 1; c <= c0 + 1; c++) { - // a diagonal neighbour's centre is ≥ √0.5 away: out of reach of the ground (and device) radius - if (c < 0 || c >= COLS || (!fly && r !== r0 && c !== c0)) continue; - const o = this._occ[r * COLS + c]; - if (!o || !this._blockerFor(o, e, w)) continue; - const d2 = (e.x - c) * (e.x - c) + (e.y - r) * (e.y - r); - const r2 = o.kind === 'device' ? BLOCK_RADIUS_SQ.device : fly ? BLOCK_RADIUS_SQ.fly : BLOCK_RADIUS_SQ.ground; - if (d2 < r2 && d2 < bd) { u = o; bd = d2; } - } - } - if (!u) return false; - e.blockedBy = u; - u.blocking.push(e); - e.moving = false; - if (this._hooks.blocked) this.emit('blocked', { blocker: u, enemy: e }); - return true; - } - - /** - * The enemies ally `u` blocks that it may target with `profile` — always selectable by their blocker, inside its range - * or not, whatever its facing ("可以选择且优先选择阻挡单位", PRTS 选择器; ai.js acquireTargets, the skills' DEFAULT - * trigger). That holds for a ranged operator on a melee tile too: the user's rule after playtest #6, "阻挡了就一定要能 - * 打到" — officially the collision pushes a blocked enemy to its blocker's front, so whatever blocks an enemy can hit - * it (DESIGN §20; it replaces the playtest #5 QA's melee-only restriction). Only for attacks that hit enemies: a heal - * attack never asks — it keeps selecting injured allies while its unit blocks (PRTS 卫戍协议/帮助 "对于医疗干员 - * (咒愈师分支除外),攻击目标为需要治疗的单位"; PRTS 选择器 adds only the blocked units its side can pick). - */ - blockedTargets(u, profile) { - const out = []; - if (!u || !u.blocking || !u.blocking.length) return out; - for (const e of u.blocking) if (e.blockedBy === u && canTargetEnemy(u, e, profile)) out.push(e); - return out; - } - - /** - * Can ally/device `u` block enemy `e` (block weight `w`) right now — everything but the contact distance. A unit on a - * tile ground units cannot pass blocks no ground enemy: the fenced tiles (围墙 tile_fence_bound / 围栏 tile_fence — - * low ground, deployable, passable to flyers only) — PRTS 围墙 / 围栏 地形机制 "部署在其中的单位,若当前阻挡类型为'地面 - * 阻挡'则无法阻挡敌人". The gate reads the tile's ground passability; on the stages the fenced tiles are the only low - * tiles ground units cannot pass. Air blocking (blockFly against flyers) stays [ASSUMED: PRTS restricts the rule to - * 地面阻挡]. Nothing walks onto such a tile, so it matters for an enemy pushed or pulled against the fence - * (Battle.displace stops it at the tile edge, 0.5 from the fenced unit — inside the ground block radius). An airborne - * unit (起飞, flag `liftoff`: "不阻挡地面敌人…可以阻挡飞行敌人") blocks flyers only. - */ - _blockerFor(u, e, w) { - if (!u.alive || !u.deployed || u.hidden || u.s.flags.noBlock || u.s.flags.sleep) return false; - if (e.isFlying && !(u.s.flags.blockFly || (u.profile && u.profile.blockFly))) return false; - // ground enemies: only a ground unit that has not taken off (起飞 "不阻挡地面敌人": flag `liftoff`), standing on a tile - // ground units can pass (not a fenced 围墙 / 围栏 tile) - if (!e.isFlying && (!u.ground || u.s.flags.liftoff || this.grid.tile(u.tileR, u.tileC).pass !== 'ALL')) return false; - const cap = u.s.blockCnt; - if (cap <= 0) return false; - let used = 0; - for (const x of u.blocking) used += x.blockWeight ?? 1; - return used + w <= cap; - } - - _unblock(e) { - const bl = e.blockedBy; - if (!bl) return; - const i = bl.blocking.indexOf(e); - if (i >= 0) bl.blocking.splice(i, 1); - e.blockedBy = null; - this._stealthSwitch(e); - } - - /** Release every enemy blocked by ally `u` (death, retreat, block count drop, substitution…). */ - releaseBlocked(u) { - if (!u || !u.blocking || !u.blocking.length) return; - const was = u.blocking; - u.blocking = []; - for (const e of was) if (e.blockedBy === u) { e.blockedBy = null; this._stealthSwitch(e); } - } - - /** - * A block on enemy `e` just ended (every release goes through here: `_unblock`, `releaseBlocked`, ai.js - * enforceBlockCapacity). Each 隐匿 source it holds stays switched off for its restore time — PRTS 作战机制 §隐匿 "对于 - * 绝大部分可隐匿的敌人而言,在被我方单位阻挡后会解除隐匿,不被阻挡的3秒后重新进入隐匿" (STEALTH_RESTORE), or the source's - * own "(解除阻挡N秒后恢复)" (buff `data.stealthRestore`, content/enemies.js: 0 s / 1 s on some enemy pages) — as a - * `stealthOff` buff per source; meanwhile it is targetable, operator splash reaches it and it is drawn solid - * (targeting.js enemyStealthed). A new block inside the window lifts it again and its end restarts the window. Our - * operators' 隐匿 / 迷彩 are never lifted by blocking (only enemies get here). - */ - _stealthSwitch(e) { - if (!e || e.side !== 'enemy' || !e.alive || !e.s.flags.stealth) return; - for (const b of e.buffs.slice()) { - if (!b.flags || !b.flags.stealth) continue; - const t = Number.isFinite(b.data?.stealthRestore) ? b.data.stealthRestore : STEALTH_RESTORE; - if (t > 0) this.addBuff(e, { key: stealthOffKey(b.key), duration: t, flags: { stealthOff: true } }); - } - } - - // ============================================================================================================= - // buffs & statuses - - addBuff(unit, b) { - if (!unit || (!unit.alive && !b.allowDead)) return null; - const buff = makeBuff(b); - const list = unit.buffs; - let idx = -1; - for (let i = 0; i < list.length; i++) if (list[i].key === buff.key) { idx = i; break; } - if (idx >= 0 && buff.refresh !== 'independent') { - const old = list[idx]; - switch (buff.refresh) { - case 'keep': - return old; - case 'stack': - old.stacks = Math.min(old.stacks + buff.stacks, Math.max(old.maxStacks, buff.maxStacks)); - old.maxStacks = Math.max(old.maxStacks, buff.maxStacks); - old.timeLeft = buff.duration; - old.duration = buff.duration; - if (buff.mods) old.mods = buff.mods; - if (buff.flags) old.flags = buff.flags; - unit.markDirty(); - return old; - case 'extend': - old.timeLeft = Math.max(old.timeLeft, buff.duration); - old.duration = Math.max(old.duration, buff.duration); - if (buff.mods) old.mods = buff.mods; - if (buff.flags) old.flags = buff.flags; - if (buff.shield > old.shield) old.shield = buff.shield; - if (buff.shieldHits > old.shieldHits) old.shieldHits = buff.shieldHits; - unit.markDirty(); - return old; - default: - list[idx] = buff; - unit.markDirty(); - return buff; - } - } - if (buff.refresh === 'independent') { - const same = list.filter((x) => x.key === buff.key); - if (same.length >= buff.maxStacks) { - const oldest = same.reduce((a, c) => (a.seq < c.seq ? a : c)); - this._removeBuffAt(unit, list.indexOf(oldest), true); - } - } - list.push(buff); - unit.markDirty(); - if (buff.visible || buff.status) { - const key = buff.status ?? buff.key; - if (list.filter((x) => (x.status ?? x.key) === key).length === 1) this._ev(['status', unit.id, key, 1]); - } - return buff; - } - - removeBuff(unit, keyOrBuff) { - if (!unit) return 0; - let n = 0; - for (let i = unit.buffs.length - 1; i >= 0; i--) { - const b = unit.buffs[i]; - if (b === keyOrBuff || b.key === keyOrBuff) { this._removeBuffAt(unit, i, true); n++; } - } - return n; - } - - _removeBuffAt(unit, i, callRemove = true) { - const b = unit.buffs[i]; - if (!b) return; - unit.buffs.splice(i, 1); - unit.markDirty(); - if ((b.visible || b.status)) { - const key = b.status ?? b.key; - if (!unit.buffs.some((x) => (x.status ?? x.key) === key)) this._ev(['status', unit.id, key, 0]); - } - if (callRemove && b.onRemove) this._safe(() => b.onRemove({ battle: this, unit, buff: b }), 'buff.onRemove', unit); - } - - _tickBuffs(dt) { - const units = this.units; - for (let i = 0, n = units.length; i < n; i++) { // units created by onTick handlers start ticking next tick - const u = units[i]; - if (!u.alive || u.removed) continue; - if (u.buffs.length) { - const arr = u.buffs.slice(); - for (const b of arr) { - if (!u.alive) break; - if (u.buffs.indexOf(b) < 0) continue; - if (b.onTick) { - if (b.interval > 0) { - b._acc += dt; - let n = 0; - while (b._acc >= b.interval - 1e-9 && n++ < 8) { - b._acc -= b.interval; - this._safe(() => b.onTick({ battle: this, unit: u, buff: b, dt: b.interval }), 'buff.onTick', u); - } - } else this._safe(() => b.onTick({ battle: this, unit: u, buff: b, dt }), 'buff.onTick', u); - } - if (b.timeLeft !== Infinity) { - b.timeLeft -= dt; - if (b.timeLeft <= 1e-9) { - const i = u.buffs.indexOf(b); - if (i >= 0) { - this._removeBuffAt(u, i, false); - if (b.onExpire) this._safe(() => b.onExpire({ battle: this, unit: u, buff: b }), 'buff.onExpire', u); - } - } - } - } - } - // natural HP regeneration - if (u.alive && u.deployed && !u.bossPool) { - const regen = u.s.hpRegen; - if (regen > 0 && u.hp < u.s.maxHp) { - u._regenAcc = (u._regenAcc ?? 0) + regen * dt; - if (u._regenAcc >= 1 || u.hp + u._regenAcc >= u.s.maxHp) { - this.heal(u, u, u._regenAcc, { self: true, silent: true, regen: true }); - u._regenAcc = 0; - } - } - } - } - } - - /** - * Apply a catalogue status. opts: { duration, source, value, force, refresh, point, resistApplied } — returns true - * when applied. Honours enemy immunities (stun/silence/sleep/frozen/levitate/feared) unless `force`. `beforeStatus` - * handlers may cancel it or change `duration` / `value`. Official rules (buffs.js STATUS): 抵抗 (the `resist` status) - * shortens the RESIST_STATUSES by its value (default half; applied after `beforeStatus`; `resistApplied` skips that - * pass — the cold-on-cold 冻结 below already used post-抵抗 lengths). A second 寒冷 while 寒冷 remains applies 冻结 for - * max(remaining, this cold after 抵抗) (PRTS 术语释义 寒冷 「持续时间取双方之中最高」). 浮空 lasts half as long on units heavier - * than LEVITATE_HALF_WEIGHT (current massLevel); 冻结's RES cut hits enemies only; 麻痹 adds stacks; "同名效果取最高" - * statuses (`valued`) keep the strongest value — a weaker application only extends past the stronger one's end (it - * then resumes); other statuses refresh to the longer duration. 诱导 (`attract`) walks the enemy to `point` - * ([r, c] or {x, y}; default the source's tile — a new application moves the point); 恐惧 (`fear`) stamps where it - * was applied and from where (fear.js stampFear: the fan of 恐惧可达地块 its movement uses). A stunned/sleeping operator - * releases the enemies it blocks; a feared/levitated/unblockable/attracted enemy is released by its blocker. - * `statusApplied` reports the final duration and `entered` (the target did not carry the status before). - * A unit that is 无敌 and 无法选中 at once (a 重生 in progress, a hovering or 永久无敌 leader part) takes no status from - * the other side, `force` included — PRTS 无敌 "无法被不同阵营选中": so a status carried by the very hit that knocked an - * enemy out does not land after its 重生's cleanse (DESIGN §21.4). A ground enemy's status never lands on an airborne - * 起飞 ally (对地规避: targeting.js evadesGround) unless `opts.ignoreSelect`. - */ - applyStatus(target, key, opts = {}) { - if (!target || !target.alive) return false; - const src = opts.source; - if (src && src.side && src.side !== target.side && target.s.flags.invulnerable && target.s.flags.untargetable) return false; - if (!opts.ignoreSelect && target.s.flags.liftoff && evadesGround(src, target)) return false; - const tpl = STATUS[key] || { flags: { [key]: true } }; - let duration = opts.duration == null ? Infinity : Number(opts.duration); - let value = opts.value; - if (!(duration > 0)) return false; - const immune = target.def && target.def.immune; - if (!opts.force && tpl.immune && immune && immune.has(tpl.immune)) return false; - // 浮空 Buff (PRTS 异常效果: "若单位数据上为飞行单位且不持有缚地异常或是持有浮空异常则Buff取消"; 行动方式 "行动类型(数据)为 - // 飞行的单位、以及已持有浮空异常的单位无法被施加浮空Buff"): refused on data flyers (`motion` FLY) and units already - // levitated — a hovering 近地悬浮 enemy is WALK in its data, so it can be levitated (no 缚地 / 浮空强化 in this mode) - if (key === 'levitate' && (target.motion === 'FLY' || target.s.flags.levitate)) return false; - if (this._hooks.beforeStatus) { - const c = this.emit('beforeStatus', { source: opts.source ?? null, target, status: key, duration, value, cancel: false }); - if (c.cancel || !target.alive) return false; - const d = Number(c.duration); - if (d === Infinity || (Number.isFinite(d) && d > 0)) duration = d; - else if (Number.isFinite(d) && d <= 0) return false; - value = c.value; - } - if (RESIST_STATUSES.has(key) && !opts.resistApplied) { - const rv = this.resistOf(target); - if (rv > 0) duration *= 1 - rv; - } - if (key === 'levitate' && target.s.massLevel > LEVITATE_HALF_WEIGHT) duration /= 2; - if (!(duration > 0)) return false; - if (key === 'cold' && target.findBuff('cold') && !(immune && immune.has('frozen'))) { - // PRTS 术语释义 寒冷: 友方寒冷 pairs into 冻结, 「持续时间取双方之中最高」. `duration` is this cold after 抵抗; - // the cold already on the target keeps timeLeft. addBuff refresh 'extend' below sets that cold to the same max. - // COLD_FREEZE_DURATION is only the fallback when neither side has a duration. resistApplied: that max is already - // post-抵抗, so the freeze must not be halved again. - // [ASSUMED] one catalogue cold, so an enemy-applied second cold uses this max too. PRTS states it on the 友方 - // line only (敌方 cold becomes 冻结 when the target already has 敌方 cold or 敌方 冻结). - const prev = target.findBuff('cold'); - const spans = [prev.timeLeft, duration].filter((t) => t === Infinity || (Number.isFinite(t) && t > 0)); - const freezeFor = spans.length ? Math.max(...spans) : COLD_FREEZE_DURATION; - this.applyStatus(target, 'freeze', { - duration: freezeFor, source: opts.source, force: opts.force, - ...(spans.length ? { resistApplied: true } : {}), - }); - if (!target.alive) return false; - } - const source = opts.source ?? null; - let entered = true; - for (const b of target.buffs) if ((b.status ?? b.key) === key) { entered = false; break; } - if (tpl.palsy) { - this.addBuff(target, { ...palsyBuff(value ?? 1), duration, source }); - } else if (tpl.valued != null && typeof tpl.mods === 'function' && opts.refresh == null) { - this._applyValuedStatus(target, key, tpl, duration, value ?? tpl.valued, source); - } else { - const mods = tpl.enemyOnlyMods && target.side !== 'enemy' ? null : typeof tpl.mods === 'function' ? tpl.mods(value) : (tpl.mods || null); - const b = this.addBuff(target, { key, duration, refresh: opts.refresh ?? 'extend', mods, flags: tpl.flags || null, status: key, visible: true, source }); - if (tpl.attract && b) this._setAttractPoint(target, b, opts.point ?? value, source); - // 恐惧: the hit position and the source's position of every application (fear.js — the fan of reachable tiles) - if (key === 'fear' && b && target.side === 'enemy') stampFear(this, target, b, source); - } - const f = tpl.flags; - if (f && target.side === 'enemy' && (f.levitate || f.unblockable || f.fear)) this._unblock(target); - if (f && target.side === 'ally' && f.noBlock) this.releaseBlocked(target); - if (this._hooks.statusApplied) this.emit('statusApplied', { source, target, status: key, duration, value, entered }); - return true; - } - - /** 抵抗 of a unit: share of a resisted status's duration removed (0 = none; the `resist` status value, ≤ 0.95). */ - resistOf(unit) { - if (!unit || !unit.buffs.length) return 0; - let r = 0; - for (const b of unit.buffs) { - if (b.status !== 'resist') continue; - const v = Number.isFinite(b.data?.value) ? b.data.value : RESIST_DEFAULT; - if (v > r) r = v; - } - return Math.min(0.95, r); - } - - /** 诱导 target point of an `attract` status buff (enemy walks there; see ai.js moveAttracted). */ - _setAttractPoint(target, buff, point, source) { - let r = null, c = null; - if (Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1])) { r = point[0]; c = point[1]; } - else if (point && typeof point === 'object' && Number.isFinite(point.x) && Number.isFinite(point.y)) { r = point.y; c = point.x; } - else if (source && Number.isFinite(source.x) && Number.isFinite(source.y)) { r = source.y; c = source.x; } - if (r == null) { if (!buff.data.attract) buff.data = { ...buff.data, attract: null }; return; } - const R = this.rect; - r = Math.max(R.r0, Math.min(R.r1, Math.round(r))); - c = Math.max(R.c0, Math.min(R.c1, Math.round(c))); - buff.data = { ...buff.data, attract: { r, c, pts: null, i: 0, ver: -1 } }; - } - - /** - * "同名效果取最高" for a content effect that is not a catalogue status (no immunities, 抵抗, `beforeStatus` / - * `statusApplied` hooks or status icon): ONE instance of `key` per target whatever the number of sources — the - * strongest `value` applies, a weaker application only extends past the stronger one's end (it then resumes), an - * equal one refreshes to the longer duration. `mods(value)` builds the buff's mods. The engine's default for two - * same-named buffs (PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"). Returns true when applied. - * @param {object} target - * @param {string} key - * @param {{ duration: number, value: number, mods: (v: number) => object, source?: object|null }} opts - */ - applyStrongest(target, key, { duration, value, mods, source = null } = {}) { - if (!target || !target.alive || !(Number(duration) > 0) || !Number.isFinite(value) || typeof mods !== 'function') return false; - this._applyValuedStatus(target, key, { mods, valued: value, plain: true }, Number(duration), value, source); - return true; - } - - /** - * "同名效果取最高": keep the strongest value; a weaker one that outlasts it resumes afterwards (buff.data.tail). A - * `plain` template (applyStrongest) is an ordinary invisible buff, not a status. - */ - _applyValuedStatus(target, key, tpl, duration, value, source) { - const strength = (v) => Math.abs(Number.isFinite(v) ? v : tpl.valued); - const make = (v, dur, tail) => ({ - ...(tpl.buff || null), // extra buff fields of the status (抵抗: the 麻痹 decay tick) - key, duration: dur, refresh: 'replace', mods: tpl.mods(v), flags: tpl.flags || null, status: tpl.plain ? null : key, - visible: !tpl.plain, source, - data: { value: v, tail }, - onExpire: ({ battle, unit, buff }) => { - const t = buff.data.tail; - if (t && t.until - battle.time > 1e-6 && unit.alive) battle.addBuff(unit, make(t.value, t.until - battle.time, null)); - }, - }); - const old = target.buffs.find((b) => b.key === key && (tpl.plain || b.status === key)); - if (!old) { this.addBuff(target, make(value, duration, null)); return; } - const oldV = old.data && Number.isFinite(old.data.value) ? old.data.value : tpl.valued; - const oldEnd = this.time + old.timeLeft, newEnd = this.time + duration; - const oldTail = old.data && old.data.tail; - const longerTail = (a, b) => (!a ? b : !b ? a : (b.until > a.until ? b : a)); - if (strength(value) > strength(oldV) + 1e-12) { - // stronger: takes over now; the weaker old one (or its tail) resumes if it lasts longer - const tail = longerTail(oldEnd > newEnd ? { value: oldV, until: oldEnd } : null, oldTail && oldTail.until > newEnd ? oldTail : null); - this.addBuff(target, make(value, duration, tail)); - } else if (strength(value) < strength(oldV) - 1e-12) { - // weaker: never overrides; remembered as the tail when it outlasts the running one - if (newEnd > oldEnd && (!oldTail || newEnd > oldTail.until)) old.data = { ...old.data, value: oldV, tail: { value, until: newEnd } }; - } else if (duration > old.timeLeft) { - old.timeLeft = duration; - old.duration = Math.max(old.duration, duration); - } - } - - removeStatus(target, key) { return this.removeBuff(target, key); } - - // ============================================================================================================= - // damage / heal - - dealDamage(source, target, dmg) { - try { return pipeDamage(this, source, target, dmg); } catch (e) { this._internalError('dealDamage', e); return 0; } - } - - heal(source, target, amount, opts = {}) { - try { return pipeHeal(this, source, target, amount, opts); } catch (e) { this._internalError('heal', e); return 0; } - } - - /** - * HP loss that ignores DEF/RES, dodge and shields (流失). May kill. opts: { source, silent, tags, from } — `from` = the - * DamageInfo this loss derives from (damage passed on to a leader, split, shared…): its tags are inherited and it is - * kept as `dmg.origin`, so `damaged` handlers that skip their own tagged damage also skip what it turned into; a loss - * derived from 无来源 damage (`from.sourceless`, element bursts) is 无来源 too (hooks see no source, `source` is credited). - * `sourceless: true` makes the loss itself 无来源 ("受到等量的无来源生命流失": hooks see no source; `source` keeps the - * credit — the stats and the per-player shared-pool tally). - * On a leader in a boss / hidden battle a loss of ≥ BOSS_HIT_LIMIT is cancelled like a hit (damage.js leaderHitCancelled). - */ - loseHp(target, amount, { source = null, silent = false, tags = null, from = null, sourceless = false } = {}) { - if (!target || !target.alive || !(amount > 0)) return 0; - // 限伤 (shared/constants.js BOSS_HIT_LIMIT): a loss passed on to a leader (parts' 传递, 无人机) is one hit too - if (leaderHitCancelled(this, target, amount)) return 0; - const t = ['hpLoss']; - for (const list of [from && from.tags, tags]) if (Array.isArray(list)) for (const x of list) if (!t.includes(x)) t.push(x); - return applyHpLoss(this, source, target, amount, { type: 'true', tags: t, noSp: true, silent, origin: from ?? null, sourceless: !!sourceless || !!(from && from.sourceless) }); - } - - reduceElement(target, amount, element = null) { return reduceElement(target, amount, element); } - - makeDamage(d) { return makeDamageInfo(d); } - - // ============================================================================================================= - // queries - - /** - * Tile buckets of the living enemies: a regular enemy on the tile of its position, a huge one (body.js) on every tile - * it occupies. - */ - _buildEnemyIndex() { - for (const k of this._ebUsed) this._eb[k].length = 0; - this._ebUsed.length = 0; - for (const e of this.enemies) { - if (!e.alive || e.hidden) continue; - if (e.hitArea) { - for (const k of bodyKeys(e)) { const b = this._eb[k]; if (!b.length) this._ebUsed.push(k); b.push(e); } - continue; - } - const r = Math.round(e.y), c = Math.round(e.x); - if (r < 0 || r >= ROWS || c < 0 || c >= COLS) continue; - const k = r * COLS + c; - const b = this._eb[k]; - if (!b.length) this._ebUsed.push(k); - b.push(e); - } - } - - /** Targetable enemies whose body is on any of `keys` (absolute tile keys; a huge enemy is listed once). */ - enemiesInKeys(keys, attacker, profile) { - const out = []; - if (!keys) return out; - for (let i = 0; i < keys.length; i++) { - const b = this._eb[keys[i]]; - if (!b || !b.length) continue; - for (const e of b) if (e.alive && (!e.hitArea || !out.includes(e)) && canTargetEnemy(attacker, e, profile)) out.push(e); - } - return out; - } - - /** - * A living enemy whose body is on any of `keys` — targetable or not (stealthed, untargetable, asleep, flying): the - * 技能范围 trigger "技能范围内存在敌人(无视其不可选中)" (skills.js SKILL_RANGE). - */ - anyEnemyInKeys(keys) { - if (!keys) return false; - for (let i = 0; i < keys.length; i++) { - const b = this._eb[keys[i]]; - if (b && b.length) for (const e of b) if (e.alive && e.deployed) return true; - } - return false; - } - - /** Allies (not devices) within tiles `keys`, sorted by HP ratio (lowest first) that need healing. */ - injuredAlliesInKeys(keys, healer, includeElement = false) { - const out = []; - if (!keys) return out; - const set = keys instanceof Set ? keys : (healer && healer.rangeKeys === keys && healer.rangeKeySet ? healer.rangeKeySet : new Set(keys)); - for (const a of this.allyUnits) { - if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') continue; - if (!set.has(a.tileR * COLS + a.tileC)) continue; - if (a !== healer && (a.s.flags.noHeal || (a.profile && a.profile.noHeal))) continue; - const injured = a.hp < a.s.maxHp - 1e-6; - const elem = includeElement && (a.elem.burn + a.elem.neural + a.elem.necrosis + a.elem.apoptosis + a.elem.erosion) > 0; - if (injured || elem) out.push(a); - } - out.sort((a, b) => a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq); - return out; - } - - lowestHpAllyInRange(unit) { - const l = this.injuredAlliesInKeys(unit.rangeKeys, unit); - return l[0] ?? null; - } - - /** Units of the opposite side (or `side`) whose tile is inside `grid` offsets relative to `unit`. */ - unitsInGrid(unit, grid, { side = null, extend = 0 } = {}) { - const keys = absoluteRangeKeys(grid || unit.rangeGrid || [[0, 0]], Math.round(unit.y), Math.round(unit.x), unit.dir, extend); - const want = side ?? (unit.side === 'ally' ? 'enemy' : 'ally'); - const set = new Set(keys); - const out = []; - const list = want === 'enemy' ? this.enemies : this.allyUnits; - for (const x of list) { - if (!x.alive || !x.deployed || x.hidden) continue; - if (bodyInKeys(x, set)) out.push(x); - } - return out; - } - - /** Allies inside `unit`'s current range (for auras) — not the 孤立 ones ("无法被同阵营选中": 炎佑), `unit` aside. */ - alliesInGrid(unit) { - const set = unit.rangeKeySet || new Set(unit.rangeKeys || []); - return this.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC) && this.allySelectable(a, unit)); - } - - /** - * May an ability of ally `by` select ally `a`? Never a 孤立 unit (炎佑, 从不混淆的方向; flag `isolated`) other than `by` - * itself — PRTS 选择器: selectors decide "天赋是否能给予某个干员Buff" and every heal / buff / aura target, and their 可选判定 - * rejects a 孤立 target of a friendly selector ("若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"; user playtest #6 - * item 18). Enemies still target it (a hostile selector). 禁疗 only keeps heals off (heal pipeline, injuredAlliesInKeys). - */ - allySelectable(a, by = null) { - return !!a && (a === by || !a.s.flags.isolated); - } - - /** Deployed allies (no devices) an ability of ally `by` may select: allies(ownerId) without the 孤立 ones. */ - alliesFor(by, ownerId = null) { - return this.allies(ownerId).filter((a) => this.allySelectable(a, by)); - } - - /** - * Living enemies whose body (body.js: a huge enemy's hit rectangle, else its position) is within `r` of (x, y). - * `centre` = a 中点判定 radius — splash around a struck / marked target (PRTS 作战机制: "中点判定…案例:阻挡,酒神1天赋的1.3 - * 溅射半径"): every enemy counts by its position (判定中心), a huge one too. - */ - enemiesInRadius(x, y, r, centre = false) { - const out = []; - const r2 = r * r + 1e-9; - for (const e of this.enemies) { - if (!e.alive || e.hidden) continue; - if (e.hitArea && !centre) { if (bodyInRadius(e, x, y, r)) out.push(e); continue; } - const dx = e.x - x, dy = e.y - y; - if (dx * dx + dy * dy <= r2) out.push(e); - } - 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, blocked or not hidden again yet after a block (targeting.js - * enemyStealthed; 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 && enemyStealthed(e))) continue; - out[n++] = e; - } - out.length = n; - return out; - } - - /** - * Every deployed ally within `r` of (x, y) — no selection rule: an enemy's area effect selects among them with - * targeting.js areaSelectable (content/enemies.js areaAllies: no 隐匿 ally, the blocker included, GitHub #97; DESIGN §22.12). - */ - alliesInRadius(x, y, r, ownerId = null, { includeDevices = false } = {}) { - const out = []; - const r2 = r * r + 1e-9; - for (const a of this.allyUnits) { - if (!a.alive || !a.deployed || a.hidden) continue; - if (!includeDevices && a.kind === 'device') continue; - if (ownerId != null && a.ownerId !== ownerId) continue; - const dx = a.x - x, dy = a.y - y; - if (dx * dx + dy * dy <= r2) out.push(a); - } - return out; - } - - /** Alive deployed allies (ops + tokens), optionally of one player. */ - allies(ownerId = null) { - return this.allyUnits.filter((a) => a.alive && a.deployed && a.kind !== 'device' && (ownerId == null || a.ownerId === ownerId)); - } - - aliveEnemies() { return this.enemies.filter((e) => e.alive); } - - unitAt(r, c) { const u = this._occ[r * COLS + c]; return u && u.alive ? u : null; } - unitById(id) { return this.units.find((u) => u.id === id) ?? null; } - tileInfo(r, c) { return this.grid.tile(r, c); } - - getPlayer(playerId) { return this.players.find((p) => p.playerId === playerId) ?? null; } - _pp(playerId) { return playerId == null ? null : this._perPlayer[playerId] ?? null; } - - /** - * Recompute a unit's absolute range tile keys: `rangeKeys` / `rangeKeySet` = current range (skill range override + - * rangeExtend — the unit's own 攻击距离 unless the running skill's range ignores it, `targeting.noRangeExtend`: 信仰搅拌机 - * S3, PRTS 备注 "此技能的攻击范围不受'攻击距离'属性影响" — plus the skill's own `targeting.rangeExtend`) ∪ - * `unit.extraRangeKeys` (content extra targets — setExtraRange); `baseRangeKeys` = the INITIAL range of the DEFAULT - * skill trigger: the unit's own grid + its permanent rangeExtend (`s.baseRangeExtend`: persistent, never-expiring - * buffs — talents, modules, bonds), no skill range, no temporary extend, no extra keys; `liveRangeGrid` = the - * relative grid (facing RIGHT) of the 攻击范围 the detail card shows (shared/protocol.js unitStatsEntry `range`; - * community report E1 after 0.1.0: the card kept the base grid while 烛煌 S3 attacked with 4-11): the grid behind - * `rangeKeys` without the extra keys (targeting.js extendedGrid; the grid itself when nothing extends it), or the - * unit's own range while a skill's grid only selects targets (`targeting.showOwnRange`: 荒芜拉普兰德 S1, no official - * range change). Rebuilt on deploy / relocate / skill range switches and whenever either extend changes (rangeChanged). - */ - _refreshRange(u) { - const tg = u.skill && u.skill.active ? u.skill.spec.targeting : null; - const grid = (tg && tg.rangeGrid) || u.rangeGrid || [[0, 0]]; - const ext = (tg && tg.noRangeExtend ? 0 : u.s.rangeExtend) + ((tg && tg.rangeExtend) || 0); - u._rangeExtend = u.s.rangeExtend; - u._baseExtend = u.s.baseRangeExtend; - const keys = absoluteRangeKeys(grid, u.tileR, u.tileC, u.dir, ext); - const set = new Set(keys); - const extra = u.extraRangeKeys; - if (extra) for (const k of extra) if (!set.has(k)) { set.add(k); keys.push(k); } - u.rangeKeys = keys; - u.rangeKeySet = set; - const own = !!(tg && tg.showOwnRange); - const lg = own ? u.rangeGrid || [[0, 0]] : grid, le = own ? u.s.rangeExtend : ext; - u.liveRangeGrid = le > 0 ? extendedGrid(lg, le) : lg; - if (u.kind !== 'device') u.baseRangeKeys = absoluteRangeKeys(u.rangeGrid || [[0, 0]], u.tileR, u.tileC, u.dir, u.s.baseRangeExtend); - if (u.skill) u.skill._trigKeys = null; // CUSTOM_RANGE trigger grid is relative to the tile - } - - /** True when a buff changed the unit's rangeExtend (total or permanent part) since its range was last built. */ - rangeChanged(u) { - return u._rangeExtend !== undefined && (u._rangeExtend !== u.s.rangeExtend || u._baseExtend !== u.s.baseRangeExtend); - } - - /** Public: rebuild a unit's range after content changed `unit.rangeGrid` (流形 copies) or its tile keys. */ - refreshRange(unit) { - if (!unit || unit.side !== 'ally' || !unit.alive || !unit.deployed) return false; - this._refreshRange(unit); - return true; - } - - /** - * Extra targetable tiles merged into the unit's range (蕾缪安 wanted targets, 维娜 S3, …): absolute tile keys - * (row × COLS + col), kept across every later range rebuild until content sets them again (null / [] clears). - * Not part of `baseRangeKeys` (the DEFAULT trigger range) — see SkillRuntime.addTriggerRange for that. - */ - setExtraRange(unit, keys) { - if (!unit || unit.side !== 'ally') return false; - const out = []; - const seen = new Set(); - for (const k of keys || []) if (Number.isInteger(k) && k >= 0 && k < ROWS * COLS && !seen.has(k)) { seen.add(k); out.push(k); } - unit.extraRangeKeys = out.length ? out : null; - if (unit.alive && unit.deployed) this._refreshRange(unit); - return true; - } - - // ============================================================================================================= - // content helpers - - /** - * Token def of `tokenId` as `owner` (an ally unit: its chess + selected skill / module; or a chess id) summons it — - * the def spawnToken uses. For content that reads summon stats / skill blackboards: exact in a multi-player field - * where two players give the same chess different loadouts (an id-only `data.getToken(id, chessId)` is not). - */ - tokenDef(tokenId, owner = null) { - return this._tokenDef(tokenId, owner, null); - } - - /** - * Whether an operator's selected skill / module produces `tokenId` (DESIGN §16, DATA.md §14 `sources`): false only - * when the owner's own tokens.json variant says so (`sources` without 'skill' / 'talent' — e.g. 琳琅诗怀雅 S1/S3 make - * no 香槟炸弹, 赫默 S1 no 医疗探机); true when the data does not tell (no owner variant, inline defs, tokens of - * other chess). - */ - producesToken(owner, tokenId, def = null) { - if (!owner || typeof owner !== 'object' || owner.kind !== 'op') return true; - const d = def ?? this.tokenDef(tokenId, owner); - const src = d && Array.isArray(d.sources) ? d.sources : null; - if (!src || src.includes('skill') || src.includes('talent')) return true; - const vs = this.data.rawToken?.(tokenId)?.variants; - const id = String(owner.defId ?? ''); - return !(vs && typeof vs === 'object' && (vs[id] || vs[id.replace(/_b$/, '_a')])); - } - - /** - * Spawn (and deploy) a token. `owner` = ally unit or playerId. Returns the token unit or null. The def is the owner's - * variant for its selected skill / module (`opts.def` = an inline def instead). While an operator runs a NON-default - * skill, a summon that skill does not produce (producesToken) is refused unless `opts.anySource`: hand-authored kit - * install hooks are written for the default skill and run under every skill (琳琅诗怀雅 S1/S3 would drop 香槟炸弹); - * under the default skill the kit stays the authority. - */ - spawnToken(owner, tokenId, row, col, opts = {}) { - const ownerUnit = owner && typeof owner === 'object' ? owner : null; - const pid = ownerUnit ? ownerUnit.ownerId : owner; - const ps = this.getPlayer(pid) ?? (ownerUnit ? ownerUnit.player : null); - const def = opts.def ? normalizeToken(tokenId, opts.def) : this._tokenDef(tokenId, ownerUnit, null); - if (!def) { this.log(`unknown token ${tokenId}`); return null; } - if (!opts.def && !opts.anySource && ownerUnit && ownerUnit.def?.loadout?.skillIsDefault === false && !this.producesToken(ownerUnit, tokenId, def)) { - this.log(`${ownerUnit.defId} (skill ${ownerUnit.def?.loadout?.skillIndex ?? '?'}) does not produce ${tokenId}`); - return null; - } - if (!Number.isInteger(row) || !Number.isInteger(col) || !this.grid.inRect(row, col)) return null; - const occ = this._occ[row * COLS + col]; - if (occ && occ.alive && !opts.force) return null; - const dir = opts.dir != null ? normDir(opts.dir) : opts.facing != null ? normDir(opts.facing) : ownerUnit ? ownerUnit.dir : ps ? ps.dir : 'RIGHT'; - const u = this._makeAlly(ps, def, 'token', row, col, { ownerUnit, dir }); - if (opts.stats) for (const [k, v] of Object.entries(opts.stats)) if (Number.isFinite(v)) u.base[k] = v; - this._setupUnit(u, opts.kit ?? null); - if (opts.untargetable) this.addBuff(u, { key: 'trait:untargetable', flags: { untargetable: true }, persist: true, allowDead: true }); - if (!this._deploy(u, { initial: false })) { - // tile still busy (a unit stands there even with `force`): drop the half-built token and its hooks - u.removed = true; - this.offOwner(u); - return null; - } - if (opts.hp != null && Number.isFinite(Number(opts.hp))) u.hp = Math.max(1, Math.min(u.s.maxHp, Number(opts.hp))); - if (opts.duration > 0 && Number.isFinite(Number(opts.duration))) this.after(Number(opts.duration), () => { if (u.alive) this.retreat(u, { reason: 'expired', permanent: true }); }, { owner: u }); - return u; - } - - /** - * Spawn a stage device (e.g. crates). opts: { hp, obstacle, obstacleKind, blockCnt, name, def, res, atk, bat, aspd }. - * An `obstacle` device is an obstacle-like tile by default (`obstacleKind` 'crate': enemies route around it at cost - * 1000 and, when it is the only way, walk into it, get blocked by it and break it — research 08 §3.1); 'block' makes - * the tile impassable. - */ - spawnDevice(key, row, col, opts = {}) { - // Devices are ally-side units on a field tile: never outside the rect, never on top of a living unit or of a - // knocked-out operator (downOn). - if (!Number.isInteger(row) || !Number.isInteger(col) || !this.grid.inRect(row, col)) return null; - const occ = this._occ[row * COLS + col]; - if (occ && occ.alive) return null; - if (this.downOn(row, col)) return null; - const hp = fin(opts.hp, 100); - const bat = fin(opts.bat, 1), aspd = fin(opts.aspd, 100); - const def = { - type: 'device', id: key, baseId: key, name: opts.name ?? key, tier: 0, golden: false, profession: 'DEVICE', subProf: null, - stats: { maxHp: hp > 0 ? hp : 100, atk: Math.max(0, fin(opts.atk, 0)), def: Math.max(0, fin(opts.def, 0)), res: Math.min(100, Math.max(0, fin(opts.res, 0))), aspd: aspd > 0 ? aspd : 100, bat: bat > 0 ? bat : 1, blockCnt: Math.max(0, fin(opts.blockCnt, 99)), cost: 0, respawnTime: 0, spRecovery: 0, tauntLevel: -1, massLevel: 0, hpRecoveryPerSec: 0 }, - rangeGrid: [[0, 0]], skill: null, talents: [], spine: key, avatar: key, - }; - const u = this._makeAlly(null, def, 'device', row, col, opts.dir != null ? { dir: opts.dir } : {}); - u.ownerId = null; - u.kit = {}; - u.profile = { noAttack: true, maxTargets: 0 }; - u.skill = new SkillRuntime(this, u, null, null, {}); - u.obstacle = !!opts.obstacle; - u.obstacleKind = opts.obstacleKind === 'block' ? 'block' : 'crate'; - u.alive = true; - u.deployed = true; - u.ground = true; - u.markDirty(); - u.hp = u.s.maxHp; - u.deploySeq = ++this._deploySeq; - u.aggroSeq = u.deploySeq; - u.deployedAt = this.time; - u.rangeKeys = []; - this._occ[row * COLS + col] = u; - if (u.obstacle) this.grid.setObstacle(row, col, true, u.obstacleKind); - this._ev(['spawn', unitInfo(u)]); - if (this._hooks.deploy) this.emit('deploy', { unit: u, initial: !this.started }); - return u; - } - - _spawnStageDevices() { - if (this.opts.devices === false) return; - for (const d of this.stage.devices || []) { - // data/stages.json `active` = present at match start (= not hidden in the 下半 level file: act1 m02 starts with no - // crates, research 08 §3.3); research stages have no `active` ⇒ `hidden` decides. - const act = d.raw && typeof d.raw.active === 'boolean' ? d.raw.active : !d.hidden && d.active !== false; - if (!act) continue; - if (d.role === 'platform' || d.role === 'mound') { - // 射击台 / mounds [ASSUMED, research 08 §8 #1]: hard-block ground movement; an operator standing on one is elevated - // (does not block) — the match lets ranged operators deploy there (deployTiles.rangedOnly). - if (!this.grid.inRect(d.row, d.col)) continue; - this.grid.setObstacle(d.row, d.col, true); - (this._elevated ??= new Set()).add(d.row * COLS + d.col); - continue; - } - const spec = OBSTACLE_DEVICES[d.key] ?? (d.role === 'crate' ? { hp: d.stats?.maxHp ?? 100, name: d.name } : null); - if (!spec) continue; - if (!this.grid.inRect(d.row, d.col)) continue; - if (this._occ[d.row * COLS + d.col]) continue; - this.spawnDevice(d.key, d.row, d.col, { hp: spec.hp, obstacle: true, name: spec.name }); - } - } - - /** Toggle a ground obstacle (enemies re-read their flow field). `kind` 'block' (default, impassable) | 'crate'. */ - setObstacle(r, c, on, kind = 'block') { this.grid.setObstacle(r, c, on, kind); } - - addProjectile(p) { return this.projectiles.add(p); } - - /** Move an ally to another tile (keeps state); never onto a living unit or a knocked-out operator (downOn). */ - relocate(unit, r, c) { - // only a living, deployed ally moves, and only onto an integer tile of this field (a dead unit left in _occ - // would later "block" from a tile it no longer stands on once it redeploys at home) - if (!unit || unit.side !== 'ally' || !unit.alive || !unit.deployed) return false; - if (!Number.isInteger(r) || !Number.isInteger(c) || !this.grid.inRect(r, c)) return false; - const k = r * COLS + c; - if (this._occ[k] && this._occ[k] !== unit && this._occ[k].alive) return false; - if (this.downOn(r, c)) return false; - const ok = unit.tileR * COLS + unit.tileC; - if (this._occ[ok] === unit) this._occ[ok] = null; - this.releaseBlocked(unit); - if (unit.obstacle) this.grid.setObstacle(unit.tileR, unit.tileC, false, unit.obstacleKind); - unit.tileR = r; unit.tileC = c; unit.x = c; unit.y = r; - unit.ground = this.grid.isLow(r, c) && !this._elevated?.has(k); - this._occ[k] = unit; - if (unit.obstacle) this.grid.setObstacle(r, c, true, unit.obstacleKind); - this._refreshRange(unit); // current + base range, CUSTOM_RANGE trigger keys - return true; - } - - /** - * 【移动】 (PRTS 术语释义 移动: "不退场,以当前血量在目标位置部署 … 本质上为一次特殊的撤退-再部署行为"): `unit` leaves its - * tile (relocate: same checks, false = refused, nothing changed) and is deployed on (r, c) at once. A new deployment — - * deploySeq / aggroSeq / deployedAt (the deploy animation; once-per-deployment effects re-arm) — announced by - * `deploy` { initial: false, move: true }: deploy effects fire again ("可以通过移动行为多次触发部署时触发的效果"). Not an - * exit: no `die` / `death`, so no knock-out, no redeploy timer or cost ("因移动撤退时不会积累再部署惩罚") — 不屈 and - * 阿戈尔's revive, which act on exits "因移动之外的原因" (PRTS 阿戈尔 备注), never see it. HP, buffs and a running skill - * stay: officially nothing carries over but what the mover's skill names (乌尔比安 S3: 技能进度 and the second talent's - * stacks); the remake keeps the rest too (owner's deviation, DESIGN §22.3). `clearSp`: the 技力 is emptied before the - * deploy handlers run ("部署时将清空技力"; 乌尔比安's 【返回】 "将清空技力,但仍可以享受后续由其他效果提供的技力"). - */ - moveRedeploy(unit, r, c, { clearSp = false } = {}) { - if (!this.relocate(unit, r, c)) return false; - unit.deploySeq = ++this._deploySeq; - unit.aggroSeq = unit.deploySeq; - unit.deployedAt = this.time; - const sk = unit.skill; - if (clearSp && sk && !sk.noSkill && sk.kind !== 'passive' && !(sk.active && sk.isTimed)) { - sk.sp = 0; - sk.charges = sk.spCost <= 0 ? sk.maxCharges : 0; // (a free skill is available once per deployment: skills.js reset) - } - this._ev(['deploy', unit.id]); - if (this._hooks.deploy) this.emit('deploy', { unit, initial: false, move: true }); - return true; - } - - /** - * 受力等级 of enemy `e` under a push / pull of 力度 `force` (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3, 大力+1 4, 特大力 5): - * force − its current 重量等级 (massLevel incl. 失重) — PRTS 游戏数据基础 §重量公式, 推与拉 (user playtest #6 item 14). - */ - forceLevel(e, force) { - return Math.round(fin(force, 0)) - (e && e.s ? e.s.massLevel : 0); - } - - /** - * Push enemy `e` with 力度 `force` (PRTS 推与拉 §推力): the official distance of its 受力等级 (constants.js PUSH_TILES: - * a 中力 push moves a weight-1 enemy 1.7 tiles, a weight-2 one 0.44, weight 3 0.12, weight ≥ 4 not at all). Radial - * (the default: away from `from`, the pusher's centre — "沿着干员向自身中心点的射线方向"; the client's buff template - * knockback[relative]: 琳琅诗怀雅 S3, 山 S3, 莫斯提马 S3) or directional (`dir` = the pusher's direction {x, y}; template - * knockback[dir], Knockback {_useSourceDirection: true, _decreaseForceLevelWhenNotInDirection: 2}: 推击手, 野鬃 S2 - * "往攻击方向"); a directional push on a target more than 45° off `dir` or nearer than 0.25 tile becomes radial with - * 受力等级 −2 ("特殊修正"). `fixed` waives both corrections (圣聆初雪 S1 "朝部署方向": KnockBackWithCharacterDirection, - * PRTS 备注 "不会因角度过大或距离过近而变化方向与力度"); `fixedAngle` only the angle one (PRTS 见行者 S2 备注 "不会因为角度过大 - * 而改变推动的方向或削减力度" — the < 0.25 tile rule still applies). `inward` = a radial push towards `from` (薄绿 S2's "拖拽", PRTS 备注 "实际为 - * 反方向(指向薄绿方向)的推开"), never nearer than PULL_STOP_RADIUS to its centre [ASSUMED: "至面前"]. `effect` = a 特效 - * push (PRTS 推与拉: one frame less of travel than a 弹道 push — constants.js PUSH_TILES_EFFECT / PUSH_EFFECT_SKILLS). - * Returns the tiles moved. - */ - push(e, force, { from = null, dir = null, fixed = false, fixedAngle = false, inward = false, effect = false } = {}) { - if (!this._displaceable(e)) return 0; - let level = this.forceLevel(e, force); - const fx0 = fin(from?.x, e.x), fy0 = fin(from?.y, e.y); - const vx = e.x - fx0, vy = e.y - fy0, d = Math.hypot(vx, vy); - let ux = 0, uy = 0; - const dirX = dir ? fin(dir.x, 0) : 0, dirY = dir ? fin(dir.y, 0) : 0; - const dl = Math.hypot(dirX, dirY); - if (dl > 0) { - ux = dirX / dl; uy = dirY / dl; - if (from && !fixed && (d < PUSH_DIRECTIONAL_MIN_DIST || (!fixedAngle && vx * ux + vy * uy < d * Math.SQRT1_2))) { - level -= 2; - if (d > 1e-6) { ux = vx / d; uy = vy / d; } - } - } else if (d > 1e-6) { ux = vx / d; uy = vy / d; } - else if (!inward && from && Array.isArray(from.fwd)) { ux = from.fwd[1]; uy = from.fwd[0]; } - else return 0; - let dist = pushTiles(level, effect); - if (inward && !(dl > 0)) { ux = -ux; uy = -uy; dist = Math.min(dist, Math.max(0, d - PULL_STOP_RADIUS)); } - return this.displace(e, { x: ux, y: uy }, dist); - } - - /** - * Pull enemy `e` with 力度 `force` towards the point `to` (PRTS 推与拉 §拉力 / §捕网): 受力等级 ≥ 0 — all the way, until it - * is within `stop` tiles of `center` (急停; `center` defaults to `to`, `stop` to PULL_STOP_RADIUS) or reaches `to`; - * −1 — PULL_WEAK_SHARE of its starting distance to `to`; −2 — PULL_CRAWL tiles; ≤ −3 — nothing. `pullToFront` aims at - * the official 拉力起点 in front of an operator. Returns the tiles moved. - */ - pull(e, force, { to, center = null, stop = PULL_STOP_RADIUS } = {}) { - if (!this._displaceable(e) || !to) return 0; - // an enemy the puller itself blocks already stands in front of it (at contact) [ASSUMED: no pull, no unblocking] - if (center && center.side === 'ally' && e.blockedBy === center) return 0; - const tx = fin(to.x, e.x), ty = fin(to.y, e.y); - const dx = tx - e.x, dy = ty - e.y, d0 = Math.hypot(dx, dy); - if (!(d0 > 1e-6)) return 0; - const ux = dx / d0, uy = dy / d0; - // travel until inside the stop circle around `center` (smaller root of |e + t·u − c| = stop), else up to `to` - let full = d0; - const cx = fin(center?.x, tx), cy = fin(center?.y, ty), r = Math.max(0, fin(stop, 0)); - const wx = e.x - cx, wy = e.y - cy, wu = wx * ux + wy * uy, w2 = wx * wx + wy * wy; - if (w2 <= r * r) full = 0; - else { - const disc = wu * wu - w2 + r * r; - if (disc >= 0) { const t = -wu - Math.sqrt(disc); if (t >= 0) full = Math.min(full, t); } - } - const level = this.forceLevel(e, force); - const dist = level >= 0 ? full : level === -1 ? Math.min(full, PULL_WEAK_SHARE * d0) : level === -2 ? Math.min(full, PULL_CRAWL) : 0; - return dist > 1e-6 ? this.displace(e, { x: ux, y: uy }, dist) : 0; - } - - /** Official distance (tiles) a push of 力度 `force` would move `e` on open ground (0 when it cannot be displaced). */ - pushDistance(e, force, { effect = false } = {}) { - return this._displaceable(e) ? pushTiles(this.forceLevel(e, force), effect) : 0; - } - - /** Pull `e` "至面前" of ally `unit` (拉力起点 PULL_ORIGIN tiles ahead along its direction, 急停 around its centre). */ - pullToFront(e, unit, force) { - if (!unit) return 0; - const f = Array.isArray(unit.fwd) ? unit.fwd : [0, 1]; - return this.pull(e, force, { to: { x: unit.x + f[1] * PULL_ORIGIN, y: unit.y + f[0] * PULL_ORIGIN }, center: unit, stop: PULL_STOP_RADIUS }); - } - - /** - * Can `e` be displaced at all: a living enemy, not a leader part of the boss pool, not 失衡免疫 (flag `noDisplace`: - * 近地悬浮, 浮空, the 胄 parts …) and not a 静态刚体 (data `staticBody`). PRTS 特殊机制 静态刚体: such a unit "可以进入 - * 失衡状态 … 但物理层面上无法产生任何速度或移动" — every air unit of the mode except “炎佑”, plus the boss 昆图斯 (build-data - * STATIC_BODIES; player report after 0.1.0, "飞机可以被薄绿的技能拉走"). A skill that reaches it still hits it (its targeting - * is the skill's own: 锏 S3, 薄绿, the 钩索师 …); only the movement is 0, so distance-based effects (drag damage, 见行者 S2's - * wall stun) come to nothing. [ASSUMED] the 0.1 s 失衡硬直 a 静态刚体 still gets is not modelled (no displacement models it). - */ - _displaceable(e) { - return !!(e && e.alive && e.side === 'enemy' && !e.isBoss && !e.s.flags.noDisplace && !(e.def && e.def.staticBody)); - } - - /** - * Move an enemy `distance` tiles along `dir` = {x, y} (normalised internally) over passable tiles — the raw mover of - * push() / pull(), which apply the official 力度 − 重量 rules (content uses those; the old `force` option is gone). - * 失衡免疫 (flag `noDisplace`: 近地悬浮, 浮空 — PRTS 异常效果 "不会被位移影响"), 静态刚体 (data `staticBody`) and leaders - * ⇒ no movement (_displaceable). The tiles it may cross follow its movement (`motion`): a hovering enemy walks the - * ground, so it stays on ground-passable tiles. - */ - displace(e, dir, distance) { - if (!this._displaceable(e) || !dir) return 0; - const dxv = fin(dir.x, 0), dyv = fin(dir.y, 0); - const len = Math.hypot(dxv, dyv); - if (!(len > 0)) return 0; - const eff = Math.min(fin(distance, 0), 2 * COLS); - if (!(eff > 0)) return 0; - const ux = dxv / len, uy = dyv / len; - let moved = 0; - const stepLen = 0.1; - while (moved + 1e-9 < eff) { - const s = Math.min(stepLen, eff - moved); // (the last step is a partial one: 0.12 tiles moves 0.12, not 0.2) - const nx = e.x + ux * s, ny = e.y + uy * s; - const r = Math.round(ny), c = Math.round(nx); - const ok = e.motion === 'FLY' ? this.grid.inRect(r, c) : this.grid.groundPassable(r, c); - if (!ok) break; - e.x = nx; e.y = ny; moved += s; - } - if (moved > 0) { - this._unblock(e); - if (e.route) e.route.pts = null; - this.fx('displace', { x: e.x, y: e.y, id: e.id }); - } - return moved; - } - - /** - * A tile no automatic placement may take (the 突袭 landing tile, tactical points, summon / device tiles): a living - * unit stands on it, or it is the rest tile (restTile) of an ally piece that has not deployed yet or waits to - * redeploy — the tile a knocked-out operator lies on ("倒地干员所在地块…所有我方单位在此处的部署行为将被阻止", PRTS - * 卫戍协议/帮助 §作战阶段 单位部署), else its home tile (a token parked there would keep that unit off the field). - */ - isReservedTile(r, c) { - if (!Number.isInteger(r) || !Number.isInteger(c) || r < 0 || r >= ROWS || c < 0 || c >= COLS) return true; - const u = this._occ[r * COLS + c]; - if (u && u.alive) return true; - for (const a of this.allyUnits) { - if (a.alive || a.removed || (a.kind !== 'op' && a.kind !== 'token')) continue; - const [tr, tc] = this.restTile(a); - if (tr === r && tc === c) return true; - } - return false; - } - - /** - * The knocked-out operator (isDown) lying on (r, c), other than `except`, or null. Official (PRTS 卫戍协议/帮助 §作战阶段 - * 单位部署): "干员退场后…原地留下一个“倒地干员”…满足再部署条件时,移除场上的该倒地干员并自动部署至该位置" and "倒地干员所在 - * 地块视为可部署,但所有我方单位在此处的部署行为将被阻止" — its tile (`body`, _layBody) takes no other ally: `_deploy` - * (initial deploys, redeploys, the 突袭 landing, summons), `spawnDevice` and `relocate` refuse it. - */ - downOn(r, c, except = null) { - for (const a of this.allyUnits) { - if (a === except || !this.isDown(a)) continue; - const [br, bc] = a.body ?? [a.tileR, a.tileC]; - if (br === r && bc === c) return a; - } - return null; - } - - /** - * The tile a withdrawn ally comes back on (redeploy, _checkRedeploys): a down operator's body tile (isDown) — where it - * fell, or its home (_layBody) — else its home tile. - */ - restTile(u) { - return this.isDown(u) ? (u.body ? [u.body[0], u.body[1]] : [u.tileR, u.tileC]) : [u.homeR, u.homeC]; - } - - /** - * Where a knocked-out operator lies (it redeploys there): the tile it fell on, except — "若干员被击倒的位置为其他干员或 - * 召唤物的初始位置,则在被击倒后,尝试返回其自身的初始位置" (PRTS 卫戍协议/帮助) — a tile that is another board piece's - * home (a piece with a board uid: the 初始位置 is the prep placement — whether that piece is on the field, waiting or - * gone: a summon only leaves its home free once it has expired or been killed, so a removed one must count too), where - * it goes back to its own home tile when that is in the rect and free (isReservedTile: no living unit, no other body, - * no other waiting piece's tile). It stays where it fell when its home is taken [ASSUMED: one attempt, at the - * knock-out]. Only an operator moved off its board tile — a 突袭 jump, 乌尔比安's anchor — can fall elsewhere. Sets - * `u.body` (b.snap `down` carries it); x / y / tileR / tileC keep where it fell, so the `kill` / `death` handlers - * (被击倒时 effects) still act there. - */ - _layBody(u) { - const r = u.tileR, c = u.tileC, hr = u.homeR, hc = u.homeC; - u.body = [r, c]; - if (r === hr && c === hc) return; - if (!this.allyUnits.some((a) => a !== u && a.uid != null && (a.kind === 'op' || a.kind === 'token') && a.homeR === r && a.homeC === c)) return; - if (!this.grid.inRect(hr, hc) || this.isReservedTile(hr, hc)) return; - u.body = [hr, hc]; - } - - /** - * Tactical point (战术点) for tactician reinforcements: a free (isReservedTile) walkable ground tile of the unit's - * initial range (baseRangeKeys), on an enemy ground path first (groundPathTiles — where a player would put the - * blocker), then nearest to the unit (Chebyshev; the smaller sideways offset — in the unit's facing-RIGHT frame, so - * its forward line — breaks ties), then the lowest local (row, col) offset (sim/dir.js localOrder: for a RIGHT-facing - * unit exactly the lowest tile key). The choice turns with the unit's direction. null when none. - */ - findTacticalPoint(unit) { - if (!unit) return null; - const onPath = this.groundPathTiles(); - let best = null, bp = 2, bd = Infinity, bo = null; - for (const k of unit.baseRangeKeys || unit.rangeKeys || []) { - const r = (k / COLS) | 0, c = k % COLS; - if (!this.grid.groundPassable(r, c) || !this.grid.canStand(r, c)) continue; - if (this.isReservedTile(r, c)) continue; - const o = localOrder(r - unit.tileR, c - unit.tileC, unit.dir); - const d = Math.max(Math.abs(r - unit.tileR), Math.abs(c - unit.tileC)) + 0.01 * Math.abs(o[0]); - if (!(d > 0)) continue; - const p = onPath.has(k) ? 0 : 1; - if (p < bp || (p === bp && (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && localBefore(o, bo))))) { bp = p; bd = d; bo = o; best = [r, c]; } - } - return best; - } - - /** - * Ground tiles the enemies of this field walk on: the flow-field routes (grid.findPath, live obstacles) of the - * non-FLY routes this battle's wave spawns on (every non-FLY route when no queued spawn names one — e.g. content-only - * battles), leg by leg (start → MOVE/APPEAR checkpoints → end, clamped to the rect). R1–R3 spawn at the lower gate - * only (research 08 §4.1), so the unused upper lane does not attract tactical points. Set of tile keys, cached per - * grid version. - */ - groundPathTiles() { - const ver = this.grid.version ?? 0; - const used = (this._pathRoutesUsed ??= new Set()); - const before = used.size; - for (const p of this._pending || []) if (!p.route && Number.isInteger(p.routeIndex)) used.add(p.routeIndex); - if (this._pathTiles && this._pathTilesVer === ver && used.size === before) return this._pathTiles; - const set = new Set(); - const R = this.rect; - const clampPt = (p) => [Math.max(R.r0, Math.min(R.r1, Math.round(p[0]))), Math.max(R.c0, Math.min(R.c1, Math.round(p[1])))]; - const ok = (p) => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]); - const walk = (route) => route && route.motion !== 'FLY' && ok(route.start); - const routes = this.routes || []; - const any = [...used].some((i) => walk(routes[i])); - routes.forEach((route, i) => { - if (!walk(route) || (any && !used.has(i))) return; - const pts = [clampPt(route.start)]; - for (const cp of route.checkpoints || []) if ((cp.type === 'MOVE' || cp.type === 'APPEAR') && ok(cp.pos)) pts.push(clampPt(cp.pos)); - if (ok(route.end)) pts.push(clampPt(route.end)); - for (let j = 1; j < pts.length; j++) { - const [a, b] = [pts[j - 1], pts[j]]; - const p = this.grid.findPath(a[0], a[1], b[0], b[1]) || this.grid.findPath(a[0], a[1], b[0], b[1], { ignoreObstacles: true }); - for (const [r, c] of p || []) set.add(r * COLS + c); - } - }); - this._pathTiles = set; - this._pathTilesVer = ver; - return set; - } - - addDp(playerId, n) { - const ps = this.getPlayer(playerId); - if (!ps || !Number.isFinite(n)) return 0; - ps.dp = Math.max(0, Math.min(this.flags.dpMax, ps.dp + n)); - return ps.dp; - } - - /** - * Record an IN_BATTLE layer gain (no-op when gains are disabled). Returns the layers added: at most the room left - * under BOND_LAYER_CAP (999, shared/constants.js) on the live copy — the client's AddBondCount `min(L + n, 999)`; the - * `layerGain` hook (魔王's +1 …) runs first, then the clamp; a bond already at the cap gains 0 (no hook, no event). - * Without a live copy of the bond (a partial PlayerBattleInput) the battle's own gains count; the match's settle - * clamps the persistent count the same way. - */ - addLayers(playerId, bondId, n, reason = '', opts = {}) { - if (!this.flags.layerGainsEnabled || !(n > 0) || !Number.isFinite(n) || playerId == null) return 0; - const pp = this._pp(playerId); - if (!pp) return 0; - const ps = this.getPlayer(playerId); - const live = ps && ps.bonds[bondId] ? (ps.bonds[bondId].layers ?? 0) : (pp.layerGains[bondId] ?? 0); - if (!(layerGainRoom(live, Infinity) > 0)) return 0; - const source = opts.source ?? null; - const ctx = { playerId, bondId, n, reason, source, tile: Array.isArray(opts.tile) ? opts.tile : this._sourceTile(source) }; - if (this._hooks.layerGain) { this.emit('layerGain', ctx); if (!(ctx.n > 0) || !Number.isFinite(ctx.n)) return 0; } - const add = layerGainRoom(live, ctx.n); - if (!(add > 0)) return 0; - pp.layerGains[bondId] = (pp.layerGains[bondId] ?? 0) + add; - if (ps && ps.bonds[bondId]) ps.bonds[bondId].layers = (ps.bonds[bondId].layers ?? 0) + add; - this._ev(['layer', playerId, bondId, add]); - return add; - } - - /** - * Tile of a gain's source unit ([r, c]): where it stands, or where it stood when it left the field during this very - * instant (a "被击倒时" gain fires from its `death`). null otherwise (no source, long gone, not a unit). - */ - _sourceTile(u) { - if (!u || typeof u !== 'object' || !Number.isFinite(u.x)) return null; - const here = u.alive && u.deployed && !u.hidden; - if (!here && !(u.deathAt === this.time && !u.alive)) return null; - return u.side === 'ally' ? [u.tileR, u.tileC] : [Math.round(u.y), Math.round(u.x)]; - } - - addCoins(playerId, n) { - const pp = this._pp(playerId); - if (!pp || !(n > 0) || !Number.isFinite(n)) return 0; - pp.coins += n; - this._ev(['bounty', playerId, n]); - return n; - } - - fx(kind, params = {}) { - const { x = 0, y = 0, ...extra } = params || {}; - this._ev(['fx', kind, Math.round(x * 100) / 100, Math.round(y * 100) / 100, extra]); - } - - log(msg) { - const k = 'log:' + msg; - if (this._errKeys.has(k)) return; - this._errKeys.add(k); - if (this.opts.verbose) this.logger.warn?.(`[sim] ${msg}`); - } - - // ============================================================================================================= - // internals: hidden / boss / clamp / events / errors - - _setHidden(e, on) { - if (e.hidden === on) return; - e.hidden = on; - if (on) this._unblock(e); - this.fx(on ? 'disappear' : 'appear', { x: e.x, y: e.y, id: e.id }); - } - - _syncBossHp(e) { - const pool = e.bossPool; - if (!pool) return; - if (Number.isFinite(pool.maxHp) && pool.maxHp > 0 && e.base.maxHp !== pool.maxHp) { e.base.maxHp = pool.maxHp; e.markDirty(); } - // a broken pool (NaN hp) must not leak NaN into the unit: show it full until the pool is sane again - const ratio = pool.maxHp > 0 ? (Number.isFinite(pool.hp) ? Math.max(0, pool.hp) / pool.maxHp : 1) : 0; - e.hp = e.s.maxHp * Math.min(1, ratio); - } - - _bossSync() { - if (!this.sharedBoss) return; - for (const e of this.enemies) if (e.alive && e.bossPool) { - this._syncBossHp(e); - if (e.bossPool.hp <= 0) this.kill(e, null); - } - } - - _clampPos(e) { - const R = this.rect; - if (e.x < R.c0 - 0.5) e.x = R.c0 - 0.5; - if (e.x > R.c1 + 0.5) e.x = R.c1 + 0.5; - if (e.y < R.r0 - 0.5) e.y = R.r0 - 0.5; - if (e.y > R.r1 + 0.5) e.y = R.r1 + 0.5; - } - - _compactEnemies() { - if (!this._enemiesDirty) return; - this.enemies = this.enemies.filter((e) => e.alive); - this._enemiesDirty = false; - } - - _ev(tuple) { - if (!this.recordEvents) return; - this._evq.push(tuple); - if (this._evq.length > EVENT_BUFFER_CAP) this._evq.splice(0, this._evq.length - EVENT_BUFFER_CAP / 2); - } - - /** Client-facing events since the last drain (DESIGN §8.2 tuples). */ - drainEvents() { - const ev = this._evq; - this._evq = []; - return ev; - } - - /** - * Compact full snapshot of this field (DESIGN §8.2 b.snap), plus (only when non-empty): - * down: [[id, respawnAt, respawnTime, state, row, col]] — operators that left the field waiting to redeploy (isDown): the - * game time their respawn timer ends, its length (s), constants.js DOWN_STATE and the tile they lie on (and - * come back on: _layBody — where they fell, or their home); - * elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView). - */ - snapshot() { - const snap = { - fieldId: this.fieldId, - t: Math.round(this.time * 1000) / 1000, - units: snapshotUnits(this.units, this.time), - dp: this.players.length ? Math.floor(this.players[0].dp) : 0, - killed: this.killed, - total: this.total, - }; - if (this.players.length > 1) { - snap.dps = {}; - for (const p of this.players) snap.dps[p.playerId] = Math.floor(p.dp); - } - if (this.sharedBoss) snap.boss = { hp: Math.max(0, Math.round(this.sharedBoss.hp)), max: Math.round(this.sharedBoss.maxHp) }; - const r2 = (v) => Math.round(v * 100) / 100; - let down = null; - for (const u of this.allyUnits) { - if (!this.isDown(u)) continue; - (down || (down = [])).push([u.id, r2(u.respawnAt), r2(Math.max(0, u.respawnAt - u.deathAt)), this._downState(u), ...this.restTile(u)]); - } - if (down) snap.down = down; - let elem = null; - for (const u of this.units) { - if (!u.alive || !u.deployed || u.hidden) continue; - const v = elementView(u, this.time); - if (v) (elem || (elem = [])).push([u.id, v[0], v[1], v[2], v[3]]); - } - if (elem) snap.elem = elem; - return snap; - } - - /** - * An operator lying on the field, waiting to redeploy on that tile (DESIGN §5.5: after its respawn time, when the tile - * is free and DP ≥ cost; _layBody, downOn). PRTS 卫戍协议/帮助 §作战阶段 单位部署: "干员退场后,将返回隐藏的待部署区,并原地 - * 留下一个“倒地干员”以供查看信息,满足再部署条件时,移除场上的该倒地干员并自动部署至该位置。" — every 退场 (GitHub #60, the owner's - * decision of 2026-10-04): knocked out ('killed'), entering the battle knocked out (FORCED_EXIT, 联防) and forced out - * by its own effects ('retreat': 史尔特尔's 余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗, 伊内丝 S3; 'merchant': a 商人 that cannot - * pay) — except the 突袭 retreat ('raid', redeployed at once on its landing tile); not removed for good, after it was - * deployed. A forced exit stays no kill: its 'die' / `death` reason is not 'killed' (no 被击倒 effect, 不屈, 阿戈尔, no - * knock-down count). The client keeps its model on that tile knocked down with a redeploy countdown (b.snap `down`, - * render/units.js); summons, devices and enemies simply leave. - */ - isDown(u) { - return !!u && u.side === 'ally' && u.kind === 'op' && !u.alive && !u.removed && u.removeReason !== 'raid' - && u.deploySeq > 0 && Number.isFinite(u.respawnAt); - } - - /** - * constants.js DOWN_STATE of a down operator: its timer runs, or it waits for its tile / the DP — the same checks as - * _checkRedeploys (a living unit or another body on its rest tile). No ally may take the tile it lies on (downOn), so - * WAIT_TILE is a safeguard only. - */ - _downState(u) { - if (this.time + 1e-9 < u.respawnAt) return DOWN_STATE.COUNTING; - const [r, c] = this.restTile(u); - const occ = this._occ[r * COLS + c]; - if ((occ && occ.alive && occ !== u) || this.downOn(r, c, u)) return DOWN_STATE.WAIT_TILE; - const ps = this.getPlayer(u.ownerId); - return !ps || ps.dp + 1e-9 < u.base.cost ? DOWN_STATE.WAIT_DP : DOWN_STATE.COUNTING; - } - - /** - * Field meta for m.field: { fieldId, kind, rect, stageId, units: UnitInfo[] } — the units on the field, knocked-out - * operators waiting to redeploy included (a client joining mid-battle shows them down). - */ - fieldMeta() { - return { - fieldId: this.fieldId, kind: this.kind, rect: { ...this.rect }, stageId: this.stageId, - units: this.units.filter((u) => (u.alive && u.deployed && !u.hidden) || this.isDown(u)).map(unitInfo), - }; - } - - /** Run a content callback (skill/buff/projectile/timer/install) isolated from errors; shares the hook depth guard. */ - _safe(fn, label, owner = null) { - if (this._emitDepth >= MAX_HOOK_DEPTH) { - this._handlerError(`hookDepth:${label}`, owner, new Error(`callback nesting deeper than ${MAX_HOOK_DEPTH}; skipped — ${this._chain(label)}`)); - return undefined; - } - const d = this._emitDepth++; - this._frameName[d] = label; - this._frameOwner[d] = owner; - this._frameCtx[d] = null; - try { return fn(); } catch (e) { this._handlerError(label, owner, e); return undefined; } finally { this._emitDepth--; } - } - - _handlerError(label, owner, e, internal = false) { - this.errorCount++; - const who = owner && owner.defId ? owner.defId : (owner && owner.name) || ''; - const key = `${label}|${who}|${e && e.message}`; - if (!this._errKeys.has(key)) { - this._errKeys.add(key); - if (this.errors.length < 100) this.errors.push({ label, who, message: String(e && e.message), stack: e && e.stack }); - if (this.opts.quiet !== true) this.logger.error?.(`[sim] ${label}${who ? ' (' + who + ')' : ''} failed: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); - } - // Content handler errors are isolated (the handler is skipped, the battle continues). Repeated *engine* - // errors mean the field state is suspect: force-end it as a timeout (DESIGN §11). - if (internal) { - this.internalErrorCount = (this.internalErrorCount ?? 0) + 1; - if (this.internalErrorCount > MAX_INTERNAL_ERRORS && !this.finished && !this._forcing) { - this._forcing = true; - try { this.forceEnd('timeout'); } catch { this._hardFinish('timeout'); } - } - } - } - - _internalError(label, e) { this._handlerError('internal:' + label, null, e, true); } - [Symbol.for('nodejs.util.inspect.custom')]() { return `Battle<${this.kind} seed=${this.seed} t=${this.time.toFixed(2)} units=${this.units.length}${this.finished ? ' ' + this.reason : ''}>`; } +} - // exposed for ai/content convenience - effectiveProfile(u) { return effectiveProfile(u); } - /** - * Perform an immediate attack with a unit's current profile (content: "立即攻击", extra attacks, counters). It is an - * attack in every respect (hooks, attack SP, a running ammo skill's bullet) — except with `noAmmo: true`: an extra - * attack that spends no ammo (no `ammoUsed`, the skill never ends on it; 圣约送葬人 "不额外消耗弹药"). - * Returns true when an attack was made. - */ - forceAttack(u, targets = null, { noAmmo = false } = {}) { - if (!u || !u.alive || !u.profile) return false; - const prof = effectiveProfile(u); - const t = targets ?? acquireTargets(this, u, prof); - if (!t || !t.length) return false; - const n0 = u.stats.attacks; - performAttack(this, u, prof, t, noAmmo ? { noAmmo: true } : null); - return u.stats.attacks > n0; +// the method modules, in this order (a name defined twice is an error, never a silent override) +for (const part of [BattlePlayers, BattleLifecycle, BattleHooks, BattleSpawns, BattleDeploy, BattleBlocking, BattleStatus, BattleCombat, BattleQueries, BattleSummons, BattleTiles, BattleDisplacement, BattleEconomy, BattleEvents]) { + for (const key of Reflect.ownKeys(part.prototype)) { + if (key === 'constructor') continue; + if (Object.prototype.hasOwnProperty.call(Battle.prototype, key)) throw new Error(`Battle.${String(key)} is defined twice`); + Object.defineProperty(Battle.prototype, key, Object.getOwnPropertyDescriptor(part.prototype, key)); } } diff --git a/server/sim/ai.js b/server/sim/ai.js index fe9a1ab1..f40b4882 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -635,7 +635,7 @@ function enemyAttack(b, e) { const radius = melee ? 0 : e.base.rangeRadius; // the range circle takes an ally whose 0.25 collider touches it (PRTS 作战机制 §碰撞体积: 索敌 uses the colliders) const reach = radius > 0 ? radius + ALLY_COLLIDER_RADIUS : 0; - // `e.profile.canTarget(ally)`: the enemy's own target rule (只攻击地面单位, 不会攻击飞行单位 …; content/enemies.js), + // `e.profile.canTarget(ally)`: the enemy's own target rule (只攻击地面单位, 不会攻击飞行单位 …; content/enemies/*.js), // applied to the candidates before the priority sort and the target count const own = e.profile && typeof e.profile.canTarget === 'function' ? e.profile.canTarget : null; if (e.atkCd > 0) { diff --git a/server/sim/battle/blocking.js b/server/sim/battle/blocking.js new file mode 100644 index 00000000..53b81c59 --- /dev/null +++ b/server/sim/battle/blocking.js @@ -0,0 +1,120 @@ +// server/sim/battle/blocking.js — Battle methods: blocking: the contact rule (block radius, capacity, fenced tiles, +// 起飞), the blocked targets of a blocker, block release and the 隐匿 switch-off after a block ends. +// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). + +import { ROWS, COLS, BLOCK_RADIUS_SQ, STEALTH_RESTORE } from '../constants.js'; +import { canTargetEnemy, stealthOffKey } from '../targeting.js'; + +export class BattleBlocking { + /** + * Try to block enemy `e` where it stands. Returns true if it is (now) blocked. Official contact rule (constants.js + * BLOCK_RADIUS, PRTS 游戏数据基础 §阻挡半径): the enemy's position within the blocker's block radius of the blocker's + * centre (ground 0.7071 — the tile's circumscribed circle, reaching 0.21 tile into the side neighbours; air 0.8944; + * devices 0.4472), and the blocker has free capacity for the enemy's block weight (and may block it at all: a unit on + * a fenced tile blocks no ground enemy, _blockerFor). Checked every tick for every + * unblocked enemy, moving or not, so an enemy overlapping an operator is taken over as soon as its blocker is gone or + * the operator's capacity frees up (user playtest #5 item 4). Several blockers in contact → the nearest [ASSUMED], + * ties → the first in row-then-column scan order. + */ + _checkBlock(e) { + if (e.blockedBy || e.hidden || !e.alive) return !!e.blockedBy; + const f = e.s.flags; + if (f.unblockable || f.levitate || f.fear) return false; + const r0 = Math.round(e.y), c0 = Math.round(e.x); + const w = e.blockWeight ?? 1; + let u = null, bd = Infinity; + const fly = e.isFlying; + for (let r = r0 - 1; r <= r0 + 1; r++) { + if (r < 0 || r >= ROWS) continue; + for (let c = c0 - 1; c <= c0 + 1; c++) { + // a diagonal neighbour's centre is ≥ √0.5 away: out of reach of the ground (and device) radius + if (c < 0 || c >= COLS || (!fly && r !== r0 && c !== c0)) continue; + const o = this._occ[r * COLS + c]; + if (!o || !this._blockerFor(o, e, w)) continue; + const d2 = (e.x - c) * (e.x - c) + (e.y - r) * (e.y - r); + const r2 = o.kind === 'device' ? BLOCK_RADIUS_SQ.device : fly ? BLOCK_RADIUS_SQ.fly : BLOCK_RADIUS_SQ.ground; + if (d2 < r2 && d2 < bd) { u = o; bd = d2; } + } + } + if (!u) return false; + e.blockedBy = u; + u.blocking.push(e); + e.moving = false; + if (this._hooks.blocked) this.emit('blocked', { blocker: u, enemy: e }); + return true; + } + + /** + * The enemies ally `u` blocks that it may target with `profile` — always selectable by their blocker, inside its range + * or not, whatever its facing ("可以选择且优先选择阻挡单位", PRTS 选择器; ai.js acquireTargets, the skills' DEFAULT + * trigger). That holds for a ranged operator on a melee tile too: the user's rule after playtest #6, "阻挡了就一定要能 + * 打到" — officially the collision pushes a blocked enemy to its blocker's front, so whatever blocks an enemy can hit + * it (DESIGN §20; it replaces the playtest #5 QA's melee-only restriction). Only for attacks that hit enemies: a heal + * attack never asks — it keeps selecting injured allies while its unit blocks (PRTS 卫戍协议/帮助 "对于医疗干员 + * (咒愈师分支除外),攻击目标为需要治疗的单位"; PRTS 选择器 adds only the blocked units its side can pick). + */ + blockedTargets(u, profile) { + const out = []; + if (!u || !u.blocking || !u.blocking.length) return out; + for (const e of u.blocking) if (e.blockedBy === u && canTargetEnemy(u, e, profile)) out.push(e); + return out; + } + + /** + * Can ally/device `u` block enemy `e` (block weight `w`) right now — everything but the contact distance. A unit on a + * tile ground units cannot pass blocks no ground enemy: the fenced tiles (围墙 tile_fence_bound / 围栏 tile_fence — + * low ground, deployable, passable to flyers only) — PRTS 围墙 / 围栏 地形机制 "部署在其中的单位,若当前阻挡类型为'地面 + * 阻挡'则无法阻挡敌人". The gate reads the tile's ground passability; on the stages the fenced tiles are the only low + * tiles ground units cannot pass. Air blocking (blockFly against flyers) stays [ASSUMED: PRTS restricts the rule to + * 地面阻挡]. Nothing walks onto such a tile, so it matters for an enemy pushed or pulled against the fence + * (Battle.displace stops it at the tile edge, 0.5 from the fenced unit — inside the ground block radius). An airborne + * unit (起飞, flag `liftoff`: "不阻挡地面敌人…可以阻挡飞行敌人") blocks flyers only. + */ + _blockerFor(u, e, w) { + if (!u.alive || !u.deployed || u.hidden || u.s.flags.noBlock || u.s.flags.sleep) return false; + if (e.isFlying && !(u.s.flags.blockFly || (u.profile && u.profile.blockFly))) return false; + // ground enemies: only a ground unit that has not taken off (起飞 "不阻挡地面敌人": flag `liftoff`), standing on a tile + // ground units can pass (not a fenced 围墙 / 围栏 tile) + if (!e.isFlying && (!u.ground || u.s.flags.liftoff || this.grid.tile(u.tileR, u.tileC).pass !== 'ALL')) return false; + const cap = u.s.blockCnt; + if (cap <= 0) return false; + let used = 0; + for (const x of u.blocking) used += x.blockWeight ?? 1; + return used + w <= cap; + } + + _unblock(e) { + const bl = e.blockedBy; + if (!bl) return; + const i = bl.blocking.indexOf(e); + if (i >= 0) bl.blocking.splice(i, 1); + e.blockedBy = null; + this._stealthSwitch(e); + } + + /** Release every enemy blocked by ally `u` (death, retreat, block count drop, substitution…). */ + releaseBlocked(u) { + if (!u || !u.blocking || !u.blocking.length) return; + const was = u.blocking; + u.blocking = []; + for (const e of was) if (e.blockedBy === u) { e.blockedBy = null; this._stealthSwitch(e); } + } + + /** + * A block on enemy `e` just ended (every release goes through here: `_unblock`, `releaseBlocked`, ai.js + * enforceBlockCapacity). Each 隐匿 source it holds stays switched off for its restore time — PRTS 作战机制 §隐匿 "对于 + * 绝大部分可隐匿的敌人而言,在被我方单位阻挡后会解除隐匿,不被阻挡的3秒后重新进入隐匿" (STEALTH_RESTORE), or the source's + * own "(解除阻挡N秒后恢复)" (buff `data.stealthRestore`, content/enemies/helpers.js STEALTH_RESTORE_BY_KEY: 0 s / 1 s on some enemy pages) — as a + * `stealthOff` buff per source; meanwhile it is targetable, operator splash reaches it and it is drawn solid + * (targeting.js enemyStealthed). A new block inside the window lifts it again and its end restarts the window. Our + * operators' 隐匿 / 迷彩 are never lifted by blocking (only enemies get here). + */ + _stealthSwitch(e) { + if (!e || e.side !== 'enemy' || !e.alive || !e.s.flags.stealth) return; + for (const b of e.buffs.slice()) { + if (!b.flags || !b.flags.stealth) continue; + const t = Number.isFinite(b.data?.stealthRestore) ? b.data.stealthRestore : STEALTH_RESTORE; + if (t > 0) this.addBuff(e, { key: stealthOffKey(b.key), duration: t, flags: { stealthOff: true } }); + } + } +} diff --git a/server/sim/battle/combat.js b/server/sim/battle/combat.js new file mode 100644 index 00000000..e4fce6ee --- /dev/null +++ b/server/sim/battle/combat.js @@ -0,0 +1,59 @@ +// server/sim/battle/combat.js — Battle methods: damage, heal and HP loss (the guarded entry points of the damage.js +// pipeline), projectiles and forced attacks (ai.js performAttack). +// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). + +import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, leaderHitCancelled } from '../damage.js'; +import { effectiveProfile, performAttack, acquireTargets } from '../ai.js'; + +export class BattleCombat { + dealDamage(source, target, dmg) { + try { return pipeDamage(this, source, target, dmg); } catch (e) { this._internalError('dealDamage', e); return 0; } + } + + heal(source, target, amount, opts = {}) { + try { return pipeHeal(this, source, target, amount, opts); } catch (e) { this._internalError('heal', e); return 0; } + } + + /** + * HP loss that ignores DEF/RES, dodge and shields (流失). May kill. opts: { source, silent, tags, from } — `from` = the + * DamageInfo this loss derives from (damage passed on to a leader, split, shared…): its tags are inherited and it is + * kept as `dmg.origin`, so `damaged` handlers that skip their own tagged damage also skip what it turned into; a loss + * derived from 无来源 damage (`from.sourceless`, element bursts) is 无来源 too (hooks see no source, `source` is credited). + * `sourceless: true` makes the loss itself 无来源 ("受到等量的无来源生命流失": hooks see no source; `source` keeps the + * credit — the stats and the per-player shared-pool tally). + * On a leader in a boss / hidden battle a loss of ≥ BOSS_HIT_LIMIT is cancelled like a hit (damage.js leaderHitCancelled). + */ + loseHp(target, amount, { source = null, silent = false, tags = null, from = null, sourceless = false } = {}) { + if (!target || !target.alive || !(amount > 0)) return 0; + // 限伤 (shared/constants.js BOSS_HIT_LIMIT): a loss passed on to a leader (parts' 传递, 无人机) is one hit too + if (leaderHitCancelled(this, target, amount)) return 0; + const t = ['hpLoss']; + for (const list of [from && from.tags, tags]) if (Array.isArray(list)) for (const x of list) if (!t.includes(x)) t.push(x); + return applyHpLoss(this, source, target, amount, { type: 'true', tags: t, noSp: true, silent, origin: from ?? null, sourceless: !!sourceless || !!(from && from.sourceless) }); + } + + reduceElement(target, amount, element = null) { return reduceElement(target, amount, element); } + + makeDamage(d) { return makeDamageInfo(d); } + + addProjectile(p) { return this.projectiles.add(p); } + + // exposed for ai/content convenience + effectiveProfile(u) { return effectiveProfile(u); } + + /** + * Perform an immediate attack with a unit's current profile (content: "立即攻击", extra attacks, counters). It is an + * attack in every respect (hooks, attack SP, a running ammo skill's bullet) — except with `noAmmo: true`: an extra + * attack that spends no ammo (no `ammoUsed`, the skill never ends on it; 圣约送葬人 "不额外消耗弹药"). + * Returns true when an attack was made. + */ + forceAttack(u, targets = null, { noAmmo = false } = {}) { + if (!u || !u.alive || !u.profile) return false; + const prof = effectiveProfile(u); + const t = targets ?? acquireTargets(this, u, prof); + if (!t || !t.length) return false; + const n0 = u.stats.attacks; + performAttack(this, u, prof, t, noAmmo ? { noAmmo: true } : null); + return u.stats.attacks > n0; + } +} diff --git a/server/sim/battle/deploy.js b/server/sim/battle/deploy.js new file mode 100644 index 00000000..9b51e715 --- /dev/null +++ b/server/sim/battle/deploy.js @@ -0,0 +1,255 @@ +// server/sim/battle/deploy.js — Battle methods: deployment, death and redeploy: _deploy (home, landing or rest tile; 联防 +// carry), kill / retreat and the removal bookkeeping (_remove, hooks of removed units released at step end), redeploy / +// automatic redeploys, leaks. +// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). + +import { COLS } from '../constants.js'; +import { unitInfo } from '../snapshot.js'; +import { fin } from './util.js'; + +export class BattleDeploy { + /** + * Deploy `u` on its home tile, or on `tile` ([r, c]: a one-off landing tile — the home stays the board tile; an + * operator that left the field comes back on its body tile, restTile). `keepSp` = { sp, charges } + * restored right after the skill reset, before the `deploy` hook fires. + */ + _deploy(u, { initial = false, carry = null, tile = null, keepSp = null } = {}) { + if ((u.alive && u.deployed) || u._removing) return false; + const R0 = tile ? tile[0] : u.homeR, C0 = tile ? tile[1] : u.homeC; + if (!this.grid.inRect(R0, C0)) { + this.log(`${u} at ${R0},${C0} is outside the field rect; not deployed`); + if (!tile) u.removed = true; + return false; + } + const k = R0 * COLS + C0; + const occ = this._occ[k]; + if (occ && occ !== u && occ.alive && occ.deployed) { this.log(`tile ${R0},${C0} occupied; ${u} not deployed`); return false; } + // "倒地干员所在地块视为可部署,但所有我方单位在此处的部署行为将被阻止" (PRTS 卫戍协议/帮助 §作战阶段 单位部署) + if (this.downOn(R0, C0, u)) { this.log(`a knocked-out operator lies on ${R0},${C0}; ${u} not deployed`); return false; } + const first = u.deploySeq === 0; + u.alive = true; + u.deployed = true; + u.removed = false; + u.hidden = false; + u.body = null; + u.x = C0; u.y = R0; u.tileR = R0; u.tileC = C0; + u.blocking = []; + u.deploySeq = ++this._deploySeq; + u.aggroSeq = u.deploySeq; + u.deployedAt = this.time; + u.atkCd = 0; + u.lastAttackAt = -Infinity; + u.elem.burn = u.elem.neural = u.elem.necrosis = u.elem.apoptosis = u.elem.erosion = 0; + u.ground = this.grid.isLow(R0, C0) && !this._elevated?.has(k); + u.markDirty(); + u.hp = u.s.maxHp; + // 联防 carry (PRTS 卫戍协议/帮助 §联防阶段 "将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力…)"): + // an operator's HP ratio here, the SP in skill.reset and again after the `deploy` hook (below) — a summon's SP only + const cs = initial || (first && this._startDeploying) ? (carry ?? u.carry) : null; + if (cs && u.kind === 'op' && Number.isFinite(cs.hpPct)) u.hp = Math.max(1, u.s.maxHp * Math.max(0.01, Math.min(1, cs.hpPct))); + this._occ[k] = u; + this._refreshRange(u); // also rebuilds baseRangeKeys (initial range incl. permanent rangeExtend) + if (!u.skill) this._setupUnit(u); + u.skill.reset(cs); + const sk = u.skill; + // "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却" (PRTS 卫戍协议/帮助; skills.js) + if (initial) sk.opReadyAt = this.time + this.flags.startOpCooldown; + if (keepSp && !sk.noSkill && sk.kind !== 'passive' && !sk.active) { + sk.charges = Math.max(0, Math.min(sk.maxCharges, Math.floor(fin(keepSp.charges, 0)))); + sk.sp = Math.max(0, Math.min(sk.spCost, fin(keepSp.sp, 0))); + if (sk.charges >= sk.maxCharges) sk.sp = sk.spCost; + } + if (first) this._ev(['spawn', unitInfo(u)]); + this._ev(['deploy', u.id]); + if (this._hooks.deploy) this.emit('deploy', { unit: u, initial }); + // 联防: "部署完成后,将对应单位的…技力修改至与上一阶段结束时相同" — the carried SP is set again once the deployment is + // done, so a deploy-time SP gift (独行, 黄沙罗盘 …) does not come on top of it; later redeploys keep those gifts + if (cs && Number.isFinite(cs.sp) && u.alive && u.skill) u.skill.setSpTotal(cs.sp); + return true; + } + + /** Mark `unit` dead (hp reached 0). Fires `kill` then `death`. */ + kill(unit, killer = null) { + if (!unit || !unit.alive) return; + unit.hp = 0; + if (this._hooks.kill) this.emit('kill', { killer, victim: unit }); + // a nested kill/retreat inside the handlers already removed it: a later handler's hp write must not stick + if (!unit.alive) { if (!unit.bossPool) unit.hp = 0; return; } + if (unit.hp > 0 && !unit.bossPool) { // revived by a kill handler (clamped: a handler may write any number) + unit.hp = Math.min(unit.hp, unit.s.maxHp); + return; + } + unit.hp = 0; // NaN / negative writes from handlers + this._remove(unit, 'killed', killer); + } + + /** + * Withdraw an ally without a kill (it may redeploy after its respawn time): an operator lies down where it stood and + * comes back there (isDown, GitHub #60) — unless `permanent`, or the 突袭 retreat ('raid') that redeploys it at once. + */ + retreat(unit, { reason = 'retreat', permanent = false } = {}) { + if (!unit || !unit.alive || unit.side !== 'ally') return; + this._remove(unit, reason, null, permanent); + } + + _remove(unit, reason, killer = null, permanent = false) { + unit.alive = false; + unit.removeReason = reason; + unit.deployed = false; + unit.deathAt = this.time; + // while the removal bookkeeping runs, a skill onEnd handler must not redeploy the unit (it would come back + // alive but without its tile in _occ, its buffs wiped and a respawn timer pending) — redeploy from `death` instead + unit._removing = true; + if (unit.skill && unit.skill.active) { + this._safe(() => unit.skill.end('death'), 'skill.end', unit); + unit.skill.active = false; + } + unit._removing = false; + if (unit.side === 'ally') { + this.releaseBlocked(unit); + const k = unit.tileR * COLS + unit.tileC; + if (this._occ[k] === unit) this._occ[k] = null; + // keep persistent buffs only + const kept = []; + for (const b of unit.buffs) { + if (b.persist) kept.push(b); + else if (b.visible || b.status) this._ev(['status', unit.id, b.status ?? b.key, 0]); + } + unit.buffs = kept; + unit.markDirty(); + if (unit.kind === 'op' && !permanent) { + if (reason === 'killed') { const pp = this._pp(unit.ownerId); if (pp) pp.deaths++; } + const mul = unit.persist.redeployMul * unit.s.redeployMul; + unit.respawnAt = this.time + Math.max(0, unit.base.respawnTime * mul); + if (this.isDown(unit)) this._layBody(unit); // before `die` / `death`: the body's tile is final for them + } else { + unit.removed = true; + } + if (unit.kind === 'device' && unit.obstacle) this.grid.setObstacle(unit.tileR, unit.tileC, false, unit.obstacleKind); + } else { + unit.removed = true; + this._unblock(unit); + this._enemiesDirty = true; + if (reason === 'killed') { + if (unit.counted) { + this.killed++; + const pp = this._pp(unit.ownerId); + if (pp) pp.killed++; + } + if (killer) killer.stats.kills++; + if (unit.bounty && unit.bounty.coins > 0) { + const payee = killer && killer.side === 'ally' && killer.ownerId != null ? killer.ownerId : (unit.bounty.ownerPlayerId ?? unit.ownerId); + this.addCoins(payee, unit.bounty.coins); + } + } + } + // the reason ('killed' | 'retreat' | 'expired' | …) lets the client keep the knock-down sound for real knock-outs + if (reason !== 'leak') this._ev(['die', unit.id, reason]); + if (this._hooks.death) this.emit('death', { unit, reason, killer }); + if (unit.removed) this._toRelease.push(unit); + } + + /** + * Drop hooks and periodic timers (`every`) owned by permanently removed units (killed/leaked enemies, expired + * tokens, destroyed devices). Runs at the end of the step, so the unit's own death/leak handlers still fire; + * one-shot `after` callbacks are kept (they run once and are gone). Without this, per-enemy content hooks pile up + * and every emit walks the handlers of every enemy that ever spawned. + */ + _releaseRemoved() { + const set = new Set(); + for (const u of this._toRelease) if (u.removed && !u.alive) set.add(u); + this._toRelease = []; + if (!set.size) return; + for (const name of Object.keys(this._hooks)) { + const list = this._hooks[name]; + let w = 0; + for (let i = 0; i < list.length; i++) { + const h = list[i]; + if (h.owner != null && set.has(h.owner)) h.removed = true; + else list[w++] = h; + } + list.length = w; + if (!w) delete this._hooks[name]; + } + for (const sc of this._sched) if (sc.interval > 0 && sc.owner != null && set.has(sc.owner)) sc.cancelled = true; + } + + /** + * Immediately redeploy a dead (or retreated) ally on its rest tile (restTile: where an operator that left the field + * lies, else its home tile). opts: + * free=true no DP cost (false: pays `base.cost`, refused when the player lacks the DP) + * tile=[r,c] land on this in-rect tile instead (the home stays the board tile); refused (false) when the tile is + * outside the rect, a living unit stands there or another knocked-out operator lies there — no fallback + * keepSp keep the SP / charges the unit had (保留技力): restored before the `deploy` hook fires + * Returns true when the unit was deployed (full HP, `deploy {initial:false}` fires). + */ + redeploy(unit, { free = true, tile = null, keepSp = false } = {}) { + if (!unit || unit.side !== 'ally' || unit.alive || unit.removed) return false; + let at = null; + if (tile != null) { + if (!Array.isArray(tile) || !Number.isInteger(tile[0]) || !Number.isInteger(tile[1]) || !this.grid.inRect(tile[0], tile[1])) return false; + at = [tile[0], tile[1]]; + } else { + const rest = this.restTile(unit); + if (rest[0] !== unit.homeR || rest[1] !== unit.homeC) at = rest; + } + const k = at ? at[0] * COLS + at[1] : unit.homeR * COLS + unit.homeC; + const occ = this._occ[k]; + if (occ && occ.alive && occ !== unit) return false; + if (at && this.downOn(at[0], at[1], unit)) return false; + const ps = this.getPlayer(unit.ownerId); + const cost = unit.base.cost; + let paid = 0; + if (!free) { + if (!ps || ps.dp + 1e-9 < cost) return false; + paid = Math.min(ps.dp, cost); + ps.dp = Math.max(0, ps.dp - cost); // paid before `deploy` fires (handlers see the new DP) + } + const sk = unit.skill; + const keep = keepSp && sk && !sk.noSkill && sk.kind !== 'passive' ? { sp: sk.sp, charges: sk.charges } : null; + if (this._deploy(unit, { initial: false, tile: at, keepSp: keep })) return true; + if (paid > 0 && ps) ps.dp = Math.min(this.flags.dpMax, ps.dp + paid); + return false; + } + + /** + * Automatic redeploys (DESIGN §5.5): an operator that left the field and whose timer is done comes back on its rest + * tile — where it lies ("满足再部署条件时,移除场上的该倒地干员并自动部署至该位置", PRTS 卫戍协议/帮助) — when that tile + * is free and its player has the DP. + */ + _checkRedeploys() { + for (const u of this.allyUnits) { + if (u.alive || u.removed || u.kind !== 'op') continue; + if (this.time + 1e-9 < u.respawnAt) continue; + // the DP first: an operator past its timer mostly waits for DP, and the tile checks scan every ally + const ps = this.getPlayer(u.ownerId); + const cost = u.base.cost; + if (!ps || ps.dp + 1e-9 < cost) continue; + const [r, c] = this.restTile(u); + const occ = this._occ[r * COLS + c]; + if (occ && occ.alive && occ !== u) continue; + if (this.downOn(r, c, u)) continue; + ps.dp = Math.max(0, ps.dp - cost); + this._deploy(u, { initial: false, tile: r === u.homeR && c === u.homeC ? null : [r, c] }); + } + } + + /** Leak: an enemy reached its goal. */ + leak(e) { + if (!e || !e.alive || e.side !== 'enemy') return; + this._recordLeak(e, false); + this._remove(e, 'leak'); + this._ev(['leak', e.id]); + if (this._hooks.enemyLeak) this.emit('enemyLeak', { enemy: e }); + } + + _recordLeak(e, timeout) { + const owner = timeout ? e.ownerId : this._ownerForTile([Math.round(e.y), Math.round(e.x)]) ?? e.ownerId; + const pp = this._pp(owner) ?? this._pp(e.ownerId); + if (pp) { + pp.leaked.push({ enemyKey: e.defId, mods: e.mods ?? null, lpr: e.lpr ?? 1, sourcePlayerId: e.sourcePlayerId ?? e.ownerId, tag: e.tag ?? null, counted: !!e.counted || e.isBoss, boss: e.isBoss || undefined, spawned: true }); + if (e.counted || e.isBoss) pp.perfect = false; + } + if (e.counted) this.leakedCount++; + } +} diff --git a/server/sim/battle/displacement.js b/server/sim/battle/displacement.js new file mode 100644 index 00000000..dfa80be1 --- /dev/null +++ b/server/sim/battle/displacement.js @@ -0,0 +1,147 @@ +// server/sim/battle/displacement.js — Battle methods: displacement of enemies: 受力等级 (force − weight), push (radial / +// directional), pull, the raw mover displace and who can be moved at all. +// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). + +import { COLS, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST } from '../constants.js'; +import { fin } from './util.js'; + +/** + * Official push distance (tiles) of a 受力等级 (constants.js PUSH_TILES: ≤ −3 → 0, ≥ 3 → the 3 value); `effect` = a 特效 + * push (PRTS 推与拉's 特效 column, PUSH_TILES_EFFECT: 见行者), else the 弹道 column. + */ +export function pushTiles(level, effect = false) { + const l = Math.round(fin(level, -99)); + if (l <= -3) return 0; + return (effect ? PUSH_TILES_EFFECT : PUSH_TILES)[Math.min(3, l)]; +} + +export class BattleDisplacement { + /** + * 受力等级 of enemy `e` under a push / pull of 力度 `force` (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3, 大力+1 4, 特大力 5): + * force − its current 重量等级 (massLevel incl. 失重) — PRTS 游戏数据基础 §重量公式, 推与拉 (user playtest #6 item 14). + */ + forceLevel(e, force) { + return Math.round(fin(force, 0)) - (e && e.s ? e.s.massLevel : 0); + } + + /** + * Push enemy `e` with 力度 `force` (PRTS 推与拉 §推力): the official distance of its 受力等级 (constants.js PUSH_TILES: + * a 中力 push moves a weight-1 enemy 1.7 tiles, a weight-2 one 0.44, weight 3 0.12, weight ≥ 4 not at all). Radial + * (the default: away from `from`, the pusher's centre — "沿着干员向自身中心点的射线方向"; the client's buff template + * knockback[relative]: 琳琅诗怀雅 S3, 山 S3, 莫斯提马 S3) or directional (`dir` = the pusher's direction {x, y}; template + * knockback[dir], Knockback {_useSourceDirection: true, _decreaseForceLevelWhenNotInDirection: 2}: 推击手, 野鬃 S2 + * "往攻击方向"); a directional push on a target more than 45° off `dir` or nearer than 0.25 tile becomes radial with + * 受力等级 −2 ("特殊修正"). `fixed` waives both corrections (圣聆初雪 S1 "朝部署方向": KnockBackWithCharacterDirection, + * PRTS 备注 "不会因角度过大或距离过近而变化方向与力度"); `fixedAngle` only the angle one (PRTS 见行者 S2 备注 "不会因为角度过大 + * 而改变推动的方向或削减力度" — the < 0.25 tile rule still applies). `inward` = a radial push towards `from` (薄绿 S2's "拖拽", PRTS 备注 "实际为 + * 反方向(指向薄绿方向)的推开"), never nearer than PULL_STOP_RADIUS to its centre [ASSUMED: "至面前"]. `effect` = a 特效 + * push (PRTS 推与拉: one frame less of travel than a 弹道 push — constants.js PUSH_TILES_EFFECT / PUSH_EFFECT_SKILLS). + * Returns the tiles moved. + */ + push(e, force, { from = null, dir = null, fixed = false, fixedAngle = false, inward = false, effect = false } = {}) { + if (!this._displaceable(e)) return 0; + let level = this.forceLevel(e, force); + const fx0 = fin(from?.x, e.x), fy0 = fin(from?.y, e.y); + const vx = e.x - fx0, vy = e.y - fy0, d = Math.hypot(vx, vy); + let ux = 0, uy = 0; + const dirX = dir ? fin(dir.x, 0) : 0, dirY = dir ? fin(dir.y, 0) : 0; + const dl = Math.hypot(dirX, dirY); + if (dl > 0) { + ux = dirX / dl; uy = dirY / dl; + if (from && !fixed && (d < PUSH_DIRECTIONAL_MIN_DIST || (!fixedAngle && vx * ux + vy * uy < d * Math.SQRT1_2))) { + level -= 2; + if (d > 1e-6) { ux = vx / d; uy = vy / d; } + } + } else if (d > 1e-6) { ux = vx / d; uy = vy / d; } + else if (!inward && from && Array.isArray(from.fwd)) { ux = from.fwd[1]; uy = from.fwd[0]; } + else return 0; + let dist = pushTiles(level, effect); + if (inward && !(dl > 0)) { ux = -ux; uy = -uy; dist = Math.min(dist, Math.max(0, d - PULL_STOP_RADIUS)); } + return this.displace(e, { x: ux, y: uy }, dist); + } + + /** + * Pull enemy `e` with 力度 `force` towards the point `to` (PRTS 推与拉 §拉力 / §捕网): 受力等级 ≥ 0 — all the way, until it + * is within `stop` tiles of `center` (急停; `center` defaults to `to`, `stop` to PULL_STOP_RADIUS) or reaches `to`; + * −1 — PULL_WEAK_SHARE of its starting distance to `to`; −2 — PULL_CRAWL tiles; ≤ −3 — nothing. `pullToFront` aims at + * the official 拉力起点 in front of an operator. Returns the tiles moved. + */ + pull(e, force, { to, center = null, stop = PULL_STOP_RADIUS } = {}) { + if (!this._displaceable(e) || !to) return 0; + // an enemy the puller itself blocks already stands in front of it (at contact) [ASSUMED: no pull, no unblocking] + if (center && center.side === 'ally' && e.blockedBy === center) return 0; + const tx = fin(to.x, e.x), ty = fin(to.y, e.y); + const dx = tx - e.x, dy = ty - e.y, d0 = Math.hypot(dx, dy); + if (!(d0 > 1e-6)) return 0; + const ux = dx / d0, uy = dy / d0; + // travel until inside the stop circle around `center` (smaller root of |e + t·u − c| = stop), else up to `to` + let full = d0; + const cx = fin(center?.x, tx), cy = fin(center?.y, ty), r = Math.max(0, fin(stop, 0)); + const wx = e.x - cx, wy = e.y - cy, wu = wx * ux + wy * uy, w2 = wx * wx + wy * wy; + if (w2 <= r * r) full = 0; + else { + const disc = wu * wu - w2 + r * r; + if (disc >= 0) { const t = -wu - Math.sqrt(disc); if (t >= 0) full = Math.min(full, t); } + } + const level = this.forceLevel(e, force); + const dist = level >= 0 ? full : level === -1 ? Math.min(full, PULL_WEAK_SHARE * d0) : level === -2 ? Math.min(full, PULL_CRAWL) : 0; + return dist > 1e-6 ? this.displace(e, { x: ux, y: uy }, dist) : 0; + } + + /** Official distance (tiles) a push of 力度 `force` would move `e` on open ground (0 when it cannot be displaced). */ + pushDistance(e, force, { effect = false } = {}) { + return this._displaceable(e) ? pushTiles(this.forceLevel(e, force), effect) : 0; + } + + /** Pull `e` "至面前" of ally `unit` (拉力起点 PULL_ORIGIN tiles ahead along its direction, 急停 around its centre). */ + pullToFront(e, unit, force) { + if (!unit) return 0; + const f = Array.isArray(unit.fwd) ? unit.fwd : [0, 1]; + return this.pull(e, force, { to: { x: unit.x + f[1] * PULL_ORIGIN, y: unit.y + f[0] * PULL_ORIGIN }, center: unit, stop: PULL_STOP_RADIUS }); + } + + /** + * Can `e` be displaced at all: a living enemy, not a leader part of the boss pool, not 失衡免疫 (flag `noDisplace`: + * 近地悬浮, 浮空, the 胄 parts …) and not a 静态刚体 (data `staticBody`). PRTS 特殊机制 静态刚体: such a unit "可以进入 + * 失衡状态 … 但物理层面上无法产生任何速度或移动" — every air unit of the mode except “炎佑”, plus the boss 昆图斯 (build-data + * STATIC_BODIES; player report after 0.1.0, "飞机可以被薄绿的技能拉走"). A skill that reaches it still hits it (its targeting + * is the skill's own: 锏 S3, 薄绿, the 钩索师 …); only the movement is 0, so distance-based effects (drag damage, 见行者 S2's + * wall stun) come to nothing. [ASSUMED] the 0.1 s 失衡硬直 a 静态刚体 still gets is not modelled (no displacement models it). + */ + _displaceable(e) { + return !!(e && e.alive && e.side === 'enemy' && !e.isBoss && !e.s.flags.noDisplace && !(e.def && e.def.staticBody)); + } + + /** + * Move an enemy `distance` tiles along `dir` = {x, y} (normalised internally) over passable tiles — the raw mover of + * push() / pull(), which apply the official 力度 − 重量 rules (content uses those; the old `force` option is gone). + * 失衡免疫 (flag `noDisplace`: 近地悬浮, 浮空 — PRTS 异常效果 "不会被位移影响"), 静态刚体 (data `staticBody`) and leaders + * ⇒ no movement (_displaceable). The tiles it may cross follow its movement (`motion`): a hovering enemy walks the + * ground, so it stays on ground-passable tiles. + */ + displace(e, dir, distance) { + if (!this._displaceable(e) || !dir) return 0; + const dxv = fin(dir.x, 0), dyv = fin(dir.y, 0); + const len = Math.hypot(dxv, dyv); + if (!(len > 0)) return 0; + const eff = Math.min(fin(distance, 0), 2 * COLS); + if (!(eff > 0)) return 0; + const ux = dxv / len, uy = dyv / len; + let moved = 0; + const stepLen = 0.1; + while (moved + 1e-9 < eff) { + const s = Math.min(stepLen, eff - moved); // (the last step is a partial one: 0.12 tiles moves 0.12, not 0.2) + const nx = e.x + ux * s, ny = e.y + uy * s; + const r = Math.round(ny), c = Math.round(nx); + const ok = e.motion === 'FLY' ? this.grid.inRect(r, c) : this.grid.groundPassable(r, c); + if (!ok) break; + e.x = nx; e.y = ny; moved += s; + } + if (moved > 0) { + this._unblock(e); + if (e.route) e.route.pts = null; + this.fx('displace', { x: e.x, y: e.y, id: e.id }); + } + return moved; + } +} diff --git a/server/sim/battle/economy.js b/server/sim/battle/economy.js new file mode 100644 index 00000000..e3e1861b --- /dev/null +++ b/server/sim/battle/economy.js @@ -0,0 +1,58 @@ +// server/sim/battle/economy.js — Battle methods: DP, IN_BATTLE bond layer gains (capped by layerGainRoom) and bounty +// coins. +// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). + +import { layerGainRoom } from '../../../shared/constants.js'; + +export class BattleEconomy { + addDp(playerId, n) { + const ps = this.getPlayer(playerId); + if (!ps || !Number.isFinite(n)) return 0; + ps.dp = Math.max(0, Math.min(this.flags.dpMax, ps.dp + n)); + return ps.dp; + } + + /** + * Record an IN_BATTLE layer gain (no-op when gains are disabled). Returns the layers added: at most the room left + * under BOND_LAYER_CAP (999, shared/constants.js) on the live copy — the client's AddBondCount `min(L + n, 999)`; the + * `layerGain` hook (魔王's +1 …) runs first, then the clamp; a bond already at the cap gains 0 (no hook, no event). + * Without a live copy of the bond (a partial PlayerBattleInput) the battle's own gains count; the match's settle + * clamps the persistent count the same way. + */ + addLayers(playerId, bondId, n, reason = '', opts = {}) { + if (!this.flags.layerGainsEnabled || !(n > 0) || !Number.isFinite(n) || playerId == null) return 0; + const pp = this._pp(playerId); + if (!pp) return 0; + const ps = this.getPlayer(playerId); + const live = ps && ps.bonds[bondId] ? (ps.bonds[bondId].layers ?? 0) : (pp.layerGains[bondId] ?? 0); + if (!(layerGainRoom(live, Infinity) > 0)) return 0; + const source = opts.source ?? null; + const ctx = { playerId, bondId, n, reason, source, tile: Array.isArray(opts.tile) ? opts.tile : this._sourceTile(source) }; + if (this._hooks.layerGain) { this.emit('layerGain', ctx); if (!(ctx.n > 0) || !Number.isFinite(ctx.n)) return 0; } + const add = layerGainRoom(live, ctx.n); + if (!(add > 0)) return 0; + pp.layerGains[bondId] = (pp.layerGains[bondId] ?? 0) + add; + if (ps && ps.bonds[bondId]) ps.bonds[bondId].layers = (ps.bonds[bondId].layers ?? 0) + add; + this._ev(['layer', playerId, bondId, add]); + return add; + } + + /** + * Tile of a gain's source unit ([r, c]): where it stands, or where it stood when it left the field during this very + * instant (a "被击倒时" gain fires from its `death`). null otherwise (no source, long gone, not a unit). + */ + _sourceTile(u) { + if (!u || typeof u !== 'object' || !Number.isFinite(u.x)) return null; + const here = u.alive && u.deployed && !u.hidden; + if (!here && !(u.deathAt === this.time && !u.alive)) return null; + return u.side === 'ally' ? [u.tileR, u.tileC] : [Math.round(u.y), Math.round(u.x)]; + } + + addCoins(playerId, n) { + const pp = this._pp(playerId); + if (!pp || !(n > 0) || !Number.isFinite(n)) return 0; + pp.coins += n; + this._ev(['bounty', playerId, n]); + return n; + } +} diff --git a/server/sim/battle/events.js b/server/sim/battle/events.js new file mode 100644 index 00000000..690b4c54 --- /dev/null +++ b/server/sim/battle/events.js @@ -0,0 +1,83 @@ +// server/sim/battle/events.js — Battle methods: client events (DESIGN §8.2 tuples, bounded buffer), the snapshot, field +// meta, fx and the sim log. +// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). + +import { EVENT_BUFFER_CAP } from '../constants.js'; +import { elementView } from '../damage.js'; +import { unitInfo, snapshotUnits } from '../snapshot.js'; + +export class BattleEvents { + fx(kind, params = {}) { + const { x = 0, y = 0, ...extra } = params || {}; + this._ev(['fx', kind, Math.round(x * 100) / 100, Math.round(y * 100) / 100, extra]); + } + + log(msg) { + const k = 'log:' + msg; + if (this._errKeys.has(k)) return; + this._errKeys.add(k); + if (this.opts.verbose) this.logger.warn?.(`[sim] ${msg}`); + } + + _ev(tuple) { + if (!this.recordEvents) return; + this._evq.push(tuple); + if (this._evq.length > EVENT_BUFFER_CAP) this._evq.splice(0, this._evq.length - EVENT_BUFFER_CAP / 2); + } + + /** Client-facing events since the last drain (DESIGN §8.2 tuples). */ + drainEvents() { + const ev = this._evq; + this._evq = []; + return ev; + } + + /** + * Compact full snapshot of this field (DESIGN §8.2 b.snap), plus (only when non-empty): + * down: [[id, respawnAt, respawnTime, state, row, col]] — operators that left the field waiting to redeploy (isDown): the + * game time their respawn timer ends, its length (s), constants.js DOWN_STATE and the tile they lie on (and + * come back on: _layBody — where they fell, or their home); + * elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView). + */ + snapshot() { + const snap = { + fieldId: this.fieldId, + t: Math.round(this.time * 1000) / 1000, + units: snapshotUnits(this.units, this.time), + dp: this.players.length ? Math.floor(this.players[0].dp) : 0, + killed: this.killed, + total: this.total, + }; + if (this.players.length > 1) { + snap.dps = {}; + for (const p of this.players) snap.dps[p.playerId] = Math.floor(p.dp); + } + if (this.sharedBoss) snap.boss = { hp: Math.max(0, Math.round(this.sharedBoss.hp)), max: Math.round(this.sharedBoss.maxHp) }; + const r2 = (v) => Math.round(v * 100) / 100; + let down = null; + for (const u of this.allyUnits) { + if (!this.isDown(u)) continue; + (down || (down = [])).push([u.id, r2(u.respawnAt), r2(Math.max(0, u.respawnAt - u.deathAt)), this._downState(u), ...this.restTile(u)]); + } + if (down) snap.down = down; + let elem = null; + for (const u of this.units) { + if (!u.alive || !u.deployed || u.hidden) continue; + const v = elementView(u, this.time); + if (v) (elem || (elem = [])).push([u.id, v[0], v[1], v[2], v[3]]); + } + if (elem) snap.elem = elem; + return snap; + } + + /** + * Field meta for m.field: { fieldId, kind, rect, stageId, units: UnitInfo[] } — the units on the field, knocked-out + * operators waiting to redeploy included (a client joining mid-battle shows them down). + */ + fieldMeta() { + return { + fieldId: this.fieldId, kind: this.kind, rect: { ...this.rect }, stageId: this.stageId, + units: this.units.filter((u) => (u.alive && u.deployed && !u.hidden) || this.isDown(u)).map(unitInfo), + }; + } +} diff --git a/server/sim/battle/hooks.js b/server/sim/battle/hooks.js new file mode 100644 index 00000000..a4acd262 --- /dev/null +++ b/server/sim/battle/hooks.js @@ -0,0 +1,188 @@ +// server/sim/battle/hooks.js — Battle methods: the hook bus (on / off / offOwner / emit, the MAX_HOOK_DEPTH guard and +// its chain diagnostics), the timers (after / every, run at the start of each tick) and the isolation of content +// callbacks (_safe, _handlerError: a failing handler is skipped and counted; repeated engine errors end the battle as a +// timeout). +// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). + +import { TICK, MAX_INTERNAL_ERRORS, MAX_HOOK_DEPTH } from '../constants.js'; + +let hookSeq = 0; + +let schedSeq = 0; + +export class BattleHooks { + // ============================================================================================================= + // hook bus + + on(name, fn, opts = {}) { + if (typeof fn !== 'function') return null; + const h = { name, fn, priority: Number(opts.priority) || 0, owner: opts.owner ?? null, seq: ++hookSeq, removed: false, once: !!opts.once }; + const list = this._hooks[name] ?? (this._hooks[name] = []); + let i = list.length; + while (i > 0 && list[i - 1].priority < h.priority) i--; + list.splice(i, 0, h); + return h; + } + + off(nameOrHandle, fn) { + if (nameOrHandle && typeof nameOrHandle === 'object') { + const h = nameOrHandle; + h.removed = true; + const list = this._hooks[h.name]; + if (list) { + const i = list.indexOf(h); + if (i >= 0) list.splice(i, 1); + if (!list.length) delete this._hooks[h.name]; + } + return; + } + const list = this._hooks[nameOrHandle]; + if (!list) return; + for (let i = list.length - 1; i >= 0; i--) if (!fn || list[i].fn === fn) { list[i].removed = true; list.splice(i, 1); } + if (!list.length) delete this._hooks[nameOrHandle]; + } + + /** Remove every hook and scheduled callback registered with `owner`. */ + offOwner(owner) { + for (const name of Object.keys(this._hooks)) { + const list = this._hooks[name]; + for (let i = list.length - 1; i >= 0; i--) if (list[i].owner === owner) { list[i].removed = true; list.splice(i, 1); } + if (!list.length) delete this._hooks[name]; + } + for (const s of this._sched) if (s.owner === owner) s.cancelled = true; + } + + hasHook(name) { return !!this._hooks[name]; } + + emit(name, ctx) { + const list = this._hooks[name]; + if (!list) return ctx; + // Recursion guard: handlers that re-trigger their own event (damaged → dealDamage → damaged …) would otherwise + // recurse until a stack overflow, whose depth (and thus the outcome) depends on the machine — not deterministic. + if (this._emitDepth >= MAX_HOOK_DEPTH) { + this._handlerError(`hookDepth:${name}`, null, new Error(`hook nesting deeper than ${MAX_HOOK_DEPTH}; handlers skipped — ${this._chain(name)}`)); + return ctx; + } + const arr = list.length === 1 ? [list[0]] : list.slice(); + const d = this._emitDepth++; + this._frameCtx[d] = ctx; + try { + for (const h of arr) { + if (h.removed) continue; + this._frameName[d] = name; + this._frameOwner[d] = h.owner; + try { h.fn(ctx, this); } catch (e) { this._handlerError(`hook:${name}`, h.owner, e); } + if (h.once) this.off(h); + if (ctx && ctx.stopPropagation) break; + } + } finally { + this._emitDepth--; + } + return ctx; + } + + /** + * The open hook/callback frames (outermost first) — names the loop when the nesting guard trips, e.g. + * "chain: hit(enemy_1430_lrrook) > damaged(chess_char_3_18_a) > hit(enemy_1430_lrrook) > … > damaged". + */ + _chain(last) { + const id = (u) => (u && typeof u === 'object' ? (u.defId ?? u.name ?? '?') : u == null ? '' : String(u)); + const lab = (i) => { + const o = this._frameOwner[i]; + let who = id(o); + const c = this._frameCtx[i]; + if (!who && c && typeof c === 'object') { // global handler: show the units of the event instead + const a = c.source ?? c.attacker ?? c.killer ?? null, t = c.target ?? c.unit ?? c.victim ?? c.enemy ?? null; + who = a || t ? `${id(a)}→${id(t)}` : ''; + } + return `${this._frameName[i]}${who ? `(${who})` : ''}`; + }; + const n = this._emitDepth; + const parts = []; + for (let i = 0; i < Math.min(n, 4); i++) parts.push(lab(i)); + if (n > 12) parts.push('…'); + for (let i = Math.max(4, n - 8); i < n; i++) parts.push(lab(i)); + parts.push(last); + return `chain: ${parts.join(' > ')}`; + } + + // ============================================================================================================= + // scheduling + + after(seconds, fn, opts = {}) { + const s = { due: this.time + Math.max(0, Number(seconds) || 0), fn, owner: opts.owner ?? null, interval: 0, seq: ++schedSeq, cancelled: false, holdsBattle: !!opts.holdsBattle }; + s.cancel = () => { s.cancelled = true; }; + this._sched.push(s); + return s; + } + + every(seconds, fn, opts = {}) { + const iv = Math.max(TICK, Number(seconds) || TICK); + const s = { due: this.time + (opts.immediate ? 0 : iv), fn, owner: opts.owner ?? null, interval: iv, seq: ++schedSeq, cancelled: false, count: 0, holdsBattle: false }; + s.cancel = () => { s.cancelled = true; }; + this._sched.push(s); + return s; + } + + _runScheduled() { + if (!this._sched.length) return; + const now = this.time + 1e-9; + let due = this._sched.filter((s) => !s.cancelled && s.due <= now); + if (!due.length) { if (this._sched.some((s) => s.cancelled)) this._sched = this._sched.filter((s) => !s.cancelled); return; } + due.sort((a, b) => a.due - b.due || a.seq - b.seq); + for (const s of due) { + if (s.cancelled) continue; + if (s.interval > 0) { + let n = 0; + while (s.due <= now && !s.cancelled && n++ < 8) { + s.count++; + this._safe(() => s.fn(this, s), 'every', s.owner); + s.due += s.interval; + } + if (s.due <= now) s.due = this.time + s.interval; + } else { + s.cancelled = true; + this._safe(() => s.fn(this, s), 'after', s.owner); + } + } + this._sched = this._sched.filter((s) => !s.cancelled); + } + + // ============================================================================================================= + // callback isolation and errors + + /** Run a content callback (skill/buff/projectile/timer/install) isolated from errors; shares the hook depth guard. */ + _safe(fn, label, owner = null) { + if (this._emitDepth >= MAX_HOOK_DEPTH) { + this._handlerError(`hookDepth:${label}`, owner, new Error(`callback nesting deeper than ${MAX_HOOK_DEPTH}; skipped — ${this._chain(label)}`)); + return undefined; + } + const d = this._emitDepth++; + this._frameName[d] = label; + this._frameOwner[d] = owner; + this._frameCtx[d] = null; + try { return fn(); } catch (e) { this._handlerError(label, owner, e); return undefined; } finally { this._emitDepth--; } + } + + _handlerError(label, owner, e, internal = false) { + this.errorCount++; + const who = owner && owner.defId ? owner.defId : (owner && owner.name) || ''; + const key = `${label}|${who}|${e && e.message}`; + if (!this._errKeys.has(key)) { + this._errKeys.add(key); + if (this.errors.length < 100) this.errors.push({ label, who, message: String(e && e.message), stack: e && e.stack }); + if (this.opts.quiet !== true) this.logger.error?.(`[sim] ${label}${who ? ' (' + who + ')' : ''} failed: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); + } + // Content handler errors are isolated (the handler is skipped, the battle continues). Repeated *engine* + // errors mean the field state is suspect: force-end it as a timeout (DESIGN §11). + if (internal) { + this.internalErrorCount = (this.internalErrorCount ?? 0) + 1; + if (this.internalErrorCount > MAX_INTERNAL_ERRORS && !this.finished && !this._forcing) { + this._forcing = true; + try { this.forceEnd('timeout'); } catch { this._hardFinish('timeout'); } + } + } + } + + _internalError(label, e) { this._handlerError('internal:' + label, null, e, true); } +} diff --git a/server/sim/battle/lifecycle.js b/server/sim/battle/lifecycle.js new file mode 100644 index 00000000..57fbbabd --- /dev/null +++ b/server/sim/battle/lifecycle.js @@ -0,0 +1,240 @@ +// server/sim/battle/lifecycle.js — Battle methods: lifecycle: start (the battle-start deployment: by column, operators +// then summons; 联防 forced exits; `battleStart`), step (the tick order of the Battle.js header), the end checks, timeout +// / forced / cleared finishes and the BattleResult. +// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). + +import { FORCED_EXIT, MAX_BATTLE_TIME } from '../constants.js'; +import { updateAlly, updateEnemy } from '../ai.js'; + +export class BattleLifecycle { + start() { + if (this.started) return; + this.started = true; + this._safe(() => this._spawnStageDevices(), 'stageDevices'); + // PRTS 卫戍协议/帮助 §作战阶段: "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物(如果有)", noted "自卫戍协议: + // 盟约 下半(2026/3/14)起,部署顺序由“从左到右>从下到上”更改为“从上到下>从左到右”" — a scanning order: down each column + // (top first), the columns from left to right; act 1's along each row, the rows from the bottom. It is the order PRTS + // gives the 阿戈尔 devour ("从最先部署(更靠左和靠上的)的【阿戈尔】干员开始", content/bonds/core.js egirOrder) and the one + // act-2 videos show. Per player the operators, left column first, top to bottom within a column (the mirrored right + // boss side from its own left, i.e. the highest field column — PRTS "部署顺序…左右镜像", research 01 §4.3), then the + // summon pieces in the same order. Until 0.1.3 the top row came first (row-major). [ASSUMED] On a shared field (联防, + // boss) the players' fields deploy at the same time (PRTS: one unit after another with a fixed delay, from the battle + // start), so the i-th operators of all players come in together (in `players` order), then the summons likewise + const seq0 = this._deploySeq; + const lists = this.players.map((ps) => ps.units.filter((u) => u.kind === 'op' || u.kind === 'token').slice().sort((a, b) => + (ps.mirror ? b.homeC - a.homeC : a.homeC - b.homeC) || b.homeR - a.homeR || a.id - b.id)); + // a summon piece flagged `deferDeploy` by content (one its owner's loadout does not make — 赫默 on S1 with a drone + // piece —, or a skill's summon when shared/constants.js SKILL_SUMMON_START_DEPLOY is off: content/tokens.js + // dockSkillSummons) stays off the field, its tile reserved (isReservedTile), until content deploys it + // (_startDeploying: a board piece content brings in during this deployment — a tactician's 流形 / 狼群 comes with + // its owner's deploy — takes its 联防 carry on that first deployment too) + this._startDeploying = true; + for (const kind of ['op', 'token']) { + const per = lists.map((l) => l.filter((u) => u.kind === kind && !u.deferDeploy)); + const n = Math.max(0, ...per.map((l) => l.length)); + for (let i = 0; i < n; i++) for (const l of per) if (l[i]) this._safe(() => this._deploy(l[i], { initial: true }), 'initialDeploy', l[i]); + } + this._startDeploying = false; + // 仇恨 (targeting.js sortAllyTargets: the later deployed is attacked first): every summon that came in during the + // initial deployment — also one an operator's deploy brought along (a tactician's 援军, a start-of-battle summon) + // — ranks after all the operators (of every player on the field [ASSUMED]), in the order it came + const summons = this.allyUnits.filter((u) => u.kind === 'token' && u.alive && u.deployed && u.deploySeq > seq0).sort((a, b) => a.deploySeq - b.deploySeq); + for (const t of summons) t.aggroSeq = ++this._deploySeq; + // 联防 (PRTS 卫戍协议/帮助 "部署完成后…上一阶段为退场状态的干员强制退场"): an operator knocked out at the end of its + // own combat is withdrawn right after the deployment — down on its tile, its redeploy timer starting now (re-read + // after battleStart below; DESIGN §5.5). Its HP ratio is the end-of-phase one (0, as after kill()) until it + // redeploys at full HP. + for (const u of this.allyUnits) { + if (u.kind === 'op' && u.alive && u.carry && u.carry.down === true) { + this._safe(() => { u.hp = 0; this.retreat(u, { reason: FORCED_EXIT }); }, 'forcedExit', u); + } + } + this.emit('battleStart', {}); + // redeploy-time effects that start with the battle (机变 征召 "所有干员的再部署时间-50%", added by a battleStart + // handler) cover the operators forced out above too: their timer is re-read with them, as a later knock-out's is + for (const u of this.allyUnits) { + if (u.kind !== 'op' || u.alive || u.removed || u.removeReason !== FORCED_EXIT) continue; + u.respawnAt = u.deathAt + Math.max(0, u.base.respawnTime * u.persist.redeployMul * u.s.redeployMul); + } + } + + step() { + if (this.finished || this._stepping) return; // re-entrant step() from a hook is a no-op + // forceEnd() requested while stepping (content hook, repeated engine errors) is deferred to the end of the + // current phase: the remaining phases are skipped and the result is built once, so nothing mutates it later. + this._stepping = true; + try { + if (!this.started) this._phase('start', () => this.start()); + const dt = this.dt; + this._phase('scheduled', () => this._runScheduled()); + this._phase('spawns', () => this._processSpawns()); + this._phase('dp', () => { + for (const ps of this.players) ps.dp = Math.min(this.flags.dpMax, ps.dp + this.flags.dpPerSec * dt); + }); + this._phase('buffs', () => this._tickBuffs(dt)); + this._phase('enemies', () => { + // enemies spawned by hooks during this loop start moving next tick (a leak hook that spawns an enemy which + // leaks at once would otherwise loop forever inside a single tick) + const list = this.enemies; + for (let i = 0, n = list.length; i < n && !this._endReq; i++) { + const e = list[i]; + if (!e.alive) continue; + try { updateEnemy(this, e, dt); } catch (err) { this._internalError('updateEnemy', err); } + this._clampPos(e); + } + }); + this._compactEnemies(); + this._phase('enemyIndex', () => this._buildEnemyIndex()); + this._phase('allies', () => { + const list = this.allyUnits; + for (let i = 0; i < list.length && !this._endReq; i++) { + const u = list[i]; + if (!u.alive || !u.deployed) continue; + try { + if (u.skill) u.skill.tick(dt); + if (u.alive && u.kind !== 'device') updateAlly(this, u, dt); + } catch (err) { this._internalError('updateAlly', err); } + } + }); + this._phase('projectiles', () => this.projectiles.update(dt)); + this._phase('redeploy', () => this._checkRedeploys()); + this._phase('boss', () => this._bossSync()); + if (this._hooks.tick) this._phase('tickHook', () => this.emit('tick', { dt })); + this._compactEnemies(); + if (this._toRelease.length) this._releaseRemoved(); + if (!this._endReq && !this.finished) { + this.tickCount++; + this.time = this.tickCount * dt; // no floating drift over long battles + this._phase('endCheck', () => this._checkEnd()); + } + } finally { + this._stepping = false; + } + if (this._endReq && !this.finished) { + try { this.forceEnd(this._endReq); } catch (e) { this._handlerError('internal:forceEnd', null, e); this._hardFinish(this._endReq); } + } + } + + /** Last-resort finish when building the normal result threw (never throws itself). */ + _hardFinish(reason) { + if (this.finished) return; + this.finished = true; + this.reason = reason === 'timeout' ? 'timeout' : 'forced'; + this._endReq = null; + try { this._result = this._buildResult(); } catch { this._result = { time: this.time, reason: this.reason, perPlayer: {}, killed: this.killed, total: this.total, errors: this.errorCount }; } + this.projectiles.clear(); + } + + /** Run until finished or `maxSeconds` of game time elapsed. Returns the result when finished. */ + runToEnd(maxSeconds = MAX_BATTLE_TIME) { + const limit = this.time + maxSeconds; + while (!this.finished && this.time < limit) this.step(); + return this.finished ? this.result() : null; + } + + _phase(name, fn) { + if (this.finished || this._endReq) return; + try { fn(); } catch (e) { this._internalError(name, e); } + } + + _checkEnd() { + if (this.finished) return; + if (this.sharedBoss && this.sharedBoss.hp <= 0) { this._finish('cleared'); return; } + // A boss / hidden field with a shared pool ends only when the pool is empty or the match forces an end (DESIGN + // §5.5) — never merely because it emptied: the h07_04 pair leader (official route: no wait) can walk into the + // objective, and the field then idles while another field may still empty the pool or the overtime drain ends it. + const poolHolds = !!this.sharedBoss && (this.kind === 'boss' || this.kind === 'hidden'); + if (this.autoFinish && !poolHolds && !this._pending.length && !this.enemies.some((e) => e.alive) && this.started) { + if (!this._hasPendingEnemySchedules()) { this._finish('cleared'); return; } + } + if (this.time >= this.timeLimit - 1e-9) { this._timeout(); return; } + if (this.time >= MAX_BATTLE_TIME) { this._timeout(); return; } + } + + _hasPendingEnemySchedules() { + for (const s of this._sched) if (!s.cancelled && s.holdsBattle) return true; + return false; + } + + _timeout() { + // Remaining non-boss enemies on the field count as leaked (DESIGN §5.5). Spawns that never happened before the + // limit are dropped: they are removed from `total` and reported in `result().unspawned` (not leaks). A content-made + // neutral (`mem.noLeak`: 隐德来希's 心烛, which follows its original) is never a leak — it is in no spawn schedule, so + // a client result listing it would be rejected (fields.js validateClientResult 'leak key'). + for (const e of this.enemies) { + if (!e.alive || e.isBoss || e.mem.noLeak) continue; + this._recordLeak(e, true); + } + this.unspawned = []; + for (const p of this._pending) { + if (p.precounted) { + this.total--; + const pp = this._pp(p.ownerPlayerId ?? this._ownerForTile(p.pos ?? this._routeFor(p.routeIndex, p.route)?.start)); + if (pp) pp.total--; + } + this.unspawned.push({ enemyKey: p.enemyKey, time: p.time, tag: p.tag ?? null, sourcePlayerId: p.sourcePlayerId ?? null }); + } + this._pending = []; + this._finish('timeout'); + } + + forceEnd(reason = 'forced') { + if (this.finished) return; + if (this._stepping) { if (!this._endReq) this._endReq = reason; return; } // finalised when step() unwinds + this._endReq = null; + if (!this.started) this.start(); + if (this.finished) return; + if (reason === 'timeout') this._timeout(); + else this._finish('forced'); + } + + _finish(reason) { + if (this.finished) return; + this.finished = true; + this.reason = reason; + this._endReq = null; + const res = this._buildResult(); + this._result = res; + this.emit('battleEnd', { result: res }); + // re-sync fields that battleEnd handlers may have changed (layer gains / coins live in perPlayer objects) + for (const pid of Object.keys(res.perPlayer)) { + const pp = res.perPlayer[pid]; + pp.perfect = !pp.leaked.some((l) => l.counted !== false); + } + this.projectiles.clear(); + } + + _buildResult() { + const perPlayer = {}; + for (const ps of this.players) { + const pp = this._perPlayer[ps.playerId]; + pp.perfect = !pp.leaked.some((l) => l.counted !== false); + // the operators and the board's summon pieces (a board uid): 联防 carries an operator's HP ratio and SP, a summon's + // SP only (match/unite.js). `sp` is the official 技力 — stored charges included (PRTS 技能 "可充能X次…当前技力上限等于该 + // 技能技力需求的X倍"); a running skill spent its SP at activation, so it reports what was left (0 for one charge). + // `skillActive` is reported, never carried. + pp.unitsEnd = ps.units.filter((u) => u.kind === 'op' || (u.kind === 'token' && u.uid != null)).map((u) => ({ + uid: u.uid, id: u.id, defId: u.defId, + hpPct: u.alive ? Math.max(0, Math.min(1, u.hp / u.s.maxHp)) : 0, + sp: u.skill && !u.skill.noSkill ? Math.round(u.skill.spTotal * 100) / 100 : 0, + skillActive: !!(u.skill && u.skill.active && u.skill.kind !== 'passive'), + alive: !!u.alive, + })); + pp.unitStats = ps.units.map((u) => ({ + id: u.id, uid: u.uid, defId: u.defId, name: u.name, kind: u.kind, + dmg: Math.round(u.stats.dmg), kills: u.stats.kills, heal: Math.round(u.stats.heal), taken: Math.round(u.stats.taken), attacks: u.stats.attacks, + })); + perPlayer[ps.playerId] = pp; + } + const res = { time: Math.round(this.time * 1000) / 1000, reason: this.reason, perPlayer, killed: this.killed, total: this.total, errors: this.errorCount }; + if (this.unspawned && this.unspawned.length) res.unspawned = this.unspawned; + if (this.sharedBoss) res.bossHpLeft = Math.max(0, this.sharedBoss.hp); + return res; + } + + /** BattleResult (valid once finished; a provisional result before). */ + result() { + if (this._result) return this._result; + return this._buildResult(); + } +} diff --git a/server/sim/battle/players.js b/server/sim/battle/players.js new file mode 100644 index 00000000..1a29aa44 --- /dev/null +++ b/server/sim/battle/players.js @@ -0,0 +1,178 @@ +// server/sim/battle/players.js — Battle methods: players and units: board → field tiles and directions, ally creation +// from the PlayerBattleInput, token defs for an owner's loadout, and the kit / profile / skill setup of a unit. +// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). + +import { COLS, BOSS_ROW_OFFSET } from '../constants.js'; +import { Unit } from '../units.js'; +import { normDir, mirrorDir } from '../dir.js'; +import { SkillRuntime } from '../skills.js'; +import { resolveProfile } from '../professions.js'; +import { normalizeToken } from '../simdata.js'; +import { setupUnitKit } from '../content/index.js'; +import { clone } from './util.js'; + +export class BattlePlayers { + _addPlayer(p) { + const bossLike = this.kind === 'boss' || this.kind === 'hidden'; + const side = p.side === 'R' ? 'R' : 'L'; + const mirror = bossLike && side === 'R'; + const ps = { + playerId: p.playerId, + seat: p.seat ?? this.players.length, + side, + colOffset: Number(p.colOffset) || 0, + rowOffset: p.rowOffset, + coords: p.coords ?? 'board', + mirror, + facing: mirror ? -1 : 1, + // default direction of this player's units on this field (the FA right side is mirrored: RIGHT ↔ LEFT) + dir: mirror ? 'LEFT' : 'RIGHT', + half: mirror || (Number(p.colOffset) || 0) >= 8 ? 'R' : 'L', + bonds: clone(p.bonds ?? {}), + bandId: p.bandId ?? null, + playerEffects: clone(p.playerEffects ?? []), + lpForBoss: p.lpForBoss ?? null, + dp: this.flags.dpInit, + units: [], + input: p, + }; + this.players.push(ps); + this._perPlayer[ps.playerId] = { + killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, + damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [], + }; + const late = []; + for (const u of p.units ?? []) { + try { + const unit = this._createAllyFromInput(ps, u); + if (unit && unit.kind === 'token' && !unit.ownerUnit && u.ownerUid != null) late.push([unit, u]); + } catch (e) { this._internalError('createUnit', e); } + } + // a token piece listed before its owner (board order is top→bottom) is linked once every unit exists, and takes + // the owner-level variant (elite owners: `_b` stats) of the owner's loadout (DESIGN §16) + for (const [t, inp] of late) { + const owner = ps.units.find((x) => x.kind === 'op' && x.uid === inp.ownerUid); + if (!owner) continue; + t.ownerUnit = owner; + const def = inp.def ? null : this._tokenDef(inp.tokenId ?? inp.chessId, owner, null); + if (!def || def === t.def) continue; + const st = def.stats; + t.def = def; t.defId = def.id; t.name = def.name; t.rangeGrid = def.rangeGrid; + Object.assign(t.base, { + maxHp: st.maxHp, atk: st.atk, def: st.def, res: st.res, aspd: st.aspd, bat: st.bat, blockCnt: st.blockCnt, spRecovery: st.spRecovery, + tauntLevel: st.tauntLevel, massLevel: st.massLevel, hpRecoveryPerSec: st.hpRecoveryPerSec, cost: st.cost, respawnTime: st.respawnTime, + }); + t.markDirty(); + t.hp = t.s.maxHp; + } + } + + /** Board → field tile for a player (see header). */ + mapTile(ps, row, col, abs = false) { + if (abs || ps.coords === 'field') return [row, col]; + const bossLike = this.kind === 'boss' || this.kind === 'hidden'; + let r = row; + if (ps.rowOffset != null) r = row + ps.rowOffset; + else if (bossLike && row >= 7) r = row + BOSS_ROW_OFFSET; + const c = ps.mirror ? (col <= 10 ? COLS - 1 - col : col) : col + ps.colOffset; + return [r, c]; + } + + /** + * Board direction → field direction for a player (DESIGN §3): as given (default RIGHT) except on the mirrored Final + * Assault right side, where RIGHT ↔ LEFT (UP / DOWN unchanged). `abs` / field coordinates are taken as they are. + */ + mapDir(ps, dir, abs = false) { + const d = normDir(dir); + return ps && ps.mirror && !abs && ps.coords !== 'field' ? mirrorDir(d) : d; + } + + _createAllyFromInput(ps, inp) { + // coerce (a string row would otherwise concatenate: "10" + -7 → "10-7"); non-integers never deploy + const row = Number(inp.row), col = Number(inp.col); + if (!Number.isInteger(row) || !Number.isInteger(col)) { this.log(`bad tile ${inp.row},${inp.col} for ${inp.chessId ?? inp.tokenId}`); return null; } + const [r, c] = this.mapTile(ps, row, col, !!inp.abs); + const dir = this.mapDir(ps, inp.dir, !!inp.abs); + if (inp.kind === 'token') { + const owner = inp.ownerUid != null ? ps.units.find((x) => x.uid === inp.ownerUid) : null; + const def = this._tokenDef(inp.tokenId ?? inp.chessId, owner, inp.def); + if (!def) { this.log(`unknown token ${inp.tokenId}`); return null; } + const u = this._makeAlly(ps, def, 'token', r, c, { uid: inp.uid, ownerUnit: owner, dir }); + u.carry = inp.carryState ?? null; + return u; + } + // the unit's own loadout (DESIGN §16): an entry without loadout fields is the DEFAULT — never another player's + // choice for the same chess id in a multi-player field (the per-battle data view maps id-only lookups) + const def = this.data.getChess(inp.chessId, { skillIndex: inp.skillIndex ?? null, moduleId: inp.moduleId ?? null }); + if (!def) { this.log(`unknown chess ${inp.chessId}`); return null; } + const u = this._makeAlly(ps, def, 'op', r, c, { uid: inp.uid, dir }); + u.items = [...(inp.items ?? [])]; + u.carry = inp.carryState ?? null; + return u; + } + + /** + * Token def: an inline def (PlayerBattleInput `def`, spawnToken `opts.def`) as given, else the data record's variant + * for the owner — `owner` = the owning ally unit (its chess and its selected skill / module: getToken(id, + * owner.defId, owner.def.loadout), DESIGN §16), a chess id, or null. + */ + _tokenDef(tokenId, owner, inline) { + if (inline) return normalizeToken(tokenId, inline); + if (!this.data.getToken) return null; + if (owner && typeof owner === 'object') return this.data.getToken(tokenId, owner.defId ?? null, owner.def?.loadout ?? null); + return this.data.getToken(tokenId, owner ?? null); + } + + _makeAlly(ps, def, kind, r, c, extra = {}) { + const st = def.stats; + const u = new Unit({ + id: ++this._idSeq, side: 'ally', kind, def, defId: def.id, name: def.name, ownerId: ps ? ps.playerId : null, + uid: extra.uid ?? null, ownerUnit: extra.ownerUnit ?? null, x: c, y: r, tileR: r, tileC: c, + dir: extra.dir != null ? normDir(extra.dir) : extra.facing != null ? normDir(extra.facing) : ps ? ps.dir : 'RIGHT', + base: { + maxHp: st.maxHp, atk: st.atk, def: st.def, res: st.res, aspd: st.aspd, bat: st.bat, blockCnt: st.blockCnt, + moveSpeed: 0, spRecovery: st.spRecovery, tauntLevel: st.tauntLevel, massLevel: st.massLevel, + hpRecoveryPerSec: st.hpRecoveryPerSec, cost: st.cost, respawnTime: st.respawnTime, + }, + }); + u.alive = false; + u.deployed = false; + u.items = []; + u.rangeGrid = def.rangeGrid; + u.player = ps; + this.units.push(u); + this.allyUnits.push(u); + if (ps) ps.units.push(u); + return u; + } + + /** Resolve kit/profile/skill for a unit (called by content/index.js; safe to call again). */ + _setupUnit(u, kit = null) { + if (!kit) { + try { kit = setupUnitKit(this, u, this.contentMode); } catch (e) { this._internalError('setupUnitKit', e); kit = null; } + } + u.kit = kit || {}; + u.profile = resolveProfile(u.def, u.kit.trait || null); + if (u.def.untargetable) this.addBuff(u, { key: 'trait:untargetable', flags: { untargetable: true }, persist: true, allowDead: true }); + // abnormal effects a summon holds (tokens.json `abnormal`, PRTS; user playtest #6 item 18): 禁疗 — no heal reaches it; + // 孤立 ("无法被同阵营选中") — no ally heal or ally selection (auras over a range) reaches it + const ab = u.def.abnormal; + if (Array.isArray(ab) && ab.length) { + const flags = {}; + if (ab.includes('healFree')) flags.noHeal = true; + if (ab.includes('isolated')) { flags.isolated = true; flags.noHeal = true; } + if (Object.keys(flags).length) this.addBuff(u, { key: 'trait:abnormal', flags, persist: true, allowDead: true }); + } + const spec = u.kit.skill || null; + u.skill = new SkillRuntime(this, u, u.def.skill, spec, u.def.skill?.bb ?? {}); + if (u.profile.install && !u._profInstalled) { + u._profInstalled = true; + this._safe(() => u.profile.install(this, u), 'profile.install', u); + } + for (const t of u.kit.talents || []) { + if (t && typeof t.install === 'function') this._safe(() => t.install(this, u), 'talent.install', u); + } + if (typeof u.kit.install === 'function') this._safe(() => u.kit.install(this, u), 'kit.install', u); + return u.kit; + } +} diff --git a/server/sim/battle/queries.js b/server/sim/battle/queries.js new file mode 100644 index 00000000..cf0ba4ba --- /dev/null +++ b/server/sim/battle/queries.js @@ -0,0 +1,247 @@ +// server/sim/battle/queries.js — Battle methods: queries: the per-tick enemy tile index, enemies / allies by tile keys, +// grid, radius and selectability, players, and the range rebuild of an ally (current, base and extra range keys). +// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). + +import { ROWS, COLS } from '../constants.js'; +import { absoluteRangeKeys, canTargetEnemy, extendedGrid, enemyStealthed } from '../targeting.js'; +import { bodyKeys, bodyInKeys, bodyInRadius } from '../body.js'; + +export class BattleQueries { + /** + * Tile buckets of the living enemies: a regular enemy on the tile of its position, a huge one (body.js) on every tile + * it occupies. + */ + _buildEnemyIndex() { + for (const k of this._ebUsed) this._eb[k].length = 0; + this._ebUsed.length = 0; + for (const e of this.enemies) { + if (!e.alive || e.hidden) continue; + if (e.hitArea) { + for (const k of bodyKeys(e)) { const b = this._eb[k]; if (!b.length) this._ebUsed.push(k); b.push(e); } + continue; + } + const r = Math.round(e.y), c = Math.round(e.x); + if (r < 0 || r >= ROWS || c < 0 || c >= COLS) continue; + const k = r * COLS + c; + const b = this._eb[k]; + if (!b.length) this._ebUsed.push(k); + b.push(e); + } + } + + /** Targetable enemies whose body is on any of `keys` (absolute tile keys; a huge enemy is listed once). */ + enemiesInKeys(keys, attacker, profile) { + const out = []; + if (!keys) return out; + for (let i = 0; i < keys.length; i++) { + const b = this._eb[keys[i]]; + if (!b || !b.length) continue; + for (const e of b) if (e.alive && (!e.hitArea || !out.includes(e)) && canTargetEnemy(attacker, e, profile)) out.push(e); + } + return out; + } + + /** + * A living enemy whose body is on any of `keys` — targetable or not (stealthed, untargetable, asleep, flying): the + * 技能范围 trigger "技能范围内存在敌人(无视其不可选中)" (skills.js SKILL_RANGE). + */ + anyEnemyInKeys(keys) { + if (!keys) return false; + for (let i = 0; i < keys.length; i++) { + const b = this._eb[keys[i]]; + if (b && b.length) for (const e of b) if (e.alive && e.deployed) return true; + } + return false; + } + + /** Allies (not devices) within tiles `keys`, sorted by HP ratio (lowest first) that need healing. */ + injuredAlliesInKeys(keys, healer, includeElement = false) { + const out = []; + if (!keys) return out; + const set = keys instanceof Set ? keys : (healer && healer.rangeKeys === keys && healer.rangeKeySet ? healer.rangeKeySet : new Set(keys)); + for (const a of this.allyUnits) { + if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') continue; + if (!set.has(a.tileR * COLS + a.tileC)) continue; + if (a !== healer && (a.s.flags.noHeal || (a.profile && a.profile.noHeal))) continue; + const injured = a.hp < a.s.maxHp - 1e-6; + const elem = includeElement && (a.elem.burn + a.elem.neural + a.elem.necrosis + a.elem.apoptosis + a.elem.erosion) > 0; + if (injured || elem) out.push(a); + } + out.sort((a, b) => a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq); + return out; + } + + lowestHpAllyInRange(unit) { + const l = this.injuredAlliesInKeys(unit.rangeKeys, unit); + return l[0] ?? null; + } + + /** Units of the opposite side (or `side`) whose tile is inside `grid` offsets relative to `unit`. */ + unitsInGrid(unit, grid, { side = null, extend = 0 } = {}) { + const keys = absoluteRangeKeys(grid || unit.rangeGrid || [[0, 0]], Math.round(unit.y), Math.round(unit.x), unit.dir, extend); + const want = side ?? (unit.side === 'ally' ? 'enemy' : 'ally'); + const set = new Set(keys); + const out = []; + const list = want === 'enemy' ? this.enemies : this.allyUnits; + for (const x of list) { + if (!x.alive || !x.deployed || x.hidden) continue; + if (bodyInKeys(x, set)) out.push(x); + } + return out; + } + + /** Allies inside `unit`'s current range (for auras) — not the 孤立 ones ("无法被同阵营选中": 炎佑), `unit` aside. */ + alliesInGrid(unit) { + const set = unit.rangeKeySet || new Set(unit.rangeKeys || []); + return this.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC) && this.allySelectable(a, unit)); + } + + /** + * May an ability of ally `by` select ally `a`? Never a 孤立 unit (炎佑, 从不混淆的方向; flag `isolated`) other than `by` + * itself — PRTS 选择器: selectors decide "天赋是否能给予某个干员Buff" and every heal / buff / aura target, and their 可选判定 + * rejects a 孤立 target of a friendly selector ("若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"; user playtest #6 + * item 18). Enemies still target it (a hostile selector). 禁疗 only keeps heals off (heal pipeline, injuredAlliesInKeys). + */ + allySelectable(a, by = null) { + return !!a && (a === by || !a.s.flags.isolated); + } + + /** Deployed allies (no devices) an ability of ally `by` may select: allies(ownerId) without the 孤立 ones. */ + alliesFor(by, ownerId = null) { + return this.allies(ownerId).filter((a) => this.allySelectable(a, by)); + } + + /** + * Living enemies whose body (body.js: a huge enemy's hit rectangle, else its position) is within `r` of (x, y). + * `centre` = a 中点判定 radius — splash around a struck / marked target (PRTS 作战机制: "中点判定…案例:阻挡,酒神1天赋的1.3 + * 溅射半径"): every enemy counts by its position (判定中心), a huge one too. + */ + enemiesInRadius(x, y, r, centre = false) { + const out = []; + const r2 = r * r + 1e-9; + for (const e of this.enemies) { + if (!e.alive || e.hidden) continue; + if (e.hitArea && !centre) { if (bodyInRadius(e, x, y, r)) out.push(e); continue; } + const dx = e.x - x, dy = e.y - y; + if (dx * dx + dy * dy <= r2) out.push(e); + } + 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, blocked or not hidden again yet after a block (targeting.js + * enemyStealthed; 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 && enemyStealthed(e))) continue; + out[n++] = e; + } + out.length = n; + return out; + } + + /** + * Every deployed ally within `r` of (x, y) — no selection rule: an enemy's area effect selects among them with + * targeting.js areaSelectable (content/enemies/helpers.js areaAllies: no 隐匿 ally, the blocker included, GitHub #97; DESIGN §22.12). + */ + alliesInRadius(x, y, r, ownerId = null, { includeDevices = false } = {}) { + const out = []; + const r2 = r * r + 1e-9; + for (const a of this.allyUnits) { + if (!a.alive || !a.deployed || a.hidden) continue; + if (!includeDevices && a.kind === 'device') continue; + if (ownerId != null && a.ownerId !== ownerId) continue; + const dx = a.x - x, dy = a.y - y; + if (dx * dx + dy * dy <= r2) out.push(a); + } + return out; + } + + /** Alive deployed allies (ops + tokens), optionally of one player. */ + allies(ownerId = null) { + return this.allyUnits.filter((a) => a.alive && a.deployed && a.kind !== 'device' && (ownerId == null || a.ownerId === ownerId)); + } + + aliveEnemies() { return this.enemies.filter((e) => e.alive); } + + unitAt(r, c) { const u = this._occ[r * COLS + c]; return u && u.alive ? u : null; } + + unitById(id) { return this.units.find((u) => u.id === id) ?? null; } + + tileInfo(r, c) { return this.grid.tile(r, c); } + + getPlayer(playerId) { return this.players.find((p) => p.playerId === playerId) ?? null; } + + _pp(playerId) { return playerId == null ? null : this._perPlayer[playerId] ?? null; } + + // ============================================================================================================= + // ranges + + /** + * Recompute a unit's absolute range tile keys: `rangeKeys` / `rangeKeySet` = current range (skill range override + + * rangeExtend — the unit's own 攻击距离 unless the running skill's range ignores it, `targeting.noRangeExtend`: 信仰搅拌机 + * S3, PRTS 备注 "此技能的攻击范围不受'攻击距离'属性影响" — plus the skill's own `targeting.rangeExtend`) ∪ + * `unit.extraRangeKeys` (content extra targets — setExtraRange); `baseRangeKeys` = the INITIAL range of the DEFAULT + * skill trigger: the unit's own grid + its permanent rangeExtend (`s.baseRangeExtend`: persistent, never-expiring + * buffs — talents, modules, bonds), no skill range, no temporary extend, no extra keys; `liveRangeGrid` = the + * relative grid (facing RIGHT) of the 攻击范围 the detail card shows (shared/protocol.js unitStatsEntry `range`; + * community report E1 after 0.1.0: the card kept the base grid while 烛煌 S3 attacked with 4-11): the grid behind + * `rangeKeys` without the extra keys (targeting.js extendedGrid; the grid itself when nothing extends it), or the + * unit's own range while a skill's grid only selects targets (`targeting.showOwnRange`: 荒芜拉普兰德 S1, no official + * range change). Rebuilt on deploy / relocate / skill range switches and whenever either extend changes (rangeChanged). + */ + _refreshRange(u) { + const tg = u.skill && u.skill.active ? u.skill.spec.targeting : null; + const grid = (tg && tg.rangeGrid) || u.rangeGrid || [[0, 0]]; + const ext = (tg && tg.noRangeExtend ? 0 : u.s.rangeExtend) + ((tg && tg.rangeExtend) || 0); + u._rangeExtend = u.s.rangeExtend; + u._baseExtend = u.s.baseRangeExtend; + const keys = absoluteRangeKeys(grid, u.tileR, u.tileC, u.dir, ext); + const set = new Set(keys); + const extra = u.extraRangeKeys; + if (extra) for (const k of extra) if (!set.has(k)) { set.add(k); keys.push(k); } + u.rangeKeys = keys; + u.rangeKeySet = set; + const own = !!(tg && tg.showOwnRange); + const lg = own ? u.rangeGrid || [[0, 0]] : grid, le = own ? u.s.rangeExtend : ext; + u.liveRangeGrid = le > 0 ? extendedGrid(lg, le) : lg; + if (u.kind !== 'device') u.baseRangeKeys = absoluteRangeKeys(u.rangeGrid || [[0, 0]], u.tileR, u.tileC, u.dir, u.s.baseRangeExtend); + if (u.skill) u.skill._trigKeys = null; // CUSTOM_RANGE trigger grid is relative to the tile + } + + /** True when a buff changed the unit's rangeExtend (total or permanent part) since its range was last built. */ + rangeChanged(u) { + return u._rangeExtend !== undefined && (u._rangeExtend !== u.s.rangeExtend || u._baseExtend !== u.s.baseRangeExtend); + } + + /** Public: rebuild a unit's range after content changed `unit.rangeGrid` (流形 copies) or its tile keys. */ + refreshRange(unit) { + if (!unit || unit.side !== 'ally' || !unit.alive || !unit.deployed) return false; + this._refreshRange(unit); + return true; + } + + /** + * Extra targetable tiles merged into the unit's range (蕾缪安 wanted targets, 维娜 S3, …): absolute tile keys + * (row × COLS + col), kept across every later range rebuild until content sets them again (null / [] clears). + * Not part of `baseRangeKeys` (the DEFAULT trigger range) — see SkillRuntime.addTriggerRange for that. + */ + setExtraRange(unit, keys) { + if (!unit || unit.side !== 'ally') return false; + const out = []; + const seen = new Set(); + for (const k of keys || []) if (Number.isInteger(k) && k >= 0 && k < ROWS * COLS && !seen.has(k)) { seen.add(k); out.push(k); } + unit.extraRangeKeys = out.length ? out : null; + if (unit.alive && unit.deployed) this._refreshRange(unit); + return true; + } +} diff --git a/server/sim/battle/spawns.js b/server/sim/battle/spawns.js new file mode 100644 index 00000000..dfecdd60 --- /dev/null +++ b/server/sim/battle/spawns.js @@ -0,0 +1,202 @@ +// server/sim/battle/spawns.js — Battle methods: enemies: the spawn queue (counted in `total` up front), routes and the +// owner of a tile, spawnEnemy, and the enemies on the field — hidden ones, the shared boss pool sync, the field clamp +// and the list compaction. +// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). + +import { MAX_ALIVE_ENEMIES } from '../constants.js'; +import { Unit } from '../units.js'; +import { compileRoute } from '../ai.js'; +import { unitInfo } from '../snapshot.js'; +import { normalizeRoute, normalizeEnemy } from '../simdata.js'; +import { fin } from './util.js'; + +const FALLBACK_ENEMY = Object.freeze({ + type: 'enemy', id: 'enemy_unknown', key: 'enemy_unknown', name: '未知敌人', rank: 'NORMAL', maxHp: 2000, atk: 200, def: 100, res: 0, + aspd: 100, bat: 2, rangeRadius: 0, moveSpeed: 1, massLevel: 1, lpr: 1, motion: 'WALK', applyWay: 'MELEE', dmgType: 'phys', + immune: new Set(), tauntLevel: 0, blockCnt: 1, epResistance: 0, epDamageResistance: 0, hpRecoveryPerSec: 0, notCountInTotal: false, + tags: [], abilities: [], skills: [], talent: {}, spine: 'unknown', avatar: 'unknown', raw: {}, +}); + +export class BattleSpawns { + _queueSpawn(s, precount) { + if (Number(s.time) === Infinity) return; // "never" — not scheduled, not counted + // capped: a bogus count (Infinity, 1e9) would otherwise hang the constructor / exhaust memory + const count = Math.min(MAX_ALIVE_ENEMIES, Math.max(1, Math.floor(fin(s.count ?? 1, 1)) || 1)); + const interval = Math.max(0, fin(s.interval, 0)); + const def = this.data.getEnemy(s.enemyKey); + for (let i = 0; i < count; i++) { + const p = { + time: Math.max(0, fin(s.time, 0)) + i * interval, enemyKey: s.enemyKey, routeIndex: s.routeIndex ?? 0, route: s.route ?? null, + mods: s.mods ?? null, sourcePlayerId: s.sourcePlayerId ?? null, bounty: s.bounty ?? null, tag: s.tag ?? null, + ownerPlayerId: s.ownerPlayerId ?? null, pos: s.pos ?? null, seq: ++this._spawnSeq, countInTotal: s.countInTotal, + }; + p.counted = p.countInTotal ?? (!(def && def.notCountInTotal) && p.tag !== 'boss' && p.tag !== 'part'); + if (precount && p.counted) { + this.total++; + const owner = p.ownerPlayerId ?? this._ownerForTile(p.pos ?? this._routeFor(p.routeIndex, p.route)?.start); + const pp = this._pp(owner); + if (pp) pp.total++; + p.precounted = true; + } + this._pending.push(p); + } + } + + _processSpawns() { + const now = this.time + 1e-9; + while (this._pending.length && this._pending[0].time <= now) { + const p = this._pending.shift(); + this._safe(() => this.spawnEnemy(p.enemyKey, { ...p, _precounted: p.precounted }), 'spawnEnemy'); + } + } + + _routeFor(idx, explicit = null) { + if (explicit) return normalizeRoute(explicit); + return this.routes[idx] ?? this.routes[0] ?? this._defaultRoute(); + } + + _defaultRoute() { + if (this._defRoute !== undefined) return this._defRoute; + const starts = this.grid.specialTiles('start'); + const ends = this.grid.specialTiles('end'); + this._defRoute = starts.length && ends.length ? { motion: 'WALK', start: starts[0], end: ends[0], checkpoints: [] } : null; + return this._defRoute; + } + + _ownerForTile(tile) { + if (!this.players.length) return null; + if (this.players.length === 1 || !tile) return this.players[0].playerId; + const c = Array.isArray(tile) ? tile[1] : tile.c; + const half = c >= 11 ? 'R' : 'L'; + return (this.players.find((p) => p.half === half) ?? this.players[0]).playerId; + } + + /** + * Spawn an enemy now. opts: { routeIndex, route, pos:[r,c], mods:{hpMul,atkMul,defMul,resMul,speedMul}, tag, + * sourcePlayerId, ownerPlayerId, bounty:{coins,ownerPlayerId}, countInTotal } + */ + spawnEnemy(enemyKey, opts = {}) { + if (this.enemies.length >= MAX_ALIVE_ENEMIES && this.aliveEnemies().length >= MAX_ALIVE_ENEMIES) { + this._handlerError('spawnEnemy', null, new Error(`more than ${MAX_ALIVE_ENEMIES} living enemies; spawn of ${enemyKey} refused`)); + return null; + } + // opts.def: an inline record (data/enemies.json shape, or an already normalised EnemyDef) for keys absent from data + let def = null; + if (opts.def && typeof opts.def === 'object') { + try { def = opts.def.type === 'enemy' && opts.def.immune instanceof Set ? opts.def : normalizeEnemy(enemyKey, opts.def); } catch (e) { this._handlerError('spawnEnemy.def', null, e); } + } + if (!def) def = this.data.getEnemy(enemyKey); + if (!def) { this.log(`unknown enemy ${enemyKey}`); def = { ...FALLBACK_ENEMY, id: enemyKey, key: enemyKey }; } + const ov = this.enemyOverrides[def.key] ?? this.enemyOverrides[enemyKey]; + if (ov && ov.stats) { + const st = ov.stats; + def = { ...def, + maxHp: Math.max(1, fin(st.maxHp ?? st.hp, def.maxHp)), atk: Math.max(0, fin(st.atk, def.atk)), def: Math.max(0, fin(st.def, def.def)), + res: Math.max(0, fin(st.res, def.res)), moveSpeed: Math.max(0, fin(st.moveSpeed, def.moveSpeed)), + bat: Math.max(0.1, fin(st.bat, def.bat)), aspd: fin(st.aspd, def.aspd) || def.aspd, + rangeRadius: Math.max(0, fin(st.rangeRadius, def.rangeRadius)), massLevel: fin(st.massLevel, def.massLevel), + lpr: fin(st.lpr, def.lpr), blockCnt: Math.max(1, fin(st.blockCnt, def.blockCnt)) }; + } + const route = opts.route ? normalizeRoute(opts.route) : this._routeFor(opts.routeIndex ?? 0); + let start = opts.pos ?? route?.start ?? [this.rect.r0, this.rect.c1]; + if (!Array.isArray(start) || !Number.isFinite(start[0]) || !Number.isFinite(start[1])) start = route?.start ?? [this.rect.r0, this.rect.c1]; + const R = this.rect; // spawn inside the field (same bounds as _clampPos) + start = [Math.max(R.r0 - 0.5, Math.min(R.r1 + 0.5, start[0])), Math.max(R.c0 - 0.5, Math.min(R.c1 + 0.5, start[1]))]; + // multipliers: finite and ≥ 0 (hp > 0), anything else counts as 1 + const mm = (v, pos = false) => { const n = fin(v, 1); return n < 0 || (pos && n <= 0) ? 1 : n; }; + const m0 = opts.mods || {}; + const m = { hpMul: mm(m0.hpMul, true), atkMul: mm(m0.atkMul), defMul: mm(m0.defMul), resMul: mm(m0.resMul), speedMul: mm(m0.speedMul) }; + const e = new Unit({ + id: ++this._idSeq, side: 'enemy', kind: 'enemy', def, defId: def.key ?? enemyKey, name: def.name, + x: start[1], y: start[0], motion: def.motion, + base: { + maxHp: def.maxHp * (m.hpMul ?? 1), atk: def.atk * (m.atkMul ?? 1), def: def.def * (m.defMul ?? 1), res: def.res * (m.resMul ?? 1), + aspd: def.aspd, bat: def.bat, blockCnt: 0, moveSpeed: def.moveSpeed * (m.speedMul ?? 1), spRecovery: 0, + tauntLevel: def.tauntLevel, massLevel: def.massLevel, hpRecoveryPerSec: def.hpRecoveryPerSec, rangeRadius: def.rangeRadius, + }, + }); + e.alive = true; + e.deployed = true; + e.blockWeight = def.blockCnt ?? 1; + e.hitArea = def.hitArea ?? null; // 巨型单位 hit rectangle (body.js); null = a point + e.lpr = def.lpr; + e.mods = opts.mods ?? null; + e.tag = opts.tag ?? null; + e.bounty = opts.bounty ?? null; + e.sourcePlayerId = opts.sourcePlayerId ?? null; + e.ownerId = opts.ownerPlayerId ?? this._ownerForTile(start); + e.spawnX = e.x; e.spawnY = e.y; + e.spawnSeq = ++this._spawnSeq; + e.deploySeq = ++this._deploySeq; + e.deployedAt = this.time; + e.atkCd = 0; + e.pauseUntil = -Infinity; // content holds (暴鸰's drop) + e.atkStandUntil = -Infinity; // standing for its attack clip (ai.js attackStand) + // every enemy profile starts with the same fields (stable object shapes keep the hot loop's property reads fast); + // `dmgType` null = the data's (content may arm a data-unarmed enemy: ai.js enemyAttack) + e.profile = { noAttack: def.dmgType === 'none', maxTargets: 1, atkScale: 1, dmgType: null }; + e.route = { legs: route ? compileRoute(route, this.rect) : [], legIdx: 0, pts: null, ptIdx: 0, suffix: null, version: -1, waitLeft: null }; + if (!e.route.legs.length) { + const end = this.grid.specialTiles('end')[0]; + if (end) e.route.legs.push({ t: 'move', r: end[0], c: end[1], final: true }); + } + e.counted = opts.countInTotal ?? (!def.notCountInTotal && e.tag !== 'boss' && e.tag !== 'part'); + if (e.counted && !opts._precounted) { + this.total++; + const pp = this._pp(e.ownerId); + if (pp) pp.total++; + } + if (e.tag === 'boss') { + e.isBoss = true; + if (this.sharedBoss) { e.bossPool = this.sharedBoss; this._syncBossHp(e); } + } + e.hp = e.bossPool ? e.hp : e.s.maxHp; + this.units.push(e); + this.enemies.push(e); + this._ev(['spawn', unitInfo(e)]); + if (this._hooks.deploy) this.emit('deploy', { unit: e, initial: false }); + if (this._hooks.enemySpawn) this.emit('enemySpawn', { enemy: e }); + return e; + } + + // ============================================================================================================= + // enemies on the field: hidden, boss pool, clamp, compaction + + _setHidden(e, on) { + if (e.hidden === on) return; + e.hidden = on; + if (on) this._unblock(e); + this.fx(on ? 'disappear' : 'appear', { x: e.x, y: e.y, id: e.id }); + } + + _syncBossHp(e) { + const pool = e.bossPool; + if (!pool) return; + if (Number.isFinite(pool.maxHp) && pool.maxHp > 0 && e.base.maxHp !== pool.maxHp) { e.base.maxHp = pool.maxHp; e.markDirty(); } + // a broken pool (NaN hp) must not leak NaN into the unit: show it full until the pool is sane again + const ratio = pool.maxHp > 0 ? (Number.isFinite(pool.hp) ? Math.max(0, pool.hp) / pool.maxHp : 1) : 0; + e.hp = e.s.maxHp * Math.min(1, ratio); + } + + _bossSync() { + if (!this.sharedBoss) return; + for (const e of this.enemies) if (e.alive && e.bossPool) { + this._syncBossHp(e); + if (e.bossPool.hp <= 0) this.kill(e, null); + } + } + + _clampPos(e) { + const R = this.rect; + if (e.x < R.c0 - 0.5) e.x = R.c0 - 0.5; + if (e.x > R.c1 + 0.5) e.x = R.c1 + 0.5; + if (e.y < R.r0 - 0.5) e.y = R.r0 - 0.5; + if (e.y > R.r1 + 0.5) e.y = R.r1 + 0.5; + } + + _compactEnemies() { + if (!this._enemiesDirty) return; + this.enemies = this.enemies.filter((e) => e.alive); + this._enemiesDirty = false; + } +} diff --git a/server/sim/battle/status.js b/server/sim/battle/status.js new file mode 100644 index 00000000..a4983963 --- /dev/null +++ b/server/sim/battle/status.js @@ -0,0 +1,294 @@ +// server/sim/battle/status.js — Battle methods: buffs and statuses: addBuff (refresh rules) / removeBuff, the per-tick +// buff pass (onTick, expiry, natural regeneration), catalogue statuses (immunities, 抵抗, 寒冷 → 冻结, 浮空, 诱导, 恐惧) and +// "同名效果取最高". +// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). + +import { COLD_FREEZE_DURATION, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT } from '../constants.js'; +import { makeBuff, STATUS, RESIST_STATUSES } from '../buffs.js'; +import { palsyBuff } from '../damage.js'; +import { evadesGround } from '../targeting.js'; +import { stampFear } from '../fear.js'; + +export class BattleStatus { + addBuff(unit, b) { + if (!unit || (!unit.alive && !b.allowDead)) return null; + const buff = makeBuff(b); + const list = unit.buffs; + let idx = -1; + for (let i = 0; i < list.length; i++) if (list[i].key === buff.key) { idx = i; break; } + if (idx >= 0 && buff.refresh !== 'independent') { + const old = list[idx]; + switch (buff.refresh) { + case 'keep': + return old; + case 'stack': + old.stacks = Math.min(old.stacks + buff.stacks, Math.max(old.maxStacks, buff.maxStacks)); + old.maxStacks = Math.max(old.maxStacks, buff.maxStacks); + old.timeLeft = buff.duration; + old.duration = buff.duration; + if (buff.mods) old.mods = buff.mods; + if (buff.flags) old.flags = buff.flags; + unit.markDirty(); + return old; + case 'extend': + old.timeLeft = Math.max(old.timeLeft, buff.duration); + old.duration = Math.max(old.duration, buff.duration); + if (buff.mods) old.mods = buff.mods; + if (buff.flags) old.flags = buff.flags; + if (buff.shield > old.shield) old.shield = buff.shield; + if (buff.shieldHits > old.shieldHits) old.shieldHits = buff.shieldHits; + unit.markDirty(); + return old; + default: + list[idx] = buff; + unit.markDirty(); + return buff; + } + } + if (buff.refresh === 'independent') { + const same = list.filter((x) => x.key === buff.key); + if (same.length >= buff.maxStacks) { + const oldest = same.reduce((a, c) => (a.seq < c.seq ? a : c)); + this._removeBuffAt(unit, list.indexOf(oldest), true); + } + } + list.push(buff); + unit.markDirty(); + if (buff.visible || buff.status) { + const key = buff.status ?? buff.key; + if (list.filter((x) => (x.status ?? x.key) === key).length === 1) this._ev(['status', unit.id, key, 1]); + } + return buff; + } + + removeBuff(unit, keyOrBuff) { + if (!unit) return 0; + let n = 0; + for (let i = unit.buffs.length - 1; i >= 0; i--) { + const b = unit.buffs[i]; + if (b === keyOrBuff || b.key === keyOrBuff) { this._removeBuffAt(unit, i, true); n++; } + } + return n; + } + + _removeBuffAt(unit, i, callRemove = true) { + const b = unit.buffs[i]; + if (!b) return; + unit.buffs.splice(i, 1); + unit.markDirty(); + if ((b.visible || b.status)) { + const key = b.status ?? b.key; + if (!unit.buffs.some((x) => (x.status ?? x.key) === key)) this._ev(['status', unit.id, key, 0]); + } + if (callRemove && b.onRemove) this._safe(() => b.onRemove({ battle: this, unit, buff: b }), 'buff.onRemove', unit); + } + + _tickBuffs(dt) { + const units = this.units; + for (let i = 0, n = units.length; i < n; i++) { // units created by onTick handlers start ticking next tick + const u = units[i]; + if (!u.alive || u.removed) continue; + if (u.buffs.length) { + const arr = u.buffs.slice(); + for (const b of arr) { + if (!u.alive) break; + if (u.buffs.indexOf(b) < 0) continue; + if (b.onTick) { + if (b.interval > 0) { + b._acc += dt; + let n = 0; + while (b._acc >= b.interval - 1e-9 && n++ < 8) { + b._acc -= b.interval; + this._safe(() => b.onTick({ battle: this, unit: u, buff: b, dt: b.interval }), 'buff.onTick', u); + } + } else this._safe(() => b.onTick({ battle: this, unit: u, buff: b, dt }), 'buff.onTick', u); + } + if (b.timeLeft !== Infinity) { + b.timeLeft -= dt; + if (b.timeLeft <= 1e-9) { + const i = u.buffs.indexOf(b); + if (i >= 0) { + this._removeBuffAt(u, i, false); + if (b.onExpire) this._safe(() => b.onExpire({ battle: this, unit: u, buff: b }), 'buff.onExpire', u); + } + } + } + } + } + // natural HP regeneration + if (u.alive && u.deployed && !u.bossPool) { + const regen = u.s.hpRegen; + if (regen > 0 && u.hp < u.s.maxHp) { + u._regenAcc = (u._regenAcc ?? 0) + regen * dt; + if (u._regenAcc >= 1 || u.hp + u._regenAcc >= u.s.maxHp) { + this.heal(u, u, u._regenAcc, { self: true, silent: true, regen: true }); + u._regenAcc = 0; + } + } + } + } + } + + /** + * Apply a catalogue status. opts: { duration, source, value, force, refresh, point, resistApplied } — returns true + * when applied. Honours enemy immunities (stun/silence/sleep/frozen/levitate/feared) unless `force`. `beforeStatus` + * handlers may cancel it or change `duration` / `value`. Official rules (buffs.js STATUS): 抵抗 (the `resist` status) + * shortens the RESIST_STATUSES by its value (default half; applied after `beforeStatus`; `resistApplied` skips that + * pass — the cold-on-cold 冻结 below already used post-抵抗 lengths). A second 寒冷 while 寒冷 remains applies 冻结 for + * max(remaining, this cold after 抵抗) (PRTS 术语释义 寒冷 「持续时间取双方之中最高」). 浮空 lasts half as long on units heavier + * than LEVITATE_HALF_WEIGHT (current massLevel); 冻结's RES cut hits enemies only; 麻痹 adds stacks; "同名效果取最高" + * statuses (`valued`) keep the strongest value — a weaker application only extends past the stronger one's end (it + * then resumes); other statuses refresh to the longer duration. 诱导 (`attract`) walks the enemy to `point` + * ([r, c] or {x, y}; default the source's tile — a new application moves the point); 恐惧 (`fear`) stamps where it + * was applied and from where (fear.js stampFear: the fan of 恐惧可达地块 its movement uses). A stunned/sleeping operator + * releases the enemies it blocks; a feared/levitated/unblockable/attracted enemy is released by its blocker. + * `statusApplied` reports the final duration and `entered` (the target did not carry the status before). + * A unit that is 无敌 and 无法选中 at once (a 重生 in progress, a hovering or 永久无敌 leader part) takes no status from + * the other side, `force` included — PRTS 无敌 "无法被不同阵营选中": so a status carried by the very hit that knocked an + * enemy out does not land after its 重生's cleanse (DESIGN §21.4). A ground enemy's status never lands on an airborne + * 起飞 ally (对地规避: targeting.js evadesGround) unless `opts.ignoreSelect`. + */ + applyStatus(target, key, opts = {}) { + if (!target || !target.alive) return false; + const src = opts.source; + if (src && src.side && src.side !== target.side && target.s.flags.invulnerable && target.s.flags.untargetable) return false; + if (!opts.ignoreSelect && target.s.flags.liftoff && evadesGround(src, target)) return false; + const tpl = STATUS[key] || { flags: { [key]: true } }; + let duration = opts.duration == null ? Infinity : Number(opts.duration); + let value = opts.value; + if (!(duration > 0)) return false; + const immune = target.def && target.def.immune; + if (!opts.force && tpl.immune && immune && immune.has(tpl.immune)) return false; + // 浮空 Buff (PRTS 异常效果: "若单位数据上为飞行单位且不持有缚地异常或是持有浮空异常则Buff取消"; 行动方式 "行动类型(数据)为 + // 飞行的单位、以及已持有浮空异常的单位无法被施加浮空Buff"): refused on data flyers (`motion` FLY) and units already + // levitated — a hovering 近地悬浮 enemy is WALK in its data, so it can be levitated (no 缚地 / 浮空强化 in this mode) + if (key === 'levitate' && (target.motion === 'FLY' || target.s.flags.levitate)) return false; + if (this._hooks.beforeStatus) { + const c = this.emit('beforeStatus', { source: opts.source ?? null, target, status: key, duration, value, cancel: false }); + if (c.cancel || !target.alive) return false; + const d = Number(c.duration); + if (d === Infinity || (Number.isFinite(d) && d > 0)) duration = d; + else if (Number.isFinite(d) && d <= 0) return false; + value = c.value; + } + if (RESIST_STATUSES.has(key) && !opts.resistApplied) { + const rv = this.resistOf(target); + if (rv > 0) duration *= 1 - rv; + } + if (key === 'levitate' && target.s.massLevel > LEVITATE_HALF_WEIGHT) duration /= 2; + if (!(duration > 0)) return false; + if (key === 'cold' && target.findBuff('cold') && !(immune && immune.has('frozen'))) { + // PRTS 术语释义 寒冷: 友方寒冷 pairs into 冻结, 「持续时间取双方之中最高」. `duration` is this cold after 抵抗; + // the cold already on the target keeps timeLeft. addBuff refresh 'extend' below sets that cold to the same max. + // COLD_FREEZE_DURATION is only the fallback when neither side has a duration. resistApplied: that max is already + // post-抵抗, so the freeze must not be halved again. + // [ASSUMED] one catalogue cold, so an enemy-applied second cold uses this max too. PRTS states it on the 友方 + // line only (敌方 cold becomes 冻结 when the target already has 敌方 cold or 敌方 冻结). + const prev = target.findBuff('cold'); + const spans = [prev.timeLeft, duration].filter((t) => t === Infinity || (Number.isFinite(t) && t > 0)); + const freezeFor = spans.length ? Math.max(...spans) : COLD_FREEZE_DURATION; + this.applyStatus(target, 'freeze', { + duration: freezeFor, source: opts.source, force: opts.force, + ...(spans.length ? { resistApplied: true } : {}), + }); + if (!target.alive) return false; + } + const source = opts.source ?? null; + let entered = true; + for (const b of target.buffs) if ((b.status ?? b.key) === key) { entered = false; break; } + if (tpl.palsy) { + this.addBuff(target, { ...palsyBuff(value ?? 1), duration, source }); + } else if (tpl.valued != null && typeof tpl.mods === 'function' && opts.refresh == null) { + this._applyValuedStatus(target, key, tpl, duration, value ?? tpl.valued, source); + } else { + const mods = tpl.enemyOnlyMods && target.side !== 'enemy' ? null : typeof tpl.mods === 'function' ? tpl.mods(value) : (tpl.mods || null); + const b = this.addBuff(target, { key, duration, refresh: opts.refresh ?? 'extend', mods, flags: tpl.flags || null, status: key, visible: true, source }); + if (tpl.attract && b) this._setAttractPoint(target, b, opts.point ?? value, source); + // 恐惧: the hit position and the source's position of every application (fear.js — the fan of reachable tiles) + if (key === 'fear' && b && target.side === 'enemy') stampFear(this, target, b, source); + } + const f = tpl.flags; + if (f && target.side === 'enemy' && (f.levitate || f.unblockable || f.fear)) this._unblock(target); + if (f && target.side === 'ally' && f.noBlock) this.releaseBlocked(target); + if (this._hooks.statusApplied) this.emit('statusApplied', { source, target, status: key, duration, value, entered }); + return true; + } + + /** 抵抗 of a unit: share of a resisted status's duration removed (0 = none; the `resist` status value, ≤ 0.95). */ + resistOf(unit) { + if (!unit || !unit.buffs.length) return 0; + let r = 0; + for (const b of unit.buffs) { + if (b.status !== 'resist') continue; + const v = Number.isFinite(b.data?.value) ? b.data.value : RESIST_DEFAULT; + if (v > r) r = v; + } + return Math.min(0.95, r); + } + + /** 诱导 target point of an `attract` status buff (enemy walks there; see ai.js moveAttracted). */ + _setAttractPoint(target, buff, point, source) { + let r = null, c = null; + if (Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1])) { r = point[0]; c = point[1]; } + else if (point && typeof point === 'object' && Number.isFinite(point.x) && Number.isFinite(point.y)) { r = point.y; c = point.x; } + else if (source && Number.isFinite(source.x) && Number.isFinite(source.y)) { r = source.y; c = source.x; } + if (r == null) { if (!buff.data.attract) buff.data = { ...buff.data, attract: null }; return; } + const R = this.rect; + r = Math.max(R.r0, Math.min(R.r1, Math.round(r))); + c = Math.max(R.c0, Math.min(R.c1, Math.round(c))); + buff.data = { ...buff.data, attract: { r, c, pts: null, i: 0, ver: -1 } }; + } + + /** + * "同名效果取最高" for a content effect that is not a catalogue status (no immunities, 抵抗, `beforeStatus` / + * `statusApplied` hooks or status icon): ONE instance of `key` per target whatever the number of sources — the + * strongest `value` applies, a weaker application only extends past the stronger one's end (it then resumes), an + * equal one refreshes to the longer duration. `mods(value)` builds the buff's mods. The engine's default for two + * same-named buffs (PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"). Returns true when applied. + * @param {object} target + * @param {string} key + * @param {{ duration: number, value: number, mods: (v: number) => object, source?: object|null }} opts + */ + applyStrongest(target, key, { duration, value, mods, source = null } = {}) { + if (!target || !target.alive || !(Number(duration) > 0) || !Number.isFinite(value) || typeof mods !== 'function') return false; + this._applyValuedStatus(target, key, { mods, valued: value, plain: true }, Number(duration), value, source); + return true; + } + + /** + * "同名效果取最高": keep the strongest value; a weaker one that outlasts it resumes afterwards (buff.data.tail). A + * `plain` template (applyStrongest) is an ordinary invisible buff, not a status. + */ + _applyValuedStatus(target, key, tpl, duration, value, source) { + const strength = (v) => Math.abs(Number.isFinite(v) ? v : tpl.valued); + const make = (v, dur, tail) => ({ + ...(tpl.buff || null), // extra buff fields of the status (抵抗: the 麻痹 decay tick) + key, duration: dur, refresh: 'replace', mods: tpl.mods(v), flags: tpl.flags || null, status: tpl.plain ? null : key, + visible: !tpl.plain, source, + data: { value: v, tail }, + onExpire: ({ battle, unit, buff }) => { + const t = buff.data.tail; + if (t && t.until - battle.time > 1e-6 && unit.alive) battle.addBuff(unit, make(t.value, t.until - battle.time, null)); + }, + }); + const old = target.buffs.find((b) => b.key === key && (tpl.plain || b.status === key)); + if (!old) { this.addBuff(target, make(value, duration, null)); return; } + const oldV = old.data && Number.isFinite(old.data.value) ? old.data.value : tpl.valued; + const oldEnd = this.time + old.timeLeft, newEnd = this.time + duration; + const oldTail = old.data && old.data.tail; + const longerTail = (a, b) => (!a ? b : !b ? a : (b.until > a.until ? b : a)); + if (strength(value) > strength(oldV) + 1e-12) { + // stronger: takes over now; the weaker old one (or its tail) resumes if it lasts longer + const tail = longerTail(oldEnd > newEnd ? { value: oldV, until: oldEnd } : null, oldTail && oldTail.until > newEnd ? oldTail : null); + this.addBuff(target, make(value, duration, tail)); + } else if (strength(value) < strength(oldV) - 1e-12) { + // weaker: never overrides; remembered as the tail when it outlasts the running one + if (newEnd > oldEnd && (!oldTail || newEnd > oldTail.until)) old.data = { ...old.data, value: oldV, tail: { value, until: newEnd } }; + } else if (duration > old.timeLeft) { + old.timeLeft = duration; + old.duration = Math.max(old.duration, duration); + } + } + + removeStatus(target, key) { return this.removeBuff(target, key); } +} diff --git a/server/sim/battle/summons.js b/server/sim/battle/summons.js new file mode 100644 index 00000000..2f1acca4 --- /dev/null +++ b/server/sim/battle/summons.js @@ -0,0 +1,139 @@ +// server/sim/battle/summons.js — Battle methods: summons and devices: the token def of an owner's loadout, whether its +// skill produces a token, spawnToken, spawnDevice and the stage devices (crates, platforms) placed at the start. +// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). + +import { COLS, OBSTACLE_DEVICES } from '../constants.js'; +import { normDir } from '../dir.js'; +import { SkillRuntime } from '../skills.js'; +import { unitInfo } from '../snapshot.js'; +import { normalizeToken } from '../simdata.js'; +import { fin } from './util.js'; + +export class BattleSummons { + /** + * Token def of `tokenId` as `owner` (an ally unit: its chess + selected skill / module; or a chess id) summons it — + * the def spawnToken uses. For content that reads summon stats / skill blackboards: exact in a multi-player field + * where two players give the same chess different loadouts (an id-only `data.getToken(id, chessId)` is not). + */ + tokenDef(tokenId, owner = null) { + return this._tokenDef(tokenId, owner, null); + } + + /** + * Whether an operator's selected skill / module produces `tokenId` (DESIGN §16, DATA.md §14 `sources`): false only + * when the owner's own tokens.json variant says so (`sources` without 'skill' / 'talent' — e.g. 琳琅诗怀雅 S1/S3 make + * no 香槟炸弹, 赫默 S1 no 医疗探机); true when the data does not tell (no owner variant, inline defs, tokens of + * other chess). + */ + producesToken(owner, tokenId, def = null) { + if (!owner || typeof owner !== 'object' || owner.kind !== 'op') return true; + const d = def ?? this.tokenDef(tokenId, owner); + const src = d && Array.isArray(d.sources) ? d.sources : null; + if (!src || src.includes('skill') || src.includes('talent')) return true; + const vs = this.data.rawToken?.(tokenId)?.variants; + const id = String(owner.defId ?? ''); + return !(vs && typeof vs === 'object' && (vs[id] || vs[id.replace(/_b$/, '_a')])); + } + + /** + * Spawn (and deploy) a token. `owner` = ally unit or playerId. Returns the token unit or null. The def is the owner's + * variant for its selected skill / module (`opts.def` = an inline def instead). While an operator runs a NON-default + * skill, a summon that skill does not produce (producesToken) is refused unless `opts.anySource`: hand-authored kit + * install hooks are written for the default skill and run under every skill (琳琅诗怀雅 S1/S3 would drop 香槟炸弹); + * under the default skill the kit stays the authority. + */ + spawnToken(owner, tokenId, row, col, opts = {}) { + const ownerUnit = owner && typeof owner === 'object' ? owner : null; + const pid = ownerUnit ? ownerUnit.ownerId : owner; + const ps = this.getPlayer(pid) ?? (ownerUnit ? ownerUnit.player : null); + const def = opts.def ? normalizeToken(tokenId, opts.def) : this._tokenDef(tokenId, ownerUnit, null); + if (!def) { this.log(`unknown token ${tokenId}`); return null; } + if (!opts.def && !opts.anySource && ownerUnit && ownerUnit.def?.loadout?.skillIsDefault === false && !this.producesToken(ownerUnit, tokenId, def)) { + this.log(`${ownerUnit.defId} (skill ${ownerUnit.def?.loadout?.skillIndex ?? '?'}) does not produce ${tokenId}`); + return null; + } + if (!Number.isInteger(row) || !Number.isInteger(col) || !this.grid.inRect(row, col)) return null; + const occ = this._occ[row * COLS + col]; + if (occ && occ.alive && !opts.force) return null; + const dir = opts.dir != null ? normDir(opts.dir) : opts.facing != null ? normDir(opts.facing) : ownerUnit ? ownerUnit.dir : ps ? ps.dir : 'RIGHT'; + const u = this._makeAlly(ps, def, 'token', row, col, { ownerUnit, dir }); + if (opts.stats) for (const [k, v] of Object.entries(opts.stats)) if (Number.isFinite(v)) u.base[k] = v; + this._setupUnit(u, opts.kit ?? null); + if (opts.untargetable) this.addBuff(u, { key: 'trait:untargetable', flags: { untargetable: true }, persist: true, allowDead: true }); + if (!this._deploy(u, { initial: false })) { + // tile still busy (a unit stands there even with `force`): drop the half-built token and its hooks + u.removed = true; + this.offOwner(u); + return null; + } + if (opts.hp != null && Number.isFinite(Number(opts.hp))) u.hp = Math.max(1, Math.min(u.s.maxHp, Number(opts.hp))); + if (opts.duration > 0 && Number.isFinite(Number(opts.duration))) this.after(Number(opts.duration), () => { if (u.alive) this.retreat(u, { reason: 'expired', permanent: true }); }, { owner: u }); + return u; + } + + /** + * Spawn a stage device (e.g. crates). opts: { hp, obstacle, obstacleKind, blockCnt, name, def, res, atk, bat, aspd }. + * An `obstacle` device is an obstacle-like tile by default (`obstacleKind` 'crate': enemies route around it at cost + * 1000 and, when it is the only way, walk into it, get blocked by it and break it — research 08 §3.1); 'block' makes + * the tile impassable. + */ + spawnDevice(key, row, col, opts = {}) { + // Devices are ally-side units on a field tile: never outside the rect, never on top of a living unit or of a + // knocked-out operator (downOn). + if (!Number.isInteger(row) || !Number.isInteger(col) || !this.grid.inRect(row, col)) return null; + const occ = this._occ[row * COLS + col]; + if (occ && occ.alive) return null; + if (this.downOn(row, col)) return null; + const hp = fin(opts.hp, 100); + const bat = fin(opts.bat, 1), aspd = fin(opts.aspd, 100); + const def = { + type: 'device', id: key, baseId: key, name: opts.name ?? key, tier: 0, golden: false, profession: 'DEVICE', subProf: null, + stats: { maxHp: hp > 0 ? hp : 100, atk: Math.max(0, fin(opts.atk, 0)), def: Math.max(0, fin(opts.def, 0)), res: Math.min(100, Math.max(0, fin(opts.res, 0))), aspd: aspd > 0 ? aspd : 100, bat: bat > 0 ? bat : 1, blockCnt: Math.max(0, fin(opts.blockCnt, 99)), cost: 0, respawnTime: 0, spRecovery: 0, tauntLevel: -1, massLevel: 0, hpRecoveryPerSec: 0 }, + rangeGrid: [[0, 0]], skill: null, talents: [], spine: key, avatar: key, + }; + const u = this._makeAlly(null, def, 'device', row, col, opts.dir != null ? { dir: opts.dir } : {}); + u.ownerId = null; + u.kit = {}; + u.profile = { noAttack: true, maxTargets: 0 }; + u.skill = new SkillRuntime(this, u, null, null, {}); + u.obstacle = !!opts.obstacle; + u.obstacleKind = opts.obstacleKind === 'block' ? 'block' : 'crate'; + u.alive = true; + u.deployed = true; + u.ground = true; + u.markDirty(); + u.hp = u.s.maxHp; + u.deploySeq = ++this._deploySeq; + u.aggroSeq = u.deploySeq; + u.deployedAt = this.time; + u.rangeKeys = []; + this._occ[row * COLS + col] = u; + if (u.obstacle) this.grid.setObstacle(row, col, true, u.obstacleKind); + this._ev(['spawn', unitInfo(u)]); + if (this._hooks.deploy) this.emit('deploy', { unit: u, initial: !this.started }); + return u; + } + + _spawnStageDevices() { + if (this.opts.devices === false) return; + for (const d of this.stage.devices || []) { + // data/stages.json `active` = present at match start (= not hidden in the 下半 level file: act1 m02 starts with no + // crates, research 08 §3.3); research stages have no `active` ⇒ `hidden` decides. + const act = d.raw && typeof d.raw.active === 'boolean' ? d.raw.active : !d.hidden && d.active !== false; + if (!act) continue; + if (d.role === 'platform' || d.role === 'mound') { + // 射击台 / mounds [ASSUMED, research 08 §8 #1]: hard-block ground movement; an operator standing on one is elevated + // (does not block) — the match lets ranged operators deploy there (deployTiles.rangedOnly). + if (!this.grid.inRect(d.row, d.col)) continue; + this.grid.setObstacle(d.row, d.col, true); + (this._elevated ??= new Set()).add(d.row * COLS + d.col); + continue; + } + const spec = OBSTACLE_DEVICES[d.key] ?? (d.role === 'crate' ? { hp: d.stats?.maxHp ?? 100, name: d.name } : null); + if (!spec) continue; + if (!this.grid.inRect(d.row, d.col)) continue; + if (this._occ[d.row * COLS + d.col]) continue; + this.spawnDevice(d.key, d.row, d.col, { hp: spec.hp, obstacle: true, name: spec.name }); + } + } +} diff --git a/server/sim/battle/tiles.js b/server/sim/battle/tiles.js new file mode 100644 index 00000000..a93ca0ae --- /dev/null +++ b/server/sim/battle/tiles.js @@ -0,0 +1,210 @@ +// server/sim/battle/tiles.js — Battle methods: field tiles: obstacles, relocation and 【移动】, knocked-out operators' +// bodies (isDown, downOn, restTile, _layBody) and the tiles no automatic placement may take, tactical points and the +// enemies' ground paths. +// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). + +import { ROWS, COLS, DOWN_STATE } from '../constants.js'; +import { localOrder, localBefore } from '../dir.js'; + +export class BattleTiles { + /** Toggle a ground obstacle (enemies re-read their flow field). `kind` 'block' (default, impassable) | 'crate'. */ + setObstacle(r, c, on, kind = 'block') { this.grid.setObstacle(r, c, on, kind); } + + /** Move an ally to another tile (keeps state); never onto a living unit or a knocked-out operator (downOn). */ + relocate(unit, r, c) { + // only a living, deployed ally moves, and only onto an integer tile of this field (a dead unit left in _occ + // would later "block" from a tile it no longer stands on once it redeploys at home) + if (!unit || unit.side !== 'ally' || !unit.alive || !unit.deployed) return false; + if (!Number.isInteger(r) || !Number.isInteger(c) || !this.grid.inRect(r, c)) return false; + const k = r * COLS + c; + if (this._occ[k] && this._occ[k] !== unit && this._occ[k].alive) return false; + if (this.downOn(r, c)) return false; + const ok = unit.tileR * COLS + unit.tileC; + if (this._occ[ok] === unit) this._occ[ok] = null; + this.releaseBlocked(unit); + if (unit.obstacle) this.grid.setObstacle(unit.tileR, unit.tileC, false, unit.obstacleKind); + unit.tileR = r; unit.tileC = c; unit.x = c; unit.y = r; + unit.ground = this.grid.isLow(r, c) && !this._elevated?.has(k); + this._occ[k] = unit; + if (unit.obstacle) this.grid.setObstacle(r, c, true, unit.obstacleKind); + this._refreshRange(unit); // current + base range, CUSTOM_RANGE trigger keys + return true; + } + + /** + * 【移动】 (PRTS 术语释义 移动: "不退场,以当前血量在目标位置部署 … 本质上为一次特殊的撤退-再部署行为"): `unit` leaves its + * tile (relocate: same checks, false = refused, nothing changed) and is deployed on (r, c) at once. A new deployment — + * deploySeq / aggroSeq / deployedAt (the deploy animation; once-per-deployment effects re-arm) — announced by + * `deploy` { initial: false, move: true }: deploy effects fire again ("可以通过移动行为多次触发部署时触发的效果"). Not an + * exit: no `die` / `death`, so no knock-out, no redeploy timer or cost ("因移动撤退时不会积累再部署惩罚") — 不屈 and + * 阿戈尔's revive, which act on exits "因移动之外的原因" (PRTS 阿戈尔 备注), never see it. HP, buffs and a running skill + * stay: officially nothing carries over but what the mover's skill names (乌尔比安 S3: 技能进度 and the second talent's + * stacks); the remake keeps the rest too (owner's deviation, DESIGN §22.3). `clearSp`: the 技力 is emptied before the + * deploy handlers run ("部署时将清空技力"; 乌尔比安's 【返回】 "将清空技力,但仍可以享受后续由其他效果提供的技力"). + */ + moveRedeploy(unit, r, c, { clearSp = false } = {}) { + if (!this.relocate(unit, r, c)) return false; + unit.deploySeq = ++this._deploySeq; + unit.aggroSeq = unit.deploySeq; + unit.deployedAt = this.time; + const sk = unit.skill; + if (clearSp && sk && !sk.noSkill && sk.kind !== 'passive' && !(sk.active && sk.isTimed)) { + sk.sp = 0; + sk.charges = sk.spCost <= 0 ? sk.maxCharges : 0; // (a free skill is available once per deployment: skills.js reset) + } + this._ev(['deploy', unit.id]); + if (this._hooks.deploy) this.emit('deploy', { unit, initial: false, move: true }); + return true; + } + + /** + * A tile no automatic placement may take (the 突袭 landing tile, tactical points, summon / device tiles): a living + * unit stands on it, or it is the rest tile (restTile) of an ally piece that has not deployed yet or waits to + * redeploy — the tile a knocked-out operator lies on ("倒地干员所在地块…所有我方单位在此处的部署行为将被阻止", PRTS + * 卫戍协议/帮助 §作战阶段 单位部署), else its home tile (a token parked there would keep that unit off the field). + */ + isReservedTile(r, c) { + if (!Number.isInteger(r) || !Number.isInteger(c) || r < 0 || r >= ROWS || c < 0 || c >= COLS) return true; + const u = this._occ[r * COLS + c]; + if (u && u.alive) return true; + for (const a of this.allyUnits) { + if (a.alive || a.removed || (a.kind !== 'op' && a.kind !== 'token')) continue; + const [tr, tc] = this.restTile(a); + if (tr === r && tc === c) return true; + } + return false; + } + + /** + * The knocked-out operator (isDown) lying on (r, c), other than `except`, or null. Official (PRTS 卫戍协议/帮助 §作战阶段 + * 单位部署): "干员退场后…原地留下一个“倒地干员”…满足再部署条件时,移除场上的该倒地干员并自动部署至该位置" and "倒地干员所在 + * 地块视为可部署,但所有我方单位在此处的部署行为将被阻止" — its tile (`body`, _layBody) takes no other ally: `_deploy` + * (initial deploys, redeploys, the 突袭 landing, summons), `spawnDevice` and `relocate` refuse it. + */ + downOn(r, c, except = null) { + for (const a of this.allyUnits) { + if (a === except || !this.isDown(a)) continue; + const [br, bc] = a.body ?? [a.tileR, a.tileC]; + if (br === r && bc === c) return a; + } + return null; + } + + /** + * The tile a withdrawn ally comes back on (redeploy, _checkRedeploys): a down operator's body tile (isDown) — where it + * fell, or its home (_layBody) — else its home tile. + */ + restTile(u) { + return this.isDown(u) ? (u.body ? [u.body[0], u.body[1]] : [u.tileR, u.tileC]) : [u.homeR, u.homeC]; + } + + /** + * Where a knocked-out operator lies (it redeploys there): the tile it fell on, except — "若干员被击倒的位置为其他干员或 + * 召唤物的初始位置,则在被击倒后,尝试返回其自身的初始位置" (PRTS 卫戍协议/帮助) — a tile that is another board piece's + * home (a piece with a board uid: the 初始位置 is the prep placement — whether that piece is on the field, waiting or + * gone: a summon only leaves its home free once it has expired or been killed, so a removed one must count too), where + * it goes back to its own home tile when that is in the rect and free (isReservedTile: no living unit, no other body, + * no other waiting piece's tile). It stays where it fell when its home is taken [ASSUMED: one attempt, at the + * knock-out]. Only an operator moved off its board tile — a 突袭 jump, 乌尔比安's anchor — can fall elsewhere. Sets + * `u.body` (b.snap `down` carries it); x / y / tileR / tileC keep where it fell, so the `kill` / `death` handlers + * (被击倒时 effects) still act there. + */ + _layBody(u) { + const r = u.tileR, c = u.tileC, hr = u.homeR, hc = u.homeC; + u.body = [r, c]; + if (r === hr && c === hc) return; + if (!this.allyUnits.some((a) => a !== u && a.uid != null && (a.kind === 'op' || a.kind === 'token') && a.homeR === r && a.homeC === c)) return; + if (!this.grid.inRect(hr, hc) || this.isReservedTile(hr, hc)) return; + u.body = [hr, hc]; + } + + /** + * Tactical point (战术点) for tactician reinforcements: a free (isReservedTile) walkable ground tile of the unit's + * initial range (baseRangeKeys), on an enemy ground path first (groundPathTiles — where a player would put the + * blocker), then nearest to the unit (Chebyshev; the smaller sideways offset — in the unit's facing-RIGHT frame, so + * its forward line — breaks ties), then the lowest local (row, col) offset (sim/dir.js localOrder: for a RIGHT-facing + * unit exactly the lowest tile key). The choice turns with the unit's direction. null when none. + */ + findTacticalPoint(unit) { + if (!unit) return null; + const onPath = this.groundPathTiles(); + let best = null, bp = 2, bd = Infinity, bo = null; + for (const k of unit.baseRangeKeys || unit.rangeKeys || []) { + const r = (k / COLS) | 0, c = k % COLS; + if (!this.grid.groundPassable(r, c) || !this.grid.canStand(r, c)) continue; + if (this.isReservedTile(r, c)) continue; + const o = localOrder(r - unit.tileR, c - unit.tileC, unit.dir); + const d = Math.max(Math.abs(r - unit.tileR), Math.abs(c - unit.tileC)) + 0.01 * Math.abs(o[0]); + if (!(d > 0)) continue; + const p = onPath.has(k) ? 0 : 1; + if (p < bp || (p === bp && (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && localBefore(o, bo))))) { bp = p; bd = d; bo = o; best = [r, c]; } + } + return best; + } + + /** + * Ground tiles the enemies of this field walk on: the flow-field routes (grid.findPath, live obstacles) of the + * non-FLY routes this battle's wave spawns on (every non-FLY route when no queued spawn names one — e.g. content-only + * battles), leg by leg (start → MOVE/APPEAR checkpoints → end, clamped to the rect). R1–R3 spawn at the lower gate + * only (research 08 §4.1), so the unused upper lane does not attract tactical points. Set of tile keys, cached per + * grid version. + */ + groundPathTiles() { + const ver = this.grid.version ?? 0; + const used = (this._pathRoutesUsed ??= new Set()); + const before = used.size; + for (const p of this._pending || []) if (!p.route && Number.isInteger(p.routeIndex)) used.add(p.routeIndex); + if (this._pathTiles && this._pathTilesVer === ver && used.size === before) return this._pathTiles; + const set = new Set(); + const R = this.rect; + const clampPt = (p) => [Math.max(R.r0, Math.min(R.r1, Math.round(p[0]))), Math.max(R.c0, Math.min(R.c1, Math.round(p[1])))]; + const ok = (p) => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]); + const walk = (route) => route && route.motion !== 'FLY' && ok(route.start); + const routes = this.routes || []; + const any = [...used].some((i) => walk(routes[i])); + routes.forEach((route, i) => { + if (!walk(route) || (any && !used.has(i))) return; + const pts = [clampPt(route.start)]; + for (const cp of route.checkpoints || []) if ((cp.type === 'MOVE' || cp.type === 'APPEAR') && ok(cp.pos)) pts.push(clampPt(cp.pos)); + if (ok(route.end)) pts.push(clampPt(route.end)); + for (let j = 1; j < pts.length; j++) { + const [a, b] = [pts[j - 1], pts[j]]; + const p = this.grid.findPath(a[0], a[1], b[0], b[1]) || this.grid.findPath(a[0], a[1], b[0], b[1], { ignoreObstacles: true }); + for (const [r, c] of p || []) set.add(r * COLS + c); + } + }); + this._pathTiles = set; + this._pathTilesVer = ver; + return set; + } + + /** + * An operator lying on the field, waiting to redeploy on that tile (DESIGN §5.5: after its respawn time, when the tile + * is free and DP ≥ cost; _layBody, downOn). PRTS 卫戍协议/帮助 §作战阶段 单位部署: "干员退场后,将返回隐藏的待部署区,并原地 + * 留下一个“倒地干员”以供查看信息,满足再部署条件时,移除场上的该倒地干员并自动部署至该位置。" — every 退场 (GitHub #60, the owner's + * decision of 2026-10-04): knocked out ('killed'), entering the battle knocked out (FORCED_EXIT, 联防) and forced out + * by its own effects ('retreat': 史尔特尔's 余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗, 伊内丝 S3; 'merchant': a 商人 that cannot + * pay) — except the 突袭 retreat ('raid', redeployed at once on its landing tile); not removed for good, after it was + * deployed. A forced exit stays no kill: its 'die' / `death` reason is not 'killed' (no 被击倒 effect, 不屈, 阿戈尔, no + * knock-down count). The client keeps its model on that tile knocked down with a redeploy countdown (b.snap `down`, + * render/units.js); summons, devices and enemies simply leave. + */ + isDown(u) { + return !!u && u.side === 'ally' && u.kind === 'op' && !u.alive && !u.removed && u.removeReason !== 'raid' + && u.deploySeq > 0 && Number.isFinite(u.respawnAt); + } + + /** + * constants.js DOWN_STATE of a down operator: its timer runs, or it waits for its tile / the DP — the same checks as + * _checkRedeploys (a living unit or another body on its rest tile). No ally may take the tile it lies on (downOn), so + * WAIT_TILE is a safeguard only. + */ + _downState(u) { + if (this.time + 1e-9 < u.respawnAt) return DOWN_STATE.COUNTING; + const [r, c] = this.restTile(u); + const occ = this._occ[r * COLS + c]; + if ((occ && occ.alive && occ !== u) || this.downOn(r, c, u)) return DOWN_STATE.WAIT_TILE; + const ps = this.getPlayer(u.ownerId); + return !ps || ps.dp + 1e-9 < u.base.cost ? DOWN_STATE.WAIT_DP : DOWN_STATE.COUNTING; + } +} diff --git a/server/sim/battle/util.js b/server/sim/battle/util.js new file mode 100644 index 00000000..22b68cdf --- /dev/null +++ b/server/sim/battle/util.js @@ -0,0 +1,12 @@ +// server/sim/battle/util.js — small helpers shared by the Battle method modules (server/sim/battle/*.js). + +/** Finite number or the default (content may pass undefined/NaN/Infinity/strings to helpers). */ +export const fin = (v, d) => { const n = typeof v === 'number' ? v : (v == null || v === '' ? NaN : Number(v)); return Number.isFinite(n) ? n : d; }; + +export function clone(v) { + if (v == null || typeof v !== 'object') return v; + if (Array.isArray(v)) return v.map(clone); + const o = {}; + for (const k of Object.keys(v)) o[k] = clone(v[k]); + return o; +} diff --git a/server/sim/constants.js b/server/sim/constants.js index e6229ace..3308a55d 100644 --- a/server/sim/constants.js +++ b/server/sim/constants.js @@ -38,7 +38,7 @@ export const BLOCK_RADIUS_SQ = Object.freeze({ ground: 0.49999037, fly: 0.799951 /** * An enemy's 隐匿 after a block ends (s): PRTS 作战机制 §隐匿 "对于绝大部分可隐匿的敌人而言,在被我方单位阻挡后会解除隐匿,不被 * 阻挡的3秒后重新进入隐匿" / §隐匿与Buff的关系 "阻挡状态解除后3s开关重新被开启而恢复隐匿". An enemy page's "(解除阻挡N秒后 - * 恢复)" overrides it per 隐匿 source (buff `data.stealthRestore`: content/enemies.js). Battle._stealthSwitch; our + * 恢复)" overrides it per 隐匿 source (buff `data.stealthRestore`: content/enemies/helpers.js STEALTH_RESTORE_BY_KEY). Battle._stealthSwitch; our * operators' 隐匿 / 迷彩 are never lifted by blocking ("我方干员并不会因为阻挡而解除隐匿"). */ export const STEALTH_RESTORE = 3; @@ -48,7 +48,7 @@ export const PROJECTILE_SPEED = 12; * Projectile speeds per visual kind (tiles/s). `none`/`beam` are instant. `boomerang` (回环射手 跃跃) is the OUTBOUND * flight to the target — PRTS 跃跃 特性 note "投射物飞行速度15,返回时飞行速度3.75"; the way back is * BOOMERANG_RETURN_SPEED (ai.js throwBoomerang). `droneBomb` = 暴鸰's bomb (the official projectile_bombd `_speed` 5; - * content/enemies.js kitBombd). + * content/enemies/fly.js kitBombd). */ export const PROJECTILE_SPEEDS = Object.freeze({ arrow: 14, bolt: 11, bomb: 8, lob: 8, orb: 10, drone: 16, enemy: 10, boomerang: 15, droneBomb: 5 }); /** 回环射手: speed (tiles/s) of a boomerang flying back from its hit point to its thrower (PRTS "返回时飞行速度3.75"). */ diff --git a/server/sim/content/bosses.js b/server/sim/content/bosses.js index 66c22709..f6810972 100644 --- a/server/sim/content/bosses.js +++ b/server/sim/content/bosses.js @@ -58,7 +58,7 @@ // 掷锤 pick their operator "(无视无法选择)"), the 盲信之誓 chains ("无视无法选择"), the 法术护盾 counter on its attacker // (a direct pick) and the ticks of a debuff already on it (【自然涌动】: a tick selects nobody). // Every area effect of a leader or part — pulses, strikes around an echo, blasts, charges and tramples, crosses, columns, -// whole-field skills — selects with enemies.js areaAllies / areaAlliesInTiles / fieldAllies (targeting.js +// whole-field skills — selects with enemies/helpers.js areaAllies / areaAlliesInTiles / fieldAllies (targeting.js // areaSelectable): no 隐匿 operator, the one blocking the unit included (GitHub #97), no untargetable or sleeping one, no 起飞 one for a // ground unit; 迷彩 is not checked (splash-type, 中点判定 / 格子判定 or "无视迷彩" on PRTS; the rest [ASSUMED], DESIGN // §22.12). Only 【盲信之誓】 ("无视无法选择、迷彩") takes everyone on its lines. diff --git a/server/sim/content/devices.js b/server/sim/content/devices.js index 221f5269..62f5d68b 100644 --- a/server/sim/content/devices.js +++ b/server/sim/content/devices.js @@ -375,7 +375,7 @@ function enterTerrain(battle, st, u, code) { * unit that already carries it gets its full `duration` back and keeps its per-second rhythm — no second effect, no * extra tick [ASSUMED: the time counts from the last contact — so an operator deployed on it, always in contact, drains * past `duration`]. An operator moved off the tile (Battle.relocate: 夕's 小自在 …; Battle.moveRedeploy: 乌尔比安 S3) - * keeps it for its time; leaving the field drops it with every buff; a 重生 clears it (enemies.js rebirthCleanse: PRTS + * keeps it for its time; leaving the field drops it with every buff; a 重生 clears it (enemies/archetypes.js rebirthCleanse: PRTS * 特殊机制 §重生 "清空自身身上除白名单外所有Buff") and contact gives it again while the unit is on the tile [ASSUMED]. The * tick (infectionDamage) is true damage no unit deals (无来源), tagged 'terrain' = 环境伤害 (PRTS 自然环境 lists 活性源石), * not 'dot' [ASSUMED: PRTS 伤害分类's list of BUFF damage does not name it]. diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 934e9815..37ac3fa9 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -4,8 +4,8 @@ // bosses.js calls it too) and attaches an ability list to every spawned enemy whose key has a kit in KITS. // Every number comes from the enemy's talent / skill blackboard (data/enemies.json, merged with the wave template's // `overrides[key].talents/skills` — the engine only applies `overrides.stats`). Numbers that exist nowhere in the -// data are named constants below, marked [ASSUMED] or sourced from the official term table (gamedata_const -// termDescriptionDict) / PRTS. +// data are named constants (enemies/helpers.js when several kit families use them, else in the family's file), marked +// [ASSUMED] or sourced from the official term table (gamedata_const termDescriptionDict) / PRTS. // // Runtime model: `enemy.mem.ab = { key, t (talent bb), tS (talent bbStr), sk {prefabKey → {cd, icd, sp, bb, bs}}, // list [abilities], times, hitShield, atkType, immune, … }`. An ability is a plain object with optional methods @@ -54,7 +54,7 @@ // 【国度】, the chimera's 源石污染区 [ASSUMED]) reach every ally. // // Special types (factions.json): -// FLY — engine (FLY motion, ranged-only targeting). Flyer kits below (御4, 护障, 寒霜, 萨科塔之翼/眼, 黑云 …). +// FLY — engine (FLY motion, ranged-only targeting). Flyer kits in enemies/fly.js (御4, 护障, 寒霜, 萨科塔之翼/眼, 黑云 …). // TIMES 频次 — "需要N次伤害击倒": maxHp := N hits; engine buff flag hitCount — every damage instance (any type, element // bursts included) removes exactly 1 on its own DamageInfo (茶器 hitCountArts: phys removes 0); HP loss // (loseHp) bypasses the rule. The faction's enemies @@ -75,7 +75,7 @@ // The mirror rule for an ally's 隐匿 — enemy area effects skip it even when it blocks the enemy (0.1.3, GitHub #97) — is areaAllies. // 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. +// SPECIAL — mostly stats; prisoners, 穿刺手, 暴虐兵长, 镜卫, 动力装甲 … in enemies/special.js. // Every enemy key of data/enemies.json is either in KITS (this file), BOSS_KEYS (bosses.js) or STATS_ONLY (with the // reason) — pinned by test/content/enemies_bosses.test.js. Bounty (悬赏) leaders are included: multi-form ones // (巨大的丑东西, 杰斯顿, 自在, 锏, 扎罗) get their second form; event-only mechanics (芦苇, 摄影区, 狂欢时刻, 悬索桥, 唤血祭坛, @@ -91,148 +91,44 @@ // (render/app.js → UnitView.setForm, units.js FORMS); the client keeps every fx with a `form` through catch-ups and // hidden tabs (shared/protocol.js fxForm). // Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss. +// +// Code layout (0.2.0): this file installs the battle-wide dispatch hooks (ensureInstalled), runs the global +// dispatchers, builds KITS from the kit families and lists STATS_ONLY; the rest lives in server/sim/content/enemies/ — +// helpers.js constants several families use, per-battle state, ability bookkeeping (abOf / attach / dispatch, T, +// canCast …), selection and damage helpers (elem, hurt, areaAllies & friends, zone, spawnChildren, +// setForm, lpLoss, expose, absorbArts …) +// archetypes.js ability building blocks (stealth, splashAttack, deathBoom, refraction, float, reborn, husk, statue, +// artsBarrier, bleed, pollution, dmgZone, skill(), blinkForward …) and the archetype kits reused by +// several keys (kitEp, kitStealth, kitTimes, kitRefraction, kitPrisoner, kitDeathSpawn, kitStun3 …) +// invisible.js, times.js, element.js, dot.js, reflection.js, fly.js, special.js — one file per special type +// (factions.json): its kits, its kit-only constants and its part of KITS +// leaders.js bounty (悬赏) leaders: kitLeaderMisc (single form) and the multi-form kits +// Every name this file exported before the split is still exported from here. -import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE, PROJECTILE_SPEEDS, ALLY_COLLIDER_RADIUS, COLS } from '../constants.js'; -import { canTargetAlly, sortAllyTargets, aggroCmp, enemyStealthed, areaSelectable, auraSelectable } from '../targeting.js'; -import { mitigate, periodicDamage } from '../damage.js'; +import { TICK } from '../constants.js'; +import { stOf, abOf, attach, safe, dispatch, canCast, elem, alliesInTiles } from './enemies/helpers.js'; +import { INVISIBLE_KITS } from './enemies/invisible.js'; +import { TIMES_KITS } from './enemies/times.js'; +import { ELEMENT_KITS } from './enemies/element.js'; +import { DOT_KITS } from './enemies/dot.js'; +import { REFLECTION_KITS } from './enemies/reflection.js'; +import { FLY_KITS } from './enemies/fly.js'; +import { SPECIAL_KITS } from './enemies/special.js'; +import { LEADER_KITS } from './enemies/leaders.js'; -// --------------------------------------------------------------------------------------------------------------- -// constants (numbers that exist nowhere in the data) - -/** 侵蚀 → engine gauge. */ -export const EROSION = 'erosion'; -/** Official erosion burst on operators (gamedata_const termDescriptionDict ba.dt.erosion; engine constants ELEMENT.erosion.ally). */ -export const EROSION_BURST = Object.freeze({ defDown: ELEMENT.erosion.ally.defDown, physDamage: ELEMENT.erosion.ally.damage }); -/** 假想敌:淤困: element damage its host's burst spreads to the allies of the 4 tiles around (PRTS 假想敌:淤困 "附着对象元素爆发时 - * …对附着对象及周围4格内的所有我方单位…造成1000同类型元素损伤"; the host itself is in its 爆发冷却). */ -const PARASITE_SPREAD = 1000; -/** 重生 of a Revive[Trigger] knock-out before its husk (PRTS 深池逐火战士 and 假想敌:再生 天赋: "被击倒后重生,持续1s,随后变为 - * 怨恨的余烬 / 再生状态,1s内不移动且持有无敌+无法阻挡+失衡免疫"; both models' knock-out clips last 1 s). */ -export const HUSK_REBIRTH = 1; -/** 转译基底·α: "变化过程持续2s" (PRTS 天赋; the model's A_Die_B / _C / _D change clips last 2 s); the original form's - * immunities ("免疫晕眩/沉睡/寒冷/冻结/浮空/恐惧", plus 失衡免疫). */ -export const TRANSLATOR_CHANGE = 2; -const TRANSLATOR_IMMUNE = Object.freeze(['stun', 'sleep', 'cold', 'freeze', 'levitate', 'fear']); -/** Death-explosion radius when the enemy has no official radius [ASSUMED]. */ -const BOOM_RADIUS = 1.25; -/** 萨卡兹枯朽战车 秽蚀轰击: radius of the 【污染秽蚀】 it leaves at its target (PRTS 萨卡兹枯朽战车 技能 "在目标位置生成半径1.7, - * 持续10s的【污染秽蚀】" — the skill blackboard's range_radius 2.2 is its trigger range; the zone's radius is in no table). */ -const TANK_ZONE_RADIUS = 1.7; -/** 【污染秽蚀】 ticks once per second; "同名效果不叠加": a unit inside several zones takes one tick per second. */ -const POLLUTION_INTERVAL = 1; -/** "数个目标" of 假想敌:骨刺 while stealthed [ASSUMED]. */ -const ACBUNN_TARGETS = 3; -/** - * 隐匿 that comes back sooner after a block than the general 3 s (PRTS 作战机制 §隐匿 "对于绝大部分可隐匿的敌人而言…不被阻挡的 - * 3秒后重新进入隐匿"; engine STEALTH_RESTORE): the enemy pages' 天赋 "隐匿(解除阻挡N秒后恢复)" — PRTS text search - * "解除阻挡0秒后恢复" (21 enemies; these 6 are in this mode) and "解除阻挡1秒后恢复" (the two 家族灭迹人), checked - * 2026-10-03. Every other stealthy enemy here has a plain "隐匿" (3 s), the 深池逐火 embers included. 清明 / 堂皇's veil - * ("获得隐匿(解除阻挡0秒后恢复)") is kitInvisShield's. - */ -const STEALTH_RESTORE_BY_KEY = Object.freeze({ - enemy_10031_cnvsld: 0, // 业余竞演者 - enemy_10034_cnvsax: 0, // 节日爵士乐手 - enemy_9008_acbunn: 0, // 假想敌:骨刺 - enemy_2034_sythef: 0, // 流泪小子 - enemy_2034_sythef_2: 0, // 流泪小子 (its stronger copy) - enemy_1389_winbab_2: 0, // 访问团强攻冠军 - enemy_1283_sgkill: 1, // 家族灭迹人 - enemy_1283_sgkill_2: 1, // 家族暗影灭迹人 -}); -/** 假想敌:再生 shield aura radius (PRTS 假想敌:再生 天赋 "进入此形态时,使半径1.8范围内的其他敌方单位(无视其可选性)获得5层…护盾"). */ -const ACPUPP_AURA_RADIUS = 1.8; -/** 重弩突袭者 直击 reach along a row/column [ASSUMED]; the skill's length and its charge before the bolt (PRTS 直击 "蓄力1.4s后 - * 向目标方向发射1支弩箭…※技能持续2.5s"). */ -const CROSS_REACH = 6, CROSS_SKILL = 2.5, CROSS_CHARGE = 1.4; -/** 暴鸰 投弹: the target's tile and its 8 neighbours (PRTS "对目标及其周围八格的我方单位造成100%物理伤害"). */ -const BOMB_REACH = 1; -/** 暴鸰 投弹: the bomb leaves on the OnAttack event of the cast's Attack clip (the official battle prefab's Boomb ability: - * animKey Attack, `_waitForAttackEvent`; the skeleton's OnAttack is on frame 8 of the 30 fps clip — data/assets.json - * hits.Attack 0.267, rounded — so exactly 8 sim ticks). It then flies as projectile_bombd (`_speed` 5 = - * PROJECTILE_SPEEDS.droneBomb, homing, `_ignoreCamouflage`). */ -export const BOMBD_RELEASE = 8 / 30; -/** 暴鸰 投弹: the cast ends once the bomb has landed and no sooner than this after the release (the Boomb ability: - * `_fireAttackFinishWhenProjectileInvalid` 1, `_minPostDelayWhenProjectileInvalid` 0.667, `_waitForAnimEndWhenProjectileInvalid` - * 0). Then its buff bomb_s (template switch_mode_restart_fsm: mode S1 + the move-speed modifier) — PRTS "技能结束后移速最终 - * 提升至200%"; the cast's end clip is already the bomb-less Idle_2 (`_endAnimKey`). */ -export const BOMBD_POST_DELAY = 0.667; -/** 帝国炮火先兆者 shell (PRTS "普通攻击向目标所在位置发射一枚于3秒后命中的弹道,弹道对半径1.2范围内的所有我方单位造成攻击力 - * 100%的无来源物理伤害 … ※弹道始终使用缓存攻击力"): flight time and blast radius. */ -const SHELL_FLIGHT = 3, SHELL_RADIUS = 1.2; -/** 假想敌:黑云 抓取: the blackboard radius counts ×2.5 (PRTS "2.5倍可变半径": range_radius 1.5 → 3.75), at most 3 prey, - * "短暂延迟后" the 延迟吞噬 lands (delay [ASSUMED] 0.5 s); its SP (= 全弹发射 hits) caps at the data's spData.maxSp. */ -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; -/** 自行炮 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]. */ -const TORTURER_RADIUS = 1; -/** Barrel zone radius of 咸鳞汁推荐者 [ASSUMED]. */ -const BARREL_RADIUS = 1.5; -/** 碎骨's grenade (PRTS 碎骨 天赋 "未被阻挡时发射榴弹对目标及其周围八格的我方单位造成相当于攻击力26%的物理伤害,并令其在5秒内 - * 防御力下降50%"; the blackboard has only defdown.def −0.5): share of ATK on every unit hit, DEF-down duration (s). - * Until 0.1.3: the full attack on the target, 100 % on the 4 neighbours, 3 s [ASSUMED]. */ -const SKULSR_GRENADE_SCALE = 0.26, SKULSR_DEFDOWN_DUR = 5; -/** Splash radius (中点判定 around the target) of 烹泉 / 沏虹's attack (PRTS 天赋 "普通攻击对目标对及目标周围半径1.0范围内的所有 - * 我方单位造成法术普通伤害(无视迷彩,不可对空)") and of 集团军重型火炮's shell (PRTS "对目标半径1.0范围内的所有我方单位造成攻击力 - * 100%的物理伤害(此弹道会强制击中主目标,碰撞无视迷彩,不可对空)"). */ -const TEA_SPLASH_RADIUS = 1, SHELL_SPLASH_RADIUS = 1; -/** 集团军重型火炮's 【燃烧区域】: PRTS "碰撞半径1.5" with the source note "1.5倍可变半径" — ×1.5 on the blackboard's - * ProjectileBoomRange.attack@projectile_range (1). Until 0.1.3 the zone took the blackboard's 1. */ -const UACANN_ZONE_SCALE = 1.5; -/** 烹泉 / 沏虹 死亡爆炸 radius (PRTS 天赋 "死亡爆炸(爆炸半径1.25,造成攻击力100%法术溅射伤害并施加15s【烹泉减益】…)"; until - * 0.1.3 the attack range, 2). */ -const TEA_BOOM_RADIUS = 1.25; -/** “庞贝”'s self-blast while blocked (PRTS 天赋 "对<固定半径>半径1.4范围内的所有我方单位造成1000预计算的无途径法术溅射伤害(不可 - * 对空)"; the blackboard has only the damage and the 10 s; until 0.1.3: radius 1 [ASSUMED]). */ -const POMPEII_BLAST_RADIUS = 1.4; -/** “巨大的丑东西” first form: its unblocked ranged attack (PRTS 天赋 "未被阻挡时,可对半径2.5范围内的1名非飞行的我方单位进行远程攻击, - * 对目标及其周围8格内的所有我方单位造成攻击力100%的物理伤害" — the data's attack radius is 0, so it had no ranged attack until - * 0.1.3) and its self-destruct ("首次被击倒后进行持续10s的重生…:重生开始的2.17s后进行自爆,对半径3.0范围内所有我方单位造成攻击力 - * 150%的物理伤害和16s晕眩"; until 0.1.3: radius 2.5 at once [ASSUMED]). */ -const MCM_RANGE = 2.5, MCM_BOMB_DELAY = 2.17; -/** “复仇者” 【冲锋】 (PRTS 技能 "仅自身未被阻挡且存在符合条件的可选目标时可触发:选择3.0半径内位于自身下个检查点前的后续路径上(包含 - * 自身当前所在地块)的距离自身最近的我方单位…"): the search radius. The blackboard's range_radius is 1.5; PRTS's figure is the - * one used. Until 0.1.3: any unit within 1.5, 隐匿 / 迷彩 ones included. */ -const RUSH_RADIUS = 3; -/** W's C4 fuse and blast radius [ASSUMED]. */ -const C4_FUSE = 1, C4_RADIUS = 1; -/** Every Nth attack for "数次攻击后" abilities without an SP cost in the data [ASSUMED]; with a cost N = spCost + 1 - * (official 粉碎攻坚手 text for spCost 2: "攻击2次后,下一次攻击" ⇒ 3rd; 码头水手 / 澪 / 清扫小队 spCost 3 ⇒ 4th). */ -const NTH_ATTACK = 3; -export const nthOf = (s) => (s && s.sp > 0 ? s.sp + 1 : NTH_ATTACK); -/** 鼠王 【唱沙】 cross reach and 【沙狱】 "及其周围" radius [ASSUMED]. */ -const DRIFT_REACH = 1, SANDSTORM_RADIUS = 1; -/** “巨大的丑东西” self-destruct radius (PRTS 天赋 "对半径3.0范围内所有我方单位…"; 2.5 [ASSUMED] until 0.1.3). */ -const MCM_BOMB_RADIUS = 3; -/** 自在 【纬地经天】: tiles covered along the row and the column of the centre ("十字型") [ASSUMED]. */ -const XI_CROSS_REACH = 2; -/** 扎罗 (PRTS 扎罗,“狼之主” 天赋; no numbers in the data): form-2 attack radius ("进行远程攻击,普通攻击为2连击,攻击范围半径1.25"), - * 【远古威慑】 aura radius / ASPD ("自身1.5半径范围内的我方单位攻击速度-50(指定状况下生效)" — during the 重生 and in the second form). - * Until 0.1.3: 2.5 / 2.5 / −30, the aura only during the 重生 [ASSUMED]. */ -const WOLF_RANGE = 1.25, WOLF_AWE_RADIUS = 1.5, WOLF_AWE_ASPD = -50; -/** 失衡 movement speed that turns 弧光锋卫's per-interval bleed into HP per tile moved [ASSUMED]. */ -const UNBALANCE_SPEED = 5; -/** 乌顶巨角卢鲁 【角力对决】: the operator cannot be pushed (fixed tiles) ⇒ "更多伤害" multiplier [ASSUMED]. */ -const ELK_FAIL_SCALE = 2; +export { + EROSION, EROSION_BURST, HUSK_REBIRTH, nthOf, abOf, attach, T, silenced, canCast, elem, hurt, alliesInTiles, + targetsNear, allTargets, areaAllies, areaAlliesInTiles, fieldAllies, auraAllies, targetAndArea, byPriority, zone, + remainingRoute, stayRoute, spawnChildren, stepToward, setHits, hitCount, isHitCount, setForm, lpLoss, expose, + expectedFinal, absorbArts, +} from './enemies/helpers.js'; +export { TRANSLATOR_CHANGE } from './enemies/leaders.js'; +export { BOMBD_RELEASE, BOMBD_POST_DELAY } from './enemies/fly.js'; +export { HOVER_KEYS, blinkForward } from './enemies/archetypes.js'; // --------------------------------------------------------------------------------------------------------------- // per-battle state + installation -const STATE = new WeakMap(); -function stOf(b) { - let st = STATE.get(b); - if (!st) { st = { lpLoss: 0, freedAll: false, deathWatch: new Set() }; STATE.set(b, st); } - return st; -} - /** Register the global dispatch hooks once per battle (idempotent). */ export function ensureInstalled(b) { const st = stOf(b); @@ -265,64 +161,9 @@ export function ensureInstalled(b) { } export function install(battle) { ensureInstalled(battle); } + export function registerMeta() {} -// --------------------------------------------------------------------------------------------------------------- -// ability bookkeeping - -/** The enemy's ability record (created on demand). */ -export function abOf(b, e) { - if (e.mem.ab) return e.mem.ab; - const key = e.defId; - const ov = (b.enemyOverrides && (b.enemyOverrides[key] ?? b.enemyOverrides[e.def && e.def.key])) || null; - const raw = (e.def && e.def.raw) || {}; - const t = { ...((e.def && e.def.talent) || {}), ...((ov && ov.talents && ov.talents.bb) || {}) }; - const tS = { ...((raw.talents && raw.talents.bbStr) || {}), ...((ov && ov.talents && ov.talents.bbStr) || {}) }; - const sk = {}; - for (const s of (ov && Array.isArray(ov.skills) ? ov.skills : (e.def && e.def.skills) || [])) { - if (!s || s.prefabKey == null) continue; - sk[s.prefabKey] = { cd: num(s.cooldown, 0), icd: num(s.initCooldown, 0), sp: num(s.spCost, 0), bb: s.bb || {}, bs: s.bbStr || {} }; - } - e.mem.ab = { key, t, tS, sk, list: [], times: null, hitShield: 0, atkType: null, immune: null, origMaxHp: e.base.maxHp }; - return e.mem.ab; -} - -/** Attach abilities to an enemy (calls their spawn()). */ -export function attach(b, e, list) { - const ab = abOf(b, e); - for (const a of list) { - if (!a) continue; - if (a.cd != null) a.left = num(a.left, num(a.icd, 0)); - ab.list.push(a); - if (a.spawn) safe(b, e, () => a.spawn(b, e, a, ab)); - } - return ab; -} - -function safe(b, e, fn) { - try { return fn(); } catch (err) { b._handlerError('content:enemies', e, err); return undefined; } -} - -function dispatch(b, e, name, c) { - const ab = e && e.mem && e.mem.ab; - if (!ab) return false; - let any = false; - for (const a of ab.list) { - const f = a[name]; - if (!f) continue; - if (a.sil && e.s.flags.silence) continue; - try { if (f.call(a, c, b, e, a, ab)) any = true; } catch (err) { b._handlerError(`enemies:${name}`, e, err); } - } - return any; -} - -const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d); -/** First finite talent value among keys. */ -export function T(ab, ...keys) { for (const k of keys) { const v = ab.t[k]; if (typeof v === 'number' && Number.isFinite(v)) return v; } return undefined; } - -export function silenced(e) { return !!e.s.flags.silence; } -export function canCast(e, sil) { return e.alive && !e.hidden && !e.s.flags.stun && !(sil && e.s.flags.silence); } - // --------------------------------------------------------------------------------------------------------------- // global dispatchers @@ -429,2700 +270,18 @@ function onTick(b, dt) { } // --------------------------------------------------------------------------------------------------------------- -// helpers (exported for bosses.js) - -/** Element damage (erosion mapped onto the engine gauge); `ignoreSelect` / `tags` as hurt(). */ -export function elem(b, src, tgt, el, amount, { ignoreSelect = false, tags = [] } = {}) { - if (!tgt || !tgt.alive || !(amount > 0)) return 0; - const element = el === 'erosion' ? EROSION : el; - return b.dealDamage(src, tgt, { type: 'element', element, amount, ignoreSelect, tags: ['enemyAbility', ...tags] }); -} - -/** - * Skill / ability damage (no dodge unless asked). `ignoreSelect` = no selection the target's 无法选择 effects stop - * (abilities that "无视无法选择", direct picks, a flying unit's blast credited to a ground leader): it also reaches an - * airborne 起飞 ally, which a ground enemy's damage otherwise skips (damage.js, targeting.js evadesGround). - */ -export function hurt(b, src, tgt, amount, type = 'phys', { canDodge = false, tags = [], isSkill = true, ignoreSelect = false } = {}) { - if (!tgt || !tgt.alive || !(amount > 0)) return 0; - return b.dealDamage(src, tgt, { amount, type, canDodge, isSkill, ignoreSelect, tags: ['enemyAbility', ...tags] }); -} - -/** Allies whose tile is within `n` of (r, c): 'plus' = Manhattan (周围四格), 'box' = Chebyshev (周围8格). */ -export function alliesInTiles(b, r, c, shape = 'plus', n = 1) { - const out = []; - for (const a of b.allies()) { - if (a.hidden) continue; - const dr = Math.abs(a.tileR - r), dc = Math.abs(a.tileC - c); - if (shape === 'plus' ? dr + dc <= n : Math.max(dr, dc) <= n) out.push(a); - } - return out; -} - -/** Allies within radius that enemy `e` could target (ranged rules: stealth / untargetable skipped). */ -export function targetsNear(b, e, r, { ranged = true, x = e.x, y = e.y } = {}) { - return b.alliesInRadius(x, y, r).filter((a) => canTargetAlly(e, a, ranged)); -} - -/** Every targetable ally on the field. */ -export function allTargets(b, e) { - return b.allies().filter((a) => canTargetAlly(e, a, true)); -} - -/** - * The allies an AREA effect of enemy `src` selects within `r` of (x, y) — splash, blast, area skill / status, pulse, zone - * tick, chain or bounce jump (targeting.js areaSelectable: no 隐匿 ally, the blocker included, no untargetable or sleeping ally, no 起飞 one - * for a ground `src`; 迷彩 is not checked). `src` = the enemy whose effect it is (null: none — 隐匿 still applies). A - * locked target goes first through targetAndArea. Abilities PRTS marks "无视无法选择 / 无视可选性" use b.alliesInRadius with - * `ignoreSelect` instead. - */ -export function areaAllies(b, src, x, y, r) { - return b.alliesInRadius(x, y, r).filter((a) => areaSelectable(src, a)); -} - -/** alliesInTiles for an area effect of `src` (周围四格 / 周围八格 / cross / 3×3 areas — 格子判定): areaSelectable. */ -export function areaAlliesInTiles(b, src, r, c, shape = 'plus', n = 1) { - return alliesInTiles(b, r, c, shape, n).filter((a) => areaSelectable(src, a)); -} - -/** Every ally on the field a whole-field ability of `src` selects ("对场上所有我方单位…": 【大潮】, 【斥退】 …). */ -export function fieldAllies(b, src) { - return b.allies().filter((a) => areaSelectable(src, a)); -} - -/** - * The allies within `r` of (x, y) a buff aura of enemy `src` takes (targeting.js auraSelectable: no 隐匿 ally, the blocker included, - * untargetable or sleeping ally — PRTS "隐匿状态下的单位一般无法被敌方的…Buff选择器选中"; an airborne 起飞 ally is still - * taken [ASSUMED, §21.22]). 寒霜's aura, which PRTS says ignores 隐匿, keeps b.alliesInRadius (allyAura). - */ -export function auraAllies(b, src, x, y, r) { - return b.alliesInRadius(x, y, r).filter((a) => auraSelectable(src, a)); -} - -/** - * A locked target `t` and the allies an area takes around it: the target was picked directly when the ability started - * (an attack's target, a channel's or a C4's lock), so a 隐匿 it gains meanwhile does not save it — PRTS 异常效果 "'直接选中' - * 的能力不会进行具体的目标选择,故同样不受这些仅在选择时生效的异常效果制约"; only the others are an area selection (`area`). - */ -export function targetAndArea(t, area) { - return t && t.alive && t.deployed ? [t, ...area.filter((u) => u !== t)] : area; -} - -/** Engine priority (blocker → taunt → latest deployed) on a copy. */ -export function byPriority(e, list) { return sortAllyTargets(e, list.slice()); } - -/** - * An HP-loss / damage zone. `tick(units)` runs every `iv` s for `life` s on the allies inside: `pick(x, y, r)` chooses - * them — by default every ally in the circle (【污染秽蚀】 "无视无法选择"); the other zones (dmgZone, 烹泉's steam) pass - * their area selection (areaAllies). - */ -export function zone(b, { x, y, r, life, iv = 1, kind = 'zone', tick, pick = null }) { - b.fx('zone', { x, y, r, dur: life, kind }); - let left = life; - const h = b.every(iv, () => { - tick(pick ? pick(x, y, r) : b.alliesInRadius(x, y, r)); - left -= iv; - if (left <= 1e-6) h.cancel(); - }); - return h; -} - -/** Remaining route of an enemy from its current position (for summons / phantoms that continue its path). */ -export function remainingRoute(e) { - const R = e.route; - const here = [e.y, e.x]; - if (!R || !Array.isArray(R.legs) || !R.legs.length) return { motion: e.motion === 'FLY' ? 'FLY' : 'WALK', start: here, end: here, checkpoints: [] }; - const steps = []; - let end = null; - for (let i = Math.max(0, R.legIdx); i < R.legs.length; i++) { - const L = R.legs[i]; - if (L.final) { end = [L.r, L.c]; break; } - if (L.t === 'move') steps.push({ t: 'move', p: [L.r, L.c] }); - else if (L.t === 'wait') steps.push({ t: 'wait', s: i === R.legIdx && R.waitLeft != null ? R.waitLeft : L.time }); - else if (L.t === 'disappear') steps.push({ t: 'disappear' }); - else if (L.t === 'appear') steps.push({ t: 'appear', p: [L.r, L.c] }); - } - if (!end) { const last = R.legs[R.legs.length - 1]; end = last && last.r != null ? [last.r, last.c] : [Math.round(e.y), Math.round(e.x)]; } - const route = { motion: e.motion === 'FLY' ? 'FLY' : 'WALK', start: here, end, checkpoints: [] }; - if (steps.length) route.steps = steps; - return route; -} - -/** A route that keeps an enemy in place (content moves it by hand). */ -export function stayRoute(e, secs = 99999) { - const p = [Math.round(e.y), Math.round(e.x)]; - return { motion: e.motion === 'FLY' ? 'FLY' : 'WALK', start: p, end: p, steps: [{ t: 'wait', s: secs }] }; -} - -/** - * Mods copied onto a split or summoned child. The parent's stat multipliers travel; a kill bounty does not - * (owner 2026-10-04: the main body alone carries it — GitHub #67, #89-2). `bountyId` is the 悬赏 card and - * `bountyCoins` is the coin copy the match writes for 联防. The parent object is left as it is. - */ -function modsWithoutBounty(mods) { - if (!mods || typeof mods !== 'object') return mods ?? null; - if (mods.bountyId == null && mods.bountyCoins == null) return mods; - const copy = { ...mods }; - delete copy.bountyId; - delete copy.bountyCoins; - return copy; -} - -/** Spawn `n` enemies at the parent's position that continue its route. Returns the spawned units. */ -export function spawnChildren(b, parent, key, n, opts = {}) { - const out = []; - const route = opts.route ?? remainingRoute(parent); - const cnt = Math.max(0, Math.min(20, Math.floor(n))); - const mods = modsWithoutBounty(opts.mods ?? parent.mods ?? null); - for (let i = 0; i < cnt; i++) { - const off = cnt > 1 ? (i - (cnt - 1) / 2) * 0.2 : 0; - const pos = opts.pos ?? [parent.y, parent.x + off]; - const c = b.spawnEnemy(key, { - pos, route, mods, tag: opts.tag ?? null, countInTotal: opts.countInTotal, - ownerPlayerId: parent.ownerId ?? null, sourcePlayerId: parent.sourcePlayerId ?? null, - }); - if (c) out.push(c); - } - if (out.length) b.fx('summon', { x: parent.x, y: parent.y, id: parent.id, key, n: out.length }); - return out; -} - -/** Move a unit straight toward (tx, ty) by `dist` tiles. Returns true on arrival. */ -export function stepToward(u, tx, ty, dist) { - const dx = tx - u.x, dy = ty - u.y; - const d = Math.hypot(dx, dy); - if (d <= dist + 1e-9) { u.x = tx; u.y = ty; return true; } - u.x += (dx / d) * dist; - u.y += (dy / d) * dist; - return false; -} - -/** Set a 频次 unit's hits (maxHp := hits, full). */ -export function setHits(e, n) { - const h = Math.max(1, Math.round(n)); - e.base.maxHp = h; - e.markDirty(); - void e.s; - e.hp = h; -} - -const HIT_COUNT_KEY = 'ab:hitCount'; -/** 频次 on/off: every damage instance removes exactly 1 HP (engine flag; `artsOnly` = 茶器, physical instances remove 0). */ -export function hitCount(b, e, on, artsOnly = false) { - if (!on) { b.removeBuff(e, HIT_COUNT_KEY); return; } - b.addBuff(e, { key: HIT_COUNT_KEY, persist: true, flags: artsOnly ? { hitCountArts: true } : { hitCount: true } }); -} -export const isHitCount = (e) => !!e.findBuff(HIT_COUNT_KEY); - -/** - * The enemy's model takes another form (render/units.js FORMS: a clip set of its skeleton): kept on the unit - * (`e.form`, published by snapshot.js unitInfo — a view built mid-battle from fieldMeta(): a teammate's field watched - * later, 联防 observers, a reconnect — starts in it) and announced by the fx `fxKind` (+ id, x, y, `params`) carrying it - * as `form` (a 'phase' fx also as its `kind`). The `form` key marks the fx as state (shared/protocol.js fxForm): the - * client never drops it, not in a catch-up frame nor while its tab is hidden. Barrier / charge 'phase' kinds are no - * forms: they go through b.fx directly, without `form`. - */ -export function setForm(b, e, form, fxKind = 'phase', params = null) { - e.form = form; - b.fx(fxKind, fxKind === 'phase' ? { ...params, x: e.x, y: e.y, id: e.id, kind: form, form } : { ...params, x: e.x, y: e.y, id: e.id, form }); -} - -/** Leader "扣除目标生命" effects: recorded for the match (hook 'lpLoss' + result.lpLoss). */ -export function lpLoss(b, amount, reason, source = null) { - if (!(amount > 0)) return; - const st = stOf(b); - st.lpLoss += amount; - b.fx('lpLoss', { x: source ? source.x : 0, y: source ? source.y : 0, value: amount, reason }); - b.emit('lpLoss', { amount, reason, source }); -} - -/** 暴露: damage taken ×scale for `dur` s. */ -export function expose(b, u, dur, scale) { - if (!u || !u.alive || !(dur > 0)) return; - b.addBuff(u, { key: 'ab:exposed', duration: dur, refresh: 'extend', mods: { dmgTakenMul: scale }, visible: true }); - b.fx('expose', { x: u.x, y: u.y, id: u.id }); -} - -/** - * Final HP damage the pipeline would deal for a phys/arts/true/elemental `dmg` before shields (mitigation is linear in - * the amount) — the same terms as damage.js dealDamage (元素伤害: 元素抗性 + elementalTakenMul, no dmgTakenMul). - */ -export function expectedFinal(src, tgt, dmg) { - const ts = tgt.s, ss = src && src.s && !dmg.sourceless ? src.s : null, ty = dmg.type; // 无来源 (bursts): no source stats - const v = mitigate(dmg.amount, ty, ts, { - defIgnorePct: (dmg.defIgnorePct || 0) + (ss ? ss.defIgnorePct : 0), defIgnoreFlat: (dmg.defIgnoreFlat || 0) + (ss ? ss.defIgnoreFlat : 0), - resIgnorePct: (dmg.resIgnorePct || 0) + (ss ? ss.resIgnorePct : 0), resIgnoreFlat: (dmg.resIgnoreFlat || 0) + (ss ? ss.resIgnoreFlat : 0), - elementalRes: ty === 'elemental' ? (tgt.def?.epDamageResistance ?? 0) : 0, - }); - let mul = (dmg.mul ?? 1) * (ty === 'elemental' ? 1 : ts.dmgTakenMul); // 脆弱 skips 元素伤害 (damage.js) - if (ss) mul *= ss.dmgDealtMul * (ty === 'phys' ? ss.physDealtMul : ty === 'arts' ? ss.artsDealtMul : 1); - mul *= ty === 'phys' ? ts.physTakenMul : ty === 'arts' ? ts.artsTakenMul : ty === 'elemental' ? ts.elementalTakenMul : ts.trueTakenMul; - const f = v * mul; - return Number.isFinite(f) && f > 0 ? f : 0; -} - -/** - * "吸收法术伤害的屏障": absorb up to `left` of an arts instance in a `hit` handler (after RES, like a shield) by scaling - * the instance down (cancelled when fully absorbed). Returns the amount absorbed. - */ -export function absorbArts(c, left) { - if (!c || c.dmg.type !== 'arts' || !(left > 0) || c.dmg.cancel) return 0; - const f = expectedFinal(c.source, c.target, c.dmg); - if (!(f > 0)) return 0; - const take = Math.min(left, f); - if (take >= f - 1e-9) c.dmg.cancel = true; - else c.dmg.amount *= (f - take) / f; - return take; -} - -/** Timed buff on each unit (same key never stacks: aura semantics). */ -function auraBuff(b, u, key, iv, mods, flags = null, visible = false) { - b.addBuff(u, { key, duration: iv + 0.1, refresh: 'replace', mods, flags, visible }); -} - -/** Mark an ally/enemy ability as watching every death on the field. */ -function watchDeaths(b, e) { stOf(b).deathWatch.add(e); } - -const tileOf = (u) => [Math.round(u.y), Math.round(u.x)]; -const onTerrain = (b, u, terrain) => { const [r, c] = tileOf(u); return b.grid.tile(r, c).terrain === terrain; }; - -// --------------------------------------------------------------------------------------------------------------- -// archetypes - -/** - * 隐匿: permanent stealth. Blocked it is lifted; it hides again STEALTH_RESTORE s after the block ends, or after its own - * "(解除阻挡N秒后恢复)" (STEALTH_RESTORE_BY_KEY → buff `data.stealthRestore`; Battle._stealthSwitch). - */ -const stealth = () => ({ - spawn(b, e, a, ab) { - const n = STEALTH_RESTORE_BY_KEY[ab.key]; - b.addBuff(e, { key: 'ab:stealth', flags: { stealth: true }, persist: true, data: n != null ? { stealthRestore: n } : {} }); - }, -}); -/** 无法被阻挡. */ -const unblockable = () => ({ spawn(b, e) { b.addBuff(e, { key: 'ab:unblockable', flags: { unblockable: true }, persist: true }); } }); -/** - * "受到伤害时开始奔跑,移动速度+N%" (鸭爵 `run.attack@move_speed`): the first damage it takes ⇒ move speed ×(1 + v) for good — - * PRTS 鸭爵 天赋 "移动速度+400%" for v 4, PRTS 卫戍协议:盟约 下半/PRTS盟约记录 鸭爵 备注 "受伤后移动速度+300%" for the act2 v 3. - */ -const runWhenHit = (v) => ({ taken(c, b, e, a) { if (a.on || !(v > 0)) return; a.on = true; b.addBuff(e, { key: 'ab:duckRun', persist: true, mods: { moveMul: 1 + v } }); } }); -/** 频次: maxHp = hits. */ -const times = (artsOnly = false) => ({ spawn(b, e) { setHits(e, e.def.maxHp); hitCount(b, e, true, artsOnly); } }); -/** "只能被阻挡数大于等于N的单位阻挡". */ -const blockWeight = (n) => ({ spawn(b, e) { e.blockWeight = n; } }); -const taunt = (n) => ({ spawn(b, e) { if (n) b.addBuff(e, { key: 'ab:taunt', mods: { taunt: n }, persist: true }); } }); -const maxTargets = (n) => ({ spawn(b, e) { if (n > 1) e.profile.maxTargets = n; } }); -/** Element damage on every attack hit: ATK × ratio. */ -const ep = (el, ratio, sil = false) => ({ sil, dealt(c, b, e) { if (ratio > 0) elem(b, e, c.target, el, e.s.atk * ratio); } }); -/** Status on every attack hit. */ -const onHitStatus = (key, dur, value) => ({ dealt(c, b, e) { if (dur > 0) b.applyStatus(c.target, key, { duration: dur, source: e, value }); } }); -/** - * A normal attack that splashes around its target (PRTS: "对主目标造成…普通伤害,对溅射目标造成…溅射伤害"): 碎骨's grenade, - * “巨大的丑东西” / “墓碑”'s unblocked ranged attack, 烹泉 / 沏虹's attack, 集团军重型火炮's shell. Its mode is fixed when the - * attack starts (`before`): `unblocked` = only an attack begun while not blocked splashes (the others are the plain melee - * hit at `meleeScale`). The main target takes the engine's attack at `scale` × ATK (e.profile.atkScale, a normal attack: - * on-hit effects apply); when it lands (the main hit's 'hit', before its dodge) the others around the target take - * `scale` × ATK as a splash — an area selection (areaAllies / areaAlliesInTiles: no 隐匿 ally, the blocker included - * (GitHub #97), no airborne 起飞 one for a ground enemy; 迷彩 is not checked — DESIGN §22.12). `tiles` 1 = the target's tile and - * its 8 neighbours (格子判定), else `radius` around the target (中点判定). `noAir`: "不可对空" (a flying ally — the 炎佑 - * dragon — is skipped; without it "可溅射飞行单位"). `dodge`: the splash hits may be dodged (烹泉 / 沏虹, whose PRTS 天赋 calls - * them 法术普通伤害 — a normal attack's damage); a 溅射伤害 splash cannot. `onEach(b, e, u)` runs on every unit hit (the target - * after its damage). - */ -function splashAttack({ scale = 1, meleeScale = 1, unblocked = true, tiles = 0, radius = 0, noAir = false, dodge = false, fxKind = 'splash', onEach = null } = {}) { - return { - before(c, b, e, a) { a.splash = !unblocked || !e.blockedBy; e.profile.atkScale = a.splash ? scale : meleeScale; }, - hitOut(c, b, e, a) { - const t = c.target; - if (!a.splash || !c.dmg.isAttack || !t) return; - b.fx('explode', { x: t.x, y: t.y, r: tiles > 0 ? tiles + 0.5 : radius, kind: fxKind, ...(tiles > 0 ? { tiles: 'box' } : {}) }); - const area = tiles > 0 ? areaAlliesInTiles(b, e, t.tileR, t.tileC, 'box', tiles) : areaAllies(b, e, t.x, t.y, radius); - for (const u of area) { - if (u === t || (noAir && u.isFlying)) continue; - hurt(b, e, u, e.s.atk * scale, c.dmg.type, { tags: ['splash'], canDodge: dodge }); - if (onEach && u.alive) onEach(b, e, u); - } - }, - dealt(c, b, e, a) { if (a.splash && onEach && c.target.alive) onEach(b, e, c.target); }, - }; -} -/** - * Tile keys of an enemy's path up to its next checkpoint, its own tile included (PRTS “复仇者” 冲锋 "位于自身下个检查点前的后续 - * 路径上(包含自身当前所在地块)"): the tiles its current move leg crosses — the straight segments through the leg's smoothed - * waypoints (ai.js planLeg; the last one is the leg's checkpoint), sampled every quarter tile. - */ -function pathKeysAhead(e) { - const keys = new Set([Math.round(e.y) * COLS + Math.round(e.x)]); - const R = e.route; - if (!R || !Array.isArray(R.pts)) return keys; - let x = e.x, y = e.y; - for (let i = R.ptIdx ?? 0; i < R.pts.length; i++) { - const p = R.pts[i], n = Math.max(1, Math.ceil(Math.hypot(p.x - x, p.y - y) * 4)); - for (let k = 1; k <= n; k++) keys.add(Math.round(y + ((p.y - y) * k) / n) * COLS + Math.round(x + ((p.x - x) * k) / n)); - x = p.x; y = p.y; - } - return keys; -} -/** "不会攻击飞行单位" (an engine candidate filter, ai.js enemyAttack: the 炎佑 dragon is a flying ally). */ -const noAirTargets = () => ({ spawn(b, e) { e.profile.canTarget = (u) => !u.isFlying; } }); -/** - * 抵抗 (ba.buffres, the engine `resist` status: control durations halved, 麻痹 loses 1 stack every 5 s) + optional - * immunities. Value from the talent's `Buff.one_minus_status_resistance` when present (−0.5 ⇒ half). - */ -const resist = (immune = []) => ({ - spawn(b, e, a, ab) { - const v = -(T(ab, 'Buff.one_minus_status_resistance') ?? -0.5); - b.applyStatus(e, 'resist', { source: e, value: v > 0 ? Math.min(1, v) : 0.5 }); - if (immune.length) ab.immune = new Set([...(ab.immune || []), ...immune]); - }, -}); -const immuneTo = (...keys) => ({ spawn(b, e, a, ab) { ab.immune = new Set([...(ab.immune || []), ...keys]); } }); - -/** Every Nth attack: status on the targets ("攻击2次后,下一次攻击会晕眩目标"). */ -const nthAttackStatus = (n, key, dur, sil = true) => ({ - sil, - spawn(b, e, a) { a.n = 0; }, - attack(c, b, e, a) { - a.n++; - if (a.n % n) return; - for (const t of c.targets) if (t.alive && dur > 0) b.applyStatus(t, key, { duration: dur, source: e }); - }, -}); -/** Every Nth attack deals ×scale (+ optional element). */ -const nthAttackPower = (n, scale, el = null, ratio = 0) => ({ - spawn(b, e, a) { a.n = 0; a.power = false; }, - before(c, b, e, a) { a.power = (a.n + 1) % n === 0; }, - hitOut(c, b, e, a) { if (a.power && c.dmg.isAttack) c.dmg.amount *= scale; }, - dealt(c, b, e, a) { if (a.power && el && ratio > 0) elem(b, e, c.target, el, e.s.atk * ratio); }, - attack(c, b, e, a) { a.n++; }, -}); - -/** Aura on other enemies within r: timed buff refreshed every iv s. */ -const enemyAura = (r, key, mods, sil = true, iv = 0.5) => ({ - sil, iv, - tick(b, e) { for (const o of b.enemiesInRadius(e.x, e.y, r)) if (o !== e) auraBuff(b, o, key, iv, mods); }, -}); -/** - * Aura on allies within r — every ally there, 隐匿 ones included: its one user, 寒霜, is PRTS's own example of an enemy - * debuff that "无视隐匿状态起作用" (PRTS 作战机制 §隐匿与Buff的关系: "敌方寒霜的攻速下降Debuff"). - */ -const allyAura = (r, key, mods, sil = true, iv = 0.5, flags = null) => ({ - sil, iv, - tick(b, e) { for (const u of b.alliesInRadius(e.x, e.y, r)) auraBuff(b, u, key, iv, mods, flags, true); }, -}); - -/** Self ATK buff once HP first drops below ratio. */ -const lowHpBuff = (ratio, mods, key = 'ab:lowhp') => ({ - taken(c, b, e, a) { - if (a.on || !(e.hpRatio < ratio)) return; - a.on = true; - b.addBuff(e, { key, mods, persist: true, visible: true }); - }, -}); - -/** Death spawn ("被击倒后生成N个"). */ -const deathSpawn = (key, cnt, extra = {}) => ({ - death(c, b, e) { if (c.reason === 'killed' && key && cnt > 0) spawnChildren(b, e, key, cnt, extra); }, -}); - -/** - * Death explosion on the allies within r it can select (areaAllies — PRTS 高能源石虫 / 冰爆源石虫 / 卷心籽 死亡爆炸 "…无视迷彩, - * 不可对空", no 无视无法选择: a 隐匿 ally is spared; the dead enemy blocks nobody). - */ -const deathBoom = ({ scale, type = 'phys', r = BOOM_RADIUS, status = null, sil = true, cond = null }) => ({ - sil, - death(c, b, e, a) { - if (c.reason !== 'killed' || (cond && !cond(e, a))) return; - const atk = e.s.atk; - b.fx('explode', { x: e.x, y: e.y, r, kind: 'deathBoom', id: e.id }); - for (const u of areaAllies(b, e, e.x, e.y, r)) { - if (scale > 0) hurt(b, e, u, atk * scale, type); - if (status && u.alive) b.applyStatus(u, status.key, { duration: status.dur, source: e, value: status.value }); - } - }, -}); - -/** 折射: RES +v (and optional max HP +hpPct) while not silenced. */ -const refraction = (res, hpPct = 0) => ({ - silAware: true, // SILENCE-flagged: silence switches it off in tick() - tick(b, e, a) { - const on = !e.s.flags.silence; - if (on === a.on) return; - a.on = on; - if (on) b.addBuff(e, { key: 'ab:refraction', mods: hpPct ? { resFlat: res, hpPct } : { resFlat: res }, persist: true, visible: true }); - else b.removeBuff(e, 'ab:refraction'); - }, -}); - -/** - * 近地悬浮 (ba.float "无法被阻挡或近战攻击"; PRTS 术语释义: "此类效果开始时,单位强制解除阻挡,随后行动方式变为飞行(算作空中 - * 单位)… 无论飞行与否,单位只会采用地面寻路"): while floating the enemy carries the 'ab:float' buff — unblockable, an air unit for - * every targeting / ground-only rule (flag `float` ⇒ Unit.isFlying: melee operators, 迷迭香's ground-only shots and - * splash, ground traps and terrain skip it; ranged attacks hit it) with 失衡免疫 (`noDisplace`, "初始模式:近地悬浮,失衡 - * 免疫" on the enemies' PRTS pages) — its `motion` stays WALK, so it keeps the ground path (and 浮空 can still lift it: - * Battle.applyStatus refuses data flyers only). Losing the float is each - * enemy's own ability (PRTS; 缚地 "使部分近地悬浮敌人掉落" is "实为敌人自身的能力"): kitSyufo, kitParrot. 喷气人's 飞行模式 is - * the same state for 7 s (its 'ab:takeoff' buff carries the flags; kitLeaderMisc). - */ -const FLOAT_KEY = 'ab:float'; -const setFloat = (b, e, on) => { - if (on) b.addBuff(e, { key: FLOAT_KEY, flags: { unblockable: true, float: true, noDisplace: true }, persist: true }); - else b.removeBuff(e, FLOAT_KEY); -}; -const float = () => ({ spawn(b, e) { setFloat(b, e, true); } }); -/** The enemies whose kit spawns them hovering (float()): the match's bot counts them as air units (bot.js fieldModel). */ -export const HOVER_KEYS = Object.freeze(['enemy_2025_syufo', 'enemy_10045_parrot']); - -/** - * "生命值首次降至一半以下时,在数秒内陷入恐惧" (SelfFear; PRTS “萨科塔之翼/之眼/昂首” "生命值首次低于50%时,对自身施加持续5s的恐惧, - * 在5s内移动速度最终提升至150%"). The fear's source is the enemy itself, so it has no 恐惧可达地块: for SelfFear.fear s it - * flies to random points of its own tile (±0.25) at ×move_speed — the engine's 恐惧 movement (fear.js; user playtest #6 - * item 13, "小范围乱飞") — then flies on along its route. - */ -const selfFear = (ab) => ({ - taken(c, b, e, a) { - if (a.done || !(e.hpRatio < 0.5)) return; - a.done = true; - const fear = T(ab, 'SelfFear.fear') ?? 0; - if (fear > 0) b.applyStatus(e, 'fear', { duration: fear, source: e }); - // PRTS “萨科塔之翼/之眼”: "在5s内移动速度最终提升至150%" — move_speed 1.5 is the final multiplier - const ms = T(ab, 'SelfFear.move_speed') ?? 0, sd = T(ab, 'SelfFear.speed_duration') ?? 0; - if (ms > 0 && sd > 0) b.addBuff(e, { key: 'ab:fearRun', duration: sd, mods: { moveMul: ms }, flags: { unblockable: true } }); - }, -}); - -/** Prisoners (禁锢 → 解放). */ -function prisoner(ab, { freeAll = false } = {}) { - const t = (k) => T(ab, k) ?? 0; - const liberate = (b, e, a) => { - if (a.free || !e.alive) return; - a.free = true; - b.removeBuff(e, 'ab:confined'); - b.addBuff(e, { key: 'ab:liberty', persist: true, visible: true, mods: { - atkPct: t('liberty.atk'), defIgnorePct: t('liberty.def_penetrate'), resFlat: t('liberty.magic_resistance'), hpRegen: t('liberty.hp_recovery_per_sec') } }); - b.fx('liberate', { x: e.x, y: e.y, id: e.id }); - if (freeAll && !stOf(b).freedAll) { - // "第一次解放时同时解放全场敌人" - stOf(b).freedAll = true; - for (const o of b.aliveEnemies()) for (const x of (o.mem.ab ? o.mem.ab.list : [])) if (x.liberate && !x.free) x.liberate(b, o, x); - } - }; - return { - liberate, - spawn(b, e, a) { - a.n = 0; a.free = false; - b.addBuff(e, { key: 'ab:confined', persist: true, visible: true, mods: { aspd: t('confinement.attack_speed'), defFlat: t('confinement.def') } }); - }, - attack(c, b, e, a) { if (!a.free && ++a.n >= (T(ab, 'confinement.times') ?? 4)) liberate(b, e, a); }, - }; -} - -/** Free every confined prisoner on the field (重犯's first liberation, 杰斯顿's killer form). */ -function freeAllPrisoners(b) { - for (const o of b.aliveEnemies()) for (const x of (o.mem.ab ? o.mem.ab.list : [])) if (x.liberate && !x.free) x.liberate(b, o, x); -} - -/** - * 重生 "清空自身身上除白名单外所有Buff" (PRTS 特殊机制 §重生): at a 重生 (reborn(), husk(), statue()) the enemy loses every - * buff an operator / summon / device put on it and every source-less catalogue status (晕眩, 减速, 恐惧, 脆弱, 诱导 … — - * buffs.js STATUS). Kept [ASSUMED: the whitelist]: its talents and traits (`persist` buffs, and what it or another enemy - * gave it — 锏's 抵抗 is a self-applied status, enemy auras refresh every few tenths of a second anyway) and the field's - * state buffs without a source (on-tile terrain, airflow — re-applied by position — and element burst locks). Without it - * a 逐火 knocked out while feared (叙拉古 / 妮芙: 恐惧 makes it unblockable) stayed unblockable — so, 隐匿, untargetable — - * as an ember until the fear ran out. 活性源石's lasting effect (devices.js touchInfection) is no field state but a timed - * buff that outlives the tile, so it is cleared like any buff (PRTS 特殊机制 非首次标记: "如无特殊说明,也默认同常规Buff一样 - * 可被重生清除"); contact gives it again while the enemy stands on the tile. Kept, its 1 s ticks — each a hit of a husk's - * 特殊生命值 — killed a 逐火 ember that had crossed the tile long before it could stand up (review of GitHub #33 item 6). - */ -/** - * 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); -} - -/** devices.js' lasting 活性源石 effect: a timed buff, not field state — cleared at a 重生 (rebirthCleanse). */ -const INFECTION_BUFF = 'terrain:infection'; - -function rebirthCleanse(b, e) { - for (const x of e.buffs.slice()) { - if (x.persist || (x.source && x.source.side === 'enemy')) continue; - if (x.source || x.status || x.key === INFECTION_BUFF) b.removeBuff(e, x); - } -} - -/** - * "首次被击倒后重生 / 进入第二形态": the first knock-out is hidden (kill credit and bounty wait for the real death) and starts - * a rebirth of `dur` s — statuses cleared (rebirthCleanse), invulnerable, untargetable, released by its blocker, inert - * (no move / attack / skill). - * `onKo(b, e, a)` runs at once (self-destruct, freeing prisoners …), `during(b, e, a, elapsed)` every tick of it; then the - * enemy stands up with `hpRatio` of its max HP, `onReborn(b, e, a)` switches the form and `invincible` s of 无敌 follow. - * `a.state`: undefined → 'reborn' → 'form2'. The 'telegraph' fx of the knock-out carries `form: 'reborn'` and the 'revive' - * fx `form: 'form2'`: models with those clip sets (render/units.js FORMS — 锏, 扎罗, “复仇者”, 杰斯顿) play them. - */ -function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = null, onReborn = null, key = 'ab:reborn' } = {}) { - const finish = (b, e, a) => { - if (!e.alive || a.state !== 'reborn') return; - a.state = 'form2'; - b.removeBuff(e, key); - e.profile.noAttack = a.noAtk; - if (onReborn) safe(b, e, () => onReborn(b, e, a)); - e.hp = Math.max(1, e.s.maxHp * Math.max(0, Math.min(1, hpRatio))); - 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 { - killed(c, b, e, a) { - if (a.state) return false; - a.state = 'reborn'; - a.t0 = b.time; - a.noAtk = e.profile.noAttack; - rebirthCleanse(b, e); - e.hp = Math.min(1, e.s.maxHp); - if (onKo) safe(b, e, () => onKo(b, e, a)); - if (!(dur > 0)) { finish(b, e, a); return true; } - e.profile.noAttack = true; - b.addBuff(e, { - key, duration: dur, visible: true, persist: true, - flags: { invulnerable: true, untargetable: true, unblockable: true, noMove: true, stun: true }, - onTick: during ? ({ battle }) => during(battle, e, a, battle.time - a.t0) : null, - onExpire: ({ battle }) => finish(battle, e, a), - }); - setForm(b, e, 'reborn', 'telegraph', { r: 1, dur, kind: 'reborn' }); - return true; - }, - }; -} - -/** 正面减伤: phys/arts damage from allies in front of the enemy (`a.facing` side) × (1 − cut); `face` updates a.facing. */ -function frontGuard(cut, face) { - return { - spawn(b, e, a) { a.facing = -1; a.px = e.x; }, - tick(b, e, a) { face(b, e, a); }, - hitIn(c, b, e, a) { - const s = c.source, ty = c.dmg.type; - if (!s || s.side !== 'ally' || (ty !== 'phys' && ty !== 'arts') || !(cut > 0)) return; - if ((s.x - e.x) * a.facing > 1e-6) c.dmg.mul *= Math.max(0, 1 - cut); - }, - }; -} -/** Faces its walking direction (horizontal component; keeps the last one while standing). */ -const faceMove = (b, e, a) => { const dx = e.x - a.px; if (Math.abs(dx) > 1e-6) a.facing = dx > 0 ? 1 : -1; a.px = e.x; }; -/** "始终面向我方干员数量较多的方向": the side with more deployed operators (ties keep the current facing; PRTS 圆仔 "无视其可选性": - * 隐匿 operators count). */ -const faceCrowd = (b, e, a) => { - let l = 0, r = 0; - for (const u of b.allies()) { if (u.kind !== 'op') continue; if (u.x > e.x + 1e-6) r++; else if (u.x < e.x - 1e-6) l++; } - if (r !== l) a.facing = r > l ? 1 : -1; -}; - -/** - * 失衡 (unbalanced movement): being pushed / pulled by operators. battle.displace() has no hook, so it is detected as - * movement beyond the enemy's own route speed between two ticks (teleport legs excluded). `onMove(b, e, a, tiles)`. - */ -function unbalanced(onMove) { - return { - tick(b, e, a, dt) { - const px = a.px, py = a.py, hid = a.hid; - a.px = e.x; a.py = e.y; a.hid = e.hidden; - if (px == null || hid || e.hidden) return; - const own = e.s.moveSpeed * MOVE_SCALE * dt * 1.5 + 1e-3; - const extra = Math.hypot(e.x - px, e.y - py) - own; - a.lx = px; a.ly = py; // where the move started (direction for onMove) - if (extra > 0.05) onMove(b, e, a, extra); - }, - }; -} - -/** An arts-only barrier ("吸收法术伤害的屏障") of `amount`; `a.left` = what is left, `refresh(b, e)` restores it. */ -function artsBarrier(amount, { key = 'ab:artsBarrier', whileUp = null } = {}) { - return { - refresh(b, e, a) { - a.left = amount; - b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'artsBarrier', value: amount }); - if (whileUp) b.addBuff(e, { key, persist: true, visible: true, mods: whileUp }); - }, - spawn(b, e, a) { if (amount > 0) a.refresh(b, e, a); }, - hitIn(c, b, e, a) { - if (!(a.left > 0)) return; - a.left -= absorbArts(c, a.left); - if (a.left > 1e-6) return; - a.left = 0; - b.fx('shieldBreak', { x: e.x, y: e.y, id: e.id }); - // the bonus it grants ends once the overflow of the breaking hit has landed (never before: an HP bonus removed - // first would rescale HP and make the overflow count twice) - if (c.dmg.cancel) b.removeBuff(e, key); else a.breaking = true; - }, - taken(c, b, e, a) { if (a.breaking) { a.breaking = false; b.removeBuff(e, key); } }, - }; -} - -/** - * "被击倒时暂时变为…,一段时间后重生" (talent Revive[Trigger]: 逐火 embers, 假想敌:再生's puppet). A knock-out of the first form is - * no kill (credit, bounty and the kill count wait for the real death): HUSK_REBIRTH s of 重生 first — statuses cleared - * (rebirthCleanse: a feared warrior is no unblockable ember), invulnerable, untargetable, unblockable, immobile, 失衡免疫 - * (PRTS 深池逐火战士 天赋 "被击倒后重生,持续1s,随后变为怨恨的余烬,1s内不移动且持有无敌+无法阻挡+失衡免疫"; PRTS 特殊机制 §重生) — - * then the husk until `delay` s after that: `hits` HP of 特殊生命值机制 (every damage instance removes 1 — PRTS 特殊机制 - * §特殊生命值机制; engine flag hitCount), no attack (缴械), walking its route on — 隐匿 (`stealthy`: targetable only while - * blocked and for 3 s after a block ends, the block the knock-out itself releases included — targeting.js - * enemyStealthed; drawn solid meanwhile) and / or unblockable (`unblock`: 再生's 傀儡 "不可被阻挡"). Killing the husk is - * the real death; a husk still standing after `delay` s stands up in its first form with full HP, and every later - * knock-out starts it again ("一次又一次地站起"). - * `onHusk(b, e)` runs as the husk begins, after the 重生 ("进入此形态时": 再生's shields). - * User report after 0.1.0 (#8): the v2.5 ember stood still, stealthed AND unblockable — nobody could ever target it. - * fx (setForm): 'ember' {id, hits, dur, form: 'husk'} at the knock-out, 'revive' {id, form: 'revived'} when it stands - * up (the renderer switches the model's clip set: render/units.js FORMS). - */ -function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, key = 'ab:ember' }) { - const revive = (b, e, a) => { - if (!e.alive || a.state !== 'husk') return; - a.state = null; - hitCount(b, e, false); - e.base.maxHp = a.max; - e.markDirty(); - void e.s; - e.hp = e.s.maxHp; - e.profile.noAttack = a.noAtk; - b.removeBuff(e, key); - b.removeBuff(e, `${key}:reborn`); - if (e.route) e.route.pts = null; - e.atkCd = 0; - setForm(b, e, 'revived', 'revive'); - }; - return { - killed(c, b, e, a) { - // an HP loss (流失 bypasses 无敌) during the 1 s 重生 cannot cut it short - if (a.state === 'husk' && b.time < a.rebornUntil) { e.hp = e.s.maxHp; return true; } - if (a.state === 'husk' || !(hits > 0) || !(delay > 0)) return false; // the husk's knock-out is the real death - a.state = 'husk'; - a.rebornUntil = b.time + HUSK_REBIRTH; - a.noAtk = e.profile.noAttack; - a.max = a.max ?? e.base.maxHp; - rebirthCleanse(b, e); - e.profile.noAttack = true; - e.lastAttackAt = -Infinity; // the snapshot shows no attack of the fallen warrior - setHits(e, hits); - hitCount(b, e, true); - b.addBuff(e, { key, visible: true, persist: true, flags: { disarm: true, ...(stealthy ? { stealth: true } : {}), ...(unblock ? { unblockable: true } : {}) } }); - b.addBuff(e, { key: `${key}:reborn`, duration: HUSK_REBIRTH, flags: { invulnerable: true, untargetable: true, unblockable: true, noMove: true, noDisplace: true } }); - // the 重生's 无法阻挡 ends a block at once (as applyStatus does for a status): a warrior knocked out while blocked - // leaves an ember whose 隐匿 is switched off until STEALTH_RESTORE (3) s after that block ended - // (Battle._stealthSwitch) — ~2 s after the 1 s 重生, which stays 无敌 + untargetable — so ranged operators and - // operator splash can finish it although its blocker took the next warrior meanwhile (players after 0.1.1: "the - // stealth monster revives forever"). [ASSUMED] the order: the 重生's cleanse first, then the ember's 隐匿 with the - // block-end switch (PRTS documents neither the order of 重生 vs that switch nor whether it survives the 重生) - b._unblock(e); - if (e.route) e.route.pts = null; - setForm(b, e, 'husk', 'ember', { hits, dur: HUSK_REBIRTH + delay }); - 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; - }, - }; -} - -/** - * 守墓石像 / 愤怒的守墓石像 (PRTS 天赋): 地面模式 — melee attacks only while blocked; the first defeat is an instant 重生 - * (statuses cleared: rebirthCleanse) to 100 % HP into 转换模式 for stone.duration s — "无法被阻挡,自缚,失衡免疫,免疫浮空。 - * 防御力增加800,法术抗性增加30", no attack [ASSUMED: PRTS lists none] — then 飞行模式, "变为飞行单位,失衡免疫": a flyer - * no push or pull moves, whose ranged attacks (the data's 1.6 radius) deal arts damage and never target flyers; the HP is - * not refilled again. Its pages list no 静态刚体 (data `staticBody` stays off), so the 失衡免疫 of the flight is a persistent - * `noDisplace` buff. 浮空 is refused from the statue on (`ab.immune`; data flyers refuse it anyway). [ASSUMED] a 浮空 it - * already carries at the knock-out runs out. fx forms 'stone' / 'fly' (its Sleep and *_2 clips, render/units.js FORMS). - * <破碎支柱> (an event device) has no counterpart in this mode. - */ -function statue(ab) { - const dur = T(ab, 'stone.duration') ?? 0; - return { - spawn(b, e) { e.profile.melee = true; }, - killed(c, b, e, a, ab2) { - if (a.done || !(dur > 0)) return false; - 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']); - setForm(b, e, 'stone', 'stone', { dur }); - b.addBuff(e, { - key: 'ab:stone', duration: dur, visible: true, flags: { noMove: true, selfBound: true, unblockable: true, noDisplace: true }, - mods: { defFlat: T(ab, 'stone.def') ?? 0, resFlat: T(ab, 'stone.magic_resistance') ?? 0 }, - onExpire: ({ battle }) => { - if (!e.alive) return; - e.motion = 'FLY'; - Object.assign(e.profile, { noAttack: a.noAtk, melee: false, dmgType: 'arts', canTarget: (u) => !u.isFlying }); - battle.addBuff(e, { key: 'ab:flight', persist: true, flags: { noDisplace: true } }); - if (e.route) e.route.pts = null; - setForm(battle, e, 'fly', 'revive', { kind: 'fly' }); - }, - }); - return true; - }, - }; -} - -/** - * Bleeding on hit (逐腐兽): arts damage per second, removed by healing. A tick selects nobody (`ignoreSelect`): it keeps - * hurting an ally that took off (起飞) after the bleed landed; so do the other enemy debuff DoTs (沙狱, burnDot, 淤困). - */ -function bleed(ab) { - const dmg = T(ab, 'Bleeding.attack@bleeding_damage') ?? 0, dur = T(ab, 'Bleeding.attack@duration') ?? 0; - return { - dealt(c, b, e) { - if (!(dmg > 0 && dur > 0)) return; - b.addBuff(c.target, { - key: 'ab:bleed', duration: dur, refresh: 'replace', interval: 1, visible: true, - onTick: ({ battle, unit }) => battle.dealDamage(e, unit, { amount: dmg, type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility', 'bleed'] }), - }); - }, - }; -} - -/** - * 【污染秽蚀】 zone (PRTS 萨卡兹枯朽战士 / 萨卡兹枯朽战车: "范围内位于低地/高地的我方干员和召唤物每秒受到50/25点真实普通伤害 - * (可对空,无视无法选择、迷彩;同名效果不叠加)"): `low` true damage per second on low ground, `high` on high ground, to every - * ally inside (flyers, stealthed, untargetable and airborne 起飞 ones included — `ignoreSelect`; an airborne 蒂比 stands on - * her low tile: `low`); a unit inside several zones takes one tick per second (`mem.pollutedAt`: the last tick it took). - */ -function pollution(b, src, x, y, r, life, low, high) { - zone(b, { x, y, r, life, iv: POLLUTION_INTERVAL, kind: 'pollution', tick(units) { - for (const u of units) { - const v = u.ground ? low : high; - if (!(v > 0) || b.time - (u.mem.pollutedAt ?? -Infinity) < POLLUTION_INTERVAL - 1e-6) continue; - u.mem.pollutedAt = b.time; - hurt(b, src, u, v, 'true', { tags: ['pollution'], ignoreSelect: true }); - } - } }); -} - -/** - * Damage zone (`amount` per tick, tagged `kind`). Unlike 【污染秽蚀】 it does not ignore 无法选择: it ticks on the allies - * its area selection takes (areaAllies: no unblocking 隐匿, untargetable or sleeping ally; no airborne 起飞 one for a ground - * enemy — PRTS 集团军重型火炮 【燃烧区域】 "碰撞不受迷彩制约,不可对空": 迷彩 only). A sourceless zone (`src` null: 假想敌:蚀裂's - * 毒雾, an enemy's 死亡爆炸 with no 无视可选性 note on PRTS) selects as an effect with no selecting enemy: 隐匿 kept out, an - * airborne 起飞 ally reached (§21.22; until 0.1.2 it took everyone inside). `noAir`: "不可对空" — a flying ally (the 炎佑 - * dragon) is skipped too (集团军重型火炮's 【燃烧区域】, since 0.1.3). - */ -function dmgZone(b, src, x, y, r, life, iv, amount, type = 'arts', kind = 'zone', el = null, elAmount = 0, { noAir = false } = {}) { - zone(b, { x, y, r, life, iv, kind, pick: (zx, zy, zr) => areaAllies(b, src, zx, zy, zr).filter((u) => !(noAir && u.isFlying)), tick(units) { - for (const u of units) { - hurt(b, src, u, amount, type, { tags: [kind] }); - if (el && elAmount > 0) elem(b, src, u, el, elAmount, { tags: [kind] }); - } - } }); -} - -/** A cooldown skill ability (`id` labels it for tests / other abilities). */ -const skill = (s, fire, { cond = null, sil = false, cd = null, icd = null, id = null } = {}) => (s || cd != null) && fire ? ({ - id: id ?? null, sil, cd: cd ?? s.cd, icd: icd ?? s.icd, cond, fire, -}) : null; - -/** Blink past the blocker along the path (弑君者 / 卢西恩). Returns the start position. */ -export function blinkForward(b, e, dist) { - const from = { x: e.x, y: e.y }; - const R = e.route; - let left = dist; - if (R && R.pts && R.ptIdx < R.pts.length) { - let i = R.ptIdx; - while (left > 1e-9 && i < R.pts.length) { - const p = R.pts[i]; - const d = Math.hypot(p.x - e.x, p.y - e.y); - if (d <= left) { e.x = p.x; e.y = p.y; left -= d; i++; } else { stepToward(e, p.x, p.y, left); left = 0; } - } - } else if (R && R.legs && R.legs[R.legIdx] && R.legs[R.legIdx].r != null) { - const L = R.legs[R.legIdx]; - stepToward(e, L.c, L.r, dist); - } - // never end inside a wall - if (!b.grid.groundPassable(Math.round(e.y), Math.round(e.x)) && e.motion !== 'FLY') { e.x = from.x; e.y = from.y; return null; } - b.addBuff(e, { key: 'ab:blink', duration: 1, flags: { invulnerable: true, unblockable: true } }); - if (R) R.pts = null; - b.fx('blink', { x: e.x, y: e.y, id: e.id, fx: from.x, fy: from.y }); - return from; -} - -// --------------------------------------------------------------------------------------------------------------- -// kits by archetype (functions reused by several keys) - -const kitEp = (el, ...keys) => (ab) => [ep(el, T(ab, ...keys) ?? 0)]; -const kitStealth = () => [stealth()]; -const kitTimes = (artsOnly = false) => () => [times(artsOnly), unblockable()]; -const kitRefraction = (ab) => [refraction(T(ab, 'refracting.magic_resistance', 'Refracting.magic_resistance') ?? 0)]; -const kitPrisoner = (freeAll = false) => (ab) => [prisoner(ab, { freeAll })]; -const kitDeathSpawn = (extra = []) => (ab) => [deathSpawn(ab.tS['DeadSpawn.enemy_key'], T(ab, 'DeadSpawn.cnt') ?? 0), ...extra]; -const kitStun3 = (ab) => [nthAttackStatus(nthOf(ab.sk.stuncombat), 'stun', (ab.sk.stuncombat && ab.sk.stuncombat.bb.stun) || 0, true)]; -const kitSelfFear = (ab) => [selfFear(ab)]; -/** 深池逐火战士 / 精锐战士 / 护卫: knock-out ⇒ 1 s 重生 ⇒ a walking, 隐匿, disarmed 余烬 / 火灰 of prop_max_hp hits for `interval` s - * (PRTS 深池逐火战士 天赋: "基础最大生命值临时变为5…具有特殊生命值机制,不进行攻击,获得隐匿、缴械,10s后若未被击倒则变回战士形态并恢复所有 - * 生命"); blocking it lifts the 隐匿, so its blocker (and every operator in range) can beat it — also during the 3 s - * after a block ends, the warrior's own block that the knock-out releases included (Battle._stealthSwitch). */ -const kitEmber = (ab) => [husk({ hits: T(ab, 'Revive[Trigger].prop_max_hp'), delay: T(ab, 'Revive[Trigger].interval') })]; -const kitPolluted = (ab) => [{ - death(c, b, e) { - if (c.reason !== 'killed') return; - pollution(b, e, e.x, e.y, T(ab, 'PollutedDie.projectile_range') ?? 1, T(ab, 'PollutedDie.projectile_life_time') ?? 0, - T(ab, 'PollutedDie.polluted_damage_low') ?? 0, T(ab, 'PollutedDie.polluted_damage_high') ?? 0); - }, -}]; - -function kitPhalanx(ab, e) { - const def = T(ab, 'auraDefup.def') ?? 0; - const r = (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || 1.5; - return [...kitRefraction(ab), { - iv: 0.5, - tick(b, e2) { - let n = 0; - for (const o of b.enemiesInRadius(e2.x, e2.y, r)) if (o !== e2 && o.defId === e2.defId) n++; - if (n > 0) auraBuff(b, e2, 'ab:phalanx', 0.5, { defFlat: def * n }); - }, - }]; -} - -function kitBlades(ab) { - const cnt = T(ab, 'DeadSpawn.cnt') ?? 0, add = T(ab, 'DeadSpawn.cnt_add') ?? 0, atk = T(ab, 'Atkup.atk', 'AtkUp.atk') ?? 0; - const key = ab.tS['DeadSpawn.enemy_key']; - return [{ - spawn(b, e, a) { a.used = 0; }, - attack(c, b, e, a) { - if (a.used >= cnt) return; - a.used++; - b.addBuff(e, { key: 'ab:blade', refresh: 'stack', stacks: 1, maxStacks: Math.max(1, cnt), mods: { atkPct: atk }, persist: true, visible: true }); - }, - death(c, b, e, a) { if (c.reason === 'killed' && key) spawnChildren(b, e, key, Math.max(1, cnt + add * a.used)); }, - }]; -} - -function kitDeepsea(ab, { swim = false, drown = false }) { - return [{ - iv: 0.25, - spawn(b, e) { if (swim) e.mem.immuneDrown = true; }, - tick(b, e, a, dt) { - const wet = onTerrain(b, e, 'deepsea'); - if (swim) { - if (wet && !a.wet) b.addBuff(e, { key: 'ab:swim', mods: { atkPct: T(ab, 'Swim.atk') ?? 0 }, flags: { stealth: true }, visible: true }); - if (!wet && a.wet) b.removeBuff(e, 'ab:swim'); - if (wet) b.removeBuff(e, 'terrain:deepsea'); // 免疫水蚀: drop devices.js' deep-water debuff - } - // PRTS 码头水手 "水蚀状态下或处于清澈水域时,每秒受到1000点无来源真实伤害" (伤害分类: 深水区/涨潮水蚀 is BUFF damage): - // damage, not a 流失 — 脆弱 scales it (player report D1 audit) - if (drown && wet) { const v = T(ab, 'Drown.damage') ?? 0; if (v > 0) b.dealDamage(null, e, { ...periodicDamage(v * dt), tags: ['dot', 'periodic', 'drown'] }); } - a.wet = wet; - }, - // 免疫水蚀: the deep-water tick (devices.js, tag 'deepsea') and drowning — not 环境伤害 ('terrain', e.g. 活性源石) - hitIn(c, b, e, a) { if (swim && a.wet && c.dmg.tags && c.dmg.tags.some((t) => t === 'deepsea' || t === 'drown')) c.dmg.cancel = true; }, - }]; -} - -/** 源石污染区 (act1 m04 infection tiles). */ -function infectionArts() { - return { hitOut(c, b, e) { if (c.dmg.isAttack && onTerrain(b, e, 'infection')) c.dmg.type = 'arts'; } }; -} - -function kitChimera(ab) { - const dmg = T(ab, 'OrigAura.damage') ?? 0, spr = T(ab, 'OrigAura.sp_recover_ratio') ?? 0; - const activate = (b, e, a) => { - if (a.on || !onTerrain(b, e, 'infection')) return; - a.on = true; - e.mem.ab.atkType = 'arts'; - b.fx('telegraph', { x: e.x, y: e.y, r: CHIMERA_AURA_RADIUS, kind: 'chimera', id: e.id }); - }; - return [{ - hitOut(c, b, e, a) { if (c.dmg.isAttack && !a.on) { activate(b, e, a); if (a.on) c.dmg.type = 'arts'; } }, - iv: 0.25, - tick(b, e, a) { - activate(b, e, a); - if (!a.on) return; - a.acc = (a.acc ?? 0) + 0.25; - const pulse = a.acc >= CHIMERA_AURA_EVERY - 1e-9; - if (pulse) a.acc -= CHIMERA_AURA_EVERY; - for (const u of b.alliesInRadius(e.x, e.y, CHIMERA_AURA_RADIUS)) { - auraBuff(b, u, 'ab:originium', 0.25, { spRecoveryMul: Math.max(0, 1 + spr) }, null, true); - // PRTS "持续视为受到源石污染区影响…每0.5秒受到50真实持续伤害": a damage instance (受击回复, the 重装 trigger), not a - // 流失 — 无来源 like the 源石污染区 terrain it stands for [ASSUMED], the chimera keeps the credit; "同类效果取最高": one - // tick per unit per period however many chimeras reach it (`mem.chimeraAt`; every chimera's is 50). A terrain - // effect, not an area selection: 隐匿 allies inside are reached too ("所有单位" [ASSUMED], DESIGN §22.12) - if (!pulse || !(dmg > 0) || b.time - (u.mem.chimeraAt ?? -Infinity) < CHIMERA_AURA_EVERY - 1e-6) continue; - u.mem.chimeraAt = b.time; - b.dealDamage(e, u, { ...periodicDamage(dmg), tags: ['dot', 'pollution'] }); - } - }, - }]; -} - -function kitNazg(ab, e) { - const r = (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || e.base.rangeRadius || 1.3; - return [{ - before(c, b, e2) { - const l = targetsNear(b, e2, r); - if (onTerrain(b, e2, 'infection')) for (const u of b.allies()) if (!l.includes(u) && onTerrain(b, u, 'infection') && canTargetAlly(e2, u, true)) l.push(u); - if (l.length) c.targets = byPriority(e2, l); - }, - }]; -} - -function kitTidmag(ab) { - return [ep('erosion', T(ab, 'EpDamage.attack@ep_damage_ratio') ?? 0), { - before(c, b, e) { - // PRTS 控潮术师 "对目标所在地块及周围四格内的所有我方单位造成法术普通伤害": the 周围四格 are an area selection (格子判定: - // a 迷彩 ally is hit; an unblocking 隐匿 or an airborne 起飞 one is not — areaAlliesInTiles) - const t0 = c.targets[0]; - if (!t0) return; - const l = c.targets.slice(); - for (const u of areaAlliesInTiles(b, e, t0.tileR, t0.tileC, 'plus', 1)) if (!l.includes(u)) l.push(u); - c.targets = l; - }, - }]; -} - -/** 深溟巢涌者 / 富营养的巢涌者: one pulse every second (PRTS "每秒"; not its attack interval, so 攻速 changes leave it alone). */ -const NEST_PULSE_INTERVAL = 1; -const NEST_PULSE_TAG = 'nestPulse'; -/** - * 深溟巢涌者 / 富营养的巢涌者 (PRTS 天赋): "不进行普通攻击", 抵抗 (一减状态抵抗率 −0.5) and 停顿免疫; "未处于消失状态时,令攻击范围 - * 内的所有我方单位每秒受到攻击力100%的无途径法术伤害" and "每次输出伤害时,再造成攻击力5%的神经损伤" (`EpDamage.ep_damage_ratio`). - * A talent aura, not an attack: `noAttack`, so it walks on while it hurts (ai.js attackStand holds only attackers — until - * 0.1.3 the pulse was its normal attack, which made it stand for its whole 1 s clip whenever an ally was in range: - * community report 「…错误的设置了攻击时不移动导致卡在原地」, GitHub #93), needs no target and pauses while it is hidden on a - * DISAPPEAR leg (消失). The pulse is an area selection (areaAllies: a 迷彩 ally and a flying one — the 炎佑 dragon — are - * hit, a 隐匿 one is not) of radius `rangeRadius` around its centre, like 鼎沸's. [ASSUMED] the pulse can be dodged (闪避: - * PRTS names no attack type, which the site reads as 普通伤害 — the 烹泉 splash rule) and a dodged one adds no 神经. - */ -function kitDsubrl(ab, e) { - const ratio = T(ab, 'EpDamage.ep_damage_ratio') ?? 0; - return [resist(['sluggish']), { - spawn(b, e2) { - e2.profile.noAttack = true; - // 每次输出伤害时: every pulse that reaches the unit (not dodged, not cancelled — the old attack's `dealt` rule) - if (ratio > 0) b.on('damaged', (c) => { if (c.source === e2 && c.dmg && c.dmg.tags.includes(NEST_PULSE_TAG)) elem(b, e2, c.target, 'neural', e2.s.atk * ratio); }, { owner: e2 }); - }, - iv: NEST_PULSE_INTERVAL, - tick(b, e2) { - if (e2.hidden) return; - const r = e2.base.rangeRadius || 1.6; - const l = areaAllies(b, e2, e2.x, e2.y, r); - if (!l.length) return; - b.fx('pulse', { x: e2.x, y: e2.y, r, id: e2.id, element: 'neural' }); - for (const u of l) hurt(b, e2, u, e2.s.atk, 'arts', { canDodge: true, tags: [NEST_PULSE_TAG] }); - }, - }]; -} - -/** 掠海漂移体 爬行模式: the stun it takes on dropping (PRTS "进入爬行模式并晕眩0.5秒"; not in its blackboard). */ -const SYUFO_CRAWL_STUN = 0.5; -/** 吉兆飞鳞 【失温坠落】: the stun when a freeze ends (PRTS "冻结结束时,获得0.05秒晕眩"). */ -const PARROT_CHILL_STUN = 0.05; - -/** - * 掠海漂移体 (PRTS): 初始模式 近地悬浮 + 失衡免疫, 不会攻击飞行单位; 受晕眩/无法行动/沉睡/冻结/缚地影响后进入爬行模式 for - * good and is stunned 0.5 s — a ground unit (blockable, melee operators hit it) that "仅进行阻挡攻击" (only its blocker). - * The engine has 晕眩 / 冻结 / 沉睡 (no operator here applies 无法行动 or 缚地). Erosion on every attack. - */ -function kitSyufo(ab) { - return [float(), ep('erosion', T(ab, 'EpDamage.attack@ep_damage_ratio') ?? 0), { - spawn(b, e) { e.profile.canTarget = (u) => !u.isFlying; }, - status(c, b, e, a) { - if (a.crawl || !(c.status === 'stun' || c.status === 'freeze' || c.status === 'sleep')) return; - a.crawl = true; - setFloat(b, e, false); - e.profile.melee = true; - b.applyStatus(e, 'stun', { duration: SYUFO_CRAWL_STUN, source: null }); - setForm(b, e, 'crawl'); - }, - }]; -} - -/** - * 吉兆飞鳞 (PRTS): 近地悬浮 (失衡免疫), no normal attack; 受晕眩/无法行动/沉睡/缚地影响后进入晕眩模式 — the float is off, it - * cannot be blocked and is stunned `Stun.duration` (8) s — and floats again once none of those effects holds it - * (初始模式); 冻结 grounds it only when the freeze ends (【失温坠落】: 0.05 s stun). 首次受到伤害后 its speed is × - * `M0SpeedUp.move_speed` for `M0SpeedUp.duration` s ("最终提升至300%"; never in 晕眩模式, dropped when its mode - * changes). 搬运模式 (carrying a 寻险水手) needs a level checkpoint (`ThrowEnemy.checkpoint` 0 = never) — not in this mode. - */ -function kitParrot(ab) { - const down = T(ab, 'Stun.duration') ?? 0; - return [float(), { - taken(c, b, e, a) { - if (a.ran || a.down) return; - a.ran = true; - b.addBuff(e, { key: 'ab:parrotRun', duration: T(ab, 'M0SpeedUp.duration') ?? 0, mods: { moveMul: T(ab, 'M0SpeedUp.move_speed') ?? 1 } }); - }, - status(c, b, e, a) { - if (c.status === 'freeze') { a.chill = true; return; } - if (a.down || !(c.status === 'stun' || c.status === 'sleep')) return; - a.down = true; - setFloat(b, e, false); - b.removeBuff(e, 'ab:parrotRun'); - b.addBuff(e, { key: 'ab:parrotDown', flags: { unblockable: true }, persist: true }); - if (down > 0) b.applyStatus(e, 'stun', { duration: down, source: null }); - b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'grounded' }); - }, - tick(b, e, a) { - if (a.chill && !e.s.flags.freeze) { a.chill = false; b.applyStatus(e, 'stun', { duration: PARROT_CHILL_STUN, source: null }); } - // "离开上述异常效果影响后进入初始模式": s.flags.stun also holds while asleep / frozen (units.js), so a sleep that - // outlasts the mode stun keeps it down - if (a.down && !e.s.flags.stun) { - a.down = false; - b.removeBuff(e, 'ab:parrotDown'); - b.removeBuff(e, 'ab:parrotRun'); - setFloat(b, e, true); - b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'float' }); - } - }, - }]; -} - -/** - * 萨卡兹枯朽战车 / 尖端 (PRTS; enemy_database spData "初始技力 2 / 技力上限 2 / 攻击回复"): an attack made with full SP is the - * skill 秽蚀轰击 — "对目标造成100%物理普通伤害,同时在目标位置生成半径1.7,持续10s的【污染秽蚀】" — and empties the SP; every - * other attack gains 1. Initial SP = max, so the FIRST attack is the skill, then every 3rd (attacks 1, 4, 7 …). Talent: - * "普通攻击只攻击位于低地的我方单位,且不会攻击飞行单位" (a candidate filter, ai.js enemyAttack: a FLY ally such as the 炎佑 dragon on a - * low tile is no target either); "被阻挡时进行近战攻击,造成攻击力200%的伤害" — normal attacks on its blocker ×chuang_atk_scale. - * [ASSUMED] the skill also fires on its blocker and deals the skill's own 100 % there (PRTS names no blocked exception: - * enemy SP skills fire as soon as they are ready and its condition — a low-ground non-flying ally within 2.2 — holds for - * the blocker), so a blocked tank hits 100 / 200 / 200 % … (v2.4.1: 200 % on every attack, the skill included). - * Reach: the 2.2 range circle takes an ally whose 0.25 collider touches it (ai.js enemyAttack, constants.js - * ALLY_COLLIDER_RADIUS; PRTS 作战机制 §碰撞体积) — 2.45 from the ally's centre, 21 tiles around it instead of 13 (user - * playtest #6 follow-up: the user remembers a long-reaching, hard-hitting ranged attack; the data scale stays 100 %). - */ -function kitTank(ab, e) { - const melee = T(ab, 'Empty.attack@chuang_atk_scale') ?? 1; - const sk = ab.sk.PollutedRangedAtk || null; - const s = sk ? sk.bb : {}; - const spd = (e.def.raw && e.def.raw.sp) || null; - const max = Math.max(1, num(spd && spd.maxSp, 0) || (sk && sk.sp) || (T(ab, 'Empty.sp') ?? 2)); - const init = Math.min(max, Math.max(0, num(spd && spd.initSp, 0))); - return [{ - spawn(b, e2, a) { a.sp = init; a.skill = false; e2.profile.canTarget = (u) => !!u.ground && !u.isFlying; }, - // SP full: this attack is 秽蚀轰击 (a blocked hit lands at once, inside this attack, while `a.skill` is set) - before(c, b, e2, a) { a.skill = a.sp >= max; }, - hitOut(c, b, e2, a) { if (c.dmg.isAttack && !a.skill && c.target === e2.blockedBy) c.dmg.amount *= melee; }, - attack(c, b, e2, a) { - if (!a.skill) { a.sp = Math.min(max, a.sp + 1); return; } - a.skill = false; - a.sp = 0; - const t = c.targets[0]; - if (t) pollution(b, e2, t.x, t.y, TANK_ZONE_RADIUS, s.projectile_life_time ?? 0, s.polluted_damage_low ?? 0, s.polluted_damage_high ?? 0); - }, - }]; -} - -function kitDeathEye(ab, e) { - const s = ab.sk.DeathEye; - const r = e.base.rangeRadius || 2.5; - return [ep('apoptosis', T(ab, 'empty.attack@ep_damage_ratio') ?? 0), skill(s, (b, e2, a) => { - const t = byPriority(e2, targetsNear(b, e2, r))[0]; - if (!t) return; - const dur = s.bb.hit_duration ?? 0; - b.addBuff(e2, { key: 'ab:channel', duration: dur, flags: { disarm: true } }); - b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'deathEye', dur }); - let n = 0; - const h = b.every(1, () => { - if (!e2.alive) { h.cancel(); return; } - // a stun / silence (SILENCE-flagged ability) or the locked target's fall interrupts the channel: no burst - if (e2.s.flags.stun || e2.s.flags.silence || !t.alive) { h.cancel(); b.removeBuff(e2, 'ab:channel'); b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'deathEyeEnd' }); return; } - hurt(b, e2, t, e2.s.atk * (s.bb.atk_scale ?? 0), 'arts'); - if (++n >= dur) { - h.cancel(); - b.fx('explode', { x: t.x, y: t.y, r: 1, kind: 'apoptosis' }); - // "对目标及其周围4格的我方单位": the locked target (a 隐匿 it gained mid-channel does not save it) + an area around it - for (const u of targetAndArea(t, areaAllies(b, e2, t.x, t.y, 1))) elem(b, e2, u, 'apoptosis', e2.s.atk * (s.bb.ep_damage_ratio ?? 0)); - } - }, { owner: e2 }); - }, { sil: true, cond: (b, e2) => targetsNear(b, e2, r).length > 0 })]; -} - -function kitInvisShield(ab, e) { - const s = ab.sk.InvisibleShield; - const r = (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || 2; - return [...kitDeathSpawn()(ab), skill(s, (b, e2) => { - // 辉光照耀 "使【范围隐匿】生效3s" (PRTS 清明 / 堂皇) = the talent's InvisibleShield.duration (3 for both); 清明's skill - // blackboard says 5 — until 0.1.3 that 5 was used - const dur = T(ab, 'InvisibleShield.duration') ?? s.bb.duration ?? 0; - b.fx('telegraph', { x: e2.x, y: e2.y, r, kind: 'invisShield', id: e2.id }); - // "获得隐匿(解除阻挡0秒后恢复)" (PRTS 清明 / 堂皇 天赋): the veil's 隐匿 is back as soon as a block ends - for (const o of b.enemiesInRadius(e2.x, e2.y, r)) if (o !== e2 && dur > 0) b.addBuff(o, { key: 'ab:veiled', duration: dur, flags: { stealth: true }, visible: true, data: { stealthRestore: 0 } }); - })]; -} - -function kitCrossbow(ab) { - const s = ab.sk.CrossAttack; - const aligned = (b, e) => b.allies().filter((u) => canTargetAlly(e, u, true) && (Math.abs(u.y - e.y) < 0.5 || Math.abs(u.x - e.x) < 0.5) && Math.hypot(u.x - e.x, u.y - e.y) <= CROSS_REACH); - const nearest = (b, e, l) => l.sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y))[0]; - // PRTS 重弩突袭者: 天赋 "隐匿(被阻挡,主动攻击期间均可解除)"; 直击 "蓄力1.4s后向目标方向发射1支弩箭,对击中的首个目标造成攻击力100% - // 的法术伤害与5s晕眩 ※技能持续2.5s": revealed for the whole skill, the bolt leaves CROSS_CHARGE s in — at the nearest unit still - // in line in the aimed direction; it stands meanwhile [ASSUMED], and a stun / silence or its death before the release - // cancels the shot [ASSUMED]. The blackboard's duration (2) is not the skill's length PRTS gives. Until 0.1.3: revealed - // 2 s, the bolt at once. - return [stealth(), skill(s, (b, e) => { - const t = nearest(b, e, aligned(b, e)); - if (!t) return; - const bb = s.bb; - const dx = Math.sign(Math.round(t.x - e.x)), dy = Math.sign(Math.round(t.y - e.y)); - b.addBuff(e, { key: 'ab:revealed', duration: CROSS_SKILL, flags: { reveal: true } }); - b.addBuff(e, { key: 'ab:aim', duration: CROSS_SKILL, flags: { noMove: true, disarm: true } }); - b.after(CROSS_CHARGE, () => { - if (!e.alive || e.hidden || e.s.flags.stun || e.s.flags.silence) return; - const tt = nearest(b, e, aligned(b, e).filter((u) => Math.sign(Math.round(u.x - e.x)) === dx && Math.sign(Math.round(u.y - e.y)) === dy)); - if (!tt) return; - b.addProjectile({ from: e, target: tt, speed: 12, visual: 'enemy', source: e, onHit: (c) => { - if (!c.target || !c.target.alive) return; - hurt(b, e, c.target, e.s.atk * (bb.atk_scale ?? 1), 'arts'); - if (bb.stun > 0 && c.target.alive) b.applyStatus(c.target, 'stun', { duration: bb.stun, source: e }); - } }); - }, { owner: e }); - }, { sil: true, cond: (b, e) => aligned(b, e).length > 0 })]; -} - -/** - * 山海众头目 / 山海众秘使 (PRTS 天赋 "隐匿,该隐匿每次生效后自身获得强击标记(不可叠加)"; 技能 破隐一击 "仅持有强击标记且被阻挡时可 - * 触发:对阻挡目标造成攻击力200%的物理普通伤害,技能开始时消耗强击标记"): the mark comes with its 隐匿 — at the spawn and every - * time it hides again (3 s after a block, Battle._stealthSwitch; or once a 反隐 ends) — and its next attack (a melee - * one: blocked) spends it at InvisibleCombat.atk_scale. A new block inside the 3 s gives no new mark (until 0.1.2 every - * block did). - */ -function kitShadowKiller(ab) { - const scale = (ab.sk.InvisibleCombat && ab.sk.InvisibleCombat.bb.atk_scale) || 1; - return [stealth(), { - spawn(b, e, a) { a.power = true; a.on = true; }, - tick(b, e, a) { const on = enemyStealthed(e); if (on && !a.on) a.power = true; a.on = on; }, - hitOut(c, b, e, a) { if (a.power && c.dmg.isAttack) c.dmg.amount *= scale; }, - attack(c, b, e, a) { a.power = false; }, - }]; -} - -function kitJazz(ab, e) { - const s = ab.sk.fire; - const r = (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || 2.5; - const revealed = (u) => !enemyStealthed(u); // blocked, 反隐 (its 隐匿 returns 0 s after a block) - return [stealth(), { - // PRTS 天赋 "隐匿期间不进行普通攻击;未受隐匿影响时进入反击模式:仅进行阻挡攻击,造成100%物理伤害": its normal attack only ever - // hits its blocker (`melee`), so a revealed (反隐) unblocked one neither shoots nor stands for its attack clip (until - // 0.1.3 it shot everyone within its 2.5 range and stood still while it had a target — ai.js attackStand) - spawn(b, e2) { e2.profile.noAttack = true; e2.profile.melee = true; }, - tick(b, e2) { e2.profile.noAttack = !revealed(e2); }, // 平时不攻击,失去隐匿时反击 - }, s ? { - // 狂欢式演奏 (PRTS 节日爵士乐手, 反击模式): "仅攻击范围内存在我方单位时可触发:锁定目标持续施法,最多持续10.6s,每0.5s对目标 - // 造成攻击力20%的法术伤害和攻击力10%的灼燃损伤 ※施法期间受到沉默影响后,立即结束技能" — ONE locked target (its own - // priority: the blocker first), floor(10.6 / 0.5) = 21 ticks at 0.5 … 10.5 s. The channel also ends when that target - // is gone, makes no normal attacks meanwhile and is never re-cast over itself — the 10 s cooldown (enemy_database - // skill 'fire') runs out mid-channel, the next cast waits for its end [ASSUMED "持续施法", as 死亡之眼]. - sil: true, cd: s.cd, icd: s.icd, cond: (b, e2) => revealed(e2) && !(e2.mem.jazzChannel && !e2.mem.jazzChannel.cancelled) && targetsNear(b, e2, r).length > 0, - fire(b, e2) { - const dur = s.bb['enemy_cnvsax[cd].duration'] ?? 0, iv = s.bb.hit_interval ?? 0.5; - const t = byPriority(e2, targetsNear(b, e2, r))[0]; - if (!t || !(dur > 0) || !(iv > 0)) return; - const n = Math.max(1, Math.floor(dur / iv + 1e-9)); - const buff = b.addBuff(e2, { key: 'ab:channel', duration: dur, flags: { disarm: true } }); - let k = 0; - const end = () => { h.cancel(); e2.mem.jazzChannel = null; if (buff) b.removeBuff(e2, buff); }; - const h = b.every(iv, () => { - if (!e2.alive || e2.s.flags.silence || !t.alive || !t.deployed) { end(); return; } - b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'jazzFire' }); - hurt(b, e2, t, e2.s.atk * (s.bb.atk_scale ?? 0), 'arts'); - elem(b, e2, t, 'burn', e2.s.atk * (s.bb.ep_damage_ratio ?? 0)); - if (++k >= n) end(); - }, { owner: e2 }); - e2.mem.jazzChannel = h; - }, - } : null]; -} - -function kitShadowBlade(ab) { - const bat = ab.t['traitAbility.base_attack_time'] ?? 0; - return [stealth(), { - iv: 0.5, - tick(b, e) { - const partner = b.enemiesInRadius(e.x, e.y, 3).find((o) => /enemy_1174_duholy/.test(o.defId)); - const r = partner && partner.mem.ab ? (T(partner.mem.ab, 'traitAbility.range_radius') ?? 1.1) : 1.1; - if (partner && Math.hypot(partner.x - e.x, partner.y - e.y) <= r + 1e-9 && e.base.bat > 0) auraBuff(b, e, 'ab:shadowSync', 0.5, { batPct: bat / e.base.bat }); - }, - }]; -} - -function kitHolyGuard(ab) { - const r = T(ab, 'traitAbility.range_radius') ?? 1.1, aspd = T(ab, 'traitAbility.attack_speed') ?? 0; - return [...kitRefraction(ab), { - iv: 0.5, - tick(b, e) { - const on = b.enemiesInRadius(e.x, e.y, r).some((o) => /enemy_1175_dushdo/.test(o.defId)); - if (!on) return; - // a buff aura (auraAllies): no 隐匿 operator, the blocker included (PRTS 作战机制 §隐匿与Buff的关系 — no 无视 note on - // its page), but an airborne 起飞 one still [ASSUMED, as §21.22's ground-enemy auras] - for (const u of auraAllies(b, e, e.x, e.y, r)) auraBuff(b, u, 'ab:forceField', 0.5, { aspd }, null, true); - }, - }]; -} - -function kitRush(ab) { - const ms = T(ab, 'rush.dlancer_t[trigger].move_speed') ?? 0, iv = T(ab, 'rush.dlancer_t[trigger].interval') ?? 0.5; - const max = T(ab, 'rush.dlancer_t[trigger].trig_cnt') ?? 0, first = T(ab, 'firstattack.atk_scale') ?? 0; - return [{ - iv, - spawn(b, e, a) { a.n = 0; }, - tick(b, e, a) { - if (!e.moving || e.blockedBy || a.n >= max) return; - a.n++; - b.addBuff(e, { key: 'ab:rush', mods: { moveMul: 1 + ms * a.n }, persist: true }); - }, - blocked(c, b, e, a) { a.charge = a.n; }, - hitOut(c, b, e, a) { - if (!c.dmg.isAttack || !(a.charge > 0) || !(max > 0)) return; - c.dmg.amount *= 1 + (first / 100) * (a.charge / max); // 被阻挡后的首次攻击: extra damage from the built-up speed - }, - attack(c, b, e, a) { if (a.charge > 0) { a.charge = 0; a.n = 0; b.removeBuff(e, 'ab:rush'); } }, - }]; -} - -/** - * 萨卡兹悖谬暴虐兵长: blocked only by a blocker with ≥ 3 free block; its first hit also strikes the units around its target - * (PRTS 技能0 暴击 "对目标和周围4格的我方单位造成攻击力150%的物理普通伤害(无视无法选择,无视迷彩)※此技能仅能触发一次"): - * the splash ignores 无法选择, so it reaches an airborne 起飞 ally too (`ignoreSelect`). - */ -function kitFirstAoe(ab) { - const scale = T(ab, 'AOEAttack.atk_scale') ?? 0; - return [blockWeight(3), { - dealt(c, b, e, a) { - if (a.done) return; - a.done = true; - b.fx('explode', { x: c.target.x, y: c.target.y, r: 1, kind: 'aoeAttack' }); - for (const u of b.alliesInRadius(c.target.x, c.target.y, 1)) { - if (u !== c.target) hurt(b, e, u, e.s.atk * scale, 'phys', { ignoreSelect: true, tags: ['aoeAttack'] }); - } - }, - }]; -} - -function kitDefDecay(ab) { - const max = T(ab, 'def_reduce.max_stack_cnt') ?? 0, def = T(ab, 'def_reduce.def') ?? 0, res = T(ab, 'def_reduce.magic_resistance') ?? 0; - return [{ - taken(c, b, e) { - if (!(max > 0)) return; - b.addBuff(e, { key: 'ab:defDecay', refresh: 'stack', stacks: 1, maxStacks: max, persist: true, mods: { defFlat: def / max, resFlat: res / max } }); - }, - }]; -} - -function kitExposeOnHit(ab) { - const dur = T(ab, 'Expose.weak[limit]') ?? 0, scale = T(ab, 'Expose.damage_scale') ?? 1; - return [{ sil: true, taken(c, b) { const s = c.source; if (s && s.side === 'ally' && s.alive && s.kind !== 'device') expose(b, s, dur, scale); } }]; -} - -/** 远眺: knocked out ⇒ 暴露 on every ally within r — PRTS 天赋 "(可被沉默;无视其可选性)": 隐匿 / untargetable ones too. */ -function kitExposeOnDeath(ab) { - const dur = T(ab, 'Expose.weak[limit]') ?? 0, scale = T(ab, 'Expose.damage_scale') ?? 1, r = T(ab, 'Expose.range_radius') ?? 1; - return [{ sil: true, death(c, b, e) { if (c.reason !== 'killed') return; b.fx('explode', { x: e.x, y: e.y, r, kind: 'expose' }); for (const u of b.alliesInRadius(e.x, e.y, r)) expose(b, u, dur, scale); } }]; -} - -/** - * 淤困's "受到的元素损伤提高至130%" on its host: an `elementHit` multiplier (SIM.md §7.2 — every "受到的元素损伤±N%"; on an - * operator at priority 20, before a 损伤屏障), never 元素伤害. One battle-wide handler, installed with the first host. - */ -function ensureParasiteHook(b) { - const st = stOf(b); - if (st.parasiteHook) return; - st.parasiteHook = true; - b.on('elementHit', (c) => { - const bf = c.target && c.dmg && c.dmg.type === 'element' ? c.target.findBuff('ab:parasite') : null; - const m = bf && bf.data ? bf.data.epMul : 1; - if (m > 0 && m !== 1) c.dmg.mul *= m; - }, { priority: 20 }); -} - -function kitParasite(ab) { - const scale = T(ab, '1.atk_scale') ?? 0, elMul = T(ab, '1.ep_damage_scale') ?? 1; - return [taunt(-1), { - iv: 0.2, - tick(b, e, a) { - const host = e.blockedBy && e.blockedBy.alive ? e.blockedBy : null; - if (a.host && a.host !== host) { b.removeBuff(a.host, 'ab:parasite'); a.host = null; } - if (!host || a.host === host) return; - a.host = host; - ensureParasiteHook(b); - b.fx('beam', { x: e.x, y: e.y, from: e.id, to: host.id, kind: 'parasite' }); - b.addBuff(host, { - // "受到的元素损伤提高至130%": a 元素损伤 multiplier on the element hit (ensureParasiteHook), not 元素伤害 - key: 'ab:parasite', visible: true, interval: 1, data: { src: e, spread: PARASITE_SPREAD, epMul: elMul }, - onTick: ({ battle, unit }) => { if (e.alive) battle.dealDamage(e, unit, { amount: e.s.atk * scale, type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility', 'parasite'] }); }, - }); - }, - death(c, b, e, a) { if (a.host) b.removeBuff(a.host, 'ab:parasite'); }, - }]; -} - -function kitBoneSpike() { - return [stealth(), { - before(c, b, e) { - if (!enemyStealthed(e)) return; // 隐匿状态下同时攻击数个目标 (back 0 s after a block) - const l = byPriority(e, targetsNear(b, e, e.base.rangeRadius)); - if (l.length) c.targets = l.slice(0, ACBUNN_TARGETS); - }, - }]; -} - -/** - * 假想敌:黑云 (PRTS): 抓取 (KillOthers, cd 10 / icd 5) "仅半径3.75范围内存在未持有【延迟吞噬】的敌方飞行普通单位时可触发: - * 选择至多3名满足上述条件的目标,短暂延迟后对其施加4秒【延迟吞噬】:束缚,效果结束时令给予方+1SP,随后强制击杀受予方 ※技能 - * 持续4秒,期间持有束缚" (the nearest prey first [ASSUMED]); 全弹发射 (FireWeapon, cd 10 / icd 5, SP cost 1) "仅全场范围内存在 - * 我方单位时可触发:清空自身SP,进行一次多连击,每击选择全场范围内的1名随机我方单位,对其造成攻击力130%的物理伤害 ※多连击的 - * 连击次数等于本技能消耗的SP数量" — SP (技力上限 spData.maxSp 3) only comes from the grabs. - */ -function kitBlackCloud(ab, e) { - const k = ab.sk.KillOthers, f = ab.sk.FireWeapon; - const maxAmmo = Math.max(1, num(e.def.raw && e.def.raw.sp && e.def.raw.sp.maxSp, 3)); - const grabR = (k ? num(k.bb.range_radius, 1.5) : 1.5) * GRAB_RADIUS_SCALE; - const dur = k ? num(k.bb.duration, 4) : 4; - const isPrey = (b, e2, o) => o !== e2 && o.alive && !o.hidden && o.isFlying && o.def.rank === 'NORMAL' && !o.isBoss && !o.findBuff('ab:devoured'); - const prey = (b, e2) => b.enemiesInRadius(e2.x, e2.y, grabR).filter((o) => isPrey(b, e2, o)); - return [ - skill(k, (b, e2) => { - const list = prey(b, e2).sort((p, q) => Math.hypot(p.x - e2.x, p.y - e2.y) - Math.hypot(q.x - e2.x, q.y - e2.y)).slice(0, GRAB_MAX_PREY); - if (!list.length) return; - b.addBuff(e2, { key: 'ab:grabbing', duration: dur, flags: { bind: true, noMove: true } }); // 技能持续4秒,期间持有束缚 - b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: list[0].id, kind: 'devour' }); - b.after(GRAB_DELAY, () => { - if (!e2.alive) return; - for (const o of list) { - if (!o.alive) continue; - b.addBuff(o, { key: 'ab:devoured', duration: dur, visible: true, flags: { bind: true, noMove: true, disarm: true } }); - b.after(dur, () => { - if (!o.alive || !e2.alive) return; - e2.mem.ab.ammo = Math.min(maxAmmo, (e2.mem.ab.ammo ?? 0) + num(k.bb.sp, 1)); - b.kill(o, null); - }, { owner: e2 }); - } - }, { owner: e2 }); - }, { sil: true, cond: (b, e2) => prey(b, e2).length > 0 }), - skill(f, (b, e2) => { - const hits = Math.floor(e2.mem.ab.ammo ?? 0); - if (!(hits > 0)) return; - e2.mem.ab.ammo = 0; - for (let i = 0; i < hits; i++) { - const t = b.rng.pick(allTargets(b, e2)); - if (!t) break; - b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'fireWeapon' }); - hurt(b, e2, t, e2.s.atk * num(f.bb.atk_scale, 1), 'phys'); - } - }, { sil: true, cond: (b, e2) => (e2.mem.ab.ammo ?? 0) >= Math.max(1, num(f.sp, 1)) && allTargets(b, e2).length > 0 }), - ]; -} - -/** 假想敌:再生 (PRTS 假想敌:再生 天赋) · knock-out ⇒ 1 s 重生 ⇒ 再生状态: a 傀儡 of prop_max_hp (15) hits that is 不可阻挡 for - * `interval` (15) s and walks on ("1s内不移动": only the 重生 stands; its model has a 傀儡 walk cycle, B_Move), back with full - * HP if still standing; entering it gives the other enemies within 1.8 (targetable or not) max_damage_block_cnt (5) - * hit shields. */ -function kitRegen(ab) { - const hits = T(ab, 'Revive[Trigger].prop_max_hp'), delay = T(ab, 'Revive[Trigger].interval'), block = T(ab, 'Aura.max_damage_block_cnt') ?? 0; - return [husk({ - hits, delay, stealthy: false, unblock: true, key: 'ab:regen', - onHusk(b, e) { - // "进入此形态时,使半径1.8范围内的其他敌方单位(无视其可选性)获得5层吸收物理/法术伤害的护盾" - b.fx('telegraph', { x: e.x, y: e.y, r: ACPUPP_AURA_RADIUS, kind: 'regenShield', id: e.id }); - for (const o of b.enemiesInRadius(e.x, e.y, ACPUPP_AURA_RADIUS)) if (o !== e && block > 0) { const oab = abOf(b, o); oab.hitShield = Math.max(oab.hitShield, block); } - }, - })]; -} - -function kitSteal(ab) { - const n = T(ab, 'DamageOrBullet.attack@minus_bullet') ?? 1; - return [selfFear(ab), { - // PRTS “萨科塔之眼” 天赋: "不会攻击飞行单位" (the 炎佑 dragon is one) — a candidate filter (ai.js enemyAttack): its next - // ground target in range instead - spawn(b, e) { e.profile.canTarget = (u) => !u.isFlying; }, - hitOut(c, b, e) { - if (!c.dmg.isAttack) return; - const t = c.target; - // 攻击时尝试夺走目标1发弹药,若无法夺走弹药则造成高额物理伤害 - if (t.skill && t.skill.active && t.skill.kind === 'ammo' && t.skill.ammoLeft > 0) { - t.skill.ammoLeft = Math.max(0, t.skill.ammoLeft - n); - if (t.skill.ammoLeft <= 0) t.skill.end('ammo'); - } else if ((t.trait && t.trait.ammo) > 0) { - t.trait.ammo = Math.max(0, t.trait.ammo - n); - } else return; - c.dmg.cancel = true; - b.fx('steal', { x: t.x, y: t.y, id: t.id, from: e.id }); - }, - }]; -} - -/** “萨科塔昂首” 【祈祷邀约】 (PRTS "令全场我方单位(不可对空,无视迷彩)获得15s【受邀祈祷】攻击速度-30"): a whole-field skill - * selection — every ally but an unblocking 隐匿 / untargetable / sleeping one (fieldAllies; no 无视无法选择). */ -function kitRoar(ab) { - const s = ab.sk.Roar; - return [selfFear(ab), skill(s, (b, e) => { - b.fx('telegraph', { x: e.x, y: e.y, r: 99, kind: 'roar', id: e.id }); - for (const u of fieldAllies(b, e)) b.addBuff(u, { key: 'ab:roar', duration: s.bb.duration ?? 0, refresh: 'extend', mods: { aspd: s.bb.attack_speed ?? 0 }, visible: true }); - }, { sil: true })]; -} - -/** - * 暴鸰 (PRTS): "不进行普通攻击"; 投弹 (boomb, cooldown / initCooldown 1) "仅攻击范围内存在我方单位时可触发:对目标及其周围八格 - * 的我方单位造成100%物理伤害(对主目标造成物理普通伤害,对溅射目标造成物理溅射伤害,伤害无视迷彩)技能结束后移速最终提升至200% - * ※此技能仅能触发一次,不可沉默". The official battle prefab (enemy_1040_bombd, read from the client) drops the bomb as a - * projectile: the cast plays the Attack clip, the bomb leaves on its OnAttack event (BOMBD_RELEASE) and flies to the - * target (projectile_bombd), and the drone switches to its bomb-less mode (S1, buff bomb_s: the *_2 clips, no bottle). - * User feedback after 0.1.0 (D4 "炸弹无法正常投放"): the damage used to land in the tick of the trigger while the drone - * kept its bomb on screen. Now: the drone hovers through its cast; at the release an 'atk' event of kind 'droneBomb' - * (the client winds the Attack clip up to it and flies the bomb) and setForm 'bombed' (fx 'phase' {kind, form: 'bombed'} - * — render FORMS: the *_2 clips, which the view starts once the Attack clip is over — the official end clip Idle_2; - * `e.form` → UnitInfo.form; the client keeps the fx through catch-ups, shared/protocol.js fxForm); on arrival the target (the ranged target by engine priority) takes 100 % ATK and every other ally of - * the 8 tiles around where it lands 100 % ATK splash (camouflage ignored); a target gone mid-flight: the bomb lands - * where it was. The cast ends once the bomb has landed, at least BOMBD_POST_DELAY after the release: move speed - * ×boomb.move_speed, and it flies on. A stun / freeze / sleep before the release interrupts the cast (Boomb - * `_immuneStunWhenAffecting` 0, like the other enemy channels here): nothing leaves the drone, it keeps its bomb and casts - * again after the skill's cooldown (1 s, counted from the interrupt) once it is free and an operator is in range. - * [ASSUMED] no drop when the drone is dead at the release; the ATK at the release; the hover through the cast (its own - * hold, `pauseUntil`: the drone has no normal attack — `noAttack` —, so ai.js attackStand, the stand of a ranged enemy - * for its attack clip, never runs for it; the drop is its only attack-like cast); the - * bomb-less look from the release (the bomb leaves the drone on that frame of the Attack clip; at the cast end — 1 tick - * before the clip ends — the client would draw the bomb back for a frame); an interrupted cast does not use up the one - * trigger (`_maxTriggerTime` 1 — the bomb is still on the drone); a stun after the release does not stop the cast end. - */ -function kitBombd(ab) { - const s = ab.sk.boomb; - const ms = (s && s.bb.move_speed) || 0; - const reach = (e) => e.base.rangeRadius || 2; - const land = (b, e, atk, t, x, y) => { - const r = t ? t.tileR : Math.round(y), c = t ? t.tileC : Math.round(x); - b.fx('explode', { x, y, r: BOMB_REACH + 0.5, kind: 'bomb', tiles: 'box' }); - if (t) hurt(b, e, t, atk, 'phys'); - // the splash on the 8 tiles is an area selection ("伤害无视迷彩", no 无视无法选择): no unblocking 隐匿 ally - for (const u of areaAlliesInTiles(b, e, r, c, 'box', BOMB_REACH)) if (u !== t) hurt(b, e, u, atk, 'phys', { tags: ['splash'] }); - }; - // the end of the cast (bomb_s): 移速最终提升至200%; it flies on - const finish = (b, e) => { - if (!e.alive) return; - e.pauseUntil = b.time; - if (ms > 0) b.addBuff(e, { key: 'ab:bombRun', mods: { moveMul: ms }, persist: true }); - }; - // the cast before its release: { a: the skill ability, rel: the scheduled release } - let pending = null; - // a stun / freeze / sleep before the release: the bomb stays on; the skill re-arms with its cooldown - const interrupt = (b, e) => { - const { a, rel } = pending; - pending = null; - rel.cancel(); - e.pauseUntil = b.time; - a.left = Math.max(TICK, num(s.cd, 1)); - }; - return [ - { spawn(b, e) { e.profile.noAttack = true; }, - tick(b, e) { if (pending && e.s.flags.stun) interrupt(b, e); } }, - skill(s, (b, e, a) => { - const t = byPriority(e, targetsNear(b, e, reach(e)))[0]; - if (!t) return; - a.cd = Infinity; a.left = Infinity; // 仅能触发一次 - e.skillAnimUntil = -1; // the cast is drawn through its 'atk' event: the client winds the Attack clip up to it - // hovers through the cast, until `finish` (bounded: the bomb lands within its projectile's maxAge, 10 s) - e.pauseUntil = Math.max(e.pauseUntil, b.time + BOMBD_RELEASE + 10 + BOMBD_POST_DELAY); - let landed = false, waited = false; - const done = () => { if (landed && waited) finish(b, e); }; - pending = { a, rel: null }; - pending.rel = b.after(BOMBD_RELEASE, () => { - if (pending && e.alive && e.s.flags.stun) { interrupt(b, e); return; } // stunned after this tick's ability pass - pending = null; - if (!e.alive) return; - const atk = e.s.atk; - b._ev(['atk', e.id, t.id, 'droneBomb']); - setForm(b, e, 'bombed'); // UnitInfo.form: a view built later draws it bomb-less - b.addProjectile({ from: e, target: t, speed: PROJECTILE_SPEEDS.droneBomb, visual: 'droneBomb', source: e, hitDead: true, - onHit: (c) => { land(b, e, atk, c.target, c.x, c.y); landed = true; done(); } }); - b.after(BOMBD_POST_DELAY, () => { waited = true; done(); }, { owner: e }); - }, { owner: e }); - }, { cond: (b, e) => targetsNear(b, e, reach(e)).length > 0 }), - ]; -} - -/** - * 帝国炮火先兆者 / 中枢先兆者 (PRTS): every normal attack fires a shell at the target's position that lands SHELL_FLIGHT s - * later and deals 100 % of the ATK at launch as physical damage without a source to every ally within SHELL_RADIUS - * (it may miss the target that moved away; "碰撞无视迷彩"). The landing is the shell's area selection (areaAllies of the - * enemy that fired it: no unblocking 隐匿 ally; the damage stays 无来源). The attack itself is the engine's (cooldown, - * pause, 'atk' event of kind 'mortar'); the damage is the shell's (ai.js `profile.deferHit`). - */ -function kitShell() { - return [{ - spawn(b, e) { e.profile.deferHit = true; e.profile.shot = 'mortar'; }, - attack(c, b, e) { - const atk = e.s.atk * (e.profile.atkScale ?? 1); - for (const t of c.targets) { - const x = t.x, y = t.y; - b.fx('bombardShell', { x, y, id: e.id, r: SHELL_RADIUS, t: SHELL_FLIGHT }); - b.after(SHELL_FLIGHT, () => { - b.fx('bombard', { x, y, r: SHELL_RADIUS, kind: 'emppnt' }); - for (const u of areaAllies(b, e, x, y, SHELL_RADIUS)) hurt(b, null, u, atk, 'phys', { isSkill: false, tags: ['shell'] }); - }); - } - }, - }]; -} - -function kitIgnitable(ab) { - return [deathBoom({ scale: T(ab, 'boom.atk_scale') ?? 0, type: 'arts', cond: (e) => !!e.mem.ab.ignited, sil: true })]; -} - -function kitFlameVine(ab) { - const add = T(ab, 'pow.add_max_atk') ?? 0, time = T(ab, 'pow.time') ?? 0; - const first = T(ab, 'pow.attack@ep_damage_ratio') ?? 0, normal = T(ab, 'pow.attack@ep_damage_ratio_normal') ?? 0; - return [{ - iv: 0.5, - spawn(b, e, a) { a.t0 = b.time; a.first = true; }, - tick(b, e, a) { - if (!a.first || !(time > 0)) return; - const k = Math.min(1, (b.time - a.t0) / time); - b.addBuff(e, { key: 'ab:pow', mods: { atkPct: add * k }, persist: true }); - }, - dealt(c, b, e, a) { - const t = c.target; - if (a.first) { - // 首次攻击 (PRTS 灼藤): every other ally of the target's 3×3 (range x-4, "格子判定,不受迷彩制约") takes 100 % ATK - // arts splash and everyone there 25 % ATK burn — an area selection (no unblocking 隐匿 ally); then the ATK ramp - // ends and later attacks add 20 % - a.first = false; - b.fx('explode', { x: t.x, y: t.y, r: 1.5, kind: 'flameVine' }); - for (const u of targetAndArea(t, areaAlliesInTiles(b, e, t.tileR, t.tileC, 'box', 1))) { if (u !== t) hurt(b, e, u, e.s.atk, 'arts'); elem(b, e, u, 'burn', e.s.atk * first); } - b.removeBuff(e, 'ab:pow'); - } else elem(b, e, t, 'burn', e.s.atk * normal); - }, - attack(c, b, e) { - // ignites nearby 卷心籽 - for (const o of b.enemiesInRadius(e.x, e.y, 1.5)) if (o.defId === 'enemy_10065_ftzlc' && o.mem.ab && !o.mem.ab.ignited) { o.mem.ab.ignited = true; b.fx('ignite', { x: o.x, y: o.y, id: o.id }); } - }, - }]; -} - -function kitDekght(ab) { - const rage = { atkPct: T(ab, 'triggerrage.atk') ?? 0, aspd: T(ab, 'triggerrage.attack_speed') ?? 0, moveMul: 1 + (T(ab, 'triggerrage.move_speed') ?? 0) }; - const partnerRage = { - otherDeath(c, b, e, a) { - const u = c.unit; - if (a.raged || !u || u === e || !/enemy_1513_dekght/.test(u.defId || '')) return; - a.raged = true; - b.addBuff(e, { key: 'ab:rage', mods: rage, persist: true, visible: true }); - }, - spawn(b, e) { watchDeaths(b, e); }, - }; - return partnerRage; -} - -/** - * 烹泉 / 沏虹 (PRTS 天赋): every normal attack hits its target and every unit within TEA_SPLASH_RADIUS of it ("法术普通伤害 - * (无视迷彩,不可对空)" — splashAttack, blocked or not); 死亡爆炸 "(爆炸半径1.25,造成攻击力100%法术溅射伤害并施加15s【烹泉减益】, - * 无视迷彩,不可对空)", 【烹泉减益】 "攻击速度-40,且固定每3秒额外-0(可被抵抗,可叠加,每层持续时间和效果独立计算)" — DeadBoom. - * attack_speed / duration; one layer per blast, each its own buff. Until 0.1.3: no attack splash; the blast took the attack - * radius (2) and left a 15 s steam zone re-applying the ASPD cut to whoever stood in it [ASSUMED]. The attack splash, a 法术普通 - * 伤害, can be dodged like the attack itself; the blast (法术溅射伤害) cannot. - */ -function kitTeapot(ab) { - return kitDeathSpawn([splashAttack({ unblocked: false, radius: TEA_SPLASH_RADIUS, noAir: true, dodge: true, fxKind: 'artsSplash' }), { - sil: true, - death(c, b, e) { - if (c.reason !== 'killed') return; - const aspd = T(ab, 'DeadBoom.attack_speed') ?? 0, dur = T(ab, 'DeadBoom.duration') ?? 0; - const r = TEA_BOOM_RADIUS, atk = e.s.atk, x = e.x, y = e.y; - // an area selection (no 无视无法选择 note): an airborne 起飞 ally and an unblocking 隐匿 one are skipped, flyers too - b.fx('explode', { x, y, r, kind: 'teaBoom', id: e.id }); - for (const u of areaAllies(b, e, x, y, r)) { - if (u.isFlying) continue; - hurt(b, null, u, atk, 'arts', { tags: ['teaBoom'] }); - if (u.alive && dur > 0 && aspd) b.addBuff(u, { key: `ab:teaBoom:${e.id}`, duration: dur, refresh: 'replace', mods: { aspd }, visible: true }); - } - }, - }])(ab); -} - -function kitNucleus(ab) { - return [{ - taken(c, b, e, a) { if (a.on) return; a.on = true; b.addBuff(e, { key: 'ab:combat', persist: true, visible: true, mods: { moveMul: T(ab, '0.move_speed') ?? 1 } }); }, - iv: T(ab, '1.interval') ?? 1, - // 【孽生者的神经毒素】 "(无视迷彩,同名效果不叠加…)": an area selection (no unblocking 隐匿 ally) - tick(b, e, a) { if (!a.on) return; for (const u of areaAllies(b, e, e.x, e.y, T(ab, '1.range_radius') ?? 0)) elem(b, e, u, 'neural', e.s.atk * (T(ab, '1.ep_damage_ratio') ?? 0)); }, - }]; -} - -function kitLeaderMisc(key, ab, e) { - switch (key) { - case 'enemy_1050_lslime': { - const dot = { dmg: T(ab, 'dot.damage') ?? 0, iv: T(ab, 'dot.interval') ?? 1, dur: T(ab, 'dot.duration') ?? 0 }; - return [maxTargets(4), lowHpBuff(T(ab, 'selfbuff.hp_ratio') ?? 0.5, { aspd: T(ab, 'selfbuff.attack_speed') ?? 0 }), { - dealt(c, b, e2) { - if (!(dot.dur > 0)) return; - b.addBuff(c.target, { key: 'ab:burnDot', duration: dot.dur, refresh: 'replace', interval: dot.iv, visible: true, - onTick: ({ battle, unit }) => battle.dealDamage(e2, unit, { amount: dot.dmg, type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility'] }) }); - }, - }, { - // 被阻挡时会进行自爆 — PRTS “庞贝” 天赋: "被阻挡时,每10秒(受晕眩/无法行动/沉睡/冻结/浮空影响时暂停计时,解除阻挡时重置计时)对 - // 半径1.4范围内的所有我方单位造成1000预计算的无途径法术溅射伤害(不可对空)": the clock runs only while it is blocked and can - // act, and restarts at 0 on every new block (until 0.1.3: a fixed 10 s clock from its spawn, radius 1, flyers hit) - iv: 0, - tick(b, e2, a, dt) { - if (!e2.blockedBy) { a.held = 0; return; } - if (!e2.canAct) return; - a.held = (a.held || 0) + dt; - if (a.held + 1e-9 < (T(ab, 'rangedamage.interval') ?? 10)) return; - a.held = 0; - b.fx('explode', { x: e2.x, y: e2.y, r: POMPEII_BLAST_RADIUS, kind: 'selfBlast' }); - for (const u of areaAllies(b, e2, e2.x, e2.y, POMPEII_BLAST_RADIUS)) if (!u.isFlying) hurt(b, e2, u, T(ab, 'rangedamage.attack@damage') ?? 0, 'arts', { tags: ['splash'] }); - }, - }]; - } - case 'enemy_1500_skulsr': { - // PRTS 碎骨 天赋: "不会攻击飞行单位"; below half HP ATK +50 %; "未被阻挡时发射榴弹对目标及其周围八格的我方单位造成相当于攻击力 - // 26%的物理伤害,并令其在5秒内防御力下降50%" ("榴弹对主目标造成物理普通伤害,对溅射目标造成物理溅射伤害,可溅射飞行单位") — the - // 26 % / 5 s / 3×3 are PRTS's, the −50 % is the blackboard's defdown.def. A blocked attack is its plain melee hit. The - // splash is an area selection: a 隐匿 operator is spared, the one blocking this enemy included (GitHub #97; #32 - // item 6, DESIGN §22.12). Community report #14 (0.1.2): the grenade used to be a full attack + 100 % on 4 neighbours. - const dd = -(T(ab, 'defdown.def') ?? 0); - return [lowHpBuff(T(ab, 'atkup.hp_ratio') ?? 0.5, { atkPct: T(ab, 'atkup.atk') ?? 0 }), noAirTargets(), - splashAttack({ scale: SKULSR_GRENADE_SCALE, tiles: 1, fxKind: 'grenade', - onEach: (b, e2, u) => { if (dd) b.applyStatus(u, 'defDown', { duration: SKULSR_DEFDOWN_DUR, source: e2, value: dd }); } })]; - } - case 'enemy_1502_crowns': { - const s = ab.sk.blink; - return [skill(s, (b, e2) => blinkForward(b, e2, s.bb.dist ?? 1.5), { sil: true, cond: (b, e2) => !!e2.blockedBy })]; - } - case 'enemy_1504_cqbw': { - const s = ab.sk.C4; - return [skill(s, (b, e2) => { - const l = byPriority(e2, targetsNear(b, e2, s.bb.range_radius ?? e2.base.rangeRadius)); - const n = e2.hpRatio < 0.5 ? 2 : 1; // 生命值降至一半以下时炸药包的使用数量增加 - for (const t of l.slice(0, n)) { - const x = t.x, y = t.y; - b.fx('telegraph', { x, y, r: C4_RADIUS, dur: C4_FUSE, kind: 'c4' }); - // the C4 sits on its target (PRTS "在…1个非飞行的我方单位身上安装C4"): it is hit; the blast around it is an area - b.after(C4_FUSE, () => { b.fx('explode', { x, y, r: C4_RADIUS, kind: 'c4' }); for (const u of targetAndArea(t, areaAllies(b, e2, x, y, C4_RADIUS))) hurt(b, e2, u, e2.s.atk * (s.bb.atk_scale ?? 1), 'phys'); }); - } - }, { cond: (b, e2) => targetsNear(b, e2, s.bb.range_radius ?? e2.base.rangeRadius).length > 0 })]; - } - case 'enemy_1509_mousek': { - // 出场时拥有能够吸收大量法术伤害的屏障,屏障存在时防御力大幅增加 (arts-only barrier + DEF while it holds) - const bar = artsBarrier(T(ab, 'shield.dynamic') ?? 0, { key: 'ab:sandDef', whileUp: { defFlat: T(ab, 'defup.def') ?? 0 } }); - const ds = ab.sk.DriftSand, ss = ab.sk.SandStorm; - return [bar, lowHpBuff(T(ab, 'enrage.hp_ratio') ?? 0.5, { dmgDealtMul: T(ab, 'enrage.damage_scale') ?? 1 }), - // 【唱沙】 the highest-max-HP unit on the field and everything in its cross: physical `damage` - skill(ds, (b, e2) => { - const t = allTargets(b, e2).sort((p, q) => q.s.maxHp - p.s.maxHp || aggroCmp(p, q))[0]; - if (!t) return; - b.fx('telegraph', { x: t.tileC, y: t.tileR, r: DRIFT_REACH, kind: 'driftSand', tiles: 'plus', id: e2.id }); - // the cross ("伤害无视迷彩", no 无视无法选择): an area selection - for (const u of areaAlliesInTiles(b, e2, t.tileR, t.tileC, 'plus', DRIFT_REACH)) hurt(b, e2, u, ds.bb.damage ?? 0, 'phys'); - }, { cond: (b, e2) => allTargets(b, e2).length > 0 }), - // 【沙狱】 the lowest-max-HP unit and those around it: ATK −`atk` and `damage` arts per second for `duration` s - skill(ss, (b, e2) => { - const t = allTargets(b, e2).sort((p, q) => p.s.maxHp - q.s.maxHp || aggroCmp(p, q))[0]; - if (!t) return; - const dur = ss.bb.duration ?? 0, x = t.x, y = t.y; - b.fx('zone', { x, y, r: SANDSTORM_RADIUS, dur, kind: 'sandStorm', id: e2.id }); - // the 沙狱弹道 "击中…范围内的所有我方单位(弹道可对空)" selects: an airborne 起飞 ally and an unblocking 隐匿 one - // are skipped (no 无视无法选择 — areaAllies) - for (const u of areaAllies(b, e2, x, y, SANDSTORM_RADIUS)) { - b.addBuff(u, { key: 'ab:sandStorm', duration: dur, refresh: 'replace', interval: 1, visible: true, mods: { atkPct: ss.bb.atk ?? 0 }, - onTick: ({ battle, unit }) => battle.dealDamage(e2, unit, { amount: ss.bb.damage ?? 0, type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility', 'sandStorm'] }) }); - } - }, { cond: (b, e2) => allTargets(b, e2).length > 0 }), - ]; - } - case 'enemy_1511_mdrock': { - // 拥有屏障吸收法术伤害,屏障存在时大幅提升生命上限与攻击速度; 周期性刷新屏障 - const s = ab.sk.RefreshShield; - const bar = artsBarrier(s ? s.bb.dynamic ?? 0 : T(ab, 'shield.dynamic') ?? 0, { key: 'ab:rockPower', - whileUp: { hpPct: (s ? s.bb.max_hp : null) ?? T(ab, 'shield.max_hp') ?? 0, aspd: (s ? s.bb.attack_speed : null) ?? T(ab, 'shield.attack_speed') ?? 0 } }); - return [bar, { - // 攻击时攻击力永久提升,最多六层 (the blackboard ATK is the 6-stack total [ASSUMED split]) - attack(c, b, e2) { b.addBuff(e2, { key: 'ab:rockCharge', refresh: 'stack', stacks: 1, maxStacks: 6, persist: true, mods: { atkPct: (T(ab, 'charge.attack@enemy_mdrock_s_1[charge].atk') ?? 0) / 6 } }); }, - }, skill(s, (b, e2) => bar.refresh(b, e2, bar))]; - } - case 'enemy_1513_dekght': { - // 攻击时使目标与周围四格的单位受到物理伤害; 【蓄力攻击】 charge `duration` s, then ATK×atk_scale on the target's cross - const s = ab.sk.ChargeAttack; - // the 周围四格 of every attack (PRTS "对溅射目标造成物理溅射伤害") and 【蓄力锤】's cross: area selections - return [kitDekght(ab), { before(c, b, e2) { const t = c.targets[0]; if (t) for (const u of areaAlliesInTiles(b, e2, t.tileR, t.tileC, 'plus', 1)) if (!c.targets.includes(u)) c.targets.push(u); } }, - skill(s, (b, e2) => { - const t = e2.blockedBy; - const dur = s.bb.duration ?? 0, r = t.tileR, cc = t.tileC; - b.addBuff(e2, { key: 'ab:charging', duration: dur, visible: true, flags: { disarm: true } }); - b.fx('telegraph', { x: cc, y: r, r: 1, dur, kind: 'chargeAttack', tiles: 'plus', id: e2.id }); - b.after(dur, () => { - if (!e2.alive || e2.s.flags.stun) return; - b.fx('explode', { x: cc, y: r, r: 1, kind: 'chargeAttack', tiles: 'plus' }); - for (const u of areaAlliesInTiles(b, e2, r, cc, 'plus', 1)) hurt(b, e2, u, e2.s.atk * (s.bb['dekght[aoe].atk_scale'] ?? s.bb.atk_scale ?? 1), 'phys'); - }, { owner: e2 }); - }, { cond: (b, e2) => !!(e2.blockedBy && e2.blockedBy.alive) })]; - } - case 'enemy_1513_dekght_2': { - // 同时攻击两名目标; 爆炸箭: three targets, after `interval` s ATK×atk_scale arts on each target's cross - const s = ab.sk.TripleAttack; - const cands = (b, e2) => byPriority(e2, targetsNear(b, e2, e2.base.rangeRadius || 2.5)); - return [kitDekght(ab), maxTargets(2), skill(s, (b, e2) => { - const delay = s.bb['dekght_2[aoe].interval'] ?? 0, scale = s.bb['dekght_2[aoe].atk_scale'] ?? 1; - for (const t of cands(b, e2).slice(0, 3)) { - const r = t.tileR, cc = t.tileC; - b.fx('telegraph', { x: cc, y: r, r: 1, dur: delay, kind: 'blastArrow', tiles: 'plus', id: e2.id }); - b.after(delay, () => { - if (!e2.alive) return; - b.fx('explode', { x: cc, y: r, r: 1, kind: 'blastArrow', tiles: 'plus' }); - // "对主目标造成法术普通伤害,对溅射目标造成法术溅射伤害": the arrow's target + the 周围四格 (an area) - for (const u of targetAndArea(t, areaAlliesInTiles(b, e2, r, cc, 'plus', 1))) hurt(b, e2, u, e2.s.atk * scale, 'arts'); - }, { owner: e2 }); - } - }, { cond: (b, e2) => cands(b, e2).length > 0 })]; - } - case 'enemy_1539_reid': { - // 生命值降至一半以下时攻击力大幅度提升; 首次被击倒后重生 (Reborn.duration s), 恢复一半生命值; - // 【冲锋】 (PRTS 技能 "仅自身未被阻挡且存在符合条件的可选目标时可触发:选择3.0半径内位于自身下个检查点前的后续路径上(包含自身当前 - // 所在地块)的距离自身最近的我方单位,将其所在地块中心设置为自身下一检查点;技能使用成功后,自身移动速度+200%,持续4.5s"): a target - // it may select (targetsNear: no 隐匿 / 迷彩 / 起飞 / untargetable operator) within RUSH_RADIUS standing on its own path to - // the next checkpoint (its tile included) ⇒ move speed ×(1+move_speed) for `duration` s. It walks that path anyway, so - // the re-aim at the target's tile is not a separate move. Until 0.1.3: any unit within the blackboard's 1.5, 隐匿 / 迷彩 - // ones included, off its path too. - const rush = ab.sk.Rush; - const prey = (b, e2) => { - const keys = pathKeysAhead(e2); - return targetsNear(b, e2, RUSH_RADIUS).filter((u) => u.ground && keys.has(u.tileR * COLS + u.tileC)); - }; - return [lowHpBuff(T(ab, 'atkup.hp_ratio') ?? 0.5, { atkPct: T(ab, 'AtkUp.atk', 'atkup.atk') ?? 0 }), - reborn({ dur: T(ab, 'Reborn.duration') ?? 0, hpRatio: T(ab, 'Reborn.hp_ratio') ?? 0.5, invincible: T(ab, 'Reborn.invincible') ?? 0 }), - skill(rush, (b, e2) => { - b.addBuff(e2, { key: 'ab:rush', duration: rush.bb.duration ?? 0, refresh: 'replace', visible: true, mods: { moveMul: 1 + (rush.bb.move_speed ?? 0) } }); - b.fx('charge', { x: e2.x, y: e2.y, id: e2.id, kind: 'avengerRush' }); - }, { cond: (b, e2) => !e2.blockedBy && prey(b, e2).length > 0 })]; - } - case 'enemy_2003_rockman': { - const s = ab.sk.StunAttack; - const cands = (b, e2) => targetsNear(b, e2, (e2.def.raw.stats && e2.def.raw.stats.rawRangeRadius) || 2.5).filter((u) => !u.s.flags.stun); - return [skill(s, (b, e2) => { - const t = byPriority(e2, cands(b, e2))[0]; - if (!t) return; - b.addProjectile({ from: e2, target: t, speed: 8, visual: 'lob', source: e2, onHit: (c) => { - if (!c.target || !c.target.alive) return; - hurt(b, e2, c.target, e2.s.atk * (s.bb.atk_scale ?? 1), 'phys'); - if (c.target.alive) b.applyStatus(c.target, 'stun', { duration: s.bb.stun ?? 0, source: e2 }); - } }); - }, { cond: (b, e2) => cands(b, e2).length > 0 })]; - } - case 'enemy_2004_balloon': { - // 喷气人 · 升空 when blocked → 飞行模式 for `duration` s: PRTS 喷气人 "近地悬浮,不可阻挡,失衡免疫,移动速度+50%,不进行 - // 攻击" — an air unit meanwhile (flag `float`: melee cannot hit it; it keeps the ground path, as setFloat), never - // displaced; "切换为飞行模式后,1.5秒内移动速度最终降低90% … 结束后,1.333秒内不可阻挡,移动速度最终降低90%" (PRTS numbers: - // the blackboard has only the duration and the +50 %). - const s = ab.sk.TakeOff; - const BRAKE = { moveMul: 0.1 }, BRAKE_UP = 1.5, LANDING = 1.333; - return [skill(s, (b, e2) => { - b.addBuff(e2, { - key: 'ab:takeoff', duration: s.bb.duration ?? 0, flags: { unblockable: true, float: true, noDisplace: true }, mods: { moveMul: 1 + (s.bb['balloon_s[fly].move_speed'] ?? 0) }, visible: true, - onExpire: ({ battle }) => { if (e2.alive) battle.addBuff(e2, { key: 'ab:landing', duration: LANDING, flags: { unblockable: true }, mods: BRAKE }); }, - }); - b.addBuff(e2, { key: 'ab:takeoffBrake', duration: BRAKE_UP, mods: BRAKE }); - b.fx('telegraph', { x: e2.x, y: e2.y, r: 0.5, kind: 'takeoff', id: e2.id }); - }, { cond: (b, e2) => !!e2.blockedBy })]; - } - case 'enemy_2005_axetro': - return [{ - attack(c, b, e2, a) { - a.last = b.time; - b.addBuff(e2, { key: 'ab:axeStack', refresh: 'stack', stacks: 1, maxStacks: T(ab, 'atkup.max_stack_cnt') ?? 1, persist: true, mods: { atkPct: T(ab, 'atkup.atk') ?? 0, aspd: T(ab, 'atkup.attack_speed') ?? 0 } }); - }, - iv: 0.5, - tick(b, e2, a) { if (a.last != null && b.time - a.last >= (T(ab, 'checker.delay') ?? 4)) { a.last = null; b.removeBuff(e2, 'ab:axeStack'); } }, - }]; - case 'enemy_2008_flking': { - // 使全战场我方所有单位攻击力、防御力减半 (text; the blackboard's atkdown.atk_scale is not the halving), - // 部署费用回复速度减半,再部署时间加倍; 周期性地添加正比于自身最大生命值的伤害防护屏障. The halving is a map effect - // (PRTS “墓碑” "※文字描述中的削弱实际属于地图效果,不属于敌人本身的能力"): every ally, 隐匿 / airborne ones included - const s = ab.sk.refreshshield; - // its attack — PRTS “墓碑” 天赋: "自身造成的远程途径伤害的攻击倍率降低至40%" (the blackboard's atkdown.atk_scale), "未被阻挡时会 - // 进行远程攻击,对目标及其周围八格内的所有我方单位造成物理伤害,不会攻击飞行单位" ("可溅射飞行单位"); blocked, a melee hit at - // 100 %. Until 0.1.3: a single-target hit at 100 % either way. - return [noAirTargets(), splashAttack({ scale: T(ab, 'atkdown.atk_scale') ?? 1, tiles: 1 }), { - iv: 0.5, - tick(b) { - for (const u of b.allies()) { - auraBuff(b, u, 'ab:tombstone', 0.5, { atkMul: 0.5, defMul: 0.5 }, null, true); - // persistent so it still counts when the unit falls (the redeploy timer is set at death) - b.addBuff(u, { key: 'ab:tombRedeploy', duration: 0.6, refresh: 'replace', persist: true, mods: { redeployMul: 2 } }); - } - }, - }, { - tick(b, e2, a, dt) { for (const p of b.players) b.addDp(p.playerId, -0.5 * b.flags.dpPerSec * dt); }, - }, skill(s, (b, e2) => b.addBuff(e2, { key: 'ab:tombShield', shield: e2.s.maxHp * (s.bb.hp_ratio ?? 0), persist: true }))]; - } - case 'enemy_2048_smgrd': - // 持续在自身周围8格生成【国度】 (the 3×3 around its tile), 大幅降低其中我方单位的攻击速度; 对【国度】中的我方单位造成高额物理伤害. - // 【国度】 is a tile effect ("将该地块及其周围8格范围内的可部署位生成【国度】"): every ally on it, 隐匿 ones too [ASSUMED] - return [{ - iv: 0.5, - tick(b, e2) { for (const u of alliesInTiles(b, Math.round(e2.y), Math.round(e2.x), 'box', 1)) auraBuff(b, u, 'ab:blackFog', 0.5, { aspd: T(ab, 'BlackFog.attack_speed') ?? 0 }, null, true); }, - hitOut(c, b, e2) { - const t = c.target; - if (c.dmg.isAttack && t.side === 'ally' && Math.max(Math.abs(t.tileR - Math.round(e2.y)), Math.abs(t.tileC - Math.round(e2.x))) <= 1) c.dmg.amount *= T(ab, 'DamageUp.atk_scale') ?? 1; - }, - }]; - case 'enemy_2050_smsha': { - const cold = T(ab, 'Attack.attack@freeze') ?? 0, n = T(ab, 'Attack.attack@chain.max_target') ?? 1, fall = T(ab, 'Attack.attack@chain.atk_scale') ?? 1; - const jr = T(ab, 'Attack.attack@projectile_range') ?? 1.6; - const cb = ab.sk.ChainBuff; - return [onHitStatus('cold', cold), { - dealt(c, b, e2) { - let prev = c.target; - const hit = new Set([prev]); - for (let k = 1; k < n; k++) { - // a jump selects within jr of the last target (中点判定): no unblocking 隐匿 or airborne 起飞 ally (areaAllies) - const nx = areaAllies(b, e2, prev.x, prev.y, jr).find((u) => !hit.has(u)); - if (!nx) break; - hit.add(nx); - hurt(b, e2, nx, e2.s.atk * Math.pow(fall, k), 'arts'); - if (nx.alive && cold > 0) b.applyStatus(nx, 'cold', { duration: cold, source: e2 }); - prev = nx; - } - }, - }, - // 【反自然馈赠】 (SILENCE): "attacks" another enemy in range and jumps between up to chain.max_target enemies - // (projectile_range per jump), raising their move speed and ASPD for `duration` s - skill(cb, (b, e2) => { - const bb = cb.bb, jump = bb.projectile_range ?? jr, max = bb['chain.max_target'] ?? 3; - const near = (x, y, r, seen) => b.enemiesInRadius(x, y, r).filter((o) => o !== e2 && !seen.has(o)) - .sort((p, q) => Math.hypot(p.x - x, p.y - y) - Math.hypot(q.x - x, q.y - y) || p.spawnSeq - q.spawnSeq)[0]; - const seen = new Set(); - let prev = e2, cur = near(e2.x, e2.y, e2.base.rangeRadius || 3.5, seen); - while (cur && seen.size < max) { - seen.add(cur); - b.fx('beam', { x: prev.x, y: prev.y, from: prev.id, to: cur.id, kind: 'chainBuff' }); - b.addBuff(cur, { key: 'ab:chainBuff', duration: bb.duration ?? 0, refresh: 'replace', visible: true, mods: { moveMul: 1 + (bb.move_speed ?? 0), aspd: bb.attack_speed ?? 0 } }); - prev = cur; - cur = near(cur.x, cur.y, jump, seen); - } - }, { sil: true, cond: (b, e2) => b.enemiesInRadius(e2.x, e2.y, e2.base.rangeRadius || 3.5).some((o) => o !== e2) })]; - } - case 'enemy_2052_smgia': { - // 数次攻击后晕眩; 受到来自自然环境的伤害时 (terrain damage), 自身在短时间内获得高额脆弱 (damage taken ×damage_scale) - const lim = T(ab, 'Weak.weak[limit]') ?? 0, scale = T(ab, 'Weak.damage_scale') ?? 1; - return [nthAttackStatus(nthOf(ab.sk.StunAttack), 'stun', (ab.sk.StunAttack && ab.sk.StunAttack.bb.stun) || 0, false), { - taken(c, b, e2) { - if (!(lim > 0) || !(c.amount > 0) || !(c.dmg.tags && c.dmg.tags.includes('terrain'))) return; - b.addBuff(e2, { key: 'ab:natureWeak', duration: lim, refresh: 'replace', visible: true, mods: { dmgTakenMul: scale } }); - }, - }]; - } - default: - return []; - } -} - -// --------------------------------------------------------------------------------------------------------------- -// multi-form leaders (悬赏 bounty targets): the first knock-out switches them to their second form (reborn helper) - -/** Distance from point p to segment a–b. */ -function segDist(px, py, ax, ay, bx, by) { - const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy; - const t = L > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / L)) : 0; - return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); -} - -/** A second normal hit on the same target ("二连击"): same damage type, no further on-hit procs. */ -const doubleHit = (on = () => true, extra = null) => ({ - dealt(c, b, e, a) { - if (!on(b, e, a) || !c.target.alive) return; - b.dealDamage(e, c.target, { amount: e.s.atk, type: c.dmg.type, canDodge: true, tags: ['enemyAbility', 'secondHit'], ...(extra ? extra(c, b, e) : {}) }); - }, -}); - -/** 巨大的丑东西 · melee ×rage; KO ⇒ self-destruct (big stun) and the 大祭司 pilot ejects: no attack, unblockable, own DEF/RES, - * faster, loses bird_run.damage true HP per second. (巨蕈/木桩 tiles and its ranged splash attack have no counterpart here: - * its data radius is 0.) */ -function kitUglyThing(ab) { - const rage = T(ab, 'combat.attack@mcmstr_rage_attack.atk_scale') ?? 1; - const bomb = ab.sk['bomb[reborning]'] ? ab.sk['bomb[reborning]'].bb : {}; - const run = { spd: T(ab, 'bird_run.move_speed') ?? 0, def: T(ab, 'bird_run.def'), res: T(ab, 'bird_run.magic_resistance'), dmg: T(ab, 'bird_run.damage') ?? 0 }; - const P = { pilot: false }; - return [ - // 远程攻击造成溅射伤害,近战攻击造成更高伤害 — PRTS 天赋: unblocked, a ranged attack on 1 non-flying unit within 2.5 hitting it - // and its 8 surrounding tiles for 100 % ATK ("可溅射飞行单位"); blocked, a melee hit at mcmstr_rage_attack.atk_scale - { spawn(b, e) { e.base.rangeRadius = Math.max(e.base.rangeRadius || 0, MCM_RANGE); e.profile.canTarget = (u) => !u.isFlying; } }, - splashAttack({ scale: 1, meleeScale: rage, tiles: 1 }), - reborn({ - dur: T(ab, 'reborn.duration') ?? 0, - onKo(b, e) { // 生命值降至0后自爆,造成大范围晕眩 (an area selection), 2.17 s into the 重生 - const x = e.x, y = e.y; - b.fx('telegraph', { x, y, r: MCM_BOMB_RADIUS, dur: MCM_BOMB_DELAY, kind: 'mechBomb', id: e.id }); - b.after(MCM_BOMB_DELAY, () => { - if (!e.alive || e.removed) return; - b.fx('explode', { x, y, r: MCM_BOMB_RADIUS, kind: 'mechBomb', id: e.id }); - for (const u of areaAllies(b, e, x, y, MCM_BOMB_RADIUS)) { - hurt(b, e, u, e.s.atk * (bomb.atk_scale ?? 0), 'phys'); - if (u.alive && bomb.stun > 0) b.applyStatus(u, 'stun', { duration: bomb.stun, source: e }); - } - }, { owner: e }); - }, - onReborn(b, e) { // 大祭司形态: 不进行攻击,无法被阻挡,每秒受到真实伤害 - P.pilot = true; - e.profile.noAttack = true; - b.addBuff(e, { - key: 'ab:pilot', persist: true, visible: true, flags: { unblockable: true }, interval: 1, - mods: { moveMul: 1 + run.spd, defFlat: run.def != null ? run.def - e.base.def : 0, resFlat: run.res != null ? run.res - e.base.res : 0 }, - onTick: ({ battle, unit }) => { if (run.dmg > 0) battle.dealDamage(null, unit, { amount: run.dmg, type: 'true', canDodge: false, tags: ['enemyAbility', 'pilotBurn'] }); }, - }); - }, - }), - ]; -} - -/** 杰斯顿·威廉姆斯 · 狱警: RES +enhance, ranged arts, every (sp+1)th attack stuns max_target units; KO ⇒ (reborn.duration s) - * 杀手: frees every prisoner, DEF/ATK/ASPD/speed up, melee physical, every (sp+1)th attack hits `times` × ignoring def_penetrate. */ -function kitWarden(ab) { - const en = (k) => T(ab, `enhance.${k}`) ?? 0; - const iron = ab.sk.ironsandstorm, pierce = ab.sk.armorpiercing; - const P = { killer: false, n: 0, power: false }; - const pen = () => (pierce && pierce.bb.def_penetrate) || 0; - return [{ - spawn(b, e, a, ab2) { - ab2.atkType = 'arts'; // 攻击造成远程法术伤害 - e.profile.melee = false; - b.addBuff(e, { key: 'ab:warden', persist: true, visible: true, mods: { resFlat: en('magic_resistance') } }); // 法术抗性大幅提升 - }, - before(c, b) { const s = P.killer ? pierce : iron; P.power = !!s && (P.n + 1) % nthOf(s) === 0; }, - hitOut(c) { if (P.killer && P.power && c.dmg.isAttack) c.dmg.defIgnorePct = Math.min(1, (c.dmg.defIgnorePct || 0) + pen()); }, - dealt(c, b, e) { - if (!P.killer || !P.power || !pierce) return; - for (let i = 1; i < (pierce.bb.times ?? 1) && c.target.alive; i++) b.dealDamage(e, c.target, { amount: e.s.atk, type: 'phys', defIgnorePct: pen(), canDodge: true, isSkill: true, tags: ['enemyAbility', 'armorPiercing'] }); - }, - attack(c, b, e) { - P.n++; - if (!P.power || P.killer || !iron) return; - // 能使多名单位晕眩: the skill attack stuns up to max_target units in its range (its target first) - const l = byPriority(e, targetsNear(b, e, e.base.rangeRadius || 2)); - for (const t of c.targets) if (!l.includes(t)) l.unshift(t); - for (const u of l.slice(0, iron.bb.max_target ?? 1)) if (u.alive) b.applyStatus(u, 'stun', { duration: iron.bb.stun ?? 0, source: e }); - }, - }, reborn({ - dur: T(ab, 'reborn.duration') ?? 0, - onKo(b) { freeAllPrisoners(b); }, // 进入杀手形态时解放全场敌人 - onReborn(b, e) { - P.killer = true; P.n = 0; - e.mem.ab.atkType = null; // 攻击造成近战物理伤害 - e.profile.melee = true; - b.removeBuff(e, 'ab:warden'); - b.addBuff(e, { key: 'ab:killer', persist: true, visible: true, - mods: { defFlat: en('def'), atkFlat: en('atk'), batPct: en('base_attack_time') / (e.base.bat || 1), moveFlat: en('move_speed') } }); - }, - })]; -} - -/** “自在” · 【纬地经天】 arts cross on the nearest unit (form 2: + the farthest); 【破桎而出】 barrier — unbroken after its - * duration ⇒ ATK×atk_scale arts around; KO ⇒ second form (ATK up, double hits, stronger 破桎而出). 晦明 attributes n/a. */ -function kitXi(ab) { - const cross = ab.sk.CrossAttack, sb = ab.sk.ShieldBurst, sb2 = ab.sk.ShieldBurstReborn; - const P = { form2: false }; - const byDist = (b, e) => allTargets(b, e).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y) || aggroCmp(p, q)); - const crossAt = (b, e, t) => { - const r0 = t.tileR, c0 = t.tileC; - b.fx('telegraph', { x: c0, y: r0, r: XI_CROSS_REACH, kind: 'xiCross', tiles: 'plus', id: e.id }); - // "伤害无视迷彩", no 无视无法选择: the cross is an area selection - for (const u of areaAlliesInTiles(b, e, r0, c0, 'plus', XI_CROSS_REACH)) if (u.tileR === r0 || u.tileC === c0) hurt(b, e, u, e.s.atk * ((cross && cross.bb.atk_scale) ?? 1), 'arts'); - }; - const burst = (s) => (b, e) => { - const dur = s.bb.duration ?? 0, r = s.bb.range_radius ?? 0; - b.fx('telegraph', { x: e.x, y: e.y, r, dur, kind: 'breakFree', id: e.id }); - b.addBuff(e, { key: 'ab:xiShield', duration: dur, shield: s.bb.dynamic ?? 0, visible: true, - // expiry (not breaking: a broken barrier is removed without onExpire) = the barrier held - onExpire: ({ battle }) => { - if (!e.alive) return; - battle.fx('explode', { x: e.x, y: e.y, r, kind: 'breakFree', id: e.id }); - for (const u of areaAllies(battle, e, e.x, e.y, r)) hurt(battle, e, u, e.s.atk * (s.bb.atk_scale ?? 0), 'arts'); - } }); - }; - const re = reborn({ - dur: T(ab, 'reborn.duration') ?? 0, invincible: T(ab, 'reborn.invincible') ?? 0, - onKo(b, e) { b.removeBuff(e, 'ab:xiShield'); }, - onReborn(b, e) { - P.form2 = true; - b.addBuff(e, { key: 'ab:xiReborn', persist: true, visible: true, mods: { atkPct: T(ab, 'reborn.atk') ?? 0 } }); // 攻击力提升 - if (s2) s2.left = s2.icd; - }, - }); - const s1 = skill(sb, burst(sb), { cond: () => !P.form2 }); - const s2 = skill(sb2, sb2 ? burst(sb2) : null, { cond: () => P.form2 }); - return [re, s1, s2, doubleHit(() => P.form2), // 普通攻击进行两次 - skill(cross, (b, e) => { - const l = byDist(b, e); - if (!l.length) return; - crossAt(b, e, l[0]); - if (P.form2 && l.length > 1) crossAt(b, e, l[l.length - 1]); // 额外对最远目标释放 - }, { cond: (b, e) => allTargets(b, e).length > 0 })]; -} - -/** 锏 · 抵抗; DEF penetration against its blocker; 【速杀】 passes through its blocker hitting those on the way; 【肆虐风雪】 - * AoE; KO ⇒ second form: stealth, more penetration, double hits, 【瞬息杀机】 at every 25 % HP lost (SP cleared + no attacks - * / heals around it). <降雪> absent. */ -function kitMace(ab) { - const pen1 = T(ab, 'DefPenetrate.enemy_blkswb_t_2.def_penetrate') ?? 0, pen2 = T(ab, 'DefPenetrate.enemy_blkswb_t_2[reborn].def_penetrate') ?? pen1; - const cs = { ratio: T(ab, 'ClearSp.hp_ratio') ?? 0, dur: T(ab, 'ClearSp.duration') ?? 0, r: T(ab, 'ClearSp.range_radius') ?? 0 }; - const P = { form2: false, next: 1 }; - const penOn = (c, e) => (c.target === e.blockedBy ? (P.form2 ? pen2 : pen1) : 0); - // its blocker is hit (PRTS 技能 "对之前阻挡自身的单位造成…"); the others on the way are an area selection (areaSelectable) - const blink = (s) => (b, e) => { - const bl = e.blockedBy; - const from = blinkForward(b, e, s.bb.dist ?? 1.5); - if (!from) return; - for (const u of b.allies()) { - if (u !== bl && (segDist(u.x, u.y, from.x, from.y, e.x, e.y) > 0.5 || !areaSelectable(e, u))) continue; - hurt(b, e, u, e.s.atk * (s.bb.atk_scale ?? 1), 'phys'); - } - }; - const circle = (s) => (b, e) => { - const r = s.bb.range_radius ?? 0; - b.fx('explode', { x: e.x, y: e.y, r, kind: 'blizzard', id: e.id }); - for (const u of areaAllies(b, e, e.x, e.y, r)) hurt(b, e, u, e.s.atk * (s.bb.atk_scale ?? 1), 'phys'); - }; - // cast with a target in the circle — the trigger selection (targetsNear: PRTS 选择器 "所有触发选择器通常不无视迷彩"): no - // airborne (起飞) operator for a ground enemy (§21.22), no unblocking 隐匿 or 迷彩 one - const inR = (b, e, s) => targetsNear(b, e, (s && s.bb.range_radius) || 0).length > 0; - return [resist(), { - hitOut(c, b, e) { const p = penOn(c, e); if (c.dmg.isAttack && p > 0) c.dmg.defIgnorePct = Math.min(1, (c.dmg.defIgnorePct || 0) + p); }, - taken(c, b, e) { // 【瞬息杀机】 every hp_ratio of max HP lost (second form) - if (!P.form2 || !(cs.ratio > 0)) return; - while (e.alive && e.hpRatio <= P.next - cs.ratio + 1e-9 && P.next - cs.ratio > 1e-9) { - P.next -= cs.ratio; - b.fx('explode', { x: e.x, y: e.y, r: cs.r, kind: 'clearSp', id: e.id }); - for (const u of areaAllies(b, e, e.x, e.y, cs.r)) { - const sk = u.skill; - if (sk && !sk.noSkill && sk.kind !== 'passive' && !sk.active && sk.spCost > 0) { sk.sp = 0; sk.charges = 0; } // 清空技力 (stored charges too) - if (cs.dur > 0) b.applyStatus(u, 'disarm', { duration: cs.dur, source: e }); - } - } - }, - }, doubleHit(() => P.form2, (c, b, e) => ({ defIgnorePct: penOn(c, e) })), - skill(ab.sk.Blink, blink(ab.sk.Blink || { bb: {} }), { id: 'blink', cond: (b, e) => !P.form2 && !!e.blockedBy }), - skill(ab.sk.Blink2, blink(ab.sk.Blink2 || { bb: {} }), { id: 'blink2', cond: (b, e) => P.form2 && !!e.blockedBy }), - skill(ab.sk.CircleAttack, circle(ab.sk.CircleAttack || { bb: {} }), { id: 'circle', cond: (b, e) => !P.form2 && inR(b, e, ab.sk.CircleAttack) }), - skill(ab.sk.CircleAttack2, circle(ab.sk.CircleAttack2 || { bb: {} }), { id: 'circle2', cond: (b, e) => P.form2 && inR(b, e, ab.sk.CircleAttack2) }), - reborn({ - dur: T(ab, 'Reborn.duration') ?? 0, invincible: T(ab, 'Reborn.invincible') ?? 0, - onReborn(b, e) { P.form2 = true; P.next = 1; b.addBuff(e, { key: 'ab:stealth', flags: { stealth: true }, persist: true }); }, - })]; -} - -/** 扎罗,“狼之主” · first form: damage taken −30 %, not stunnable, 【溶血骇惧】; KO ⇒ a Reborn.duration s 重生 (its HP refills, - * 【远古威慑】 slows the units around it) ⇒ second form: ranged double hits within WOLF_RANGE, ATK/ASPD up, invincible at first, - * 【远古威慑】 still on. 【溶血骇惧】 (PRTS 技能 "使场上最多3名我方单位攻击速度-70,获得无法撤退,逐渐流失生命(从0/秒开始线性递增,在 - * 40秒后达到最大流失速度30%最大生命值/秒),持续时间无限 … 场上存在被此技能影响的单位时狼之主获得静默 … 释放此技能时狼之主记录自身当前 - * 生命值,累计损失20%生命值后解除场上全部我方单位的技能效果"): the FearCage blackboard's attack_speed, hp_ratio (the loss rate - * reached at duration_bleed s), hp_ratio_offset (the cure); its SP (the cast's cooldown, spCost s at +1/s [ASSUMED]) stands - * still while a unit is caught (静默), each ended effect gives back sp, and the last one starts duration_wait s more of 静默 - * ("每结束1个技能效果狼之主回复5SP,场上技能效果全部结束时狼之主获得7s静默"). Until 0.1.3 the loss peaked at 5 % [ASSUMED], the buff - * ended after 40 s, hp_ratio (30 %) was read as the cure and the cast came every spCost s whatever was caught. Not modelled: - * 血债账款 / 【狂暴怒嗥】. */ -function kitWolfLord(ab) { - const fc = ab.sk.FearCage; - const P = { form2: false, caught: 0, waitUntil: -Infinity, cast: null }; - const caught = (b) => b.allies().some((u) => u.findBuff('ab:fearCage')); - const cure = (b) => { for (const u of b.allies()) { const d = u.findBuff('ab:fearCage'); if (d) b.removeBuff(u, d); } }; - // 【远古威慑】 (a buff aura, auraAllies): no 隐匿 operator, the blocker included, an airborne 起飞 one still — as 深池伙友卫队's - const awe = (b, e) => { for (const u of auraAllies(b, e, e.x, e.y, WOLF_AWE_RADIUS)) auraBuff(b, u, 'ab:ancientAwe', 0.2, { aspd: WOLF_AWE_ASPD }, null, true); }; - return [{ - spawn(b, e, a, ab2) { - ab2.immune = new Set([...(ab2.immune || []), 'stun']); // 第一形态: 不可被晕眩 - b.addBuff(e, { key: 'ab:wolfGuard', persist: true, visible: true, mods: { dmgTakenMul: 1 - (T(ab, 'Passive.damage_resistance') ?? 0) } }); - }, - iv: 0.1, - tick(b, e, a, dt) { - if (P.form2) { awe(b, e); return; } // 第二形态: 【远古威慑】生效 - if (!fc) return; - // afflicted units recover once 扎罗 lost hp_ratio_offset of its HP since the cast - const cut = Math.abs(fc.bb.hp_ratio_offset ?? 0.2); - let n = 0; - for (const u of b.allies()) { - const d = u.findBuff('ab:fearCage'); - if (!d || d.data.src !== e) continue; - if (e.hpRatio <= d.data.hp0 - cut + 1e-9) b.removeBuff(u, d); else n++; - } - if (P.cast) { - if (n < P.caught) { // ended effects (cured, knocked out): +sp each - P.cast.left -= (fc.bb.sp ?? 0) * (P.caught - n); - if (n === 0) P.waitUntil = b.time + (fc.bb.duration_wait ?? 0); - } - if (n > 0 || b.time < P.waitUntil) P.cast.left += dt; // 静默: its SP stands still - } - P.caught = n; - }, - }, - // 【溶血骇惧】 every spCost s (enemy SP +1/s [ASSUMED]), first form only, never during its 静默 - P.cast = skill(fc, (b, e) => { - const l = b.rng.shuffle(allTargets(b, e).filter((u) => u.kind === 'op' && !u.findBuff('ab:fearCage'))).slice(0, fc.bb.max_target ?? 3); - const ramp = Math.max(1, fc.bb.duration_bleed ?? 40), peak = fc.bb.hp_ratio ?? 0; - for (const u of l) { - b.fx('beam', { x: e.x, y: e.y, from: e.id, to: u.id, kind: 'fearCage' }); - b.addBuff(u, { key: 'ab:fearCage', refresh: 'replace', visible: true, interval: 1, data: { src: e, hp0: e.hpRatio, t0: b.time }, - mods: { aspd: fc.bb.attack_speed ?? 0 }, - onTick: ({ battle, unit, buff }) => { - // its 扎罗 gone from the field without a 重生 (leaked, removed): the effect ends, no loss this tick [ASSUMED — no - // official text; the 重生's onKo cure covers its knock-out]. Until 0.1.3 a leak left it draining for good. - const src = buff.data.src; - if (!src || !src.alive || src.removed) { battle.removeBuff(unit, buff); return; } - const k = Math.min(1, (battle.time - buff.data.t0) / ramp); - battle.loseHp(unit, unit.s.maxHp * peak * k, { source: e }); - } }); - } - }, { cd: fc ? fc.sp || fc.cd : null, icd: fc ? fc.sp || fc.icd : null, cond: (b, e) => !P.form2 && !caught(b) && b.time >= P.waitUntil && allTargets(b, e).some((u) => u.kind === 'op') }), - doubleHit(() => P.form2), // 攻击变为远程二连击 - reborn({ - dur: T(ab, 'Reborn.duration') ?? 0, invincible: T(ab, 'Passive2.invincible_time') ?? 0, - onKo(b, e) { - cure(b); - b.removeBuff(e, 'ab:wolfGuard'); - if (e.mem.ab.immune) e.mem.ab.immune.delete('stun'); - }, - during(b, e, a, t) { // 重生期间【远古威慑】生效; its HP refills slowly - const dur = T(ab, 'Reborn.duration') ?? 1; - e.hp = Math.max(1, e.s.maxHp * Math.min(1, t / Math.max(TICK, dur))); - awe(b, e); - }, - onReborn(b, e) { - P.form2 = true; - e.profile.melee = false; - e.base.rangeRadius = Math.max(e.base.rangeRadius || 0, WOLF_RANGE); - b.addBuff(e, { key: 'ab:wolfRage', persist: true, visible: true, mods: { atkPct: T(ab, 'Passive2.atk') ?? 0, batPct: (T(ab, 'Passive2.base_attack_time') ?? 0) / (e.base.bat || 1) } }); - }, - })]; -} - -/** - * 转译基底·α (PRTS 转译基底·α 天赋; enemy_database talents Passive / Mode_*_Passive / Mode_Fuchou_Anger; skills ChangeToB/C/D): - * 原始形态 — no attack, every damage instance is cancelled ("受到伤害时取消此伤害"), 失衡免疫, immune to 晕眩/沉睡/寒冷/冻结/浮空/ - * 恐惧. The 4th physical damage instance taken ⇒ 寻仇者, the 4th arts one ⇒ 特战术师, being blocked ⇒ 幽灵 — once ("仅可变化一次"); - * the change takes TRANSLATOR_CHANGE (2) s, standing still and still in the original form [ASSUMED: immobile, damage - * still cancelled]; then the form's flat stat changes apply (move speed relative to the data's 1.0, attack interval - * +N s, 重量等级 +N): - * 寻仇者 (form B): melee only, physical; ATK +100 % while HP < 50 % (Mode_Fuchou_Anger.atk); - * 特战术师 (form D): ranged only (the data's 2.4 radius), arts, 2 targets at once — flyers included [ASSUMED: the - * translator's text "仅进行远程攻击,同时攻击2个目标,造成法术伤害" names no exception; the standalone PRTS 特战术师's talent - * "不会攻击飞行单位" may carry over — then `canTarget: (u) => !u.isFlying` in finish()]; - * 幽灵 (form C): no stat change, unblockable, no attack (PRTS 转译基底·α "幽灵形态 无属性变化;无法被阻挡" — no attack line, - * unlike the armed forms — and the linked PRTS 幽灵 天赋 "不进行普通攻击,无法被阻挡", 攻击方式 不攻击). - * The armed forms attack through the engine's enemy attack (ai.js enemyAttack, `profile.dmgType`), so attack clips, - * projectiles and the attack hooks are the normal ones. Before its change ends it cannot be killed at all (damage is - * cancelled; an HP loss stops at 1 HP) — user report after 0.1.0 (#5): its model never changed (no FORMS clip set), so - * it died on the manifest's die clip, the 寻仇者's B_Die, from its first-form look; and the v2.5 kit let damage through, - * so some lineups killed it before either counter reached 4. fx 'phase' {id, kind: translator_fuchou | _shushi | _youling} starts the model's 2 s - * change clip (render/units.js FORMS). Form letters: B / C / D follow the talents' order Fuchou / Youling / Shushi — C, - * the only clip set without an attack, is the non-attacking 幽灵 (skills ChangeToB / C / D). - */ -function kitTranslator(ab, e) { - const t = (k) => T(ab, k) ?? 0; - const P = { phys: 0, arts: 0, form: null, done: false, anger: false }; - const PRE = { fuchou: 'Mode_Fuchou_Passive', shushi: 'Mode_Shushi_Passive', youling: 'Mode_Youling_Passive' }; - const mods = (pre) => ({ - hpFlat: t(`${pre}.max_hp`), atkFlat: t(`${pre}.atk`), defFlat: t(`${pre}.def`), resFlat: t(`${pre}.magic_resistance`), - moveMul: Math.max(0, 1 + t(`${pre}.move_speed`) / (e.def.moveSpeed || 1)), batPct: t(`${pre}.base_attack_time`) / (e.base.bat || 1), - massFlat: t(`${pre}.mass_level`), - }); - const finish = (b, e2) => { - if (!e2.alive || P.done) return; - P.done = true; - b.removeBuff(e2, 'ab:origin'); - ab.immune = null; - b.addBuff(e2, { key: 'ab:form', persist: true, visible: true, mods: mods(PRE[P.form]), flags: P.form === 'youling' ? { unblockable: true } : null }); - if (P.form !== 'youling') { - const melee = P.form === 'fuchou'; - Object.assign(e2.profile, { noAttack: false, melee, dmgType: melee ? 'phys' : 'arts', maxTargets: melee ? 1 : 2 }); - e2.atkCd = 0; - } - if (e2.route) e2.route.pts = null; - }; - const change = (b, e2, form) => { - if (P.form || !e2.alive) return; - P.form = form; - b.addBuff(e2, { key: 'ab:change', duration: TRANSLATOR_CHANGE, persist: true, flags: { noMove: true }, onExpire: ({ battle }) => finish(battle, e2) }); - setForm(b, e2, `translator_${form}`, 'phase', { dur: TRANSLATOR_CHANGE }); - }; - return [{ - spawn(b, e2) { - b.addBuff(e2, { key: 'ab:origin', persist: true, flags: { noDisplace: true } }); - ab.immune = new Set(TRANSLATOR_IMMUNE); - }, - hitIn(c, b, e2) { - if (P.done) return; - const s = c.source || c.credit, ty = c.dmg.type; - if (!P.form && s && s.side === 'ally') { - if (ty === 'phys' && ++P.phys >= (t('Passive.phy_max_count') || 4)) change(b, e2, 'fuchou'); - else if (ty === 'arts' && ++P.arts >= (t('Passive.magic_max_count') || 4)) change(b, e2, 'shushi'); - } - c.dmg.cancel = true; - }, - // an HP loss (流失 — no 伤害 instance, so not cancelled: 隐德来希's 心烛 hand-over …) cannot knock it out before its - // change ends: its original form has no death clip of its own (A_Die_B / _C / _D are the changes) [ASSUMED floor 1 HP] - killed(c, b, e2) { - if (P.done) return false; - e2.hp = Math.max(1, e2.hp); - return true; - }, - blocked(c, b, e2) { change(b, e2, 'youling'); }, - tick(b, e2) { - // 寻仇者: "生命值低于50%时,攻击力+100%" - if (P.form !== 'fuchou' || !P.done) return; - const on = e2.hpRatio < 0.5; - if (on === P.anger) return; - P.anger = on; - if (on) b.addBuff(e2, { key: 'ab:anger', persist: true, visible: true, mods: { atkPct: t('Mode_Fuchou_Anger.atk') } }); - else b.removeBuff(e2, 'ab:anger'); - }, - }]; -} - -/** 乌顶巨角卢鲁 · 【角力对决】 when blocked: charges `duration` s (no normal attacks; a stun or 失衡 interrupts), then hits its - * blocker; operators hold fixed tiles, so the push always fails ⇒ extra damage and a fail_duration stun. */ -function kitElk(ab) { - const s = ab.sk.skill; - const P = { ch: null }; - const stop = (b, e) => { P.ch = null; b.removeBuff(e, 'ab:elkCharge'); }; - return [{ - tick(b, e) { - const ch = P.ch; - if (!ch) return; - if (e.s.flags.stun || !ch.t.alive || e.blockedBy !== ch.t) { stop(b, e); b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'chargeBroken' }); return; } - if (b.time + 1e-9 < ch.until) return; - stop(b, e); - b.fx('explode', { x: ch.t.x, y: ch.t.y, r: 0.5, kind: 'elkClash' }); - hurt(b, e, ch.t, e.s.atk * ((s && s.bb.atk_scale_s) ?? 1) * ELK_FAIL_SCALE, 'phys'); - const st = T(ab, 'data.attack@fail_duration') ?? 0; - if (ch.t.alive && st > 0) b.applyStatus(ch.t, 'stun', { duration: st, source: e }); - }, - }, unbalanced((b, e) => { if (P.ch) stop(b, e); }), - skill(s, (b, e) => { - const dur = s.bb.duration ?? 0; - P.ch = { t: e.blockedBy, until: b.time + dur }; - b.addBuff(e, { key: 'ab:elkCharge', duration: dur + 0.1, visible: true, flags: { disarm: true } }); - b.fx('telegraph', { x: e.blockedBy.x, y: e.blockedBy.y, r: 0.5, dur, kind: 'elkCharge', id: e.id }); - }, { cond: (b, e) => !P.ch && !!(e.blockedBy && e.blockedBy.alive) })]; -} - -// --------------------------------------------------------------------------------------------------------------- -// KITS: enemyKey → (ab, e, battle) => ability list. One line per key (name · what). +// KITS: enemyKey → (ab, e, battle) => ability list. One line per key (name · what), in the family files +// (enemies/*.js); spread here in the original order — the same keys in the same order as before the split. export const KITS = Object.freeze({ - // --- INVISIBLE 隐匿 - enemy_1009_lurker: kitStealth, // 潜伏者 · stealth - enemy_1019_jshoot: kitStealth, // 隐形弩手 · stealth - enemy_1019_jshoot_2: kitStealth, // 隐形弩手组长 · stealth - enemy_1023_jmage: kitStealth, // 隐形术师 · stealth - enemy_1283_sgkill: kitStealth, // 家族灭迹人 · stealth (清算时刻 n/a) - enemy_1283_sgkill_2: kitStealth, // 家族暗影灭迹人 · stealth - enemy_1299_ymkilr: kitShadowKiller, // 山海众头目 · stealth; its 隐匿 turning on brings the mark: next attack ×InvisibleCombat.atk_scale (§22.8) - enemy_1299_ymkilr_2: kitShadowKiller, // 山海众秘使 · same - enemy_1389_winbab_2: kitStealth, // 访问团强攻冠军 · stealth (供暖器 priority n/a) - enemy_1404_msnip: kitCrossbow, // 重弩突袭者 · stealth + 直击 (row/column bolt: arts + stun, reveals itself) - enemy_10031_cnvsld: kitStealth, // 业余竞演者 · stealth - enemy_10034_cnvsax: kitJazz, // 节日爵士乐手 · stealth; revealed: attacks its blocker only; channel on one target (arts + burn) - enemy_10042_prtrop: kitStealth, // 架桥船工 · stealth (bridges n/a) - enemy_10042_prtrop_2: kitStealth, // 扶桥老手 · stealth - enemy_9008_acbunn: kitBoneSpike, // 假想敌:骨刺 · stealth; hits 3 targets while stealthed - enemy_1175_dushdo_2: kitShadowBlade, // 深池伙友影刃精英 · stealth; BAT −1.3 s next to 卫队精英 - enemy_2034_sythef: (ab) => [stealth(), onHitStatus('stun', T(ab, 'Combat.attack@stun'))], // 流泪小子 · stealth, attacks stun - enemy_2034_sythef_2: (ab) => [stealth(), onHitStatus('stun', T(ab, 'Combat.attack@stun'))], // 流泪小子 · same - - // --- TIMES 频次 (hit-count units + their creators) - enemy_1196_msfyin: kitTimes(), // 木制瑞印 · 2 hits, unblockable - enemy_1196_msfyin_2: kitTimes(), // 红木瑞印 · 3 hits - enemy_1198_msfshu: kitTimes(), // 小说卷轴 · 3 hits - enemy_1198_msfshu_2: kitTimes(), // 诗画卷轴 · 4 hits - enemy_1200_msfjin: kitTimes(), // 青铜镜 · 30 hits - enemy_1200_msfjin_2: kitTimes(), // 黄铜镜 · 35 hits - enemy_1202_msfzhi: kitTimes(), // 木制镇纸 · 3 hits - enemy_1202_msfzhi_2: kitTimes(), // 红木镇纸 · 4 hits - enemy_1204_msfhu: kitTimes(true), // 青瓷茶器 · 4 arts/true hits - enemy_1204_msfhu_2: kitTimes(true), // 彩瓷茶器 · 6 arts/true hits - enemy_1208_msfji: kitTimes(), // 铜矛头 · 25 hits - enemy_1208_msfji_2: kitTimes(), // 铁矛头 · 30 hits - enemy_1210_msfden: kitTimes(), // 铜灯盘 · 35 hits - enemy_1210_msfden_2: kitTimes(), // 铁灯盘 · 45 hits - enemy_1195_sfyin: kitDeathSpawn(), // 磨砻 · death: 2 木制瑞印 - enemy_1195_sfyin_2: kitDeathSpawn(), // 明鉴 · death: 2 红木瑞印 - enemy_1197_sfshu: kitDeathSpawn(), // 俗心 · death: 3 小说卷轴 - enemy_1197_sfshu_2: kitDeathSpawn(), // 雅气 · death: 3 诗画卷轴 - enemy_1199_sfjin: kitDeathSpawn(), // 身观 · death: 1 青铜镜 (taunt from data) - enemy_1207_sfji: kitBlades, // 沉沙 · each attack spends a blade (+ATK); death: unspent blades → 铜矛头 (≥1) - enemy_1207_sfji_2: kitBlades, // 新硎 · same (铁矛头) - enemy_1209_sfden: kitInvisShield, // 清明 · veils nearby enemies (stealth) every 15 s; death: 铜灯盘 - enemy_1209_sfden_2: kitInvisShield, // 堂皇 · same (铁灯盘) - enemy_1203_sfhu: kitTeapot, // 烹泉 · death: 4 青瓷茶器 + ASPD-down arts blast zone - enemy_1203_sfhu_2: kitTeapot, // 沏虹 · death: 4 彩瓷茶器 + blast zone - enemy_1288_duskls: kitEmber, // 深池逐火战士 · every KO: 1 s 重生 ⇒ walking 隐匿 5-hit ember (block it to hit it), back after 10 s - enemy_1288_duskls_2: kitEmber, // 深池逐火精锐战士 · same - enemy_1292_duskld: kitEmber, // 深池逐火护卫 · 10-hit 火灰 - enemy_9010_acpupp: kitRegen, // 假想敌:再生 · every KO: 1 s 重生 ⇒ walking unblockable 15-hit puppet (15 s) + 5-hit shields within 1.8 - - // --- ELEMENT 元素 - enemy_1148_dssbr: kitEp('neural', 'epdamage.attack@ep_damage_ratio'), // 底海滑动者 · neural on hit - enemy_1148_dssbr_2: kitEp('neural', 'epdamage.attack@ep_damage_ratio'), // 富营养的滑动者 · neural on hit - enemy_1158_divman: (ab) => kitDeepsea(ab, { swim: true }), // 潜水员 · in deep water: ATK up + stealth; immune to 水蚀 - enemy_1160_hvyslr: (ab) => [...kitStun3(ab), ...kitDeepsea(ab, { drown: true })], // 码头水手 · every (spCost+1)th attack stuns; drowns in deep water - enemy_1160_hvyslr_2: (ab) => [...kitStun3(ab), ...kitDeepsea(ab, { drown: true })], // 码头水手长 · same - enemy_1161_tidmag: kitTidmag, // 控潮术师 · attack hits target + 4 neighbours, erosion - enemy_1161_tidmag_2: kitTidmag, // 领潮员 · same - enemy_1162_magmot: (ab) => [...kitTidmag(ab), deathSpawn(ab.tS['DeathRattle.enemy_key'], 1)], // 术师快艇 · same + 控潮术师 on death - enemy_1305_mhslim: kitEp('burn', 'EpDamage.attack@ep_damage_ratio'), // 灼热源石虫 · burn on hit - enemy_1305_mhslim_2: kitEp('burn', 'EpDamage.attack@ep_damage_ratio'), // 炽焰源石虫 · burn on hit - enemy_2021_syfish: kitEp('erosion', 'EpDamage.attack@ep_damage_ratio'), // 骨海漂流体 · erosion on hit - enemy_2025_syufo: kitSyufo, // 掠海漂移体 · 近地悬浮 (drops to 爬行模式 when stunned) + erosion - enemy_1229_darmy: kitEp('apoptosis', 'epdamage.attack@ep_damage_ratio'), // 萨卡兹王庭军战士 · apoptosis on hit - enemy_1229_darmy_2: kitEp('apoptosis', 'epdamage.attack@ep_damage_ratio'), // 萨卡兹王庭军精锐战士 · apoptosis on hit - enemy_1275_dwlock_2: kitDeathEye, // 萨卡兹王庭军精锐术师 · apoptosis on hit + DeathEye channel → group apoptosis - enemy_1439_dslntf: kitNucleus, // 元核孽生者 · first damage: combat state (speed ×, neural pulse around) - enemy_1439_dslntf_2: kitNucleus, // 异光体孽生者 · same - enemy_9007_acelem: kitParasite, // 假想敌:淤困 · taunt −1; parasitises its blocker (arts/s, element taken ×, its burst spreads 1000) - enemy_10065_ftzlc: kitIgnitable, // 卷心籽 · ignited by 灼藤 → arts death blast - enemy_10067_ftsjc: kitFlameVine, // 灼藤 · ATK ramps until the 1st attack (3×3 arts + burn), burn on hit, ignites 卷心籽 - - // --- DOT 持续 - enemy_1234_dsubrl: kitDsubrl, // 深溟巢涌者 · no attack: an arts pulse on every ally in range each second + neural; 抵抗, immune 停顿 - enemy_1234_dsubrl_2: kitDsubrl, // 富营养的巢涌者 · same - enemy_1267_nhpbr: kitPolluted, // 萨卡兹枯朽战士 · death: 污染秽蚀 zone (50 / 25 true per second) - enemy_1267_nhpbr_2: kitPolluted, // 萨卡兹枯朽战士组长 · same - enemy_1270_nhstlk: (ab) => [bleed(ab)], // 逐腐兽 · bleeding (arts/s, cleared by healing) - enemy_1270_nhstlk_2: (ab) => [bleed(ab)], // 疯狂的逐腐兽 · same - enemy_1272_nhtank: kitTank, // 萨卡兹枯朽战车 · ground targets only, melee ×2, 秽蚀轰击 on attacks 1, 4, 7 … - enemy_1272_nhtank_2: kitTank, // 尖端萨卡兹枯朽战车 · same - enemy_9006_actoxi: (ab) => [{ // 假想敌:蚀裂 · death: poison cloud on its killer - sil: true, - death(c, b, e) { - if (c.reason !== 'killed') return; - const k = c.killer && c.killer.side === 'ally' && c.killer.alive ? c.killer : null; - const atk = e.s.atk; - // a sourceless zone (`src` null): its ticks skip a 隐匿 / untargetable / sleeping operator (PRTS 假想敌:蚀裂: no 无视 - // 可选性 note — PRTS 作战机制's "无来源的毒雾" that reaches 隐匿 units is the stage hazard), not an airborne 起飞 one - const cloud = (x, y) => dmgZone(b, null, x, y, T(ab, '1.projectile_range') ?? 0.8, T(ab, '1.projectile_life_time') ?? 0, T(ab, '1.interval') ?? 1, atk * (T(ab, '1.damage_atk_scale') ?? 0), 'arts', 'poison'); - if (k) b.addProjectile({ from: e, target: k, speed: 8, visual: 'lob', source: null, hitDead: true, onHit: (h) => cloud(h.x ?? k.x, h.y ?? k.y) }); - else cloud(e.x, e.y); - }, - }], - // 集团军重型火炮 · PRTS 天赋: "不会攻击飞行单位"; the shell hits every unit within 1.0 of its target for 100 % ATK ("此弹道会强制 - // 击中主目标,碰撞无视迷彩,不可对空"; no splash until 0.1.3) and each hit on the main target leaves a 3 s 【燃烧区域】 of radius - // projectile_range × UACANN_ZONE_SCALE - enemy_10122_uacann_2: (ab) => [noAirTargets(), splashAttack({ unblocked: false, radius: SHELL_SPLASH_RADIUS, noAir: true, fxKind: 'shell' }), { - dealt(c, b, e) { - // 【燃烧区域】 "不可对空": a flying ally (the 炎佑 dragon) standing in it takes nothing (until 0.1.3 it burned) - dmgZone(b, e, c.target.x, c.target.y, (T(ab, 'ProjectileBoomRange.attack@projectile_range') ?? 1) * UACANN_ZONE_SCALE, T(ab, 'ProjectileBoomRange.attack@projectile_life_time') ?? 0, 1, T(ab, 'ProjectileBoomRange.attack@value') ?? 0, 'arts', 'burning', null, 0, { noAir: true }); - }, - }], - enemy_10054_cjhot: (ab, e) => [immuneTo('sluggish'), { // 鼎沸 · pulses arts + burn around itself; immune 停顿 - iv: e.base.bat || 1, - tick(b, e2) { - const r = (e2.def.raw.stats && e2.def.raw.stats.rawRangeRadius) || 1.6; - b.fx('explode', { x: e2.x, y: e2.y, r, kind: 'boil' }); - for (const u of areaAllies(b, e2, e2.x, e2.y, r)) { hurt(b, e2, u, e2.s.atk * (T(ab, 'aoe.atk_scale') ?? 1), 'arts'); elem(b, e2, u, 'burn', e2.s.atk * (T(ab, 'aoe.ep_damage_ratio') ?? 0)); } - }, - }], - - // --- REFLECTION 折射 - enemy_1165_duhond: kitRefraction, // 深池侦察犬 · refraction - enemy_1165_duhond_2: kitRefraction, // 深池侦察犬pro · refraction - enemy_1166_dusbr: kitRefraction, // 深池侦察兵 · refraction - enemy_1166_dusbr_2: kitRefraction, // 深池侦察队长 · refraction - enemy_1168_dumage: kitRefraction, // 深池暗影术师 · refraction - enemy_1168_dumage_2: kitRefraction, // 深池暗影术师队长 · refraction - enemy_1170_dushld: kitRefraction, // 深池重甲卫士 · refraction - enemy_1170_dushld_2: kitRefraction, // 深池重甲卫士队长 · refraction - enemy_1169_duphlx: kitPhalanx, // 深池方阵步兵 · refraction + DEF +200 per nearby same unit - enemy_1169_duphlx_2: kitPhalanx, // 深池方阵指挥官 · same - enemy_1172_dugago: (ab) => [...kitRefraction(ab), statue(ab)], // 守墓石像 · refraction; melee when blocked; 1st KO → unblockable statue 10 s → arts flyer - enemy_1172_dugago_2: (ab) => [...kitRefraction(ab), statue(ab)], // 愤怒的守墓石像 · same - enemy_1174_duholy: kitHolyGuard, // 深池伙友卫队 · refraction; ASPD-down field next to 影刃 (taunt from data) - enemy_1174_duholy_2: kitHolyGuard, // 深池伙友卫队精英 · same - enemy_9011_acrefr: (ab) => [refraction(T(ab, 'Refracting.magic_resistance') ?? 0, T(ab, 'Refracting.max_hp') ?? 0)], // 假想敌:镜膜 · refraction + max HP while active - - // --- FLY 飞行 (motion is engine; abilities) - enemy_1017_defdrn: (ab) => [enemyAura(T(ab, 'defup.range_radius') ?? 2.5, 'ab:defdrn', { defFlat: T(ab, 'defup.def') ?? 0 })], // 御4 · DEF aura - enemy_1355_mrfly: (ab, e) => [enemyAura(e.base.rangeRadius || 2.5, 'ab:mrfly', { resFlat: T(ab, 'magdef_add.magic_resistance') ?? 0 })], // 护障 · RES aura - enemy_1355_mrfly_2: (ab, e) => [enemyAura(e.base.rangeRadius || 2.5, 'ab:mrfly', { resFlat: T(ab, 'magdef_add.magic_resistance') ?? 0 })], // 护障·P · RES aura - enemy_1042_frostd: (ab) => [allyAura(T(ab, 'defup.range_radius') ?? 2.5, 'ab:frost', { aspd: (T(ab, 'atkSpeedDown.attack_speed') ?? 0) * 100 })], // 寒霜 · ASPD −50 aura on operators - enemy_1040_bombd: kitBombd, // 暴鸰 · no normal attack: ONE bomb (target + 8 tiles), then ×2 speed - enemy_10083_hlbird: kitSelfFear, // “萨科塔之翼” · below half HP: 5 s self-fear, flutters in its tile ×1.5 - enemy_10084_hlegle: kitSteal, // “萨科塔之眼” · fear below half; steals 1 ammo instead of hitting - enemy_10085_hllevi_2: kitRoar, // “萨科塔昂首” · fear below half; 祈祷邀约 global ASPD −30 - enemy_1407_hummbd: kitExposeOnDeath, // 远眺 · death: exposes operators around (damage taken ×1.2) - enemy_9009_acfort: kitBlackCloud, // 假想敌:黑云 · devours ≤ 3 normal flyers for ammo, fires it all at random allies - enemy_1112_emppnt: kitShell, // 帝国炮火先兆者 · attacks are shells landing 3 s later (r 1.2) - enemy_1112_emppnt_2: kitShell, // 帝国炮火中枢先兆者 · same - enemy_1321_wdarft: (ab) => [skill(ab.sk.BornBugs, (b, e) => spawnChildren(b, e, 'enemy_1269_nhfly', BUGS_PER_CAST))], // 枯朽萃聚使徒 · spawns 枯朽之种 - enemy_1269_nhfly: () => [{ // 枯朽之种 · no normal attack: dives onto a nearby operator and self-destructs - spawn(b, e) { e.profile.noAttack = true; }, - tick(b, e, a, dt) { - if (a.t && !a.t.alive) { a.t = null; b.removeBuff(e, 'ab:dive'); } - if (!a.t) { - const l = targetsNear(b, e, e.base.rangeRadius || 1).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y)); - if (!l.length) return; - a.t = l[0]; - b.addBuff(e, { key: 'ab:dive', flags: { noMove: true } }); - } - if (!stepToward(e, a.t.x, a.t.y, Math.max(e.s.moveSpeed, 0.5) * MOVE_SCALE * dt) && Math.hypot(a.t.x - e.x, a.t.y - e.y) > 0.3) return; - hurt(b, e, a.t, e.s.atk, 'phys'); - b.fx('explode', { x: e.x, y: e.y, r: 0.5, kind: 'seed' }); - b.kill(e, null); - }, - }], - - // --- SPECIAL 特异 / others - enemy_1045_hammer: kitStun3, // 粉碎攻坚手 · every 3rd attack stuns - enemy_1045_hammer_2: kitStun3, // 粉碎攻坚组长 · same - enemy_1116_liprr: kitPrisoner(), // 普通囚犯 · confined (ASPD −50) → freed after 4 attacks (ATK +50 %) - enemy_1116_liprr_2: kitPrisoner(), // 老练囚犯 · same - enemy_1118_lidbox_2: kitPrisoner(), // 拳师囚犯 · + DEF penetration when freed - enemy_1119_vofsd: kitPrisoner(), // 强壮囚犯 · + RES and regen when freed - enemy_1121_lifbos: kitPrisoner(true), // 重犯 · confined DEF up; first liberation frees every prisoner - enemy_1121_lifbos_2: kitPrisoner(true), // 传奇重犯 · same - enemy_1072_dlancer: kitRush, // 萨卡兹穿刺手 · accelerates while walking; first hit after block ×speed - enemy_1320_wdrrl_2: kitFirstAoe, // 萨卡兹悖谬暴虐兵长 · block ≥3 only; first attack splashes - enemy_1302_ymtro_2: () => [blockWeight(4)], // “越长尘” · block ≥4 only (passengers n/a) - enemy_1329_cbshld: kitDefDecay, // 弧光镜卫 · DEF/RES drop with every damage instance - enemy_1329_cbshld_2: kitDefDecay, // 弧光镜卫长 · same - enemy_1249_lysdb_2: (ab) => [{ spawn(b, e, a, ab2) { ab2.hitShield = T(ab, 'Shield.max_block_damage_cnt') ?? 0; } }], // 莱茵生命防卫科高级成员 · blocks one phys/arts hit - enemy_1402_tgshd_2: kitExposeOnHit, // 重装侦察兵 · exposes its attackers 5 s - enemy_1081_sotisd: (ab) => [taunt(T(ab, 'taunt.taunt_level') ?? 0)], // 游击队盾卫 · taunt +1 - enemy_1427_lrnazg: kitNazg, // “灵幛” · attack hits everyone around it (+ on infection tiles) - enemy_1422_lrsldr: () => [infectionArts()], // 萨卡兹枯朽前锋 · arts attacks on 源石污染区 (infection tiles) - enemy_1422_lrsldr_2: () => [infectionArts()], // 萨卡兹枯朽辟路前锋 · same - enemy_1425_lrcmra: kitChimera, // 孽罪奇美拉 · activated on infection: arts attacks + pollution aura - enemy_1425_lrcmra_2: kitChimera, // 渎罪奇美拉 · same - enemy_1430_lrrook: (ab) => [{ // 愧悔魂灵圣杯 · shares damage taken by nearby ground enemies - spawn(b, e, a) { a.r = T(ab, 'takeDmg.range_radius') ?? 1.7; a.share = T(ab, 'takeDmg.damage_scale') ?? 0; stOf(b).chalices = (stOf(b).chalices ?? 0) + 1; }, - death(c, b) { stOf(b).chalices = Math.max(0, (stOf(b).chalices ?? 1) - 1); }, - }], - enemy_1025_reveng: (ab) => [lowHpBuff(0.5, { atkPct: T(ab, 'atkup.atk') ?? 0 })], // 寻仇者 · ATK up below half HP - enemy_1021_bslime: (ab) => [deathBoom({ scale: T(ab, 'boom.atk_scale') ?? 0 })], // 高能源石虫 · death: phys blast - enemy_1067_snslime: (ab, e) => [deathBoom({ scale: T(ab, 'boom.atk_scale') ?? 0, r: (e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || BOOM_RADIUS, status: { key: 'cold', dur: T(ab, 'boom.freeze') ?? 0 } })], // 冰爆源石虫 · death: phys blast + cold - enemy_1069_icebrk_2: (ab) => [{ hitOut(c, b, e) { if (c.dmg.isAttack && c.target.s.flags.freeze) c.dmg.amount *= T(ab, 'atkup.atk_scale') ?? 1; } }], // 雪怪小队破冰者 · ×3 vs frozen - 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) => { // 高准度伦蒂尼姆城防自行炮 · 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; - // each shot picks inside the 9-tile zone (格子判定): an area selection — a 迷彩 ally may be shot, an unblocking 隐匿 - // one not (areaSelectable; it used to skip 迷彩 like a normal attack) - const t = b.allies().filter((a) => areaSelectable(e, a) && 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) => [{ - death(c, b, e) { - const key = ab.tS['Summon.enemy_key'], n = T(ab, 'Summon.cnt') ?? 0; - if (c.reason !== 'killed' || !key || !(n > 0)) return; - spawnChildren(b, e, key, n, { route: stayRoute(e), countInTotal: false, mods: null }); - }, - }, { - spawn(b, e) { watchDeaths(b, e); }, - otherDeath(c, b, e) { - const u = c.unit; - if (!u || u === e || c.reason !== 'killed' || Math.hypot(u.x - e.x, u.y - e.y) > (T(ab, 'Attack.range_radius') ?? TORTURER_RADIUS) + 1e-9) return; - b.heal(e, e, e.s.maxHp * (T(ab, 'Attack.hp_ratio') ?? 0), { self: true }); - b.addBuff(e, { key: 'ab:torture', refresh: 'stack', stacks: 1, maxStacks: T(ab, 'Attack.max_stack_cnt') ?? 1, persist: true, mods: { atkPct: T(ab, 'Attack.atk') ?? 0 } }); - }, - hitOut(c, b, e) { if (c.dmg.isAttack && c.target !== e.blockedBy) c.dmg.amount *= T(ab, 'Attack.attack@ranged_atk_scale') ?? 1; }, - }], - enemy_1501_demonk: () => [maxTargets(2)], // 萨卡兹百夫长 · attacks 2 targets - enemy_10018_sgrobh: () => [maxTargets(2)], // “独轮车玩具” · attacks 2 targets - enemy_2001_duckmi: (ab) => [unblockable(), runWhenHit(T(ab, 'run.attack@move_speed') ?? 0)], // 鸭爵 · unblockable, no attack; runs +400 % once hurt - enemy_2001_duckmi_2: (ab) => [unblockable(), runWhenHit(T(ab, 'run.attack@move_speed') ?? 0)], // 鸭爵 (鸭爵 strategy) · same, +300 % - enemy_10159_mntrjn: () => [unblockable()], // 伊利昂的木驮兽 · unblockable (passengers n/a) - enemy_2009_csaudc: kitEp('neural', 'combat.attack@ep_damage_ratio'), // 骇笑看客 · neural on hit - enemy_2010_csdcr: (ab) => [ep('neural', T(ab, 'attack.attack@ep_damage_ratio') ?? 0), { // 绯红歌伶 · neural on hit; every 20 hits taken: global enemy ASPD up - taken(c, b, e, a) { - a.n = (a.n ?? 0) + 1; - if (a.n < (T(ab, 'AttackSpeedUp.stack_cnt') ?? Infinity)) return; - a.n = 0; - b.fx('telegraph', { x: e.x, y: e.y, r: 99, kind: 'songOfWar', id: e.id }); - for (const o of b.aliveEnemies()) b.addBuff(o, { key: 'ab:songOfWar', duration: T(ab, 'AttackSpeedUp.duration') ?? 0, refresh: 'extend', mods: { aspd: T(ab, 'AttackSpeedUp.attack_speed') ?? 0 }, visible: true }); - }, - }], - enemy_10001_trslim: (ab) => [skill(ab.sk.StartRun, (b, e) => { // 简饲源石虫 · below half: runs (faster, unblockable 3 s) - const s = ab.sk.StartRun.bb; - b.addBuff(e, { key: 'ab:run', duration: s.block_free_time ?? 0, mods: { moveMul: 1 + (s.move_speed ?? 0) }, flags: { unblockable: true }, visible: true }); - }, { sil: true, cond: (b, e) => e.hpRatio < 0.5 })], - enemy_10027_vtsk: (ab) => { // “帝国的甲胄” · entrance barrage on the highest-HP unit; ranged ×0.8; 3-hit charge attack - const ap = ab.sk.Appear, mc = ab.sk.MultiCombat; - return [{ - spawn(b, e) { - const n = ap ? ap.bb.times ?? 0 : 0; - b.after(0.5, () => { - if (!e.alive || !n) return; - const lock = allTargets(b, e).sort((p, q) => q.hp - p.hp)[0]; - if (!lock) return; - b.fx('telegraph', { x: lock.x, y: lock.y, r: 1, kind: 'barrage', id: e.id }); - for (let i = 0; i < n; i++) { - // "对碰撞范围内的1名当前生命值最高的我方单位" — an area selection: never an airborne 起飞 ally nor an unblocking - // 隐匿 one (PRTS “帝国的甲胄”; areaAlliesInTiles) - const t = areaAlliesInTiles(b, e, lock.tileR, lock.tileC, 'box', 1).sort((p, q) => q.hp - p.hp)[0]; - if (t) hurt(b, e, t, e.s.atk, 'phys'); - } - }, { owner: e }); - }, - hitOut(c, b, e) { if (c.dmg.isAttack && c.target !== e.blockedBy) c.dmg.amount *= T(ab, 'range.attack@atk_scale_range') ?? 1; }, - }, mc ? { - cd: mc.cd, icd: mc.icd, fire(b, e) { e.mem.ab.multi = mc.bb.times ?? 1; }, - dealt(c, b, e) { - const m = e.mem.ab.multi; - if (!(m > 1)) return; - e.mem.ab.multi = 0; - for (let i = 1; i < m; i++) hurt(b, e, c.target, e.s.atk, 'phys'); - }, - } : null]; - }, - enemy_10044_wintun: (ab) => { // 咸鳞汁推荐者 · fast with the barrel; first attack (SILENCE): big hit + buff zone for enemies - const s = ab.sk.BlockedBoom ? ab.sk.BlockedBoom.bb : {}; - return [{ - sil: true, // silenced: the barrel is kept (and its speed) until an unsilenced attack - spawn(b, e) { b.addBuff(e, { key: 'ab:barrel', persist: true, mods: { moveMul: T(ab, '1.move_speed') ?? 1 } }); }, - hitOut(c, b, e, a) { if (!a.done && c.dmg.isAttack) c.dmg.amount *= s.blockee_atk_scale ?? 1; }, - attack(c, b, e, a) { - if (a.done) return; - a.done = true; - b.removeBuff(e, 'ab:barrel'); - const x = e.x, y = e.y, life = s.fixed_duration ?? 0; - b.fx('zone', { x, y, r: BARREL_RADIUS, dur: life, kind: 'barrel' }); - let left = life; - const h = b.every(0.5, () => { - for (const o of b.enemiesInRadius(x, y, BARREL_RADIUS)) auraBuff(b, o, 'ab:barrelZone', 0.5, { aspd: s.attack_speed ?? 0, dodgePhys: s.prob ?? 0 }); - left -= 0.5; - if (left <= 0) h.cancel(); - }); - }, - }]; - }, - enemy_10045_parrot: kitParrot, // 吉兆飞鳞 · 近地悬浮 (grounded 8 s when stunned); sprints when first hit - enemy_10087_hlchgr: (ab) => [{ // 圣堂剑士 · spends ammo (every 6 s, max 5) for permanent speed/ATK - iv: T(ab, 'SkillTrigger.interval') ?? 6, - tick(b, e, a) { - a.n = (a.n ?? 0) + 1; - if (a.n > 5) return; - const s = ab.sk.ForeverEnhance ? ab.sk.ForeverEnhance.bb : {}; - b.addBuff(e, { key: 'ab:enhance', refresh: 'stack', stacks: 1, maxStacks: 5, persist: true, mods: { moveFlat: (s.move_speed_add ?? 0) * e.base.moveSpeed, atkPct: s.atk_add ?? 0 } }); - }, - }], - enemy_10094_crstf: kitEp('neural', 'ep.ep_damage_ratio'), // 临时收音师 · neural on hit (filming zones n/a → always) - enemy_10097_crshd: (ab) => [ // 心虚设计师 · neural to its blocker every s; front damage −80 % (faces its walk) - { iv: 1, tick(b, e) { if (e.blockedBy) elem(b, e, e.blockedBy, 'neural', e.s.atk * (T(ab, 'block.ep_damage_ratio') ?? 0)); } }, - frontGuard(T(ab, 'weakness.damage_resistance') ?? 0, faceMove), // 减少来自正面的物理/法术伤害 (cameras absent: it never turns) - ], - enemy_10098_crhro: (ab) => [ep('neural', T(ab, 'inside.attack@ep_damage_ratio') ?? 0), // 主角阵营角色 · neural on hit; 重生 once (reborn.duration s, full HP) - reborn({ dur: T(ab, 'reborn.duration') ?? 0, invincible: T(ab, 'reborn.invincible') ?? 0 })], - enemy_10099_crvln: kitEp('neural', 'inside.attack@ep_damage_ratio'), // 反派阵营角色 · neural on hit - enemy_10116_ymgtop: (ab) => { // 水遁忍者 · spinning phase (SILENCE): phys AoE every s; 失衡 stops it - const spin = { - sil: true, cd: ab.sk.SwitchModeTrigger ? ab.sk.SwitchModeTrigger.cd : 60, icd: ab.sk.SwitchModeTrigger ? ab.sk.SwitchModeTrigger.icd : 10, - fire(b, e, a) { a.until = b.time + (T(ab, 'EndRotate.rotate_duration') ?? 0); b.fx('telegraph', { x: e.x, y: e.y, r: 1, kind: 'spin', id: e.id }); }, - iv: T(ab, 'RotateDamage.interval') ?? 1, - // "每秒对半径1.0范围内的所有我方单位造成…(无视迷彩,不可对空),自身受阻止攻击类异常效果影响期间无法造成此伤害": an area - // selection, skipped while 晕眩 / 冻结 / 浮空 (flag stun), 沉睡 or 缴械 hold it (PRTS 异常效果 §阻止攻击; until 0.1.3 it spun on) - tick(b, e, a) { - if (!(a.until > b.time) || e.s.flags.stun || e.s.flags.sleep || e.s.flags.disarm) return; - for (const u of areaAllies(b, e, e.x, e.y, T(ab, 'RotateDamage.attack@range_radius') ?? 1)) hurt(b, e, u, e.s.atk * (T(ab, 'RotateDamage.attack@atk_scale') ?? 1), 'phys'); - }, - }; - // 漩涡形态 "不进行普通攻击" (PRTS 天赋): no blocked attack while it spins (until 0.1.3 its blocker took both) - const noAtk = { tick(b, e) { e.profile.noAttack = spin.until > b.time; } }; - return [spin, noAtk, unbalanced((b, e) => { if (spin.until > b.time) { spin.until = 0; b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'spinStop' }); } })]; - }, - enemy_10118_ymgprc: (ab) => { // 澪 · double hits; 寒晖: every 4th attack (spCost 3) is a ×1.5 double hit - const scale = (ab.sk.PowerAttack && ab.sk.PowerAttack.bb.atk_scale) || 1; - const pw = nthAttackPower(nthOf(ab.sk.PowerAttack), scale); - return [pw, { dealt(c, b, e) { hurt(b, e, c.target, e.s.atk * (pw.power ? scale : 1), 'phys', { isSkill: false }); } }]; - }, - enemy_10127_rkmbst_2: (ab) => [taunt(1), { spawn(b, e) { // 异质裂兽·α · M0 shield (damage ×0.5, ASPD +100, barrier) - b.addBuff(e, { key: 'ab:m0', persist: true, visible: true, mods: { dmgTakenMul: 1 - (T(ab, 'M0Shield.damage_resistance') ?? 0), aspd: T(ab, 'M0Shield.attack_speed') ?? 0 } }); - const sh = e.s.maxHp * (T(ab, 'M0Shield.init_shield_hp_ratio') ?? 0); - if (sh > 0) b.addBuff(e, { key: 'ab:m0barrier', shield: sh, persist: true }); - } }], - enemy_10156_mncrer: (ab) => [{ sil: true, death(c, b, e) { // 新手祭司学徒 · death: heals nearby enemies - if (c.reason !== 'killed') return; - const r = T(ab, 'Boom.projectile_range') ?? 1, amt = e.s.atk * (T(ab, 'Boom.heal_scale') ?? 0); - b.fx('explode', { x: e.x, y: e.y, r, kind: 'heal' }); - for (const o of b.enemiesInRadius(e.x, e.y, r)) if (o !== e) b.heal(e, o, amt); - } }], - enemy_10162_mnctpt: (ab) => [{ // 自制投石机 · 3-hit attacks with small splash - dealt(c, b, e) { - const n = T(ab, 'Attack.attack@times') ?? 1, r = T(ab, 'Attack.attack@projectile_range') ?? 0; - const t = c.target; - for (let i = 1; i < n; i++) hurt(b, e, t, e.s.atk, 'phys'); - // the stone's splash ("碰撞无视迷彩"): an area selection — no unblocking 隐匿 ally - if (r > 0) for (const u of areaAllies(b, e, t.x, t.y, r)) if (u !== t) for (let i = 0; i < n; i++) hurt(b, e, u, e.s.atk, 'phys'); - }, - }], - enemy_1050_lslime: (ab, e) => kitLeaderMisc('enemy_1050_lslime', ab, e), // “庞贝” · 4 targets, burning DoT, self-blast when blocked, ASPD up below half - enemy_1500_skulsr: (ab, e) => kitLeaderMisc('enemy_1500_skulsr', ab, e), // 碎骨 · unblocked grenades (splash + DEF down); ATK up below half - enemy_1502_crowns: (ab, e) => kitLeaderMisc('enemy_1502_crowns', ab, e), // 弑君者 · blinks past its blocker - enemy_1504_cqbw: (ab, e) => kitLeaderMisc('enemy_1504_cqbw', ab, e), // W · C4 (2 below half) - enemy_1509_mousek: (ab, e) => kitLeaderMisc('enemy_1509_mousek', ab, e), // 鼠王 · opening barrier with DEF up; enrage below half - enemy_1511_mdrock: (ab, e) => kitLeaderMisc('enemy_1511_mdrock', ab, e), // 泥岩 · stacking ATK, refreshed barrier (+HP/ASPD while up) - enemy_1513_dekght: (ab, e) => kitLeaderMisc('enemy_1513_dekght', ab, e), // 腐败骑士 · plus-shaped hits; rage when 凋零骑士 dies - enemy_1513_dekght_2: (ab, e) => kitLeaderMisc('enemy_1513_dekght_2', ab, e), // 凋零骑士 · 2 targets; rage when 腐败骑士 dies - enemy_1539_reid: (ab, e) => kitLeaderMisc('enemy_1539_reid', ab, e), // “复仇者” · ATK up below half; revives once at 50 % - enemy_2003_rockman: (ab, e) => kitLeaderMisc('enemy_2003_rockman', ab, e), // 迷路的巨像 · long-stun boulder on a non-stunned unit - enemy_2004_balloon: (ab, e) => kitLeaderMisc('enemy_2004_balloon', ab, e), // 喷气人 · takes off when blocked: 7 s hovering, unblockable, 失衡免疫 - enemy_2005_axetro: (ab, e) => kitLeaderMisc('enemy_2005_axetro', ab, e), // “遗弃者” · attack stacks, reset after 4 s idle - enemy_2008_flking: (ab, e) => kitLeaderMisc('enemy_2008_flking', ab, e), // “墓碑” · operators' ATK/DEF halved; periodic barrier - enemy_2048_smgrd: (ab, e) => kitLeaderMisc('enemy_2048_smgrd', ab, e), // “邪魔的利刃” · 国度 ASPD-down around it, ×2.5 inside - enemy_2050_smsha: (ab, e) => kitLeaderMisc('enemy_2050_smsha', ab, e), // 陷落雪祀 · cold + 3-target chain - enemy_2052_smgia: (ab, e) => kitLeaderMisc('enemy_2052_smgia', ab, e), // 纠缠藤蔓 · every 3rd attack stuns 15 s; fragile after terrain damage - - // --- multi-form leaders and other bounty (悬赏) targets - enemy_1512_mcmstr: kitUglyThing, // “巨大的丑东西” · melee ×3; KO ⇒ stun blast, fleeing 大祭司 (true HP loss/s) - enemy_1516_jakill: kitWarden, // 杰斯顿·威廉姆斯 · ranged arts + multi-stun ⇒ killer form (frees prisoners, armour piercing) - enemy_1517_xi: kitXi, // “自在” · cross arts, 破桎而出 barrier blast ⇒ form 2 (double hits, 2 crosses) - enemy_1525_blkswb: kitMace, // 锏 · 抵抗, DEF pen, 速杀 blink, AoE ⇒ form 2 (stealth, double hits, SP clear) - enemy_1535_wlfmster: kitWolfLord, // 扎罗 · −30 % damage, 溶血骇惧 ⇒ 远古威慑 rebirth ⇒ ranged double hits - enemy_10081_mpplai: kitTranslator, // 转译基底·α · damage cancelled; 4th phys / 4th arts hit or blocked ⇒ 2 s change ⇒ 寻仇者 / 特战术师 / 幽灵 - enemy_10144_xdelk_2: kitElk, // 乌顶巨角卢鲁 · 角力对决 charge on its blocker (push fails ⇒ damage + stun) - // 圆仔 · PRTS 天赋 "无法攻击/被阻挡;受到来源于正面的物理和法术伤害-80%;自身始终朝向我方干员数量最多的方向" (no attack: applyWay - // NONE; its 倒走 / 【炫耀】 is "仅用于演出,无额外效果"): unblockable, faces the bigger crowd, front damage −80 % - enemy_2085_skzjxd: (ab) => [unblockable(), frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)], - enemy_2085_skzjxd_2: (ab) => [unblockable(), frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)], // (鸭爵 strategy) same - // 失衡 (pushed / pulled by operators) - enemy_1328_cbjedi: (ab) => [unbalanced((b, e, a, d) => { // 弧光锋卫 · takes damage in proportion to the distance moved while unbalanced - // PRTS 修正 "失衡移动时持续受到真实伤害", "处于失衡状态时,每0.066s受到400点无来源真实持续伤害" (伤害分类: 弧光锋卫失衡状态下的自残 - // 伤害 is BUFF damage): damage, not a 流失 (player report D1 audit) - const v = T(ab, 'unbalanced_bleed.damage') ?? 0, iv = T(ab, 'unbalanced_bleed.interval') ?? 1; - if (v > 0 && iv > 0) b.dealDamage(null, e, { ...periodicDamage((v * d) / (iv * UNBALANCE_SPEED)), tags: ['dot', 'periodic', 'unbalanced'] }); - })], - enemy_10112_ymgds: (ab) => [unbalanced((b, e) => { // 冒失的小弟 · stunned after being unbalanced - const st = T(ab, 'StunAfterUnbalance.stun') ?? 0; - if (st > 0) b.applyStatus(e, 'stun', { duration: st, source: null }); - })], - enemy_10138_xdsnow: (ab) => [unbalanced((b, e, a) => { // 雪孩子 · pushed / pulled into high ground ⇒ hitWall.value damage - const dx = e.x - a.lx, dy = e.y - a.ly, d = Math.hypot(dx, dy); - if (!(d > 0)) return; - const r = Math.round(e.y + (dy / d) * 0.6), c = Math.round(e.x + (dx / d) * 0.6); - if (b.grid.isLow(r, c) && b.grid.groundPassable(r, c)) return; // stopped by nothing: no collision - b.fx('explode', { x: e.x, y: e.y, r: 0.4, kind: 'wallHit', id: e.id }); - hurt(b, null, e, T(ab, 'hitWall.value') ?? 0, 'true', { tags: ['wallHit'] }); - })], - // 拥霜羽兽 · PRTS 天赋 "不会攻击飞行单位"; unbalanced once ⇒ 失去蛋的模式 "不进行普通攻击,不可阻挡,移动速度最终提升至200%" (until - // 0.1.3 it kept its ranged attack without the egg and stood for each attack clip — ai.js attackStand —, and shot the 炎佑 dragon) - enemy_10141_xdpeng_2: (ab) => [noAirTargets(), unbalanced((b, e, a) => { - if (a.done) return; - a.done = true; - e.profile.noAttack = true; - b.addBuff(e, { key: 'ab:noEgg', persist: true, visible: true, flags: { unblockable: true }, mods: { moveMul: 1 + (T(ab, 'speed.move_speed') ?? 0) } }); - })], + ...INVISIBLE_KITS, + ...TIMES_KITS, + ...ELEMENT_KITS, + ...DOT_KITS, + ...REFLECTION_KITS, + ...FLY_KITS, + ...SPECIAL_KITS, + ...LEADER_KITS, }); /** Keys deliberately left to their data stats (reason). */ diff --git a/server/sim/content/enemies/archetypes.js b/server/sim/content/enemies/archetypes.js new file mode 100644 index 00000000..f7033e9e --- /dev/null +++ b/server/sim/content/enemies/archetypes.js @@ -0,0 +1,632 @@ +// server/sim/content/enemies/archetypes.js — ability archetypes of the enemy kits (split from content/enemies.js; the +// module map is in its header): stealth, splashAttack, deathBoom, refraction, float, prisoner, reborn, frontGuard, +// unbalanced, artsBarrier, husk, statue, bleed, pollution, dmgZone, skill(), blinkForward … and the archetype kits +// reused by several keys (kitEp, kitTimes …). + +import { MOVE_SCALE, COLS } from '../../constants.js'; +import { + HUSK_REBIRTH, BOOM_RADIUS, POLLUTION_INTERVAL, STEALTH_RESTORE_BY_KEY, nthOf, stOf, safe, num, T, elem, hurt, + areaAllies, areaAlliesInTiles, zone, spawnChildren, stepToward, setHits, hitCount, setForm, absorbArts, auraBuff, +} from './helpers.js'; + +// --------------------------------------------------------------------------------------------------------------- +// archetypes + +/** + * 隐匿: permanent stealth. Blocked it is lifted; it hides again STEALTH_RESTORE s after the block ends, or after its own + * "(解除阻挡N秒后恢复)" (STEALTH_RESTORE_BY_KEY → buff `data.stealthRestore`; Battle._stealthSwitch). + */ +const stealth = () => ({ + spawn(b, e, a, ab) { + const n = STEALTH_RESTORE_BY_KEY[ab.key]; + b.addBuff(e, { key: 'ab:stealth', flags: { stealth: true }, persist: true, data: n != null ? { stealthRestore: n } : {} }); + }, +}); + +/** 无法被阻挡. */ +const unblockable = () => ({ spawn(b, e) { b.addBuff(e, { key: 'ab:unblockable', flags: { unblockable: true }, persist: true }); } }); + +/** + * "受到伤害时开始奔跑,移动速度+N%" (鸭爵 `run.attack@move_speed`): the first damage it takes ⇒ move speed ×(1 + v) for good — + * PRTS 鸭爵 天赋 "移动速度+400%" for v 4, PRTS 卫戍协议:盟约 下半/PRTS盟约记录 鸭爵 备注 "受伤后移动速度+300%" for the act2 v 3. + */ +const runWhenHit = (v) => ({ taken(c, b, e, a) { if (a.on || !(v > 0)) return; a.on = true; b.addBuff(e, { key: 'ab:duckRun', persist: true, mods: { moveMul: 1 + v } }); } }); + +/** 频次: maxHp = hits. */ +const times = (artsOnly = false) => ({ spawn(b, e) { setHits(e, e.def.maxHp); hitCount(b, e, true, artsOnly); } }); + +/** "只能被阻挡数大于等于N的单位阻挡". */ +const blockWeight = (n) => ({ spawn(b, e) { e.blockWeight = n; } }); + +const taunt = (n) => ({ spawn(b, e) { if (n) b.addBuff(e, { key: 'ab:taunt', mods: { taunt: n }, persist: true }); } }); + +const maxTargets = (n) => ({ spawn(b, e) { if (n > 1) e.profile.maxTargets = n; } }); + +/** Element damage on every attack hit: ATK × ratio. */ +const ep = (el, ratio, sil = false) => ({ sil, dealt(c, b, e) { if (ratio > 0) elem(b, e, c.target, el, e.s.atk * ratio); } }); + +/** Status on every attack hit. */ +const onHitStatus = (key, dur, value) => ({ dealt(c, b, e) { if (dur > 0) b.applyStatus(c.target, key, { duration: dur, source: e, value }); } }); + +/** + * A normal attack that splashes around its target (PRTS: "对主目标造成…普通伤害,对溅射目标造成…溅射伤害"): 碎骨's grenade, + * “巨大的丑东西” / “墓碑”'s unblocked ranged attack, 烹泉 / 沏虹's attack, 集团军重型火炮's shell. Its mode is fixed when the + * attack starts (`before`): `unblocked` = only an attack begun while not blocked splashes (the others are the plain melee + * hit at `meleeScale`). The main target takes the engine's attack at `scale` × ATK (e.profile.atkScale, a normal attack: + * on-hit effects apply); when it lands (the main hit's 'hit', before its dodge) the others around the target take + * `scale` × ATK as a splash — an area selection (areaAllies / areaAlliesInTiles: no 隐匿 ally, the blocker included + * (GitHub #97), no airborne 起飞 one for a ground enemy; 迷彩 is not checked — DESIGN §22.12). `tiles` 1 = the target's tile and + * its 8 neighbours (格子判定), else `radius` around the target (中点判定). `noAir`: "不可对空" (a flying ally — the 炎佑 + * dragon — is skipped; without it "可溅射飞行单位"). `dodge`: the splash hits may be dodged (烹泉 / 沏虹, whose PRTS 天赋 calls + * them 法术普通伤害 — a normal attack's damage); a 溅射伤害 splash cannot. `onEach(b, e, u)` runs on every unit hit (the target + * after its damage). + */ +function splashAttack({ scale = 1, meleeScale = 1, unblocked = true, tiles = 0, radius = 0, noAir = false, dodge = false, fxKind = 'splash', onEach = null } = {}) { + return { + before(c, b, e, a) { a.splash = !unblocked || !e.blockedBy; e.profile.atkScale = a.splash ? scale : meleeScale; }, + hitOut(c, b, e, a) { + const t = c.target; + if (!a.splash || !c.dmg.isAttack || !t) return; + b.fx('explode', { x: t.x, y: t.y, r: tiles > 0 ? tiles + 0.5 : radius, kind: fxKind, ...(tiles > 0 ? { tiles: 'box' } : {}) }); + const area = tiles > 0 ? areaAlliesInTiles(b, e, t.tileR, t.tileC, 'box', tiles) : areaAllies(b, e, t.x, t.y, radius); + for (const u of area) { + if (u === t || (noAir && u.isFlying)) continue; + hurt(b, e, u, e.s.atk * scale, c.dmg.type, { tags: ['splash'], canDodge: dodge }); + if (onEach && u.alive) onEach(b, e, u); + } + }, + dealt(c, b, e, a) { if (a.splash && onEach && c.target.alive) onEach(b, e, c.target); }, + }; +} + +/** + * Tile keys of an enemy's path up to its next checkpoint, its own tile included (PRTS “复仇者” 冲锋 "位于自身下个检查点前的后续 + * 路径上(包含自身当前所在地块)"): the tiles its current move leg crosses — the straight segments through the leg's smoothed + * waypoints (ai.js planLeg; the last one is the leg's checkpoint), sampled every quarter tile. + */ +function pathKeysAhead(e) { + const keys = new Set([Math.round(e.y) * COLS + Math.round(e.x)]); + const R = e.route; + if (!R || !Array.isArray(R.pts)) return keys; + let x = e.x, y = e.y; + for (let i = R.ptIdx ?? 0; i < R.pts.length; i++) { + const p = R.pts[i], n = Math.max(1, Math.ceil(Math.hypot(p.x - x, p.y - y) * 4)); + for (let k = 1; k <= n; k++) keys.add(Math.round(y + ((p.y - y) * k) / n) * COLS + Math.round(x + ((p.x - x) * k) / n)); + x = p.x; y = p.y; + } + return keys; +} + +/** "不会攻击飞行单位" (an engine candidate filter, ai.js enemyAttack: the 炎佑 dragon is a flying ally). */ +const noAirTargets = () => ({ spawn(b, e) { e.profile.canTarget = (u) => !u.isFlying; } }); + +/** + * 抵抗 (ba.buffres, the engine `resist` status: control durations halved, 麻痹 loses 1 stack every 5 s) + optional + * immunities. Value from the talent's `Buff.one_minus_status_resistance` when present (−0.5 ⇒ half). + */ +const resist = (immune = []) => ({ + spawn(b, e, a, ab) { + const v = -(T(ab, 'Buff.one_minus_status_resistance') ?? -0.5); + b.applyStatus(e, 'resist', { source: e, value: v > 0 ? Math.min(1, v) : 0.5 }); + if (immune.length) ab.immune = new Set([...(ab.immune || []), ...immune]); + }, +}); + +const immuneTo = (...keys) => ({ spawn(b, e, a, ab) { ab.immune = new Set([...(ab.immune || []), ...keys]); } }); + +/** Every Nth attack: status on the targets ("攻击2次后,下一次攻击会晕眩目标"). */ +const nthAttackStatus = (n, key, dur, sil = true) => ({ + sil, + spawn(b, e, a) { a.n = 0; }, + attack(c, b, e, a) { + a.n++; + if (a.n % n) return; + for (const t of c.targets) if (t.alive && dur > 0) b.applyStatus(t, key, { duration: dur, source: e }); + }, +}); + +/** Every Nth attack deals ×scale (+ optional element). */ +const nthAttackPower = (n, scale, el = null, ratio = 0) => ({ + spawn(b, e, a) { a.n = 0; a.power = false; }, + before(c, b, e, a) { a.power = (a.n + 1) % n === 0; }, + hitOut(c, b, e, a) { if (a.power && c.dmg.isAttack) c.dmg.amount *= scale; }, + dealt(c, b, e, a) { if (a.power && el && ratio > 0) elem(b, e, c.target, el, e.s.atk * ratio); }, + attack(c, b, e, a) { a.n++; }, +}); + +/** Aura on other enemies within r: timed buff refreshed every iv s. */ +const enemyAura = (r, key, mods, sil = true, iv = 0.5) => ({ + sil, iv, + tick(b, e) { for (const o of b.enemiesInRadius(e.x, e.y, r)) if (o !== e) auraBuff(b, o, key, iv, mods); }, +}); + +/** + * Aura on allies within r — every ally there, 隐匿 ones included: its one user, 寒霜, is PRTS's own example of an enemy + * debuff that "无视隐匿状态起作用" (PRTS 作战机制 §隐匿与Buff的关系: "敌方寒霜的攻速下降Debuff"). + */ +const allyAura = (r, key, mods, sil = true, iv = 0.5, flags = null) => ({ + sil, iv, + tick(b, e) { for (const u of b.alliesInRadius(e.x, e.y, r)) auraBuff(b, u, key, iv, mods, flags, true); }, +}); + +/** Self ATK buff once HP first drops below ratio. */ +const lowHpBuff = (ratio, mods, key = 'ab:lowhp') => ({ + taken(c, b, e, a) { + if (a.on || !(e.hpRatio < ratio)) return; + a.on = true; + b.addBuff(e, { key, mods, persist: true, visible: true }); + }, +}); + +/** Death spawn ("被击倒后生成N个"). */ +const deathSpawn = (key, cnt, extra = {}) => ({ + death(c, b, e) { if (c.reason === 'killed' && key && cnt > 0) spawnChildren(b, e, key, cnt, extra); }, +}); + +/** + * Death explosion on the allies within r it can select (areaAllies — PRTS 高能源石虫 / 冰爆源石虫 / 卷心籽 死亡爆炸 "…无视迷彩, + * 不可对空", no 无视无法选择: a 隐匿 ally is spared; the dead enemy blocks nobody). + */ +const deathBoom = ({ scale, type = 'phys', r = BOOM_RADIUS, status = null, sil = true, cond = null }) => ({ + sil, + death(c, b, e, a) { + if (c.reason !== 'killed' || (cond && !cond(e, a))) return; + const atk = e.s.atk; + b.fx('explode', { x: e.x, y: e.y, r, kind: 'deathBoom', id: e.id }); + for (const u of areaAllies(b, e, e.x, e.y, r)) { + if (scale > 0) hurt(b, e, u, atk * scale, type); + if (status && u.alive) b.applyStatus(u, status.key, { duration: status.dur, source: e, value: status.value }); + } + }, +}); + +/** 折射: RES +v (and optional max HP +hpPct) while not silenced. */ +const refraction = (res, hpPct = 0) => ({ + silAware: true, // SILENCE-flagged: silence switches it off in tick() + tick(b, e, a) { + const on = !e.s.flags.silence; + if (on === a.on) return; + a.on = on; + if (on) b.addBuff(e, { key: 'ab:refraction', mods: hpPct ? { resFlat: res, hpPct } : { resFlat: res }, persist: true, visible: true }); + else b.removeBuff(e, 'ab:refraction'); + }, +}); + +/** + * 近地悬浮 (ba.float "无法被阻挡或近战攻击"; PRTS 术语释义: "此类效果开始时,单位强制解除阻挡,随后行动方式变为飞行(算作空中 + * 单位)… 无论飞行与否,单位只会采用地面寻路"): while floating the enemy carries the 'ab:float' buff — unblockable, an air unit for + * every targeting / ground-only rule (flag `float` ⇒ Unit.isFlying: melee operators, 迷迭香's ground-only shots and + * splash, ground traps and terrain skip it; ranged attacks hit it) with 失衡免疫 (`noDisplace`, "初始模式:近地悬浮,失衡 + * 免疫" on the enemies' PRTS pages) — its `motion` stays WALK, so it keeps the ground path (and 浮空 can still lift it: + * Battle.applyStatus refuses data flyers only). Losing the float is each + * enemy's own ability (PRTS; 缚地 "使部分近地悬浮敌人掉落" is "实为敌人自身的能力"): kitSyufo, kitParrot. 喷气人's 飞行模式 is + * the same state for 7 s (its 'ab:takeoff' buff carries the flags; kitLeaderMisc). + */ +const FLOAT_KEY = 'ab:float'; + +const setFloat = (b, e, on) => { + if (on) b.addBuff(e, { key: FLOAT_KEY, flags: { unblockable: true, float: true, noDisplace: true }, persist: true }); + else b.removeBuff(e, FLOAT_KEY); +}; + +const float = () => ({ spawn(b, e) { setFloat(b, e, true); } }); + +/** The enemies whose kit spawns them hovering (float()): the match's bot counts them as air units (bot.js fieldModel). */ +export const HOVER_KEYS = Object.freeze(['enemy_2025_syufo', 'enemy_10045_parrot']); + +/** + * "生命值首次降至一半以下时,在数秒内陷入恐惧" (SelfFear; PRTS “萨科塔之翼/之眼/昂首” "生命值首次低于50%时,对自身施加持续5s的恐惧, + * 在5s内移动速度最终提升至150%"). The fear's source is the enemy itself, so it has no 恐惧可达地块: for SelfFear.fear s it + * flies to random points of its own tile (±0.25) at ×move_speed — the engine's 恐惧 movement (fear.js; user playtest #6 + * item 13, "小范围乱飞") — then flies on along its route. + */ +const selfFear = (ab) => ({ + taken(c, b, e, a) { + if (a.done || !(e.hpRatio < 0.5)) return; + a.done = true; + const fear = T(ab, 'SelfFear.fear') ?? 0; + if (fear > 0) b.applyStatus(e, 'fear', { duration: fear, source: e }); + // PRTS “萨科塔之翼/之眼”: "在5s内移动速度最终提升至150%" — move_speed 1.5 is the final multiplier + const ms = T(ab, 'SelfFear.move_speed') ?? 0, sd = T(ab, 'SelfFear.speed_duration') ?? 0; + if (ms > 0 && sd > 0) b.addBuff(e, { key: 'ab:fearRun', duration: sd, mods: { moveMul: ms }, flags: { unblockable: true } }); + }, +}); + +/** Prisoners (禁锢 → 解放). */ +function prisoner(ab, { freeAll = false } = {}) { + const t = (k) => T(ab, k) ?? 0; + const liberate = (b, e, a) => { + if (a.free || !e.alive) return; + a.free = true; + b.removeBuff(e, 'ab:confined'); + b.addBuff(e, { key: 'ab:liberty', persist: true, visible: true, mods: { + atkPct: t('liberty.atk'), defIgnorePct: t('liberty.def_penetrate'), resFlat: t('liberty.magic_resistance'), hpRegen: t('liberty.hp_recovery_per_sec') } }); + b.fx('liberate', { x: e.x, y: e.y, id: e.id }); + if (freeAll && !stOf(b).freedAll) { + // "第一次解放时同时解放全场敌人" + stOf(b).freedAll = true; + for (const o of b.aliveEnemies()) for (const x of (o.mem.ab ? o.mem.ab.list : [])) if (x.liberate && !x.free) x.liberate(b, o, x); + } + }; + return { + liberate, + spawn(b, e, a) { + a.n = 0; a.free = false; + b.addBuff(e, { key: 'ab:confined', persist: true, visible: true, mods: { aspd: t('confinement.attack_speed'), defFlat: t('confinement.def') } }); + }, + attack(c, b, e, a) { if (!a.free && ++a.n >= (T(ab, 'confinement.times') ?? 4)) liberate(b, e, a); }, + }; +} + +/** Free every confined prisoner on the field (重犯's first liberation, 杰斯顿's killer form). */ +function freeAllPrisoners(b) { + for (const o of b.aliveEnemies()) for (const x of (o.mem.ab ? o.mem.ab.list : [])) if (x.liberate && !x.free) x.liberate(b, o, x); +} + +/** + * 重生 "清空自身身上除白名单外所有Buff" (PRTS 特殊机制 §重生): at a 重生 (reborn(), husk(), statue()) the enemy loses every + * buff an operator / summon / device put on it and every source-less catalogue status (晕眩, 减速, 恐惧, 脆弱, 诱导 … — + * buffs.js STATUS). Kept [ASSUMED: the whitelist]: its talents and traits (`persist` buffs, and what it or another enemy + * gave it — 锏's 抵抗 is a self-applied status, enemy auras refresh every few tenths of a second anyway) and the field's + * state buffs without a source (on-tile terrain, airflow — re-applied by position — and element burst locks). Without it + * a 逐火 knocked out while feared (叙拉古 / 妮芙: 恐惧 makes it unblockable) stayed unblockable — so, 隐匿, untargetable — + * as an ember until the fear ran out. 活性源石's lasting effect (devices.js touchInfection) is no field state but a timed + * buff that outlives the tile, so it is cleared like any buff (PRTS 特殊机制 非首次标记: "如无特殊说明,也默认同常规Buff一样 + * 可被重生清除"); contact gives it again while the enemy stands on the tile. Kept, its 1 s ticks — each a hit of a husk's + * 特殊生命值 — killed a 逐火 ember that had crossed the tile long before it could stand up (review of GitHub #33 item 6). + */ +/** + * 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); +} + +/** devices.js' lasting 活性源石 effect: a timed buff, not field state — cleared at a 重生 (rebirthCleanse). */ +const INFECTION_BUFF = 'terrain:infection'; + +function rebirthCleanse(b, e) { + for (const x of e.buffs.slice()) { + if (x.persist || (x.source && x.source.side === 'enemy')) continue; + if (x.source || x.status || x.key === INFECTION_BUFF) b.removeBuff(e, x); + } +} + +/** + * "首次被击倒后重生 / 进入第二形态": the first knock-out is hidden (kill credit and bounty wait for the real death) and starts + * a rebirth of `dur` s — statuses cleared (rebirthCleanse), invulnerable, untargetable, released by its blocker, inert + * (no move / attack / skill). + * `onKo(b, e, a)` runs at once (self-destruct, freeing prisoners …), `during(b, e, a, elapsed)` every tick of it; then the + * enemy stands up with `hpRatio` of its max HP, `onReborn(b, e, a)` switches the form and `invincible` s of 无敌 follow. + * `a.state`: undefined → 'reborn' → 'form2'. The 'telegraph' fx of the knock-out carries `form: 'reborn'` and the 'revive' + * fx `form: 'form2'`: models with those clip sets (render/units.js FORMS — 锏, 扎罗, “复仇者”, 杰斯顿) play them. + */ +function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = null, onReborn = null, key = 'ab:reborn' } = {}) { + const finish = (b, e, a) => { + if (!e.alive || a.state !== 'reborn') return; + a.state = 'form2'; + b.removeBuff(e, key); + e.profile.noAttack = a.noAtk; + if (onReborn) safe(b, e, () => onReborn(b, e, a)); + e.hp = Math.max(1, e.s.maxHp * Math.max(0, Math.min(1, hpRatio))); + 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 { + killed(c, b, e, a) { + if (a.state) return false; + a.state = 'reborn'; + a.t0 = b.time; + a.noAtk = e.profile.noAttack; + rebirthCleanse(b, e); + e.hp = Math.min(1, e.s.maxHp); + if (onKo) safe(b, e, () => onKo(b, e, a)); + if (!(dur > 0)) { finish(b, e, a); return true; } + e.profile.noAttack = true; + b.addBuff(e, { + key, duration: dur, visible: true, persist: true, + flags: { invulnerable: true, untargetable: true, unblockable: true, noMove: true, stun: true }, + onTick: during ? ({ battle }) => during(battle, e, a, battle.time - a.t0) : null, + onExpire: ({ battle }) => finish(battle, e, a), + }); + setForm(b, e, 'reborn', 'telegraph', { r: 1, dur, kind: 'reborn' }); + return true; + }, + }; +} + +/** 正面减伤: phys/arts damage from allies in front of the enemy (`a.facing` side) × (1 − cut); `face` updates a.facing. */ +function frontGuard(cut, face) { + return { + spawn(b, e, a) { a.facing = -1; a.px = e.x; }, + tick(b, e, a) { face(b, e, a); }, + hitIn(c, b, e, a) { + const s = c.source, ty = c.dmg.type; + if (!s || s.side !== 'ally' || (ty !== 'phys' && ty !== 'arts') || !(cut > 0)) return; + if ((s.x - e.x) * a.facing > 1e-6) c.dmg.mul *= Math.max(0, 1 - cut); + }, + }; +} + +/** Faces its walking direction (horizontal component; keeps the last one while standing). */ +const faceMove = (b, e, a) => { const dx = e.x - a.px; if (Math.abs(dx) > 1e-6) a.facing = dx > 0 ? 1 : -1; a.px = e.x; }; + +/** "始终面向我方干员数量较多的方向": the side with more deployed operators (ties keep the current facing; PRTS 圆仔 "无视其可选性": + * 隐匿 operators count). */ +const faceCrowd = (b, e, a) => { + let l = 0, r = 0; + for (const u of b.allies()) { if (u.kind !== 'op') continue; if (u.x > e.x + 1e-6) r++; else if (u.x < e.x - 1e-6) l++; } + if (r !== l) a.facing = r > l ? 1 : -1; +}; + +/** + * 失衡 (unbalanced movement): being pushed / pulled by operators. battle.displace() has no hook, so it is detected as + * movement beyond the enemy's own route speed between two ticks (teleport legs excluded). `onMove(b, e, a, tiles)`. + */ +function unbalanced(onMove) { + return { + tick(b, e, a, dt) { + const px = a.px, py = a.py, hid = a.hid; + a.px = e.x; a.py = e.y; a.hid = e.hidden; + if (px == null || hid || e.hidden) return; + const own = e.s.moveSpeed * MOVE_SCALE * dt * 1.5 + 1e-3; + const extra = Math.hypot(e.x - px, e.y - py) - own; + a.lx = px; a.ly = py; // where the move started (direction for onMove) + if (extra > 0.05) onMove(b, e, a, extra); + }, + }; +} + +/** An arts-only barrier ("吸收法术伤害的屏障") of `amount`; `a.left` = what is left, `refresh(b, e)` restores it. */ +function artsBarrier(amount, { key = 'ab:artsBarrier', whileUp = null } = {}) { + return { + refresh(b, e, a) { + a.left = amount; + b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'artsBarrier', value: amount }); + if (whileUp) b.addBuff(e, { key, persist: true, visible: true, mods: whileUp }); + }, + spawn(b, e, a) { if (amount > 0) a.refresh(b, e, a); }, + hitIn(c, b, e, a) { + if (!(a.left > 0)) return; + a.left -= absorbArts(c, a.left); + if (a.left > 1e-6) return; + a.left = 0; + b.fx('shieldBreak', { x: e.x, y: e.y, id: e.id }); + // the bonus it grants ends once the overflow of the breaking hit has landed (never before: an HP bonus removed + // first would rescale HP and make the overflow count twice) + if (c.dmg.cancel) b.removeBuff(e, key); else a.breaking = true; + }, + taken(c, b, e, a) { if (a.breaking) { a.breaking = false; b.removeBuff(e, key); } }, + }; +} + +/** + * "被击倒时暂时变为…,一段时间后重生" (talent Revive[Trigger]: 逐火 embers, 假想敌:再生's puppet). A knock-out of the first form is + * no kill (credit, bounty and the kill count wait for the real death): HUSK_REBIRTH s of 重生 first — statuses cleared + * (rebirthCleanse: a feared warrior is no unblockable ember), invulnerable, untargetable, unblockable, immobile, 失衡免疫 + * (PRTS 深池逐火战士 天赋 "被击倒后重生,持续1s,随后变为怨恨的余烬,1s内不移动且持有无敌+无法阻挡+失衡免疫"; PRTS 特殊机制 §重生) — + * then the husk until `delay` s after that: `hits` HP of 特殊生命值机制 (every damage instance removes 1 — PRTS 特殊机制 + * §特殊生命值机制; engine flag hitCount), no attack (缴械), walking its route on — 隐匿 (`stealthy`: targetable only while + * blocked and for 3 s after a block ends, the block the knock-out itself releases included — targeting.js + * enemyStealthed; drawn solid meanwhile) and / or unblockable (`unblock`: 再生's 傀儡 "不可被阻挡"). Killing the husk is + * the real death; a husk still standing after `delay` s stands up in its first form with full HP, and every later + * knock-out starts it again ("一次又一次地站起"). + * `onHusk(b, e)` runs as the husk begins, after the 重生 ("进入此形态时": 再生's shields). + * User report after 0.1.0 (#8): the v2.5 ember stood still, stealthed AND unblockable — nobody could ever target it. + * fx (setForm): 'ember' {id, hits, dur, form: 'husk'} at the knock-out, 'revive' {id, form: 'revived'} when it stands + * up (the renderer switches the model's clip set: render/units.js FORMS). + */ +function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, key = 'ab:ember' }) { + const revive = (b, e, a) => { + if (!e.alive || a.state !== 'husk') return; + a.state = null; + hitCount(b, e, false); + e.base.maxHp = a.max; + e.markDirty(); + void e.s; + e.hp = e.s.maxHp; + e.profile.noAttack = a.noAtk; + b.removeBuff(e, key); + b.removeBuff(e, `${key}:reborn`); + if (e.route) e.route.pts = null; + e.atkCd = 0; + setForm(b, e, 'revived', 'revive'); + }; + return { + killed(c, b, e, a) { + // an HP loss (流失 bypasses 无敌) during the 1 s 重生 cannot cut it short + if (a.state === 'husk' && b.time < a.rebornUntil) { e.hp = e.s.maxHp; return true; } + if (a.state === 'husk' || !(hits > 0) || !(delay > 0)) return false; // the husk's knock-out is the real death + a.state = 'husk'; + a.rebornUntil = b.time + HUSK_REBIRTH; + a.noAtk = e.profile.noAttack; + a.max = a.max ?? e.base.maxHp; + rebirthCleanse(b, e); + e.profile.noAttack = true; + e.lastAttackAt = -Infinity; // the snapshot shows no attack of the fallen warrior + setHits(e, hits); + hitCount(b, e, true); + b.addBuff(e, { key, visible: true, persist: true, flags: { disarm: true, ...(stealthy ? { stealth: true } : {}), ...(unblock ? { unblockable: true } : {}) } }); + b.addBuff(e, { key: `${key}:reborn`, duration: HUSK_REBIRTH, flags: { invulnerable: true, untargetable: true, unblockable: true, noMove: true, noDisplace: true } }); + // the 重生's 无法阻挡 ends a block at once (as applyStatus does for a status): a warrior knocked out while blocked + // leaves an ember whose 隐匿 is switched off until STEALTH_RESTORE (3) s after that block ended + // (Battle._stealthSwitch) — ~2 s after the 1 s 重生, which stays 无敌 + untargetable — so ranged operators and + // operator splash can finish it although its blocker took the next warrior meanwhile (players after 0.1.1: "the + // stealth monster revives forever"). [ASSUMED] the order: the 重生's cleanse first, then the ember's 隐匿 with the + // block-end switch (PRTS documents neither the order of 重生 vs that switch nor whether it survives the 重生) + b._unblock(e); + if (e.route) e.route.pts = null; + setForm(b, e, 'husk', 'ember', { hits, dur: HUSK_REBIRTH + delay }); + 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; + }, + }; +} + +/** + * 守墓石像 / 愤怒的守墓石像 (PRTS 天赋): 地面模式 — melee attacks only while blocked; the first defeat is an instant 重生 + * (statuses cleared: rebirthCleanse) to 100 % HP into 转换模式 for stone.duration s — "无法被阻挡,自缚,失衡免疫,免疫浮空。 + * 防御力增加800,法术抗性增加30", no attack [ASSUMED: PRTS lists none] — then 飞行模式, "变为飞行单位,失衡免疫": a flyer + * no push or pull moves, whose ranged attacks (the data's 1.6 radius) deal arts damage and never target flyers; the HP is + * not refilled again. Its pages list no 静态刚体 (data `staticBody` stays off), so the 失衡免疫 of the flight is a persistent + * `noDisplace` buff. 浮空 is refused from the statue on (`ab.immune`; data flyers refuse it anyway). [ASSUMED] a 浮空 it + * already carries at the knock-out runs out. fx forms 'stone' / 'fly' (its Sleep and *_2 clips, render/units.js FORMS). + * <破碎支柱> (an event device) has no counterpart in this mode. + */ +function statue(ab) { + const dur = T(ab, 'stone.duration') ?? 0; + return { + spawn(b, e) { e.profile.melee = true; }, + killed(c, b, e, a, ab2) { + if (a.done || !(dur > 0)) return false; + 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']); + setForm(b, e, 'stone', 'stone', { dur }); + b.addBuff(e, { + key: 'ab:stone', duration: dur, visible: true, flags: { noMove: true, selfBound: true, unblockable: true, noDisplace: true }, + mods: { defFlat: T(ab, 'stone.def') ?? 0, resFlat: T(ab, 'stone.magic_resistance') ?? 0 }, + onExpire: ({ battle }) => { + if (!e.alive) return; + e.motion = 'FLY'; + Object.assign(e.profile, { noAttack: a.noAtk, melee: false, dmgType: 'arts', canTarget: (u) => !u.isFlying }); + battle.addBuff(e, { key: 'ab:flight', persist: true, flags: { noDisplace: true } }); + if (e.route) e.route.pts = null; + setForm(battle, e, 'fly', 'revive', { kind: 'fly' }); + }, + }); + return true; + }, + }; +} + +/** + * Bleeding on hit (逐腐兽): arts damage per second, removed by healing. A tick selects nobody (`ignoreSelect`): it keeps + * hurting an ally that took off (起飞) after the bleed landed; so do the other enemy debuff DoTs (沙狱, burnDot, 淤困). + */ +function bleed(ab) { + const dmg = T(ab, 'Bleeding.attack@bleeding_damage') ?? 0, dur = T(ab, 'Bleeding.attack@duration') ?? 0; + return { + dealt(c, b, e) { + if (!(dmg > 0 && dur > 0)) return; + b.addBuff(c.target, { + key: 'ab:bleed', duration: dur, refresh: 'replace', interval: 1, visible: true, + onTick: ({ battle, unit }) => battle.dealDamage(e, unit, { amount: dmg, type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility', 'bleed'] }), + }); + }, + }; +} + +/** + * 【污染秽蚀】 zone (PRTS 萨卡兹枯朽战士 / 萨卡兹枯朽战车: "范围内位于低地/高地的我方干员和召唤物每秒受到50/25点真实普通伤害 + * (可对空,无视无法选择、迷彩;同名效果不叠加)"): `low` true damage per second on low ground, `high` on high ground, to every + * ally inside (flyers, stealthed, untargetable and airborne 起飞 ones included — `ignoreSelect`; an airborne 蒂比 stands on + * her low tile: `low`); a unit inside several zones takes one tick per second (`mem.pollutedAt`: the last tick it took). + */ +function pollution(b, src, x, y, r, life, low, high) { + zone(b, { x, y, r, life, iv: POLLUTION_INTERVAL, kind: 'pollution', tick(units) { + for (const u of units) { + const v = u.ground ? low : high; + if (!(v > 0) || b.time - (u.mem.pollutedAt ?? -Infinity) < POLLUTION_INTERVAL - 1e-6) continue; + u.mem.pollutedAt = b.time; + hurt(b, src, u, v, 'true', { tags: ['pollution'], ignoreSelect: true }); + } + } }); +} + +/** + * Damage zone (`amount` per tick, tagged `kind`). Unlike 【污染秽蚀】 it does not ignore 无法选择: it ticks on the allies + * its area selection takes (areaAllies: no unblocking 隐匿, untargetable or sleeping ally; no airborne 起飞 one for a ground + * enemy — PRTS 集团军重型火炮 【燃烧区域】 "碰撞不受迷彩制约,不可对空": 迷彩 only). A sourceless zone (`src` null: 假想敌:蚀裂's + * 毒雾, an enemy's 死亡爆炸 with no 无视可选性 note on PRTS) selects as an effect with no selecting enemy: 隐匿 kept out, an + * airborne 起飞 ally reached (§21.22; until 0.1.2 it took everyone inside). `noAir`: "不可对空" — a flying ally (the 炎佑 + * dragon) is skipped too (集团军重型火炮's 【燃烧区域】, since 0.1.3). + */ +function dmgZone(b, src, x, y, r, life, iv, amount, type = 'arts', kind = 'zone', el = null, elAmount = 0, { noAir = false } = {}) { + zone(b, { x, y, r, life, iv, kind, pick: (zx, zy, zr) => areaAllies(b, src, zx, zy, zr).filter((u) => !(noAir && u.isFlying)), tick(units) { + for (const u of units) { + hurt(b, src, u, amount, type, { tags: [kind] }); + if (el && elAmount > 0) elem(b, src, u, el, elAmount, { tags: [kind] }); + } + } }); +} + +/** A cooldown skill ability (`id` labels it for tests / other abilities). */ +const skill = (s, fire, { cond = null, sil = false, cd = null, icd = null, id = null } = {}) => (s || cd != null) && fire ? ({ + id: id ?? null, sil, cd: cd ?? s.cd, icd: icd ?? s.icd, cond, fire, +}) : null; + +/** Blink past the blocker along the path (弑君者 / 卢西恩). Returns the start position. */ +export function blinkForward(b, e, dist) { + const from = { x: e.x, y: e.y }; + const R = e.route; + let left = dist; + if (R && R.pts && R.ptIdx < R.pts.length) { + let i = R.ptIdx; + while (left > 1e-9 && i < R.pts.length) { + const p = R.pts[i]; + const d = Math.hypot(p.x - e.x, p.y - e.y); + if (d <= left) { e.x = p.x; e.y = p.y; left -= d; i++; } else { stepToward(e, p.x, p.y, left); left = 0; } + } + } else if (R && R.legs && R.legs[R.legIdx] && R.legs[R.legIdx].r != null) { + const L = R.legs[R.legIdx]; + stepToward(e, L.c, L.r, dist); + } + // never end inside a wall + if (!b.grid.groundPassable(Math.round(e.y), Math.round(e.x)) && e.motion !== 'FLY') { e.x = from.x; e.y = from.y; return null; } + b.addBuff(e, { key: 'ab:blink', duration: 1, flags: { invulnerable: true, unblockable: true } }); + if (R) R.pts = null; + b.fx('blink', { x: e.x, y: e.y, id: e.id, fx: from.x, fy: from.y }); + return from; +} + +// --------------------------------------------------------------------------------------------------------------- +// kits by archetype (functions reused by several keys) + +const kitEp = (el, ...keys) => (ab) => [ep(el, T(ab, ...keys) ?? 0)]; + +const kitStealth = () => [stealth()]; + +const kitTimes = (artsOnly = false) => () => [times(artsOnly), unblockable()]; + +const kitRefraction = (ab) => [refraction(T(ab, 'refracting.magic_resistance', 'Refracting.magic_resistance') ?? 0)]; + +const kitPrisoner = (freeAll = false) => (ab) => [prisoner(ab, { freeAll })]; + +const kitDeathSpawn = (extra = []) => (ab) => [deathSpawn(ab.tS['DeadSpawn.enemy_key'], T(ab, 'DeadSpawn.cnt') ?? 0), ...extra]; + +const kitStun3 = (ab) => [nthAttackStatus(nthOf(ab.sk.stuncombat), 'stun', (ab.sk.stuncombat && ab.sk.stuncombat.bb.stun) || 0, true)]; + +const kitSelfFear = (ab) => [selfFear(ab)]; + +/** 深池逐火战士 / 精锐战士 / 护卫: knock-out ⇒ 1 s 重生 ⇒ a walking, 隐匿, disarmed 余烬 / 火灰 of prop_max_hp hits for `interval` s + * (PRTS 深池逐火战士 天赋: "基础最大生命值临时变为5…具有特殊生命值机制,不进行攻击,获得隐匿、缴械,10s后若未被击倒则变回战士形态并恢复所有 + * 生命"); blocking it lifts the 隐匿, so its blocker (and every operator in range) can beat it — also during the 3 s + * after a block ends, the warrior's own block that the knock-out releases included (Battle._stealthSwitch). */ +const kitEmber = (ab) => [husk({ hits: T(ab, 'Revive[Trigger].prop_max_hp'), delay: T(ab, 'Revive[Trigger].interval') })]; + +const kitPolluted = (ab) => [{ + death(c, b, e) { + if (c.reason !== 'killed') return; + pollution(b, e, e.x, e.y, T(ab, 'PollutedDie.projectile_range') ?? 1, T(ab, 'PollutedDie.projectile_life_time') ?? 0, + T(ab, 'PollutedDie.polluted_damage_low') ?? 0, T(ab, 'PollutedDie.polluted_damage_high') ?? 0); + }, +}]; + +// used by the other enemy modules (content/enemies.js and content/enemies/*.js) +export { + stealth, unblockable, runWhenHit, blockWeight, taunt, maxTargets, ep, onHitStatus, splashAttack, pathKeysAhead, + noAirTargets, resist, immuneTo, nthAttackStatus, nthAttackPower, enemyAura, allyAura, lowHpBuff, deathSpawn, + deathBoom, refraction, setFloat, float, selfFear, freeAllPrisoners, reborn, frontGuard, faceMove, faceCrowd, + unbalanced, artsBarrier, husk, statue, bleed, pollution, dmgZone, skill, kitEp, kitStealth, kitTimes, kitRefraction, + kitPrisoner, kitDeathSpawn, kitStun3, kitSelfFear, kitEmber, kitPolluted, +}; diff --git a/server/sim/content/enemies/dot.js b/server/sim/content/enemies/dot.js new file mode 100644 index 00000000..9489e40d --- /dev/null +++ b/server/sim/content/enemies/dot.js @@ -0,0 +1,134 @@ +// server/sim/content/enemies/dot.js — DOT 持续 kits (pulses, 污染秽蚀 / burning zones, bleeding) and their part of KITS +// (split from content/enemies.js). + +import { SHELL_SPLASH_RADIUS, num, T, elem, hurt, areaAllies } from './helpers.js'; +import { splashAttack, noAirTargets, resist, immuneTo, bleed, pollution, dmgZone, kitPolluted } from './archetypes.js'; + +// --------------------------------------------------------------------------------------------------------------- +// constants (numbers that exist nowhere in the data) + +/** 萨卡兹枯朽战车 秽蚀轰击: radius of the 【污染秽蚀】 it leaves at its target (PRTS 萨卡兹枯朽战车 技能 "在目标位置生成半径1.7, + * 持续10s的【污染秽蚀】" — the skill blackboard's range_radius 2.2 is its trigger range; the zone's radius is in no table). */ +const TANK_ZONE_RADIUS = 1.7; + +/** 集团军重型火炮's 【燃烧区域】: PRTS "碰撞半径1.5" with the source note "1.5倍可变半径" — ×1.5 on the blackboard's + * ProjectileBoomRange.attack@projectile_range (1). Until 0.1.3 the zone took the blackboard's 1. */ +const UACANN_ZONE_SCALE = 1.5; + +// --------------------------------------------------------------------------------------------------------------- +// kits + +/** 深溟巢涌者 / 富营养的巢涌者: one pulse every second (PRTS "每秒"; not its attack interval, so 攻速 changes leave it alone). */ +const NEST_PULSE_INTERVAL = 1; + +const NEST_PULSE_TAG = 'nestPulse'; + +/** + * 深溟巢涌者 / 富营养的巢涌者 (PRTS 天赋): "不进行普通攻击", 抵抗 (一减状态抵抗率 −0.5) and 停顿免疫; "未处于消失状态时,令攻击范围 + * 内的所有我方单位每秒受到攻击力100%的无途径法术伤害" and "每次输出伤害时,再造成攻击力5%的神经损伤" (`EpDamage.ep_damage_ratio`). + * A talent aura, not an attack: `noAttack`, so it walks on while it hurts (ai.js attackStand holds only attackers — until + * 0.1.3 the pulse was its normal attack, which made it stand for its whole 1 s clip whenever an ally was in range: + * community report 「…错误的设置了攻击时不移动导致卡在原地」, GitHub #93), needs no target and pauses while it is hidden on a + * DISAPPEAR leg (消失). The pulse is an area selection (areaAllies: a 迷彩 ally and a flying one — the 炎佑 dragon — are + * hit, a 隐匿 one is not) of radius `rangeRadius` around its centre, like 鼎沸's. [ASSUMED] the pulse can be dodged (闪避: + * PRTS names no attack type, which the site reads as 普通伤害 — the 烹泉 splash rule) and a dodged one adds no 神经. + */ +function kitDsubrl(ab, e) { + const ratio = T(ab, 'EpDamage.ep_damage_ratio') ?? 0; + return [resist(['sluggish']), { + spawn(b, e2) { + e2.profile.noAttack = true; + // 每次输出伤害时: every pulse that reaches the unit (not dodged, not cancelled — the old attack's `dealt` rule) + if (ratio > 0) b.on('damaged', (c) => { if (c.source === e2 && c.dmg && c.dmg.tags.includes(NEST_PULSE_TAG)) elem(b, e2, c.target, 'neural', e2.s.atk * ratio); }, { owner: e2 }); + }, + iv: NEST_PULSE_INTERVAL, + tick(b, e2) { + if (e2.hidden) return; + const r = e2.base.rangeRadius || 1.6; + const l = areaAllies(b, e2, e2.x, e2.y, r); + if (!l.length) return; + b.fx('pulse', { x: e2.x, y: e2.y, r, id: e2.id, element: 'neural' }); + for (const u of l) hurt(b, e2, u, e2.s.atk, 'arts', { canDodge: true, tags: [NEST_PULSE_TAG] }); + }, + }]; +} + +/** + * 萨卡兹枯朽战车 / 尖端 (PRTS; enemy_database spData "初始技力 2 / 技力上限 2 / 攻击回复"): an attack made with full SP is the + * skill 秽蚀轰击 — "对目标造成100%物理普通伤害,同时在目标位置生成半径1.7,持续10s的【污染秽蚀】" — and empties the SP; every + * other attack gains 1. Initial SP = max, so the FIRST attack is the skill, then every 3rd (attacks 1, 4, 7 …). Talent: + * "普通攻击只攻击位于低地的我方单位,且不会攻击飞行单位" (a candidate filter, ai.js enemyAttack: a FLY ally such as the 炎佑 dragon on a + * low tile is no target either); "被阻挡时进行近战攻击,造成攻击力200%的伤害" — normal attacks on its blocker ×chuang_atk_scale. + * [ASSUMED] the skill also fires on its blocker and deals the skill's own 100 % there (PRTS names no blocked exception: + * enemy SP skills fire as soon as they are ready and its condition — a low-ground non-flying ally within 2.2 — holds for + * the blocker), so a blocked tank hits 100 / 200 / 200 % … (v2.4.1: 200 % on every attack, the skill included). + * Reach: the 2.2 range circle takes an ally whose 0.25 collider touches it (ai.js enemyAttack, constants.js + * ALLY_COLLIDER_RADIUS; PRTS 作战机制 §碰撞体积) — 2.45 from the ally's centre, 21 tiles around it instead of 13 (user + * playtest #6 follow-up: the user remembers a long-reaching, hard-hitting ranged attack; the data scale stays 100 %). + */ +function kitTank(ab, e) { + const melee = T(ab, 'Empty.attack@chuang_atk_scale') ?? 1; + const sk = ab.sk.PollutedRangedAtk || null; + const s = sk ? sk.bb : {}; + const spd = (e.def.raw && e.def.raw.sp) || null; + const max = Math.max(1, num(spd && spd.maxSp, 0) || (sk && sk.sp) || (T(ab, 'Empty.sp') ?? 2)); + const init = Math.min(max, Math.max(0, num(spd && spd.initSp, 0))); + return [{ + spawn(b, e2, a) { a.sp = init; a.skill = false; e2.profile.canTarget = (u) => !!u.ground && !u.isFlying; }, + // SP full: this attack is 秽蚀轰击 (a blocked hit lands at once, inside this attack, while `a.skill` is set) + before(c, b, e2, a) { a.skill = a.sp >= max; }, + hitOut(c, b, e2, a) { if (c.dmg.isAttack && !a.skill && c.target === e2.blockedBy) c.dmg.amount *= melee; }, + attack(c, b, e2, a) { + if (!a.skill) { a.sp = Math.min(max, a.sp + 1); return; } + a.skill = false; + a.sp = 0; + const t = c.targets[0]; + if (t) pollution(b, e2, t.x, t.y, TANK_ZONE_RADIUS, s.projectile_life_time ?? 0, s.polluted_damage_low ?? 0, s.polluted_damage_high ?? 0); + }, + }]; +} + +// --------------------------------------------------------------------------------------------------------------- +// this family's part of KITS (content/enemies.js spreads the parts in this order) + +export const DOT_KITS = Object.freeze({ + // --- DOT 持续 + enemy_1234_dsubrl: kitDsubrl, // 深溟巢涌者 · no attack: an arts pulse on every ally in range each second + neural; 抵抗, immune 停顿 + enemy_1234_dsubrl_2: kitDsubrl, // 富营养的巢涌者 · same + enemy_1267_nhpbr: kitPolluted, // 萨卡兹枯朽战士 · death: 污染秽蚀 zone (50 / 25 true per second) + enemy_1267_nhpbr_2: kitPolluted, // 萨卡兹枯朽战士组长 · same + enemy_1270_nhstlk: (ab) => [bleed(ab)], // 逐腐兽 · bleeding (arts/s, cleared by healing) + enemy_1270_nhstlk_2: (ab) => [bleed(ab)], // 疯狂的逐腐兽 · same + enemy_1272_nhtank: kitTank, // 萨卡兹枯朽战车 · ground targets only, melee ×2, 秽蚀轰击 on attacks 1, 4, 7 … + enemy_1272_nhtank_2: kitTank, // 尖端萨卡兹枯朽战车 · same + enemy_9006_actoxi: (ab) => [{ // 假想敌:蚀裂 · death: poison cloud on its killer + sil: true, + death(c, b, e) { + if (c.reason !== 'killed') return; + const k = c.killer && c.killer.side === 'ally' && c.killer.alive ? c.killer : null; + const atk = e.s.atk; + // a sourceless zone (`src` null): its ticks skip a 隐匿 / untargetable / sleeping operator (PRTS 假想敌:蚀裂: no 无视 + // 可选性 note — PRTS 作战机制's "无来源的毒雾" that reaches 隐匿 units is the stage hazard), not an airborne 起飞 one + const cloud = (x, y) => dmgZone(b, null, x, y, T(ab, '1.projectile_range') ?? 0.8, T(ab, '1.projectile_life_time') ?? 0, T(ab, '1.interval') ?? 1, atk * (T(ab, '1.damage_atk_scale') ?? 0), 'arts', 'poison'); + if (k) b.addProjectile({ from: e, target: k, speed: 8, visual: 'lob', source: null, hitDead: true, onHit: (h) => cloud(h.x ?? k.x, h.y ?? k.y) }); + else cloud(e.x, e.y); + }, + }], + // 集团军重型火炮 · PRTS 天赋: "不会攻击飞行单位"; the shell hits every unit within 1.0 of its target for 100 % ATK ("此弹道会强制 + // 击中主目标,碰撞无视迷彩,不可对空"; no splash until 0.1.3) and each hit on the main target leaves a 3 s 【燃烧区域】 of radius + // projectile_range × UACANN_ZONE_SCALE + enemy_10122_uacann_2: (ab) => [noAirTargets(), splashAttack({ unblocked: false, radius: SHELL_SPLASH_RADIUS, noAir: true, fxKind: 'shell' }), { + dealt(c, b, e) { + // 【燃烧区域】 "不可对空": a flying ally (the 炎佑 dragon) standing in it takes nothing (until 0.1.3 it burned) + dmgZone(b, e, c.target.x, c.target.y, (T(ab, 'ProjectileBoomRange.attack@projectile_range') ?? 1) * UACANN_ZONE_SCALE, T(ab, 'ProjectileBoomRange.attack@projectile_life_time') ?? 0, 1, T(ab, 'ProjectileBoomRange.attack@value') ?? 0, 'arts', 'burning', null, 0, { noAir: true }); + }, + }], + enemy_10054_cjhot: (ab, e) => [immuneTo('sluggish'), { // 鼎沸 · pulses arts + burn around itself; immune 停顿 + iv: e.base.bat || 1, + tick(b, e2) { + const r = (e2.def.raw.stats && e2.def.raw.stats.rawRangeRadius) || 1.6; + b.fx('explode', { x: e2.x, y: e2.y, r, kind: 'boil' }); + for (const u of areaAllies(b, e2, e2.x, e2.y, r)) { hurt(b, e2, u, e2.s.atk * (T(ab, 'aoe.atk_scale') ?? 1), 'arts'); elem(b, e2, u, 'burn', e2.s.atk * (T(ab, 'aoe.ep_damage_ratio') ?? 0)); } + }, + }], +}); diff --git a/server/sim/content/enemies/element.js b/server/sim/content/enemies/element.js new file mode 100644 index 00000000..1fe5e4f7 --- /dev/null +++ b/server/sim/content/enemies/element.js @@ -0,0 +1,206 @@ +// server/sim/content/enemies/element.js — ELEMENT 元素 kits (element damage, 海嗣 / 萨卡兹 / 孽生 enemies) and their part of +// KITS (split from content/enemies.js). + +import { periodicDamage } from '../../damage.js'; +import { + stOf, T, elem, hurt, targetsNear, areaAllies, areaAlliesInTiles, targetAndArea, byPriority, setForm, onTerrain, +} from './helpers.js'; +import { taunt, ep, deathSpawn, deathBoom, setFloat, float, skill, kitEp, kitStun3 } from './archetypes.js'; + +// --------------------------------------------------------------------------------------------------------------- +// constants (numbers that exist nowhere in the data) + +/** 假想敌:淤困: element damage its host's burst spreads to the allies of the 4 tiles around (PRTS 假想敌:淤困 "附着对象元素爆发时 + * …对附着对象及周围4格内的所有我方单位…造成1000同类型元素损伤"; the host itself is in its 爆发冷却). */ +const PARASITE_SPREAD = 1000; + +// --------------------------------------------------------------------------------------------------------------- +// kits + +function kitDeepsea(ab, { swim = false, drown = false }) { + return [{ + iv: 0.25, + spawn(b, e) { if (swim) e.mem.immuneDrown = true; }, + tick(b, e, a, dt) { + const wet = onTerrain(b, e, 'deepsea'); + if (swim) { + if (wet && !a.wet) b.addBuff(e, { key: 'ab:swim', mods: { atkPct: T(ab, 'Swim.atk') ?? 0 }, flags: { stealth: true }, visible: true }); + if (!wet && a.wet) b.removeBuff(e, 'ab:swim'); + if (wet) b.removeBuff(e, 'terrain:deepsea'); // 免疫水蚀: drop devices.js' deep-water debuff + } + // PRTS 码头水手 "水蚀状态下或处于清澈水域时,每秒受到1000点无来源真实伤害" (伤害分类: 深水区/涨潮水蚀 is BUFF damage): + // damage, not a 流失 — 脆弱 scales it (player report D1 audit) + if (drown && wet) { const v = T(ab, 'Drown.damage') ?? 0; if (v > 0) b.dealDamage(null, e, { ...periodicDamage(v * dt), tags: ['dot', 'periodic', 'drown'] }); } + a.wet = wet; + }, + // 免疫水蚀: the deep-water tick (devices.js, tag 'deepsea') and drowning — not 环境伤害 ('terrain', e.g. 活性源石) + hitIn(c, b, e, a) { if (swim && a.wet && c.dmg.tags && c.dmg.tags.some((t) => t === 'deepsea' || t === 'drown')) c.dmg.cancel = true; }, + }]; +} + +function kitTidmag(ab) { + return [ep('erosion', T(ab, 'EpDamage.attack@ep_damage_ratio') ?? 0), { + before(c, b, e) { + // PRTS 控潮术师 "对目标所在地块及周围四格内的所有我方单位造成法术普通伤害": the 周围四格 are an area selection (格子判定: + // a 迷彩 ally is hit; an unblocking 隐匿 or an airborne 起飞 one is not — areaAlliesInTiles) + const t0 = c.targets[0]; + if (!t0) return; + const l = c.targets.slice(); + for (const u of areaAlliesInTiles(b, e, t0.tileR, t0.tileC, 'plus', 1)) if (!l.includes(u)) l.push(u); + c.targets = l; + }, + }]; +} + +/** 掠海漂移体 爬行模式: the stun it takes on dropping (PRTS "进入爬行模式并晕眩0.5秒"; not in its blackboard). */ +const SYUFO_CRAWL_STUN = 0.5; + +/** + * 掠海漂移体 (PRTS): 初始模式 近地悬浮 + 失衡免疫, 不会攻击飞行单位; 受晕眩/无法行动/沉睡/冻结/缚地影响后进入爬行模式 for + * good and is stunned 0.5 s — a ground unit (blockable, melee operators hit it) that "仅进行阻挡攻击" (only its blocker). + * The engine has 晕眩 / 冻结 / 沉睡 (no operator here applies 无法行动 or 缚地). Erosion on every attack. + */ +function kitSyufo(ab) { + return [float(), ep('erosion', T(ab, 'EpDamage.attack@ep_damage_ratio') ?? 0), { + spawn(b, e) { e.profile.canTarget = (u) => !u.isFlying; }, + status(c, b, e, a) { + if (a.crawl || !(c.status === 'stun' || c.status === 'freeze' || c.status === 'sleep')) return; + a.crawl = true; + setFloat(b, e, false); + e.profile.melee = true; + b.applyStatus(e, 'stun', { duration: SYUFO_CRAWL_STUN, source: null }); + setForm(b, e, 'crawl'); + }, + }]; +} + +function kitDeathEye(ab, e) { + const s = ab.sk.DeathEye; + const r = e.base.rangeRadius || 2.5; + return [ep('apoptosis', T(ab, 'empty.attack@ep_damage_ratio') ?? 0), skill(s, (b, e2, a) => { + const t = byPriority(e2, targetsNear(b, e2, r))[0]; + if (!t) return; + const dur = s.bb.hit_duration ?? 0; + b.addBuff(e2, { key: 'ab:channel', duration: dur, flags: { disarm: true } }); + b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'deathEye', dur }); + let n = 0; + const h = b.every(1, () => { + if (!e2.alive) { h.cancel(); return; } + // a stun / silence (SILENCE-flagged ability) or the locked target's fall interrupts the channel: no burst + if (e2.s.flags.stun || e2.s.flags.silence || !t.alive) { h.cancel(); b.removeBuff(e2, 'ab:channel'); b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'deathEyeEnd' }); return; } + hurt(b, e2, t, e2.s.atk * (s.bb.atk_scale ?? 0), 'arts'); + if (++n >= dur) { + h.cancel(); + b.fx('explode', { x: t.x, y: t.y, r: 1, kind: 'apoptosis' }); + // "对目标及其周围4格的我方单位": the locked target (a 隐匿 it gained mid-channel does not save it) + an area around it + for (const u of targetAndArea(t, areaAllies(b, e2, t.x, t.y, 1))) elem(b, e2, u, 'apoptosis', e2.s.atk * (s.bb.ep_damage_ratio ?? 0)); + } + }, { owner: e2 }); + }, { sil: true, cond: (b, e2) => targetsNear(b, e2, r).length > 0 })]; +} + +/** + * 淤困's "受到的元素损伤提高至130%" on its host: an `elementHit` multiplier (SIM.md §7.2 — every "受到的元素损伤±N%"; on an + * operator at priority 20, before a 损伤屏障), never 元素伤害. One battle-wide handler, installed with the first host. + */ +function ensureParasiteHook(b) { + const st = stOf(b); + if (st.parasiteHook) return; + st.parasiteHook = true; + b.on('elementHit', (c) => { + const bf = c.target && c.dmg && c.dmg.type === 'element' ? c.target.findBuff('ab:parasite') : null; + const m = bf && bf.data ? bf.data.epMul : 1; + if (m > 0 && m !== 1) c.dmg.mul *= m; + }, { priority: 20 }); +} + +function kitParasite(ab) { + const scale = T(ab, '1.atk_scale') ?? 0, elMul = T(ab, '1.ep_damage_scale') ?? 1; + return [taunt(-1), { + iv: 0.2, + tick(b, e, a) { + const host = e.blockedBy && e.blockedBy.alive ? e.blockedBy : null; + if (a.host && a.host !== host) { b.removeBuff(a.host, 'ab:parasite'); a.host = null; } + if (!host || a.host === host) return; + a.host = host; + ensureParasiteHook(b); + b.fx('beam', { x: e.x, y: e.y, from: e.id, to: host.id, kind: 'parasite' }); + b.addBuff(host, { + // "受到的元素损伤提高至130%": a 元素损伤 multiplier on the element hit (ensureParasiteHook), not 元素伤害 + key: 'ab:parasite', visible: true, interval: 1, data: { src: e, spread: PARASITE_SPREAD, epMul: elMul }, + onTick: ({ battle, unit }) => { if (e.alive) battle.dealDamage(e, unit, { amount: e.s.atk * scale, type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility', 'parasite'] }); }, + }); + }, + death(c, b, e, a) { if (a.host) b.removeBuff(a.host, 'ab:parasite'); }, + }]; +} + +function kitIgnitable(ab) { + return [deathBoom({ scale: T(ab, 'boom.atk_scale') ?? 0, type: 'arts', cond: (e) => !!e.mem.ab.ignited, sil: true })]; +} + +function kitFlameVine(ab) { + const add = T(ab, 'pow.add_max_atk') ?? 0, time = T(ab, 'pow.time') ?? 0; + const first = T(ab, 'pow.attack@ep_damage_ratio') ?? 0, normal = T(ab, 'pow.attack@ep_damage_ratio_normal') ?? 0; + return [{ + iv: 0.5, + spawn(b, e, a) { a.t0 = b.time; a.first = true; }, + tick(b, e, a) { + if (!a.first || !(time > 0)) return; + const k = Math.min(1, (b.time - a.t0) / time); + b.addBuff(e, { key: 'ab:pow', mods: { atkPct: add * k }, persist: true }); + }, + dealt(c, b, e, a) { + const t = c.target; + if (a.first) { + // 首次攻击 (PRTS 灼藤): every other ally of the target's 3×3 (range x-4, "格子判定,不受迷彩制约") takes 100 % ATK + // arts splash and everyone there 25 % ATK burn — an area selection (no unblocking 隐匿 ally); then the ATK ramp + // ends and later attacks add 20 % + a.first = false; + b.fx('explode', { x: t.x, y: t.y, r: 1.5, kind: 'flameVine' }); + for (const u of targetAndArea(t, areaAlliesInTiles(b, e, t.tileR, t.tileC, 'box', 1))) { if (u !== t) hurt(b, e, u, e.s.atk, 'arts'); elem(b, e, u, 'burn', e.s.atk * first); } + b.removeBuff(e, 'ab:pow'); + } else elem(b, e, t, 'burn', e.s.atk * normal); + }, + attack(c, b, e) { + // ignites nearby 卷心籽 + for (const o of b.enemiesInRadius(e.x, e.y, 1.5)) if (o.defId === 'enemy_10065_ftzlc' && o.mem.ab && !o.mem.ab.ignited) { o.mem.ab.ignited = true; b.fx('ignite', { x: o.x, y: o.y, id: o.id }); } + }, + }]; +} + +function kitNucleus(ab) { + return [{ + taken(c, b, e, a) { if (a.on) return; a.on = true; b.addBuff(e, { key: 'ab:combat', persist: true, visible: true, mods: { moveMul: T(ab, '0.move_speed') ?? 1 } }); }, + iv: T(ab, '1.interval') ?? 1, + // 【孽生者的神经毒素】 "(无视迷彩,同名效果不叠加…)": an area selection (no unblocking 隐匿 ally) + tick(b, e, a) { if (!a.on) return; for (const u of areaAllies(b, e, e.x, e.y, T(ab, '1.range_radius') ?? 0)) elem(b, e, u, 'neural', e.s.atk * (T(ab, '1.ep_damage_ratio') ?? 0)); }, + }]; +} + +// --------------------------------------------------------------------------------------------------------------- +// this family's part of KITS (content/enemies.js spreads the parts in this order) + +export const ELEMENT_KITS = Object.freeze({ + // --- ELEMENT 元素 + enemy_1148_dssbr: kitEp('neural', 'epdamage.attack@ep_damage_ratio'), // 底海滑动者 · neural on hit + enemy_1148_dssbr_2: kitEp('neural', 'epdamage.attack@ep_damage_ratio'), // 富营养的滑动者 · neural on hit + enemy_1158_divman: (ab) => kitDeepsea(ab, { swim: true }), // 潜水员 · in deep water: ATK up + stealth; immune to 水蚀 + enemy_1160_hvyslr: (ab) => [...kitStun3(ab), ...kitDeepsea(ab, { drown: true })], // 码头水手 · every (spCost+1)th attack stuns; drowns in deep water + enemy_1160_hvyslr_2: (ab) => [...kitStun3(ab), ...kitDeepsea(ab, { drown: true })], // 码头水手长 · same + enemy_1161_tidmag: kitTidmag, // 控潮术师 · attack hits target + 4 neighbours, erosion + enemy_1161_tidmag_2: kitTidmag, // 领潮员 · same + enemy_1162_magmot: (ab) => [...kitTidmag(ab), deathSpawn(ab.tS['DeathRattle.enemy_key'], 1)], // 术师快艇 · same + 控潮术师 on death + enemy_1305_mhslim: kitEp('burn', 'EpDamage.attack@ep_damage_ratio'), // 灼热源石虫 · burn on hit + enemy_1305_mhslim_2: kitEp('burn', 'EpDamage.attack@ep_damage_ratio'), // 炽焰源石虫 · burn on hit + enemy_2021_syfish: kitEp('erosion', 'EpDamage.attack@ep_damage_ratio'), // 骨海漂流体 · erosion on hit + enemy_2025_syufo: kitSyufo, // 掠海漂移体 · 近地悬浮 (drops to 爬行模式 when stunned) + erosion + enemy_1229_darmy: kitEp('apoptosis', 'epdamage.attack@ep_damage_ratio'), // 萨卡兹王庭军战士 · apoptosis on hit + enemy_1229_darmy_2: kitEp('apoptosis', 'epdamage.attack@ep_damage_ratio'), // 萨卡兹王庭军精锐战士 · apoptosis on hit + enemy_1275_dwlock_2: kitDeathEye, // 萨卡兹王庭军精锐术师 · apoptosis on hit + DeathEye channel → group apoptosis + enemy_1439_dslntf: kitNucleus, // 元核孽生者 · first damage: combat state (speed ×, neural pulse around) + enemy_1439_dslntf_2: kitNucleus, // 异光体孽生者 · same + enemy_9007_acelem: kitParasite, // 假想敌:淤困 · taunt −1; parasitises its blocker (arts/s, element taken ×, its burst spreads 1000) + enemy_10065_ftzlc: kitIgnitable, // 卷心籽 · ignited by 灼藤 → arts death blast + enemy_10067_ftsjc: kitFlameVine, // 灼藤 · ATK ramps until the 1st attack (3×3 arts + burn), burn on hit, ignites 卷心籽 +}); diff --git a/server/sim/content/enemies/fly.js b/server/sim/content/enemies/fly.js new file mode 100644 index 00000000..f90e917c --- /dev/null +++ b/server/sim/content/enemies/fly.js @@ -0,0 +1,264 @@ +// server/sim/content/enemies/fly.js — FLY 飞行 kits (auras, 暴鸰, the “萨科塔”, 假想敌:黑云, shells, seeds) and their part of KITS +// (split from content/enemies.js). + +import { TICK, MOVE_SCALE, PROJECTILE_SPEEDS } from '../../constants.js'; +import { + num, T, hurt, targetsNear, allTargets, areaAllies, areaAlliesInTiles, fieldAllies, byPriority, spawnChildren, + stepToward, setForm, expose, +} from './helpers.js'; +import { enemyAura, allyAura, selfFear, skill, kitSelfFear } from './archetypes.js'; + +// --------------------------------------------------------------------------------------------------------------- +// constants (numbers that exist nowhere in the data) + +/** 暴鸰 投弹: the target's tile and its 8 neighbours (PRTS "对目标及其周围八格的我方单位造成100%物理伤害"). */ +const BOMB_REACH = 1; + +/** 暴鸰 投弹: the bomb leaves on the OnAttack event of the cast's Attack clip (the official battle prefab's Boomb ability: + * animKey Attack, `_waitForAttackEvent`; the skeleton's OnAttack is on frame 8 of the 30 fps clip — data/assets.json + * hits.Attack 0.267, rounded — so exactly 8 sim ticks). It then flies as projectile_bombd (`_speed` 5 = + * PROJECTILE_SPEEDS.droneBomb, homing, `_ignoreCamouflage`). */ +export const BOMBD_RELEASE = 8 / 30; + +/** 暴鸰 投弹: the cast ends once the bomb has landed and no sooner than this after the release (the Boomb ability: + * `_fireAttackFinishWhenProjectileInvalid` 1, `_minPostDelayWhenProjectileInvalid` 0.667, `_waitForAnimEndWhenProjectileInvalid` + * 0). Then its buff bomb_s (template switch_mode_restart_fsm: mode S1 + the move-speed modifier) — PRTS "技能结束后移速最终 + * 提升至200%"; the cast's end clip is already the bomb-less Idle_2 (`_endAnimKey`). */ +export const BOMBD_POST_DELAY = 0.667; + +/** 帝国炮火先兆者 shell (PRTS "普通攻击向目标所在位置发射一枚于3秒后命中的弹道,弹道对半径1.2范围内的所有我方单位造成攻击力 + * 100%的无来源物理伤害 … ※弹道始终使用缓存攻击力"): flight time and blast radius. */ +const SHELL_FLIGHT = 3, SHELL_RADIUS = 1.2; + +/** 假想敌:黑云 抓取: the blackboard radius counts ×2.5 (PRTS "2.5倍可变半径": range_radius 1.5 → 3.75), at most 3 prey, + * "短暂延迟后" the 延迟吞噬 lands (delay [ASSUMED] 0.5 s); its SP (= 全弹发射 hits) caps at the data's spData.maxSp. */ +const GRAB_RADIUS_SCALE = 2.5, GRAB_MAX_PREY = 3, GRAB_DELAY = 0.5; + +/** 枯朽之种 summoned per BornBugs cast ("数个") [ASSUMED]. */ +const BUGS_PER_CAST = 3; + +// --------------------------------------------------------------------------------------------------------------- +// kits + +/** 远眺: knocked out ⇒ 暴露 on every ally within r — PRTS 天赋 "(可被沉默;无视其可选性)": 隐匿 / untargetable ones too. */ +function kitExposeOnDeath(ab) { + const dur = T(ab, 'Expose.weak[limit]') ?? 0, scale = T(ab, 'Expose.damage_scale') ?? 1, r = T(ab, 'Expose.range_radius') ?? 1; + return [{ sil: true, death(c, b, e) { if (c.reason !== 'killed') return; b.fx('explode', { x: e.x, y: e.y, r, kind: 'expose' }); for (const u of b.alliesInRadius(e.x, e.y, r)) expose(b, u, dur, scale); } }]; +} + +/** + * 假想敌:黑云 (PRTS): 抓取 (KillOthers, cd 10 / icd 5) "仅半径3.75范围内存在未持有【延迟吞噬】的敌方飞行普通单位时可触发: + * 选择至多3名满足上述条件的目标,短暂延迟后对其施加4秒【延迟吞噬】:束缚,效果结束时令给予方+1SP,随后强制击杀受予方 ※技能 + * 持续4秒,期间持有束缚" (the nearest prey first [ASSUMED]); 全弹发射 (FireWeapon, cd 10 / icd 5, SP cost 1) "仅全场范围内存在 + * 我方单位时可触发:清空自身SP,进行一次多连击,每击选择全场范围内的1名随机我方单位,对其造成攻击力130%的物理伤害 ※多连击的 + * 连击次数等于本技能消耗的SP数量" — SP (技力上限 spData.maxSp 3) only comes from the grabs. + */ +function kitBlackCloud(ab, e) { + const k = ab.sk.KillOthers, f = ab.sk.FireWeapon; + const maxAmmo = Math.max(1, num(e.def.raw && e.def.raw.sp && e.def.raw.sp.maxSp, 3)); + const grabR = (k ? num(k.bb.range_radius, 1.5) : 1.5) * GRAB_RADIUS_SCALE; + const dur = k ? num(k.bb.duration, 4) : 4; + const isPrey = (b, e2, o) => o !== e2 && o.alive && !o.hidden && o.isFlying && o.def.rank === 'NORMAL' && !o.isBoss && !o.findBuff('ab:devoured'); + const prey = (b, e2) => b.enemiesInRadius(e2.x, e2.y, grabR).filter((o) => isPrey(b, e2, o)); + return [ + skill(k, (b, e2) => { + const list = prey(b, e2).sort((p, q) => Math.hypot(p.x - e2.x, p.y - e2.y) - Math.hypot(q.x - e2.x, q.y - e2.y)).slice(0, GRAB_MAX_PREY); + if (!list.length) return; + b.addBuff(e2, { key: 'ab:grabbing', duration: dur, flags: { bind: true, noMove: true } }); // 技能持续4秒,期间持有束缚 + b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: list[0].id, kind: 'devour' }); + b.after(GRAB_DELAY, () => { + if (!e2.alive) return; + for (const o of list) { + if (!o.alive) continue; + b.addBuff(o, { key: 'ab:devoured', duration: dur, visible: true, flags: { bind: true, noMove: true, disarm: true } }); + b.after(dur, () => { + if (!o.alive || !e2.alive) return; + e2.mem.ab.ammo = Math.min(maxAmmo, (e2.mem.ab.ammo ?? 0) + num(k.bb.sp, 1)); + b.kill(o, null); + }, { owner: e2 }); + } + }, { owner: e2 }); + }, { sil: true, cond: (b, e2) => prey(b, e2).length > 0 }), + skill(f, (b, e2) => { + const hits = Math.floor(e2.mem.ab.ammo ?? 0); + if (!(hits > 0)) return; + e2.mem.ab.ammo = 0; + for (let i = 0; i < hits; i++) { + const t = b.rng.pick(allTargets(b, e2)); + if (!t) break; + b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'fireWeapon' }); + hurt(b, e2, t, e2.s.atk * num(f.bb.atk_scale, 1), 'phys'); + } + }, { sil: true, cond: (b, e2) => (e2.mem.ab.ammo ?? 0) >= Math.max(1, num(f.sp, 1)) && allTargets(b, e2).length > 0 }), + ]; +} + +function kitSteal(ab) { + const n = T(ab, 'DamageOrBullet.attack@minus_bullet') ?? 1; + return [selfFear(ab), { + // PRTS “萨科塔之眼” 天赋: "不会攻击飞行单位" (the 炎佑 dragon is one) — a candidate filter (ai.js enemyAttack): its next + // ground target in range instead + spawn(b, e) { e.profile.canTarget = (u) => !u.isFlying; }, + hitOut(c, b, e) { + if (!c.dmg.isAttack) return; + const t = c.target; + // 攻击时尝试夺走目标1发弹药,若无法夺走弹药则造成高额物理伤害 + if (t.skill && t.skill.active && t.skill.kind === 'ammo' && t.skill.ammoLeft > 0) { + t.skill.ammoLeft = Math.max(0, t.skill.ammoLeft - n); + if (t.skill.ammoLeft <= 0) t.skill.end('ammo'); + } else if ((t.trait && t.trait.ammo) > 0) { + t.trait.ammo = Math.max(0, t.trait.ammo - n); + } else return; + c.dmg.cancel = true; + b.fx('steal', { x: t.x, y: t.y, id: t.id, from: e.id }); + }, + }]; +} + +/** “萨科塔昂首” 【祈祷邀约】 (PRTS "令全场我方单位(不可对空,无视迷彩)获得15s【受邀祈祷】攻击速度-30"): a whole-field skill + * selection — every ally but an unblocking 隐匿 / untargetable / sleeping one (fieldAllies; no 无视无法选择). */ +function kitRoar(ab) { + const s = ab.sk.Roar; + return [selfFear(ab), skill(s, (b, e) => { + b.fx('telegraph', { x: e.x, y: e.y, r: 99, kind: 'roar', id: e.id }); + for (const u of fieldAllies(b, e)) b.addBuff(u, { key: 'ab:roar', duration: s.bb.duration ?? 0, refresh: 'extend', mods: { aspd: s.bb.attack_speed ?? 0 }, visible: true }); + }, { sil: true })]; +} + +/** + * 暴鸰 (PRTS): "不进行普通攻击"; 投弹 (boomb, cooldown / initCooldown 1) "仅攻击范围内存在我方单位时可触发:对目标及其周围八格 + * 的我方单位造成100%物理伤害(对主目标造成物理普通伤害,对溅射目标造成物理溅射伤害,伤害无视迷彩)技能结束后移速最终提升至200% + * ※此技能仅能触发一次,不可沉默". The official battle prefab (enemy_1040_bombd, read from the client) drops the bomb as a + * projectile: the cast plays the Attack clip, the bomb leaves on its OnAttack event (BOMBD_RELEASE) and flies to the + * target (projectile_bombd), and the drone switches to its bomb-less mode (S1, buff bomb_s: the *_2 clips, no bottle). + * User feedback after 0.1.0 (D4 "炸弹无法正常投放"): the damage used to land in the tick of the trigger while the drone + * kept its bomb on screen. Now: the drone hovers through its cast; at the release an 'atk' event of kind 'droneBomb' + * (the client winds the Attack clip up to it and flies the bomb) and setForm 'bombed' (fx 'phase' {kind, form: 'bombed'} + * — render FORMS: the *_2 clips, which the view starts once the Attack clip is over — the official end clip Idle_2; + * `e.form` → UnitInfo.form; the client keeps the fx through catch-ups, shared/protocol.js fxForm); on arrival the target (the ranged target by engine priority) takes 100 % ATK and every other ally of + * the 8 tiles around where it lands 100 % ATK splash (camouflage ignored); a target gone mid-flight: the bomb lands + * where it was. The cast ends once the bomb has landed, at least BOMBD_POST_DELAY after the release: move speed + * ×boomb.move_speed, and it flies on. A stun / freeze / sleep before the release interrupts the cast (Boomb + * `_immuneStunWhenAffecting` 0, like the other enemy channels here): nothing leaves the drone, it keeps its bomb and casts + * again after the skill's cooldown (1 s, counted from the interrupt) once it is free and an operator is in range. + * [ASSUMED] no drop when the drone is dead at the release; the ATK at the release; the hover through the cast (its own + * hold, `pauseUntil`: the drone has no normal attack — `noAttack` —, so ai.js attackStand, the stand of a ranged enemy + * for its attack clip, never runs for it; the drop is its only attack-like cast); the + * bomb-less look from the release (the bomb leaves the drone on that frame of the Attack clip; at the cast end — 1 tick + * before the clip ends — the client would draw the bomb back for a frame); an interrupted cast does not use up the one + * trigger (`_maxTriggerTime` 1 — the bomb is still on the drone); a stun after the release does not stop the cast end. + */ +function kitBombd(ab) { + const s = ab.sk.boomb; + const ms = (s && s.bb.move_speed) || 0; + const reach = (e) => e.base.rangeRadius || 2; + const land = (b, e, atk, t, x, y) => { + const r = t ? t.tileR : Math.round(y), c = t ? t.tileC : Math.round(x); + b.fx('explode', { x, y, r: BOMB_REACH + 0.5, kind: 'bomb', tiles: 'box' }); + if (t) hurt(b, e, t, atk, 'phys'); + // the splash on the 8 tiles is an area selection ("伤害无视迷彩", no 无视无法选择): no unblocking 隐匿 ally + for (const u of areaAlliesInTiles(b, e, r, c, 'box', BOMB_REACH)) if (u !== t) hurt(b, e, u, atk, 'phys', { tags: ['splash'] }); + }; + // the end of the cast (bomb_s): 移速最终提升至200%; it flies on + const finish = (b, e) => { + if (!e.alive) return; + e.pauseUntil = b.time; + if (ms > 0) b.addBuff(e, { key: 'ab:bombRun', mods: { moveMul: ms }, persist: true }); + }; + // the cast before its release: { a: the skill ability, rel: the scheduled release } + let pending = null; + // a stun / freeze / sleep before the release: the bomb stays on; the skill re-arms with its cooldown + const interrupt = (b, e) => { + const { a, rel } = pending; + pending = null; + rel.cancel(); + e.pauseUntil = b.time; + a.left = Math.max(TICK, num(s.cd, 1)); + }; + return [ + { spawn(b, e) { e.profile.noAttack = true; }, + tick(b, e) { if (pending && e.s.flags.stun) interrupt(b, e); } }, + skill(s, (b, e, a) => { + const t = byPriority(e, targetsNear(b, e, reach(e)))[0]; + if (!t) return; + a.cd = Infinity; a.left = Infinity; // 仅能触发一次 + e.skillAnimUntil = -1; // the cast is drawn through its 'atk' event: the client winds the Attack clip up to it + // hovers through the cast, until `finish` (bounded: the bomb lands within its projectile's maxAge, 10 s) + e.pauseUntil = Math.max(e.pauseUntil, b.time + BOMBD_RELEASE + 10 + BOMBD_POST_DELAY); + let landed = false, waited = false; + const done = () => { if (landed && waited) finish(b, e); }; + pending = { a, rel: null }; + pending.rel = b.after(BOMBD_RELEASE, () => { + if (pending && e.alive && e.s.flags.stun) { interrupt(b, e); return; } // stunned after this tick's ability pass + pending = null; + if (!e.alive) return; + const atk = e.s.atk; + b._ev(['atk', e.id, t.id, 'droneBomb']); + setForm(b, e, 'bombed'); // UnitInfo.form: a view built later draws it bomb-less + b.addProjectile({ from: e, target: t, speed: PROJECTILE_SPEEDS.droneBomb, visual: 'droneBomb', source: e, hitDead: true, + onHit: (c) => { land(b, e, atk, c.target, c.x, c.y); landed = true; done(); } }); + b.after(BOMBD_POST_DELAY, () => { waited = true; done(); }, { owner: e }); + }, { owner: e }); + }, { cond: (b, e) => targetsNear(b, e, reach(e)).length > 0 }), + ]; +} + +/** + * 帝国炮火先兆者 / 中枢先兆者 (PRTS): every normal attack fires a shell at the target's position that lands SHELL_FLIGHT s + * later and deals 100 % of the ATK at launch as physical damage without a source to every ally within SHELL_RADIUS + * (it may miss the target that moved away; "碰撞无视迷彩"). The landing is the shell's area selection (areaAllies of the + * enemy that fired it: no unblocking 隐匿 ally; the damage stays 无来源). The attack itself is the engine's (cooldown, + * pause, 'atk' event of kind 'mortar'); the damage is the shell's (ai.js `profile.deferHit`). + */ +function kitShell() { + return [{ + spawn(b, e) { e.profile.deferHit = true; e.profile.shot = 'mortar'; }, + attack(c, b, e) { + const atk = e.s.atk * (e.profile.atkScale ?? 1); + for (const t of c.targets) { + const x = t.x, y = t.y; + b.fx('bombardShell', { x, y, id: e.id, r: SHELL_RADIUS, t: SHELL_FLIGHT }); + b.after(SHELL_FLIGHT, () => { + b.fx('bombard', { x, y, r: SHELL_RADIUS, kind: 'emppnt' }); + for (const u of areaAllies(b, e, x, y, SHELL_RADIUS)) hurt(b, null, u, atk, 'phys', { isSkill: false, tags: ['shell'] }); + }); + } + }, + }]; +} + +// --------------------------------------------------------------------------------------------------------------- +// this family's part of KITS (content/enemies.js spreads the parts in this order) + +export const FLY_KITS = Object.freeze({ + // --- FLY 飞行 (motion is engine; abilities) + enemy_1017_defdrn: (ab) => [enemyAura(T(ab, 'defup.range_radius') ?? 2.5, 'ab:defdrn', { defFlat: T(ab, 'defup.def') ?? 0 })], // 御4 · DEF aura + enemy_1355_mrfly: (ab, e) => [enemyAura(e.base.rangeRadius || 2.5, 'ab:mrfly', { resFlat: T(ab, 'magdef_add.magic_resistance') ?? 0 })], // 护障 · RES aura + enemy_1355_mrfly_2: (ab, e) => [enemyAura(e.base.rangeRadius || 2.5, 'ab:mrfly', { resFlat: T(ab, 'magdef_add.magic_resistance') ?? 0 })], // 护障·P · RES aura + enemy_1042_frostd: (ab) => [allyAura(T(ab, 'defup.range_radius') ?? 2.5, 'ab:frost', { aspd: (T(ab, 'atkSpeedDown.attack_speed') ?? 0) * 100 })], // 寒霜 · ASPD −50 aura on operators + enemy_1040_bombd: kitBombd, // 暴鸰 · no normal attack: ONE bomb (target + 8 tiles), then ×2 speed + enemy_10083_hlbird: kitSelfFear, // “萨科塔之翼” · below half HP: 5 s self-fear, flutters in its tile ×1.5 + enemy_10084_hlegle: kitSteal, // “萨科塔之眼” · fear below half; steals 1 ammo instead of hitting + enemy_10085_hllevi_2: kitRoar, // “萨科塔昂首” · fear below half; 祈祷邀约 global ASPD −30 + enemy_1407_hummbd: kitExposeOnDeath, // 远眺 · death: exposes operators around (damage taken ×1.2) + enemy_9009_acfort: kitBlackCloud, // 假想敌:黑云 · devours ≤ 3 normal flyers for ammo, fires it all at random allies + enemy_1112_emppnt: kitShell, // 帝国炮火先兆者 · attacks are shells landing 3 s later (r 1.2) + enemy_1112_emppnt_2: kitShell, // 帝国炮火中枢先兆者 · same + enemy_1321_wdarft: (ab) => [skill(ab.sk.BornBugs, (b, e) => spawnChildren(b, e, 'enemy_1269_nhfly', BUGS_PER_CAST))], // 枯朽萃聚使徒 · spawns 枯朽之种 + enemy_1269_nhfly: () => [{ // 枯朽之种 · no normal attack: dives onto a nearby operator and self-destructs + spawn(b, e) { e.profile.noAttack = true; }, + tick(b, e, a, dt) { + if (a.t && !a.t.alive) { a.t = null; b.removeBuff(e, 'ab:dive'); } + if (!a.t) { + const l = targetsNear(b, e, e.base.rangeRadius || 1).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y)); + if (!l.length) return; + a.t = l[0]; + b.addBuff(e, { key: 'ab:dive', flags: { noMove: true } }); + } + if (!stepToward(e, a.t.x, a.t.y, Math.max(e.s.moveSpeed, 0.5) * MOVE_SCALE * dt) && Math.hypot(a.t.x - e.x, a.t.y - e.y) > 0.3) return; + hurt(b, e, a.t, e.s.atk, 'phys'); + b.fx('explode', { x: e.x, y: e.y, r: 0.5, kind: 'seed' }); + b.kill(e, null); + }, + }], +}); diff --git a/server/sim/content/enemies/helpers.js b/server/sim/content/enemies/helpers.js new file mode 100644 index 00000000..c2d2cbab --- /dev/null +++ b/server/sim/content/enemies/helpers.js @@ -0,0 +1,392 @@ +// server/sim/content/enemies/helpers.js — shared helpers of the enemy kits (split from content/enemies.js; the module +// map is in its header): the constants several families use, the per-battle state, the ability bookkeeping (abOf / +// attach / dispatch, T, canCast …) and the selection and damage helpers (elem, hurt, areaAllies & friends, zone, +// spawnChildren, setForm, lpLoss, expose …). + +import { ELEMENT } from '../../constants.js'; +import { canTargetAlly, sortAllyTargets, areaSelectable, auraSelectable } from '../../targeting.js'; +import { mitigate } from '../../damage.js'; + +// --------------------------------------------------------------------------------------------------------------- +// constants (numbers that exist nowhere in the data) + +/** 侵蚀 → engine gauge. */ +export const EROSION = 'erosion'; + +/** Official erosion burst on operators (gamedata_const termDescriptionDict ba.dt.erosion; engine constants ELEMENT.erosion.ally). */ +export const EROSION_BURST = Object.freeze({ defDown: ELEMENT.erosion.ally.defDown, physDamage: ELEMENT.erosion.ally.damage }); + +/** 重生 of a Revive[Trigger] knock-out before its husk (PRTS 深池逐火战士 and 假想敌:再生 天赋: "被击倒后重生,持续1s,随后变为 + * 怨恨的余烬 / 再生状态,1s内不移动且持有无敌+无法阻挡+失衡免疫"; both models' knock-out clips last 1 s). */ +export const HUSK_REBIRTH = 1; + +/** Death-explosion radius when the enemy has no official radius [ASSUMED]. */ +const BOOM_RADIUS = 1.25; + +/** 【污染秽蚀】 ticks once per second; "同名效果不叠加": a unit inside several zones takes one tick per second. */ +const POLLUTION_INTERVAL = 1; + +/** + * 隐匿 that comes back sooner after a block than the general 3 s (PRTS 作战机制 §隐匿 "对于绝大部分可隐匿的敌人而言…不被阻挡的 + * 3秒后重新进入隐匿"; engine STEALTH_RESTORE): the enemy pages' 天赋 "隐匿(解除阻挡N秒后恢复)" — PRTS text search + * "解除阻挡0秒后恢复" (21 enemies; these 6 are in this mode) and "解除阻挡1秒后恢复" (the two 家族灭迹人), checked + * 2026-10-03. Every other stealthy enemy here has a plain "隐匿" (3 s), the 深池逐火 embers included. 清明 / 堂皇's veil + * ("获得隐匿(解除阻挡0秒后恢复)") is kitInvisShield's. + */ +const STEALTH_RESTORE_BY_KEY = Object.freeze({ + enemy_10031_cnvsld: 0, // 业余竞演者 + enemy_10034_cnvsax: 0, // 节日爵士乐手 + enemy_9008_acbunn: 0, // 假想敌:骨刺 + enemy_2034_sythef: 0, // 流泪小子 + enemy_2034_sythef_2: 0, // 流泪小子 (its stronger copy) + enemy_1389_winbab_2: 0, // 访问团强攻冠军 + enemy_1283_sgkill: 1, // 家族灭迹人 + enemy_1283_sgkill_2: 1, // 家族暗影灭迹人 +}); + +/** Splash radius (中点判定 around the target) of 烹泉 / 沏虹's attack (PRTS 天赋 "普通攻击对目标对及目标周围半径1.0范围内的所有 + * 我方单位造成法术普通伤害(无视迷彩,不可对空)") and of 集团军重型火炮's shell (PRTS "对目标半径1.0范围内的所有我方单位造成攻击力 + * 100%的物理伤害(此弹道会强制击中主目标,碰撞无视迷彩,不可对空)"). */ +const TEA_SPLASH_RADIUS = 1, SHELL_SPLASH_RADIUS = 1; + +/** Every Nth attack for "数次攻击后" abilities without an SP cost in the data [ASSUMED]; with a cost N = spCost + 1 + * (official 粉碎攻坚手 text for spCost 2: "攻击2次后,下一次攻击" ⇒ 3rd; 码头水手 / 澪 / 清扫小队 spCost 3 ⇒ 4th). */ +const NTH_ATTACK = 3; + +export const nthOf = (s) => (s && s.sp > 0 ? s.sp + 1 : NTH_ATTACK); + +// --------------------------------------------------------------------------------------------------------------- +// per-battle state + installation + +const STATE = new WeakMap(); + +function stOf(b) { + let st = STATE.get(b); + if (!st) { st = { lpLoss: 0, freedAll: false, deathWatch: new Set() }; STATE.set(b, st); } + return st; +} + +// --------------------------------------------------------------------------------------------------------------- +// ability bookkeeping + +/** The enemy's ability record (created on demand). */ +export function abOf(b, e) { + if (e.mem.ab) return e.mem.ab; + const key = e.defId; + const ov = (b.enemyOverrides && (b.enemyOverrides[key] ?? b.enemyOverrides[e.def && e.def.key])) || null; + const raw = (e.def && e.def.raw) || {}; + const t = { ...((e.def && e.def.talent) || {}), ...((ov && ov.talents && ov.talents.bb) || {}) }; + const tS = { ...((raw.talents && raw.talents.bbStr) || {}), ...((ov && ov.talents && ov.talents.bbStr) || {}) }; + const sk = {}; + for (const s of (ov && Array.isArray(ov.skills) ? ov.skills : (e.def && e.def.skills) || [])) { + if (!s || s.prefabKey == null) continue; + sk[s.prefabKey] = { cd: num(s.cooldown, 0), icd: num(s.initCooldown, 0), sp: num(s.spCost, 0), bb: s.bb || {}, bs: s.bbStr || {} }; + } + e.mem.ab = { key, t, tS, sk, list: [], times: null, hitShield: 0, atkType: null, immune: null, origMaxHp: e.base.maxHp }; + return e.mem.ab; +} + +/** Attach abilities to an enemy (calls their spawn()). */ +export function attach(b, e, list) { + const ab = abOf(b, e); + for (const a of list) { + if (!a) continue; + if (a.cd != null) a.left = num(a.left, num(a.icd, 0)); + ab.list.push(a); + if (a.spawn) safe(b, e, () => a.spawn(b, e, a, ab)); + } + return ab; +} + +function safe(b, e, fn) { + try { return fn(); } catch (err) { b._handlerError('content:enemies', e, err); return undefined; } +} + +function dispatch(b, e, name, c) { + const ab = e && e.mem && e.mem.ab; + if (!ab) return false; + let any = false; + for (const a of ab.list) { + const f = a[name]; + if (!f) continue; + if (a.sil && e.s.flags.silence) continue; + try { if (f.call(a, c, b, e, a, ab)) any = true; } catch (err) { b._handlerError(`enemies:${name}`, e, err); } + } + return any; +} + +const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d); + +/** First finite talent value among keys. */ +export function T(ab, ...keys) { for (const k of keys) { const v = ab.t[k]; if (typeof v === 'number' && Number.isFinite(v)) return v; } return undefined; } + +export function silenced(e) { return !!e.s.flags.silence; } + +export function canCast(e, sil) { return e.alive && !e.hidden && !e.s.flags.stun && !(sil && e.s.flags.silence); } + +// --------------------------------------------------------------------------------------------------------------- +// helpers (exported for bosses.js) + +/** Element damage (erosion mapped onto the engine gauge); `ignoreSelect` / `tags` as hurt(). */ +export function elem(b, src, tgt, el, amount, { ignoreSelect = false, tags = [] } = {}) { + if (!tgt || !tgt.alive || !(amount > 0)) return 0; + const element = el === 'erosion' ? EROSION : el; + return b.dealDamage(src, tgt, { type: 'element', element, amount, ignoreSelect, tags: ['enemyAbility', ...tags] }); +} + +/** + * Skill / ability damage (no dodge unless asked). `ignoreSelect` = no selection the target's 无法选择 effects stop + * (abilities that "无视无法选择", direct picks, a flying unit's blast credited to a ground leader): it also reaches an + * airborne 起飞 ally, which a ground enemy's damage otherwise skips (damage.js, targeting.js evadesGround). + */ +export function hurt(b, src, tgt, amount, type = 'phys', { canDodge = false, tags = [], isSkill = true, ignoreSelect = false } = {}) { + if (!tgt || !tgt.alive || !(amount > 0)) return 0; + return b.dealDamage(src, tgt, { amount, type, canDodge, isSkill, ignoreSelect, tags: ['enemyAbility', ...tags] }); +} + +/** Allies whose tile is within `n` of (r, c): 'plus' = Manhattan (周围四格), 'box' = Chebyshev (周围8格). */ +export function alliesInTiles(b, r, c, shape = 'plus', n = 1) { + const out = []; + for (const a of b.allies()) { + if (a.hidden) continue; + const dr = Math.abs(a.tileR - r), dc = Math.abs(a.tileC - c); + if (shape === 'plus' ? dr + dc <= n : Math.max(dr, dc) <= n) out.push(a); + } + return out; +} + +/** Allies within radius that enemy `e` could target (ranged rules: stealth / untargetable skipped). */ +export function targetsNear(b, e, r, { ranged = true, x = e.x, y = e.y } = {}) { + return b.alliesInRadius(x, y, r).filter((a) => canTargetAlly(e, a, ranged)); +} + +/** Every targetable ally on the field. */ +export function allTargets(b, e) { + return b.allies().filter((a) => canTargetAlly(e, a, true)); +} + +/** + * The allies an AREA effect of enemy `src` selects within `r` of (x, y) — splash, blast, area skill / status, pulse, zone + * tick, chain or bounce jump (targeting.js areaSelectable: no 隐匿 ally, the blocker included, no untargetable or sleeping ally, no 起飞 one + * for a ground `src`; 迷彩 is not checked). `src` = the enemy whose effect it is (null: none — 隐匿 still applies). A + * locked target goes first through targetAndArea. Abilities PRTS marks "无视无法选择 / 无视可选性" use b.alliesInRadius with + * `ignoreSelect` instead. + */ +export function areaAllies(b, src, x, y, r) { + return b.alliesInRadius(x, y, r).filter((a) => areaSelectable(src, a)); +} + +/** alliesInTiles for an area effect of `src` (周围四格 / 周围八格 / cross / 3×3 areas — 格子判定): areaSelectable. */ +export function areaAlliesInTiles(b, src, r, c, shape = 'plus', n = 1) { + return alliesInTiles(b, r, c, shape, n).filter((a) => areaSelectable(src, a)); +} + +/** Every ally on the field a whole-field ability of `src` selects ("对场上所有我方单位…": 【大潮】, 【斥退】 …). */ +export function fieldAllies(b, src) { + return b.allies().filter((a) => areaSelectable(src, a)); +} + +/** + * The allies within `r` of (x, y) a buff aura of enemy `src` takes (targeting.js auraSelectable: no 隐匿 ally, the blocker included, + * untargetable or sleeping ally — PRTS "隐匿状态下的单位一般无法被敌方的…Buff选择器选中"; an airborne 起飞 ally is still + * taken [ASSUMED, §21.22]). 寒霜's aura, which PRTS says ignores 隐匿, keeps b.alliesInRadius (allyAura). + */ +export function auraAllies(b, src, x, y, r) { + return b.alliesInRadius(x, y, r).filter((a) => auraSelectable(src, a)); +} + +/** + * A locked target `t` and the allies an area takes around it: the target was picked directly when the ability started + * (an attack's target, a channel's or a C4's lock), so a 隐匿 it gains meanwhile does not save it — PRTS 异常效果 "'直接选中' + * 的能力不会进行具体的目标选择,故同样不受这些仅在选择时生效的异常效果制约"; only the others are an area selection (`area`). + */ +export function targetAndArea(t, area) { + return t && t.alive && t.deployed ? [t, ...area.filter((u) => u !== t)] : area; +} + +/** Engine priority (blocker → taunt → latest deployed) on a copy. */ +export function byPriority(e, list) { return sortAllyTargets(e, list.slice()); } + +/** + * An HP-loss / damage zone. `tick(units)` runs every `iv` s for `life` s on the allies inside: `pick(x, y, r)` chooses + * them — by default every ally in the circle (【污染秽蚀】 "无视无法选择"); the other zones (dmgZone, 烹泉's steam) pass + * their area selection (areaAllies). + */ +export function zone(b, { x, y, r, life, iv = 1, kind = 'zone', tick, pick = null }) { + b.fx('zone', { x, y, r, dur: life, kind }); + let left = life; + const h = b.every(iv, () => { + tick(pick ? pick(x, y, r) : b.alliesInRadius(x, y, r)); + left -= iv; + if (left <= 1e-6) h.cancel(); + }); + return h; +} + +/** Remaining route of an enemy from its current position (for summons / phantoms that continue its path). */ +export function remainingRoute(e) { + const R = e.route; + const here = [e.y, e.x]; + if (!R || !Array.isArray(R.legs) || !R.legs.length) return { motion: e.motion === 'FLY' ? 'FLY' : 'WALK', start: here, end: here, checkpoints: [] }; + const steps = []; + let end = null; + for (let i = Math.max(0, R.legIdx); i < R.legs.length; i++) { + const L = R.legs[i]; + if (L.final) { end = [L.r, L.c]; break; } + if (L.t === 'move') steps.push({ t: 'move', p: [L.r, L.c] }); + else if (L.t === 'wait') steps.push({ t: 'wait', s: i === R.legIdx && R.waitLeft != null ? R.waitLeft : L.time }); + else if (L.t === 'disappear') steps.push({ t: 'disappear' }); + else if (L.t === 'appear') steps.push({ t: 'appear', p: [L.r, L.c] }); + } + if (!end) { const last = R.legs[R.legs.length - 1]; end = last && last.r != null ? [last.r, last.c] : [Math.round(e.y), Math.round(e.x)]; } + const route = { motion: e.motion === 'FLY' ? 'FLY' : 'WALK', start: here, end, checkpoints: [] }; + if (steps.length) route.steps = steps; + return route; +} + +/** A route that keeps an enemy in place (content moves it by hand). */ +export function stayRoute(e, secs = 99999) { + const p = [Math.round(e.y), Math.round(e.x)]; + return { motion: e.motion === 'FLY' ? 'FLY' : 'WALK', start: p, end: p, steps: [{ t: 'wait', s: secs }] }; +} + +/** + * Mods copied onto a split or summoned child. The parent's stat multipliers travel; a kill bounty does not + * (owner 2026-10-04: the main body alone carries it — GitHub #67, #89-2). `bountyId` is the 悬赏 card and + * `bountyCoins` is the coin copy the match writes for 联防. The parent object is left as it is. + */ +function modsWithoutBounty(mods) { + if (!mods || typeof mods !== 'object') return mods ?? null; + if (mods.bountyId == null && mods.bountyCoins == null) return mods; + const copy = { ...mods }; + delete copy.bountyId; + delete copy.bountyCoins; + return copy; +} + +/** Spawn `n` enemies at the parent's position that continue its route. Returns the spawned units. */ +export function spawnChildren(b, parent, key, n, opts = {}) { + const out = []; + const route = opts.route ?? remainingRoute(parent); + const cnt = Math.max(0, Math.min(20, Math.floor(n))); + const mods = modsWithoutBounty(opts.mods ?? parent.mods ?? null); + for (let i = 0; i < cnt; i++) { + const off = cnt > 1 ? (i - (cnt - 1) / 2) * 0.2 : 0; + const pos = opts.pos ?? [parent.y, parent.x + off]; + const c = b.spawnEnemy(key, { + pos, route, mods, tag: opts.tag ?? null, countInTotal: opts.countInTotal, + ownerPlayerId: parent.ownerId ?? null, sourcePlayerId: parent.sourcePlayerId ?? null, + }); + if (c) out.push(c); + } + if (out.length) b.fx('summon', { x: parent.x, y: parent.y, id: parent.id, key, n: out.length }); + return out; +} + +/** Move a unit straight toward (tx, ty) by `dist` tiles. Returns true on arrival. */ +export function stepToward(u, tx, ty, dist) { + const dx = tx - u.x, dy = ty - u.y; + const d = Math.hypot(dx, dy); + if (d <= dist + 1e-9) { u.x = tx; u.y = ty; return true; } + u.x += (dx / d) * dist; + u.y += (dy / d) * dist; + return false; +} + +/** Set a 频次 unit's hits (maxHp := hits, full). */ +export function setHits(e, n) { + const h = Math.max(1, Math.round(n)); + e.base.maxHp = h; + e.markDirty(); + void e.s; + e.hp = h; +} + +const HIT_COUNT_KEY = 'ab:hitCount'; + +/** 频次 on/off: every damage instance removes exactly 1 HP (engine flag; `artsOnly` = 茶器, physical instances remove 0). */ +export function hitCount(b, e, on, artsOnly = false) { + if (!on) { b.removeBuff(e, HIT_COUNT_KEY); return; } + b.addBuff(e, { key: HIT_COUNT_KEY, persist: true, flags: artsOnly ? { hitCountArts: true } : { hitCount: true } }); +} + +export const isHitCount = (e) => !!e.findBuff(HIT_COUNT_KEY); + +/** + * The enemy's model takes another form (render/units.js FORMS: a clip set of its skeleton): kept on the unit + * (`e.form`, published by snapshot.js unitInfo — a view built mid-battle from fieldMeta(): a teammate's field watched + * later, 联防 observers, a reconnect — starts in it) and announced by the fx `fxKind` (+ id, x, y, `params`) carrying it + * as `form` (a 'phase' fx also as its `kind`). The `form` key marks the fx as state (shared/protocol.js fxForm): the + * client never drops it, not in a catch-up frame nor while its tab is hidden. Barrier / charge 'phase' kinds are no + * forms: they go through b.fx directly, without `form`. + */ +export function setForm(b, e, form, fxKind = 'phase', params = null) { + e.form = form; + b.fx(fxKind, fxKind === 'phase' ? { ...params, x: e.x, y: e.y, id: e.id, kind: form, form } : { ...params, x: e.x, y: e.y, id: e.id, form }); +} + +/** Leader "扣除目标生命" effects: recorded for the match (hook 'lpLoss' + result.lpLoss). */ +export function lpLoss(b, amount, reason, source = null) { + if (!(amount > 0)) return; + const st = stOf(b); + st.lpLoss += amount; + b.fx('lpLoss', { x: source ? source.x : 0, y: source ? source.y : 0, value: amount, reason }); + b.emit('lpLoss', { amount, reason, source }); +} + +/** 暴露: damage taken ×scale for `dur` s. */ +export function expose(b, u, dur, scale) { + if (!u || !u.alive || !(dur > 0)) return; + b.addBuff(u, { key: 'ab:exposed', duration: dur, refresh: 'extend', mods: { dmgTakenMul: scale }, visible: true }); + b.fx('expose', { x: u.x, y: u.y, id: u.id }); +} + +/** + * Final HP damage the pipeline would deal for a phys/arts/true/elemental `dmg` before shields (mitigation is linear in + * the amount) — the same terms as damage.js dealDamage (元素伤害: 元素抗性 + elementalTakenMul, no dmgTakenMul). + */ +export function expectedFinal(src, tgt, dmg) { + const ts = tgt.s, ss = src && src.s && !dmg.sourceless ? src.s : null, ty = dmg.type; // 无来源 (bursts): no source stats + const v = mitigate(dmg.amount, ty, ts, { + defIgnorePct: (dmg.defIgnorePct || 0) + (ss ? ss.defIgnorePct : 0), defIgnoreFlat: (dmg.defIgnoreFlat || 0) + (ss ? ss.defIgnoreFlat : 0), + resIgnorePct: (dmg.resIgnorePct || 0) + (ss ? ss.resIgnorePct : 0), resIgnoreFlat: (dmg.resIgnoreFlat || 0) + (ss ? ss.resIgnoreFlat : 0), + elementalRes: ty === 'elemental' ? (tgt.def?.epDamageResistance ?? 0) : 0, + }); + let mul = (dmg.mul ?? 1) * (ty === 'elemental' ? 1 : ts.dmgTakenMul); // 脆弱 skips 元素伤害 (damage.js) + if (ss) mul *= ss.dmgDealtMul * (ty === 'phys' ? ss.physDealtMul : ty === 'arts' ? ss.artsDealtMul : 1); + mul *= ty === 'phys' ? ts.physTakenMul : ty === 'arts' ? ts.artsTakenMul : ty === 'elemental' ? ts.elementalTakenMul : ts.trueTakenMul; + const f = v * mul; + return Number.isFinite(f) && f > 0 ? f : 0; +} + +/** + * "吸收法术伤害的屏障": absorb up to `left` of an arts instance in a `hit` handler (after RES, like a shield) by scaling + * the instance down (cancelled when fully absorbed). Returns the amount absorbed. + */ +export function absorbArts(c, left) { + if (!c || c.dmg.type !== 'arts' || !(left > 0) || c.dmg.cancel) return 0; + const f = expectedFinal(c.source, c.target, c.dmg); + if (!(f > 0)) return 0; + const take = Math.min(left, f); + if (take >= f - 1e-9) c.dmg.cancel = true; + else c.dmg.amount *= (f - take) / f; + return take; +} + +/** Timed buff on each unit (same key never stacks: aura semantics). */ +function auraBuff(b, u, key, iv, mods, flags = null, visible = false) { + b.addBuff(u, { key, duration: iv + 0.1, refresh: 'replace', mods, flags, visible }); +} + +/** Mark an ally/enemy ability as watching every death on the field. */ +function watchDeaths(b, e) { stOf(b).deathWatch.add(e); } + +const tileOf = (u) => [Math.round(u.y), Math.round(u.x)]; + +const onTerrain = (b, u, terrain) => { const [r, c] = tileOf(u); return b.grid.tile(r, c).terrain === terrain; }; + +// used by the other enemy modules (content/enemies.js and content/enemies/*.js) +export { + BOOM_RADIUS, POLLUTION_INTERVAL, STEALTH_RESTORE_BY_KEY, TEA_SPLASH_RADIUS, SHELL_SPLASH_RADIUS, stOf, safe, dispatch, + num, auraBuff, watchDeaths, onTerrain, +}; diff --git a/server/sim/content/enemies/invisible.js b/server/sim/content/enemies/invisible.js new file mode 100644 index 00000000..7a9f242e --- /dev/null +++ b/server/sim/content/enemies/invisible.js @@ -0,0 +1,149 @@ +// server/sim/content/enemies/invisible.js — INVISIBLE 隐匿 kits (stealthy enemies) and their part of KITS (split from +// content/enemies.js). + +import { canTargetAlly, enemyStealthed } from '../../targeting.js'; +import { T, elem, hurt, targetsNear, byPriority, auraBuff } from './helpers.js'; +import { stealth, onHitStatus, skill, kitStealth } from './archetypes.js'; + +// --------------------------------------------------------------------------------------------------------------- +// constants (numbers that exist nowhere in the data) + +/** "数个目标" of 假想敌:骨刺 while stealthed [ASSUMED]. */ +const ACBUNN_TARGETS = 3; + +/** 重弩突袭者 直击 reach along a row/column [ASSUMED]; the skill's length and its charge before the bolt (PRTS 直击 "蓄力1.4s后 + * 向目标方向发射1支弩箭…※技能持续2.5s"). */ +const CROSS_REACH = 6, CROSS_SKILL = 2.5, CROSS_CHARGE = 1.4; + +// --------------------------------------------------------------------------------------------------------------- +// kits + +function kitCrossbow(ab) { + const s = ab.sk.CrossAttack; + const aligned = (b, e) => b.allies().filter((u) => canTargetAlly(e, u, true) && (Math.abs(u.y - e.y) < 0.5 || Math.abs(u.x - e.x) < 0.5) && Math.hypot(u.x - e.x, u.y - e.y) <= CROSS_REACH); + const nearest = (b, e, l) => l.sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y))[0]; + // PRTS 重弩突袭者: 天赋 "隐匿(被阻挡,主动攻击期间均可解除)"; 直击 "蓄力1.4s后向目标方向发射1支弩箭,对击中的首个目标造成攻击力100% + // 的法术伤害与5s晕眩 ※技能持续2.5s": revealed for the whole skill, the bolt leaves CROSS_CHARGE s in — at the nearest unit still + // in line in the aimed direction; it stands meanwhile [ASSUMED], and a stun / silence or its death before the release + // cancels the shot [ASSUMED]. The blackboard's duration (2) is not the skill's length PRTS gives. Until 0.1.3: revealed + // 2 s, the bolt at once. + return [stealth(), skill(s, (b, e) => { + const t = nearest(b, e, aligned(b, e)); + if (!t) return; + const bb = s.bb; + const dx = Math.sign(Math.round(t.x - e.x)), dy = Math.sign(Math.round(t.y - e.y)); + b.addBuff(e, { key: 'ab:revealed', duration: CROSS_SKILL, flags: { reveal: true } }); + b.addBuff(e, { key: 'ab:aim', duration: CROSS_SKILL, flags: { noMove: true, disarm: true } }); + b.after(CROSS_CHARGE, () => { + if (!e.alive || e.hidden || e.s.flags.stun || e.s.flags.silence) return; + const tt = nearest(b, e, aligned(b, e).filter((u) => Math.sign(Math.round(u.x - e.x)) === dx && Math.sign(Math.round(u.y - e.y)) === dy)); + if (!tt) return; + b.addProjectile({ from: e, target: tt, speed: 12, visual: 'enemy', source: e, onHit: (c) => { + if (!c.target || !c.target.alive) return; + hurt(b, e, c.target, e.s.atk * (bb.atk_scale ?? 1), 'arts'); + if (bb.stun > 0 && c.target.alive) b.applyStatus(c.target, 'stun', { duration: bb.stun, source: e }); + } }); + }, { owner: e }); + }, { sil: true, cond: (b, e) => aligned(b, e).length > 0 })]; +} + +/** + * 山海众头目 / 山海众秘使 (PRTS 天赋 "隐匿,该隐匿每次生效后自身获得强击标记(不可叠加)"; 技能 破隐一击 "仅持有强击标记且被阻挡时可 + * 触发:对阻挡目标造成攻击力200%的物理普通伤害,技能开始时消耗强击标记"): the mark comes with its 隐匿 — at the spawn and every + * time it hides again (3 s after a block, Battle._stealthSwitch; or once a 反隐 ends) — and its next attack (a melee + * one: blocked) spends it at InvisibleCombat.atk_scale. A new block inside the 3 s gives no new mark (until 0.1.2 every + * block did). + */ +function kitShadowKiller(ab) { + const scale = (ab.sk.InvisibleCombat && ab.sk.InvisibleCombat.bb.atk_scale) || 1; + return [stealth(), { + spawn(b, e, a) { a.power = true; a.on = true; }, + tick(b, e, a) { const on = enemyStealthed(e); if (on && !a.on) a.power = true; a.on = on; }, + hitOut(c, b, e, a) { if (a.power && c.dmg.isAttack) c.dmg.amount *= scale; }, + attack(c, b, e, a) { a.power = false; }, + }]; +} + +function kitJazz(ab, e) { + const s = ab.sk.fire; + const r = (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || 2.5; + const revealed = (u) => !enemyStealthed(u); // blocked, 反隐 (its 隐匿 returns 0 s after a block) + return [stealth(), { + // PRTS 天赋 "隐匿期间不进行普通攻击;未受隐匿影响时进入反击模式:仅进行阻挡攻击,造成100%物理伤害": its normal attack only ever + // hits its blocker (`melee`), so a revealed (反隐) unblocked one neither shoots nor stands for its attack clip (until + // 0.1.3 it shot everyone within its 2.5 range and stood still while it had a target — ai.js attackStand) + spawn(b, e2) { e2.profile.noAttack = true; e2.profile.melee = true; }, + tick(b, e2) { e2.profile.noAttack = !revealed(e2); }, // 平时不攻击,失去隐匿时反击 + }, s ? { + // 狂欢式演奏 (PRTS 节日爵士乐手, 反击模式): "仅攻击范围内存在我方单位时可触发:锁定目标持续施法,最多持续10.6s,每0.5s对目标 + // 造成攻击力20%的法术伤害和攻击力10%的灼燃损伤 ※施法期间受到沉默影响后,立即结束技能" — ONE locked target (its own + // priority: the blocker first), floor(10.6 / 0.5) = 21 ticks at 0.5 … 10.5 s. The channel also ends when that target + // is gone, makes no normal attacks meanwhile and is never re-cast over itself — the 10 s cooldown (enemy_database + // skill 'fire') runs out mid-channel, the next cast waits for its end [ASSUMED "持续施法", as 死亡之眼]. + sil: true, cd: s.cd, icd: s.icd, cond: (b, e2) => revealed(e2) && !(e2.mem.jazzChannel && !e2.mem.jazzChannel.cancelled) && targetsNear(b, e2, r).length > 0, + fire(b, e2) { + const dur = s.bb['enemy_cnvsax[cd].duration'] ?? 0, iv = s.bb.hit_interval ?? 0.5; + const t = byPriority(e2, targetsNear(b, e2, r))[0]; + if (!t || !(dur > 0) || !(iv > 0)) return; + const n = Math.max(1, Math.floor(dur / iv + 1e-9)); + const buff = b.addBuff(e2, { key: 'ab:channel', duration: dur, flags: { disarm: true } }); + let k = 0; + const end = () => { h.cancel(); e2.mem.jazzChannel = null; if (buff) b.removeBuff(e2, buff); }; + const h = b.every(iv, () => { + if (!e2.alive || e2.s.flags.silence || !t.alive || !t.deployed) { end(); return; } + b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'jazzFire' }); + hurt(b, e2, t, e2.s.atk * (s.bb.atk_scale ?? 0), 'arts'); + elem(b, e2, t, 'burn', e2.s.atk * (s.bb.ep_damage_ratio ?? 0)); + if (++k >= n) end(); + }, { owner: e2 }); + e2.mem.jazzChannel = h; + }, + } : null]; +} + +function kitShadowBlade(ab) { + const bat = ab.t['traitAbility.base_attack_time'] ?? 0; + return [stealth(), { + iv: 0.5, + tick(b, e) { + const partner = b.enemiesInRadius(e.x, e.y, 3).find((o) => /enemy_1174_duholy/.test(o.defId)); + const r = partner && partner.mem.ab ? (T(partner.mem.ab, 'traitAbility.range_radius') ?? 1.1) : 1.1; + if (partner && Math.hypot(partner.x - e.x, partner.y - e.y) <= r + 1e-9 && e.base.bat > 0) auraBuff(b, e, 'ab:shadowSync', 0.5, { batPct: bat / e.base.bat }); + }, + }]; +} + +function kitBoneSpike() { + return [stealth(), { + before(c, b, e) { + if (!enemyStealthed(e)) return; // 隐匿状态下同时攻击数个目标 (back 0 s after a block) + const l = byPriority(e, targetsNear(b, e, e.base.rangeRadius)); + if (l.length) c.targets = l.slice(0, ACBUNN_TARGETS); + }, + }]; +} + +// --------------------------------------------------------------------------------------------------------------- +// this family's part of KITS (content/enemies.js spreads the parts in this order) + +export const INVISIBLE_KITS = Object.freeze({ + // --- INVISIBLE 隐匿 + enemy_1009_lurker: kitStealth, // 潜伏者 · stealth + enemy_1019_jshoot: kitStealth, // 隐形弩手 · stealth + enemy_1019_jshoot_2: kitStealth, // 隐形弩手组长 · stealth + enemy_1023_jmage: kitStealth, // 隐形术师 · stealth + enemy_1283_sgkill: kitStealth, // 家族灭迹人 · stealth (清算时刻 n/a) + enemy_1283_sgkill_2: kitStealth, // 家族暗影灭迹人 · stealth + enemy_1299_ymkilr: kitShadowKiller, // 山海众头目 · stealth; its 隐匿 turning on brings the mark: next attack ×InvisibleCombat.atk_scale (§22.8) + enemy_1299_ymkilr_2: kitShadowKiller, // 山海众秘使 · same + enemy_1389_winbab_2: kitStealth, // 访问团强攻冠军 · stealth (供暖器 priority n/a) + enemy_1404_msnip: kitCrossbow, // 重弩突袭者 · stealth + 直击 (row/column bolt: arts + stun, reveals itself) + enemy_10031_cnvsld: kitStealth, // 业余竞演者 · stealth + enemy_10034_cnvsax: kitJazz, // 节日爵士乐手 · stealth; revealed: attacks its blocker only; channel on one target (arts + burn) + enemy_10042_prtrop: kitStealth, // 架桥船工 · stealth (bridges n/a) + enemy_10042_prtrop_2: kitStealth, // 扶桥老手 · stealth + enemy_9008_acbunn: kitBoneSpike, // 假想敌:骨刺 · stealth; hits 3 targets while stealthed + enemy_1175_dushdo_2: kitShadowBlade, // 深池伙友影刃精英 · stealth; BAT −1.3 s next to 卫队精英 + enemy_2034_sythef: (ab) => [stealth(), onHitStatus('stun', T(ab, 'Combat.attack@stun'))], // 流泪小子 · stealth, attacks stun + enemy_2034_sythef_2: (ab) => [stealth(), onHitStatus('stun', T(ab, 'Combat.attack@stun'))], // 流泪小子 · same +}); diff --git a/server/sim/content/enemies/leaders.js b/server/sim/content/enemies/leaders.js new file mode 100644 index 00000000..441794de --- /dev/null +++ b/server/sim/content/enemies/leaders.js @@ -0,0 +1,801 @@ +// server/sim/content/enemies/leaders.js — bounty (悬赏) leader kits — the single-form ones (kitLeaderMisc) and the +// multi-form ones (reborn second forms, 转译基底·α, 乌顶巨角卢鲁) — and their part of KITS (split from content/enemies.js). + +import { TICK, COLS } from '../../constants.js'; +import { aggroCmp, areaSelectable } from '../../targeting.js'; +import { periodicDamage } from '../../damage.js'; +import { + nthOf, T, hurt, alliesInTiles, targetsNear, allTargets, areaAllies, areaAlliesInTiles, auraAllies, targetAndArea, + byPriority, setForm, auraBuff, watchDeaths, +} from './helpers.js'; +import { + unblockable, maxTargets, onHitStatus, splashAttack, pathKeysAhead, noAirTargets, resist, nthAttackStatus, lowHpBuff, + freeAllPrisoners, reborn, frontGuard, faceCrowd, unbalanced, artsBarrier, skill, blinkForward, +} from './archetypes.js'; + +// --------------------------------------------------------------------------------------------------------------- +// constants (numbers that exist nowhere in the data) + +/** 转译基底·α: "变化过程持续2s" (PRTS 天赋; the model's A_Die_B / _C / _D change clips last 2 s); the original form's + * immunities ("免疫晕眩/沉睡/寒冷/冻结/浮空/恐惧", plus 失衡免疫). */ +export const TRANSLATOR_CHANGE = 2; + +const TRANSLATOR_IMMUNE = Object.freeze(['stun', 'sleep', 'cold', 'freeze', 'levitate', 'fear']); + +/** 碎骨's grenade (PRTS 碎骨 天赋 "未被阻挡时发射榴弹对目标及其周围八格的我方单位造成相当于攻击力26%的物理伤害,并令其在5秒内 + * 防御力下降50%"; the blackboard has only defdown.def −0.5): share of ATK on every unit hit, DEF-down duration (s). + * Until 0.1.3: the full attack on the target, 100 % on the 4 neighbours, 3 s [ASSUMED]. */ +const SKULSR_GRENADE_SCALE = 0.26, SKULSR_DEFDOWN_DUR = 5; + +/** “庞贝”'s self-blast while blocked (PRTS 天赋 "对<固定半径>半径1.4范围内的所有我方单位造成1000预计算的无途径法术溅射伤害(不可 + * 对空)"; the blackboard has only the damage and the 10 s; until 0.1.3: radius 1 [ASSUMED]). */ +const POMPEII_BLAST_RADIUS = 1.4; + +/** “巨大的丑东西” first form: its unblocked ranged attack (PRTS 天赋 "未被阻挡时,可对半径2.5范围内的1名非飞行的我方单位进行远程攻击, + * 对目标及其周围8格内的所有我方单位造成攻击力100%的物理伤害" — the data's attack radius is 0, so it had no ranged attack until + * 0.1.3) and its self-destruct ("首次被击倒后进行持续10s的重生…:重生开始的2.17s后进行自爆,对半径3.0范围内所有我方单位造成攻击力 + * 150%的物理伤害和16s晕眩"; until 0.1.3: radius 2.5 at once [ASSUMED]). */ +const MCM_RANGE = 2.5, MCM_BOMB_DELAY = 2.17; + +/** “复仇者” 【冲锋】 (PRTS 技能 "仅自身未被阻挡且存在符合条件的可选目标时可触发:选择3.0半径内位于自身下个检查点前的后续路径上(包含 + * 自身当前所在地块)的距离自身最近的我方单位…"): the search radius. The blackboard's range_radius is 1.5; PRTS's figure is the + * one used. Until 0.1.3: any unit within 1.5, 隐匿 / 迷彩 ones included. */ +const RUSH_RADIUS = 3; + +/** W's C4 fuse and blast radius [ASSUMED]. */ +const C4_FUSE = 1, C4_RADIUS = 1; + +/** 鼠王 【唱沙】 cross reach and 【沙狱】 "及其周围" radius [ASSUMED]. */ +const DRIFT_REACH = 1, SANDSTORM_RADIUS = 1; + +/** “巨大的丑东西” self-destruct radius (PRTS 天赋 "对半径3.0范围内所有我方单位…"; 2.5 [ASSUMED] until 0.1.3). */ +const MCM_BOMB_RADIUS = 3; + +/** 自在 【纬地经天】: tiles covered along the row and the column of the centre ("十字型") [ASSUMED]. */ +const XI_CROSS_REACH = 2; + +/** 扎罗 (PRTS 扎罗,“狼之主” 天赋; no numbers in the data): form-2 attack radius ("进行远程攻击,普通攻击为2连击,攻击范围半径1.25"), + * 【远古威慑】 aura radius / ASPD ("自身1.5半径范围内的我方单位攻击速度-50(指定状况下生效)" — during the 重生 and in the second form). + * Until 0.1.3: 2.5 / 2.5 / −30, the aura only during the 重生 [ASSUMED]. */ +const WOLF_RANGE = 1.25, WOLF_AWE_RADIUS = 1.5, WOLF_AWE_ASPD = -50; + +/** 失衡 movement speed that turns 弧光锋卫's per-interval bleed into HP per tile moved [ASSUMED]. */ +const UNBALANCE_SPEED = 5; + +/** 乌顶巨角卢鲁 【角力对决】: the operator cannot be pushed (fixed tiles) ⇒ "更多伤害" multiplier [ASSUMED]. */ +const ELK_FAIL_SCALE = 2; + +// --------------------------------------------------------------------------------------------------------------- +// kits + +function kitDekght(ab) { + const rage = { atkPct: T(ab, 'triggerrage.atk') ?? 0, aspd: T(ab, 'triggerrage.attack_speed') ?? 0, moveMul: 1 + (T(ab, 'triggerrage.move_speed') ?? 0) }; + const partnerRage = { + otherDeath(c, b, e, a) { + const u = c.unit; + if (a.raged || !u || u === e || !/enemy_1513_dekght/.test(u.defId || '')) return; + a.raged = true; + b.addBuff(e, { key: 'ab:rage', mods: rage, persist: true, visible: true }); + }, + spawn(b, e) { watchDeaths(b, e); }, + }; + return partnerRage; +} + +function kitLeaderMisc(key, ab, e) { + switch (key) { + case 'enemy_1050_lslime': { + const dot = { dmg: T(ab, 'dot.damage') ?? 0, iv: T(ab, 'dot.interval') ?? 1, dur: T(ab, 'dot.duration') ?? 0 }; + return [maxTargets(4), lowHpBuff(T(ab, 'selfbuff.hp_ratio') ?? 0.5, { aspd: T(ab, 'selfbuff.attack_speed') ?? 0 }), { + dealt(c, b, e2) { + if (!(dot.dur > 0)) return; + b.addBuff(c.target, { key: 'ab:burnDot', duration: dot.dur, refresh: 'replace', interval: dot.iv, visible: true, + onTick: ({ battle, unit }) => battle.dealDamage(e2, unit, { amount: dot.dmg, type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility'] }) }); + }, + }, { + // 被阻挡时会进行自爆 — PRTS “庞贝” 天赋: "被阻挡时,每10秒(受晕眩/无法行动/沉睡/冻结/浮空影响时暂停计时,解除阻挡时重置计时)对 + // 半径1.4范围内的所有我方单位造成1000预计算的无途径法术溅射伤害(不可对空)": the clock runs only while it is blocked and can + // act, and restarts at 0 on every new block (until 0.1.3: a fixed 10 s clock from its spawn, radius 1, flyers hit) + iv: 0, + tick(b, e2, a, dt) { + if (!e2.blockedBy) { a.held = 0; return; } + if (!e2.canAct) return; + a.held = (a.held || 0) + dt; + if (a.held + 1e-9 < (T(ab, 'rangedamage.interval') ?? 10)) return; + a.held = 0; + b.fx('explode', { x: e2.x, y: e2.y, r: POMPEII_BLAST_RADIUS, kind: 'selfBlast' }); + for (const u of areaAllies(b, e2, e2.x, e2.y, POMPEII_BLAST_RADIUS)) if (!u.isFlying) hurt(b, e2, u, T(ab, 'rangedamage.attack@damage') ?? 0, 'arts', { tags: ['splash'] }); + }, + }]; + } + case 'enemy_1500_skulsr': { + // PRTS 碎骨 天赋: "不会攻击飞行单位"; below half HP ATK +50 %; "未被阻挡时发射榴弹对目标及其周围八格的我方单位造成相当于攻击力 + // 26%的物理伤害,并令其在5秒内防御力下降50%" ("榴弹对主目标造成物理普通伤害,对溅射目标造成物理溅射伤害,可溅射飞行单位") — the + // 26 % / 5 s / 3×3 are PRTS's, the −50 % is the blackboard's defdown.def. A blocked attack is its plain melee hit. The + // splash is an area selection: a 隐匿 operator is spared, the one blocking this enemy included (GitHub #97; #32 + // item 6, DESIGN §22.12). Community report #14 (0.1.2): the grenade used to be a full attack + 100 % on 4 neighbours. + const dd = -(T(ab, 'defdown.def') ?? 0); + return [lowHpBuff(T(ab, 'atkup.hp_ratio') ?? 0.5, { atkPct: T(ab, 'atkup.atk') ?? 0 }), noAirTargets(), + splashAttack({ scale: SKULSR_GRENADE_SCALE, tiles: 1, fxKind: 'grenade', + onEach: (b, e2, u) => { if (dd) b.applyStatus(u, 'defDown', { duration: SKULSR_DEFDOWN_DUR, source: e2, value: dd }); } })]; + } + case 'enemy_1502_crowns': { + const s = ab.sk.blink; + return [skill(s, (b, e2) => blinkForward(b, e2, s.bb.dist ?? 1.5), { sil: true, cond: (b, e2) => !!e2.blockedBy })]; + } + case 'enemy_1504_cqbw': { + const s = ab.sk.C4; + return [skill(s, (b, e2) => { + const l = byPriority(e2, targetsNear(b, e2, s.bb.range_radius ?? e2.base.rangeRadius)); + const n = e2.hpRatio < 0.5 ? 2 : 1; // 生命值降至一半以下时炸药包的使用数量增加 + for (const t of l.slice(0, n)) { + const x = t.x, y = t.y; + b.fx('telegraph', { x, y, r: C4_RADIUS, dur: C4_FUSE, kind: 'c4' }); + // the C4 sits on its target (PRTS "在…1个非飞行的我方单位身上安装C4"): it is hit; the blast around it is an area + b.after(C4_FUSE, () => { b.fx('explode', { x, y, r: C4_RADIUS, kind: 'c4' }); for (const u of targetAndArea(t, areaAllies(b, e2, x, y, C4_RADIUS))) hurt(b, e2, u, e2.s.atk * (s.bb.atk_scale ?? 1), 'phys'); }); + } + }, { cond: (b, e2) => targetsNear(b, e2, s.bb.range_radius ?? e2.base.rangeRadius).length > 0 })]; + } + case 'enemy_1509_mousek': { + // 出场时拥有能够吸收大量法术伤害的屏障,屏障存在时防御力大幅增加 (arts-only barrier + DEF while it holds) + const bar = artsBarrier(T(ab, 'shield.dynamic') ?? 0, { key: 'ab:sandDef', whileUp: { defFlat: T(ab, 'defup.def') ?? 0 } }); + const ds = ab.sk.DriftSand, ss = ab.sk.SandStorm; + return [bar, lowHpBuff(T(ab, 'enrage.hp_ratio') ?? 0.5, { dmgDealtMul: T(ab, 'enrage.damage_scale') ?? 1 }), + // 【唱沙】 the highest-max-HP unit on the field and everything in its cross: physical `damage` + skill(ds, (b, e2) => { + const t = allTargets(b, e2).sort((p, q) => q.s.maxHp - p.s.maxHp || aggroCmp(p, q))[0]; + if (!t) return; + b.fx('telegraph', { x: t.tileC, y: t.tileR, r: DRIFT_REACH, kind: 'driftSand', tiles: 'plus', id: e2.id }); + // the cross ("伤害无视迷彩", no 无视无法选择): an area selection + for (const u of areaAlliesInTiles(b, e2, t.tileR, t.tileC, 'plus', DRIFT_REACH)) hurt(b, e2, u, ds.bb.damage ?? 0, 'phys'); + }, { cond: (b, e2) => allTargets(b, e2).length > 0 }), + // 【沙狱】 the lowest-max-HP unit and those around it: ATK −`atk` and `damage` arts per second for `duration` s + skill(ss, (b, e2) => { + const t = allTargets(b, e2).sort((p, q) => p.s.maxHp - q.s.maxHp || aggroCmp(p, q))[0]; + if (!t) return; + const dur = ss.bb.duration ?? 0, x = t.x, y = t.y; + b.fx('zone', { x, y, r: SANDSTORM_RADIUS, dur, kind: 'sandStorm', id: e2.id }); + // the 沙狱弹道 "击中…范围内的所有我方单位(弹道可对空)" selects: an airborne 起飞 ally and an unblocking 隐匿 one + // are skipped (no 无视无法选择 — areaAllies) + for (const u of areaAllies(b, e2, x, y, SANDSTORM_RADIUS)) { + b.addBuff(u, { key: 'ab:sandStorm', duration: dur, refresh: 'replace', interval: 1, visible: true, mods: { atkPct: ss.bb.atk ?? 0 }, + onTick: ({ battle, unit }) => battle.dealDamage(e2, unit, { amount: ss.bb.damage ?? 0, type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility', 'sandStorm'] }) }); + } + }, { cond: (b, e2) => allTargets(b, e2).length > 0 }), + ]; + } + case 'enemy_1511_mdrock': { + // 拥有屏障吸收法术伤害,屏障存在时大幅提升生命上限与攻击速度; 周期性刷新屏障 + const s = ab.sk.RefreshShield; + const bar = artsBarrier(s ? s.bb.dynamic ?? 0 : T(ab, 'shield.dynamic') ?? 0, { key: 'ab:rockPower', + whileUp: { hpPct: (s ? s.bb.max_hp : null) ?? T(ab, 'shield.max_hp') ?? 0, aspd: (s ? s.bb.attack_speed : null) ?? T(ab, 'shield.attack_speed') ?? 0 } }); + return [bar, { + // 攻击时攻击力永久提升,最多六层 (the blackboard ATK is the 6-stack total [ASSUMED split]) + attack(c, b, e2) { b.addBuff(e2, { key: 'ab:rockCharge', refresh: 'stack', stacks: 1, maxStacks: 6, persist: true, mods: { atkPct: (T(ab, 'charge.attack@enemy_mdrock_s_1[charge].atk') ?? 0) / 6 } }); }, + }, skill(s, (b, e2) => bar.refresh(b, e2, bar))]; + } + case 'enemy_1513_dekght': { + // 攻击时使目标与周围四格的单位受到物理伤害; 【蓄力攻击】 charge `duration` s, then ATK×atk_scale on the target's cross + const s = ab.sk.ChargeAttack; + // the 周围四格 of every attack (PRTS "对溅射目标造成物理溅射伤害") and 【蓄力锤】's cross: area selections + return [kitDekght(ab), { before(c, b, e2) { const t = c.targets[0]; if (t) for (const u of areaAlliesInTiles(b, e2, t.tileR, t.tileC, 'plus', 1)) if (!c.targets.includes(u)) c.targets.push(u); } }, + skill(s, (b, e2) => { + const t = e2.blockedBy; + const dur = s.bb.duration ?? 0, r = t.tileR, cc = t.tileC; + b.addBuff(e2, { key: 'ab:charging', duration: dur, visible: true, flags: { disarm: true } }); + b.fx('telegraph', { x: cc, y: r, r: 1, dur, kind: 'chargeAttack', tiles: 'plus', id: e2.id }); + b.after(dur, () => { + if (!e2.alive || e2.s.flags.stun) return; + b.fx('explode', { x: cc, y: r, r: 1, kind: 'chargeAttack', tiles: 'plus' }); + for (const u of areaAlliesInTiles(b, e2, r, cc, 'plus', 1)) hurt(b, e2, u, e2.s.atk * (s.bb['dekght[aoe].atk_scale'] ?? s.bb.atk_scale ?? 1), 'phys'); + }, { owner: e2 }); + }, { cond: (b, e2) => !!(e2.blockedBy && e2.blockedBy.alive) })]; + } + case 'enemy_1513_dekght_2': { + // 同时攻击两名目标; 爆炸箭: three targets, after `interval` s ATK×atk_scale arts on each target's cross + const s = ab.sk.TripleAttack; + const cands = (b, e2) => byPriority(e2, targetsNear(b, e2, e2.base.rangeRadius || 2.5)); + return [kitDekght(ab), maxTargets(2), skill(s, (b, e2) => { + const delay = s.bb['dekght_2[aoe].interval'] ?? 0, scale = s.bb['dekght_2[aoe].atk_scale'] ?? 1; + for (const t of cands(b, e2).slice(0, 3)) { + const r = t.tileR, cc = t.tileC; + b.fx('telegraph', { x: cc, y: r, r: 1, dur: delay, kind: 'blastArrow', tiles: 'plus', id: e2.id }); + b.after(delay, () => { + if (!e2.alive) return; + b.fx('explode', { x: cc, y: r, r: 1, kind: 'blastArrow', tiles: 'plus' }); + // "对主目标造成法术普通伤害,对溅射目标造成法术溅射伤害": the arrow's target + the 周围四格 (an area) + for (const u of targetAndArea(t, areaAlliesInTiles(b, e2, r, cc, 'plus', 1))) hurt(b, e2, u, e2.s.atk * scale, 'arts'); + }, { owner: e2 }); + } + }, { cond: (b, e2) => cands(b, e2).length > 0 })]; + } + case 'enemy_1539_reid': { + // 生命值降至一半以下时攻击力大幅度提升; 首次被击倒后重生 (Reborn.duration s), 恢复一半生命值; + // 【冲锋】 (PRTS 技能 "仅自身未被阻挡且存在符合条件的可选目标时可触发:选择3.0半径内位于自身下个检查点前的后续路径上(包含自身当前 + // 所在地块)的距离自身最近的我方单位,将其所在地块中心设置为自身下一检查点;技能使用成功后,自身移动速度+200%,持续4.5s"): a target + // it may select (targetsNear: no 隐匿 / 迷彩 / 起飞 / untargetable operator) within RUSH_RADIUS standing on its own path to + // the next checkpoint (its tile included) ⇒ move speed ×(1+move_speed) for `duration` s. It walks that path anyway, so + // the re-aim at the target's tile is not a separate move. Until 0.1.3: any unit within the blackboard's 1.5, 隐匿 / 迷彩 + // ones included, off its path too. + const rush = ab.sk.Rush; + const prey = (b, e2) => { + const keys = pathKeysAhead(e2); + return targetsNear(b, e2, RUSH_RADIUS).filter((u) => u.ground && keys.has(u.tileR * COLS + u.tileC)); + }; + return [lowHpBuff(T(ab, 'atkup.hp_ratio') ?? 0.5, { atkPct: T(ab, 'AtkUp.atk', 'atkup.atk') ?? 0 }), + reborn({ dur: T(ab, 'Reborn.duration') ?? 0, hpRatio: T(ab, 'Reborn.hp_ratio') ?? 0.5, invincible: T(ab, 'Reborn.invincible') ?? 0 }), + skill(rush, (b, e2) => { + b.addBuff(e2, { key: 'ab:rush', duration: rush.bb.duration ?? 0, refresh: 'replace', visible: true, mods: { moveMul: 1 + (rush.bb.move_speed ?? 0) } }); + b.fx('charge', { x: e2.x, y: e2.y, id: e2.id, kind: 'avengerRush' }); + }, { cond: (b, e2) => !e2.blockedBy && prey(b, e2).length > 0 })]; + } + case 'enemy_2003_rockman': { + const s = ab.sk.StunAttack; + const cands = (b, e2) => targetsNear(b, e2, (e2.def.raw.stats && e2.def.raw.stats.rawRangeRadius) || 2.5).filter((u) => !u.s.flags.stun); + return [skill(s, (b, e2) => { + const t = byPriority(e2, cands(b, e2))[0]; + if (!t) return; + b.addProjectile({ from: e2, target: t, speed: 8, visual: 'lob', source: e2, onHit: (c) => { + if (!c.target || !c.target.alive) return; + hurt(b, e2, c.target, e2.s.atk * (s.bb.atk_scale ?? 1), 'phys'); + if (c.target.alive) b.applyStatus(c.target, 'stun', { duration: s.bb.stun ?? 0, source: e2 }); + } }); + }, { cond: (b, e2) => cands(b, e2).length > 0 })]; + } + case 'enemy_2004_balloon': { + // 喷气人 · 升空 when blocked → 飞行模式 for `duration` s: PRTS 喷气人 "近地悬浮,不可阻挡,失衡免疫,移动速度+50%,不进行 + // 攻击" — an air unit meanwhile (flag `float`: melee cannot hit it; it keeps the ground path, as setFloat), never + // displaced; "切换为飞行模式后,1.5秒内移动速度最终降低90% … 结束后,1.333秒内不可阻挡,移动速度最终降低90%" (PRTS numbers: + // the blackboard has only the duration and the +50 %). + const s = ab.sk.TakeOff; + const BRAKE = { moveMul: 0.1 }, BRAKE_UP = 1.5, LANDING = 1.333; + return [skill(s, (b, e2) => { + b.addBuff(e2, { + key: 'ab:takeoff', duration: s.bb.duration ?? 0, flags: { unblockable: true, float: true, noDisplace: true }, mods: { moveMul: 1 + (s.bb['balloon_s[fly].move_speed'] ?? 0) }, visible: true, + onExpire: ({ battle }) => { if (e2.alive) battle.addBuff(e2, { key: 'ab:landing', duration: LANDING, flags: { unblockable: true }, mods: BRAKE }); }, + }); + b.addBuff(e2, { key: 'ab:takeoffBrake', duration: BRAKE_UP, mods: BRAKE }); + b.fx('telegraph', { x: e2.x, y: e2.y, r: 0.5, kind: 'takeoff', id: e2.id }); + }, { cond: (b, e2) => !!e2.blockedBy })]; + } + case 'enemy_2005_axetro': + return [{ + attack(c, b, e2, a) { + a.last = b.time; + b.addBuff(e2, { key: 'ab:axeStack', refresh: 'stack', stacks: 1, maxStacks: T(ab, 'atkup.max_stack_cnt') ?? 1, persist: true, mods: { atkPct: T(ab, 'atkup.atk') ?? 0, aspd: T(ab, 'atkup.attack_speed') ?? 0 } }); + }, + iv: 0.5, + tick(b, e2, a) { if (a.last != null && b.time - a.last >= (T(ab, 'checker.delay') ?? 4)) { a.last = null; b.removeBuff(e2, 'ab:axeStack'); } }, + }]; + case 'enemy_2008_flking': { + // 使全战场我方所有单位攻击力、防御力减半 (text; the blackboard's atkdown.atk_scale is not the halving), + // 部署费用回复速度减半,再部署时间加倍; 周期性地添加正比于自身最大生命值的伤害防护屏障. The halving is a map effect + // (PRTS “墓碑” "※文字描述中的削弱实际属于地图效果,不属于敌人本身的能力"): every ally, 隐匿 / airborne ones included + const s = ab.sk.refreshshield; + // its attack — PRTS “墓碑” 天赋: "自身造成的远程途径伤害的攻击倍率降低至40%" (the blackboard's atkdown.atk_scale), "未被阻挡时会 + // 进行远程攻击,对目标及其周围八格内的所有我方单位造成物理伤害,不会攻击飞行单位" ("可溅射飞行单位"); blocked, a melee hit at + // 100 %. Until 0.1.3: a single-target hit at 100 % either way. + return [noAirTargets(), splashAttack({ scale: T(ab, 'atkdown.atk_scale') ?? 1, tiles: 1 }), { + iv: 0.5, + tick(b) { + for (const u of b.allies()) { + auraBuff(b, u, 'ab:tombstone', 0.5, { atkMul: 0.5, defMul: 0.5 }, null, true); + // persistent so it still counts when the unit falls (the redeploy timer is set at death) + b.addBuff(u, { key: 'ab:tombRedeploy', duration: 0.6, refresh: 'replace', persist: true, mods: { redeployMul: 2 } }); + } + }, + }, { + tick(b, e2, a, dt) { for (const p of b.players) b.addDp(p.playerId, -0.5 * b.flags.dpPerSec * dt); }, + }, skill(s, (b, e2) => b.addBuff(e2, { key: 'ab:tombShield', shield: e2.s.maxHp * (s.bb.hp_ratio ?? 0), persist: true }))]; + } + case 'enemy_2048_smgrd': + // 持续在自身周围8格生成【国度】 (the 3×3 around its tile), 大幅降低其中我方单位的攻击速度; 对【国度】中的我方单位造成高额物理伤害. + // 【国度】 is a tile effect ("将该地块及其周围8格范围内的可部署位生成【国度】"): every ally on it, 隐匿 ones too [ASSUMED] + return [{ + iv: 0.5, + tick(b, e2) { for (const u of alliesInTiles(b, Math.round(e2.y), Math.round(e2.x), 'box', 1)) auraBuff(b, u, 'ab:blackFog', 0.5, { aspd: T(ab, 'BlackFog.attack_speed') ?? 0 }, null, true); }, + hitOut(c, b, e2) { + const t = c.target; + if (c.dmg.isAttack && t.side === 'ally' && Math.max(Math.abs(t.tileR - Math.round(e2.y)), Math.abs(t.tileC - Math.round(e2.x))) <= 1) c.dmg.amount *= T(ab, 'DamageUp.atk_scale') ?? 1; + }, + }]; + case 'enemy_2050_smsha': { + const cold = T(ab, 'Attack.attack@freeze') ?? 0, n = T(ab, 'Attack.attack@chain.max_target') ?? 1, fall = T(ab, 'Attack.attack@chain.atk_scale') ?? 1; + const jr = T(ab, 'Attack.attack@projectile_range') ?? 1.6; + const cb = ab.sk.ChainBuff; + return [onHitStatus('cold', cold), { + dealt(c, b, e2) { + let prev = c.target; + const hit = new Set([prev]); + for (let k = 1; k < n; k++) { + // a jump selects within jr of the last target (中点判定): no unblocking 隐匿 or airborne 起飞 ally (areaAllies) + const nx = areaAllies(b, e2, prev.x, prev.y, jr).find((u) => !hit.has(u)); + if (!nx) break; + hit.add(nx); + hurt(b, e2, nx, e2.s.atk * Math.pow(fall, k), 'arts'); + if (nx.alive && cold > 0) b.applyStatus(nx, 'cold', { duration: cold, source: e2 }); + prev = nx; + } + }, + }, + // 【反自然馈赠】 (SILENCE): "attacks" another enemy in range and jumps between up to chain.max_target enemies + // (projectile_range per jump), raising their move speed and ASPD for `duration` s + skill(cb, (b, e2) => { + const bb = cb.bb, jump = bb.projectile_range ?? jr, max = bb['chain.max_target'] ?? 3; + const near = (x, y, r, seen) => b.enemiesInRadius(x, y, r).filter((o) => o !== e2 && !seen.has(o)) + .sort((p, q) => Math.hypot(p.x - x, p.y - y) - Math.hypot(q.x - x, q.y - y) || p.spawnSeq - q.spawnSeq)[0]; + const seen = new Set(); + let prev = e2, cur = near(e2.x, e2.y, e2.base.rangeRadius || 3.5, seen); + while (cur && seen.size < max) { + seen.add(cur); + b.fx('beam', { x: prev.x, y: prev.y, from: prev.id, to: cur.id, kind: 'chainBuff' }); + b.addBuff(cur, { key: 'ab:chainBuff', duration: bb.duration ?? 0, refresh: 'replace', visible: true, mods: { moveMul: 1 + (bb.move_speed ?? 0), aspd: bb.attack_speed ?? 0 } }); + prev = cur; + cur = near(cur.x, cur.y, jump, seen); + } + }, { sil: true, cond: (b, e2) => b.enemiesInRadius(e2.x, e2.y, e2.base.rangeRadius || 3.5).some((o) => o !== e2) })]; + } + case 'enemy_2052_smgia': { + // 数次攻击后晕眩; 受到来自自然环境的伤害时 (terrain damage), 自身在短时间内获得高额脆弱 (damage taken ×damage_scale) + const lim = T(ab, 'Weak.weak[limit]') ?? 0, scale = T(ab, 'Weak.damage_scale') ?? 1; + return [nthAttackStatus(nthOf(ab.sk.StunAttack), 'stun', (ab.sk.StunAttack && ab.sk.StunAttack.bb.stun) || 0, false), { + taken(c, b, e2) { + if (!(lim > 0) || !(c.amount > 0) || !(c.dmg.tags && c.dmg.tags.includes('terrain'))) return; + b.addBuff(e2, { key: 'ab:natureWeak', duration: lim, refresh: 'replace', visible: true, mods: { dmgTakenMul: scale } }); + }, + }]; + } + default: + return []; + } +} + +// --------------------------------------------------------------------------------------------------------------- +// multi-form leaders (悬赏 bounty targets): the first knock-out switches them to their second form (reborn helper) + +/** Distance from point p to segment a–b. */ +function segDist(px, py, ax, ay, bx, by) { + const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy; + const t = L > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / L)) : 0; + return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); +} + +/** A second normal hit on the same target ("二连击"): same damage type, no further on-hit procs. */ +const doubleHit = (on = () => true, extra = null) => ({ + dealt(c, b, e, a) { + if (!on(b, e, a) || !c.target.alive) return; + b.dealDamage(e, c.target, { amount: e.s.atk, type: c.dmg.type, canDodge: true, tags: ['enemyAbility', 'secondHit'], ...(extra ? extra(c, b, e) : {}) }); + }, +}); + +/** 巨大的丑东西 · melee ×rage; KO ⇒ self-destruct (big stun) and the 大祭司 pilot ejects: no attack, unblockable, own DEF/RES, + * faster, loses bird_run.damage true HP per second. (巨蕈/木桩 tiles and its ranged splash attack have no counterpart here: + * its data radius is 0.) */ +function kitUglyThing(ab) { + const rage = T(ab, 'combat.attack@mcmstr_rage_attack.atk_scale') ?? 1; + const bomb = ab.sk['bomb[reborning]'] ? ab.sk['bomb[reborning]'].bb : {}; + const run = { spd: T(ab, 'bird_run.move_speed') ?? 0, def: T(ab, 'bird_run.def'), res: T(ab, 'bird_run.magic_resistance'), dmg: T(ab, 'bird_run.damage') ?? 0 }; + const P = { pilot: false }; + return [ + // 远程攻击造成溅射伤害,近战攻击造成更高伤害 — PRTS 天赋: unblocked, a ranged attack on 1 non-flying unit within 2.5 hitting it + // and its 8 surrounding tiles for 100 % ATK ("可溅射飞行单位"); blocked, a melee hit at mcmstr_rage_attack.atk_scale + { spawn(b, e) { e.base.rangeRadius = Math.max(e.base.rangeRadius || 0, MCM_RANGE); e.profile.canTarget = (u) => !u.isFlying; } }, + splashAttack({ scale: 1, meleeScale: rage, tiles: 1 }), + reborn({ + dur: T(ab, 'reborn.duration') ?? 0, + onKo(b, e) { // 生命值降至0后自爆,造成大范围晕眩 (an area selection), 2.17 s into the 重生 + const x = e.x, y = e.y; + b.fx('telegraph', { x, y, r: MCM_BOMB_RADIUS, dur: MCM_BOMB_DELAY, kind: 'mechBomb', id: e.id }); + b.after(MCM_BOMB_DELAY, () => { + if (!e.alive || e.removed) return; + b.fx('explode', { x, y, r: MCM_BOMB_RADIUS, kind: 'mechBomb', id: e.id }); + for (const u of areaAllies(b, e, x, y, MCM_BOMB_RADIUS)) { + hurt(b, e, u, e.s.atk * (bomb.atk_scale ?? 0), 'phys'); + if (u.alive && bomb.stun > 0) b.applyStatus(u, 'stun', { duration: bomb.stun, source: e }); + } + }, { owner: e }); + }, + onReborn(b, e) { // 大祭司形态: 不进行攻击,无法被阻挡,每秒受到真实伤害 + P.pilot = true; + e.profile.noAttack = true; + b.addBuff(e, { + key: 'ab:pilot', persist: true, visible: true, flags: { unblockable: true }, interval: 1, + mods: { moveMul: 1 + run.spd, defFlat: run.def != null ? run.def - e.base.def : 0, resFlat: run.res != null ? run.res - e.base.res : 0 }, + onTick: ({ battle, unit }) => { if (run.dmg > 0) battle.dealDamage(null, unit, { amount: run.dmg, type: 'true', canDodge: false, tags: ['enemyAbility', 'pilotBurn'] }); }, + }); + }, + }), + ]; +} + +/** 杰斯顿·威廉姆斯 · 狱警: RES +enhance, ranged arts, every (sp+1)th attack stuns max_target units; KO ⇒ (reborn.duration s) + * 杀手: frees every prisoner, DEF/ATK/ASPD/speed up, melee physical, every (sp+1)th attack hits `times` × ignoring def_penetrate. */ +function kitWarden(ab) { + const en = (k) => T(ab, `enhance.${k}`) ?? 0; + const iron = ab.sk.ironsandstorm, pierce = ab.sk.armorpiercing; + const P = { killer: false, n: 0, power: false }; + const pen = () => (pierce && pierce.bb.def_penetrate) || 0; + return [{ + spawn(b, e, a, ab2) { + ab2.atkType = 'arts'; // 攻击造成远程法术伤害 + e.profile.melee = false; + b.addBuff(e, { key: 'ab:warden', persist: true, visible: true, mods: { resFlat: en('magic_resistance') } }); // 法术抗性大幅提升 + }, + before(c, b) { const s = P.killer ? pierce : iron; P.power = !!s && (P.n + 1) % nthOf(s) === 0; }, + hitOut(c) { if (P.killer && P.power && c.dmg.isAttack) c.dmg.defIgnorePct = Math.min(1, (c.dmg.defIgnorePct || 0) + pen()); }, + dealt(c, b, e) { + if (!P.killer || !P.power || !pierce) return; + for (let i = 1; i < (pierce.bb.times ?? 1) && c.target.alive; i++) b.dealDamage(e, c.target, { amount: e.s.atk, type: 'phys', defIgnorePct: pen(), canDodge: true, isSkill: true, tags: ['enemyAbility', 'armorPiercing'] }); + }, + attack(c, b, e) { + P.n++; + if (!P.power || P.killer || !iron) return; + // 能使多名单位晕眩: the skill attack stuns up to max_target units in its range (its target first) + const l = byPriority(e, targetsNear(b, e, e.base.rangeRadius || 2)); + for (const t of c.targets) if (!l.includes(t)) l.unshift(t); + for (const u of l.slice(0, iron.bb.max_target ?? 1)) if (u.alive) b.applyStatus(u, 'stun', { duration: iron.bb.stun ?? 0, source: e }); + }, + }, reborn({ + dur: T(ab, 'reborn.duration') ?? 0, + onKo(b) { freeAllPrisoners(b); }, // 进入杀手形态时解放全场敌人 + onReborn(b, e) { + P.killer = true; P.n = 0; + e.mem.ab.atkType = null; // 攻击造成近战物理伤害 + e.profile.melee = true; + b.removeBuff(e, 'ab:warden'); + b.addBuff(e, { key: 'ab:killer', persist: true, visible: true, + mods: { defFlat: en('def'), atkFlat: en('atk'), batPct: en('base_attack_time') / (e.base.bat || 1), moveFlat: en('move_speed') } }); + }, + })]; +} + +/** “自在” · 【纬地经天】 arts cross on the nearest unit (form 2: + the farthest); 【破桎而出】 barrier — unbroken after its + * duration ⇒ ATK×atk_scale arts around; KO ⇒ second form (ATK up, double hits, stronger 破桎而出). 晦明 attributes n/a. */ +function kitXi(ab) { + const cross = ab.sk.CrossAttack, sb = ab.sk.ShieldBurst, sb2 = ab.sk.ShieldBurstReborn; + const P = { form2: false }; + const byDist = (b, e) => allTargets(b, e).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y) || aggroCmp(p, q)); + const crossAt = (b, e, t) => { + const r0 = t.tileR, c0 = t.tileC; + b.fx('telegraph', { x: c0, y: r0, r: XI_CROSS_REACH, kind: 'xiCross', tiles: 'plus', id: e.id }); + // "伤害无视迷彩", no 无视无法选择: the cross is an area selection + for (const u of areaAlliesInTiles(b, e, r0, c0, 'plus', XI_CROSS_REACH)) if (u.tileR === r0 || u.tileC === c0) hurt(b, e, u, e.s.atk * ((cross && cross.bb.atk_scale) ?? 1), 'arts'); + }; + const burst = (s) => (b, e) => { + const dur = s.bb.duration ?? 0, r = s.bb.range_radius ?? 0; + b.fx('telegraph', { x: e.x, y: e.y, r, dur, kind: 'breakFree', id: e.id }); + b.addBuff(e, { key: 'ab:xiShield', duration: dur, shield: s.bb.dynamic ?? 0, visible: true, + // expiry (not breaking: a broken barrier is removed without onExpire) = the barrier held + onExpire: ({ battle }) => { + if (!e.alive) return; + battle.fx('explode', { x: e.x, y: e.y, r, kind: 'breakFree', id: e.id }); + for (const u of areaAllies(battle, e, e.x, e.y, r)) hurt(battle, e, u, e.s.atk * (s.bb.atk_scale ?? 0), 'arts'); + } }); + }; + const re = reborn({ + dur: T(ab, 'reborn.duration') ?? 0, invincible: T(ab, 'reborn.invincible') ?? 0, + onKo(b, e) { b.removeBuff(e, 'ab:xiShield'); }, + onReborn(b, e) { + P.form2 = true; + b.addBuff(e, { key: 'ab:xiReborn', persist: true, visible: true, mods: { atkPct: T(ab, 'reborn.atk') ?? 0 } }); // 攻击力提升 + if (s2) s2.left = s2.icd; + }, + }); + const s1 = skill(sb, burst(sb), { cond: () => !P.form2 }); + const s2 = skill(sb2, sb2 ? burst(sb2) : null, { cond: () => P.form2 }); + return [re, s1, s2, doubleHit(() => P.form2), // 普通攻击进行两次 + skill(cross, (b, e) => { + const l = byDist(b, e); + if (!l.length) return; + crossAt(b, e, l[0]); + if (P.form2 && l.length > 1) crossAt(b, e, l[l.length - 1]); // 额外对最远目标释放 + }, { cond: (b, e) => allTargets(b, e).length > 0 })]; +} + +/** 锏 · 抵抗; DEF penetration against its blocker; 【速杀】 passes through its blocker hitting those on the way; 【肆虐风雪】 + * AoE; KO ⇒ second form: stealth, more penetration, double hits, 【瞬息杀机】 at every 25 % HP lost (SP cleared + no attacks + * / heals around it). <降雪> absent. */ +function kitMace(ab) { + const pen1 = T(ab, 'DefPenetrate.enemy_blkswb_t_2.def_penetrate') ?? 0, pen2 = T(ab, 'DefPenetrate.enemy_blkswb_t_2[reborn].def_penetrate') ?? pen1; + const cs = { ratio: T(ab, 'ClearSp.hp_ratio') ?? 0, dur: T(ab, 'ClearSp.duration') ?? 0, r: T(ab, 'ClearSp.range_radius') ?? 0 }; + const P = { form2: false, next: 1 }; + const penOn = (c, e) => (c.target === e.blockedBy ? (P.form2 ? pen2 : pen1) : 0); + // its blocker is hit (PRTS 技能 "对之前阻挡自身的单位造成…"); the others on the way are an area selection (areaSelectable) + const blink = (s) => (b, e) => { + const bl = e.blockedBy; + const from = blinkForward(b, e, s.bb.dist ?? 1.5); + if (!from) return; + for (const u of b.allies()) { + if (u !== bl && (segDist(u.x, u.y, from.x, from.y, e.x, e.y) > 0.5 || !areaSelectable(e, u))) continue; + hurt(b, e, u, e.s.atk * (s.bb.atk_scale ?? 1), 'phys'); + } + }; + const circle = (s) => (b, e) => { + const r = s.bb.range_radius ?? 0; + b.fx('explode', { x: e.x, y: e.y, r, kind: 'blizzard', id: e.id }); + for (const u of areaAllies(b, e, e.x, e.y, r)) hurt(b, e, u, e.s.atk * (s.bb.atk_scale ?? 1), 'phys'); + }; + // cast with a target in the circle — the trigger selection (targetsNear: PRTS 选择器 "所有触发选择器通常不无视迷彩"): no + // airborne (起飞) operator for a ground enemy (§21.22), no unblocking 隐匿 or 迷彩 one + const inR = (b, e, s) => targetsNear(b, e, (s && s.bb.range_radius) || 0).length > 0; + return [resist(), { + hitOut(c, b, e) { const p = penOn(c, e); if (c.dmg.isAttack && p > 0) c.dmg.defIgnorePct = Math.min(1, (c.dmg.defIgnorePct || 0) + p); }, + taken(c, b, e) { // 【瞬息杀机】 every hp_ratio of max HP lost (second form) + if (!P.form2 || !(cs.ratio > 0)) return; + while (e.alive && e.hpRatio <= P.next - cs.ratio + 1e-9 && P.next - cs.ratio > 1e-9) { + P.next -= cs.ratio; + b.fx('explode', { x: e.x, y: e.y, r: cs.r, kind: 'clearSp', id: e.id }); + for (const u of areaAllies(b, e, e.x, e.y, cs.r)) { + const sk = u.skill; + if (sk && !sk.noSkill && sk.kind !== 'passive' && !sk.active && sk.spCost > 0) { sk.sp = 0; sk.charges = 0; } // 清空技力 (stored charges too) + if (cs.dur > 0) b.applyStatus(u, 'disarm', { duration: cs.dur, source: e }); + } + } + }, + }, doubleHit(() => P.form2, (c, b, e) => ({ defIgnorePct: penOn(c, e) })), + skill(ab.sk.Blink, blink(ab.sk.Blink || { bb: {} }), { id: 'blink', cond: (b, e) => !P.form2 && !!e.blockedBy }), + skill(ab.sk.Blink2, blink(ab.sk.Blink2 || { bb: {} }), { id: 'blink2', cond: (b, e) => P.form2 && !!e.blockedBy }), + skill(ab.sk.CircleAttack, circle(ab.sk.CircleAttack || { bb: {} }), { id: 'circle', cond: (b, e) => !P.form2 && inR(b, e, ab.sk.CircleAttack) }), + skill(ab.sk.CircleAttack2, circle(ab.sk.CircleAttack2 || { bb: {} }), { id: 'circle2', cond: (b, e) => P.form2 && inR(b, e, ab.sk.CircleAttack2) }), + reborn({ + dur: T(ab, 'Reborn.duration') ?? 0, invincible: T(ab, 'Reborn.invincible') ?? 0, + onReborn(b, e) { P.form2 = true; P.next = 1; b.addBuff(e, { key: 'ab:stealth', flags: { stealth: true }, persist: true }); }, + })]; +} + +/** 扎罗,“狼之主” · first form: damage taken −30 %, not stunnable, 【溶血骇惧】; KO ⇒ a Reborn.duration s 重生 (its HP refills, + * 【远古威慑】 slows the units around it) ⇒ second form: ranged double hits within WOLF_RANGE, ATK/ASPD up, invincible at first, + * 【远古威慑】 still on. 【溶血骇惧】 (PRTS 技能 "使场上最多3名我方单位攻击速度-70,获得无法撤退,逐渐流失生命(从0/秒开始线性递增,在 + * 40秒后达到最大流失速度30%最大生命值/秒),持续时间无限 … 场上存在被此技能影响的单位时狼之主获得静默 … 释放此技能时狼之主记录自身当前 + * 生命值,累计损失20%生命值后解除场上全部我方单位的技能效果"): the FearCage blackboard's attack_speed, hp_ratio (the loss rate + * reached at duration_bleed s), hp_ratio_offset (the cure); its SP (the cast's cooldown, spCost s at +1/s [ASSUMED]) stands + * still while a unit is caught (静默), each ended effect gives back sp, and the last one starts duration_wait s more of 静默 + * ("每结束1个技能效果狼之主回复5SP,场上技能效果全部结束时狼之主获得7s静默"). Until 0.1.3 the loss peaked at 5 % [ASSUMED], the buff + * ended after 40 s, hp_ratio (30 %) was read as the cure and the cast came every spCost s whatever was caught. Not modelled: + * 血债账款 / 【狂暴怒嗥】. */ +function kitWolfLord(ab) { + const fc = ab.sk.FearCage; + const P = { form2: false, caught: 0, waitUntil: -Infinity, cast: null }; + const caught = (b) => b.allies().some((u) => u.findBuff('ab:fearCage')); + const cure = (b) => { for (const u of b.allies()) { const d = u.findBuff('ab:fearCage'); if (d) b.removeBuff(u, d); } }; + // 【远古威慑】 (a buff aura, auraAllies): no 隐匿 operator, the blocker included, an airborne 起飞 one still — as 深池伙友卫队's + const awe = (b, e) => { for (const u of auraAllies(b, e, e.x, e.y, WOLF_AWE_RADIUS)) auraBuff(b, u, 'ab:ancientAwe', 0.2, { aspd: WOLF_AWE_ASPD }, null, true); }; + return [{ + spawn(b, e, a, ab2) { + ab2.immune = new Set([...(ab2.immune || []), 'stun']); // 第一形态: 不可被晕眩 + b.addBuff(e, { key: 'ab:wolfGuard', persist: true, visible: true, mods: { dmgTakenMul: 1 - (T(ab, 'Passive.damage_resistance') ?? 0) } }); + }, + iv: 0.1, + tick(b, e, a, dt) { + if (P.form2) { awe(b, e); return; } // 第二形态: 【远古威慑】生效 + if (!fc) return; + // afflicted units recover once 扎罗 lost hp_ratio_offset of its HP since the cast + const cut = Math.abs(fc.bb.hp_ratio_offset ?? 0.2); + let n = 0; + for (const u of b.allies()) { + const d = u.findBuff('ab:fearCage'); + if (!d || d.data.src !== e) continue; + if (e.hpRatio <= d.data.hp0 - cut + 1e-9) b.removeBuff(u, d); else n++; + } + if (P.cast) { + if (n < P.caught) { // ended effects (cured, knocked out): +sp each + P.cast.left -= (fc.bb.sp ?? 0) * (P.caught - n); + if (n === 0) P.waitUntil = b.time + (fc.bb.duration_wait ?? 0); + } + if (n > 0 || b.time < P.waitUntil) P.cast.left += dt; // 静默: its SP stands still + } + P.caught = n; + }, + }, + // 【溶血骇惧】 every spCost s (enemy SP +1/s [ASSUMED]), first form only, never during its 静默 + P.cast = skill(fc, (b, e) => { + const l = b.rng.shuffle(allTargets(b, e).filter((u) => u.kind === 'op' && !u.findBuff('ab:fearCage'))).slice(0, fc.bb.max_target ?? 3); + const ramp = Math.max(1, fc.bb.duration_bleed ?? 40), peak = fc.bb.hp_ratio ?? 0; + for (const u of l) { + b.fx('beam', { x: e.x, y: e.y, from: e.id, to: u.id, kind: 'fearCage' }); + b.addBuff(u, { key: 'ab:fearCage', refresh: 'replace', visible: true, interval: 1, data: { src: e, hp0: e.hpRatio, t0: b.time }, + mods: { aspd: fc.bb.attack_speed ?? 0 }, + onTick: ({ battle, unit, buff }) => { + // its 扎罗 gone from the field without a 重生 (leaked, removed): the effect ends, no loss this tick [ASSUMED — no + // official text; the 重生's onKo cure covers its knock-out]. Until 0.1.3 a leak left it draining for good. + const src = buff.data.src; + if (!src || !src.alive || src.removed) { battle.removeBuff(unit, buff); return; } + const k = Math.min(1, (battle.time - buff.data.t0) / ramp); + battle.loseHp(unit, unit.s.maxHp * peak * k, { source: e }); + } }); + } + }, { cd: fc ? fc.sp || fc.cd : null, icd: fc ? fc.sp || fc.icd : null, cond: (b, e) => !P.form2 && !caught(b) && b.time >= P.waitUntil && allTargets(b, e).some((u) => u.kind === 'op') }), + doubleHit(() => P.form2), // 攻击变为远程二连击 + reborn({ + dur: T(ab, 'Reborn.duration') ?? 0, invincible: T(ab, 'Passive2.invincible_time') ?? 0, + onKo(b, e) { + cure(b); + b.removeBuff(e, 'ab:wolfGuard'); + if (e.mem.ab.immune) e.mem.ab.immune.delete('stun'); + }, + during(b, e, a, t) { // 重生期间【远古威慑】生效; its HP refills slowly + const dur = T(ab, 'Reborn.duration') ?? 1; + e.hp = Math.max(1, e.s.maxHp * Math.min(1, t / Math.max(TICK, dur))); + awe(b, e); + }, + onReborn(b, e) { + P.form2 = true; + e.profile.melee = false; + e.base.rangeRadius = Math.max(e.base.rangeRadius || 0, WOLF_RANGE); + b.addBuff(e, { key: 'ab:wolfRage', persist: true, visible: true, mods: { atkPct: T(ab, 'Passive2.atk') ?? 0, batPct: (T(ab, 'Passive2.base_attack_time') ?? 0) / (e.base.bat || 1) } }); + }, + })]; +} + +/** + * 转译基底·α (PRTS 转译基底·α 天赋; enemy_database talents Passive / Mode_*_Passive / Mode_Fuchou_Anger; skills ChangeToB/C/D): + * 原始形态 — no attack, every damage instance is cancelled ("受到伤害时取消此伤害"), 失衡免疫, immune to 晕眩/沉睡/寒冷/冻结/浮空/ + * 恐惧. The 4th physical damage instance taken ⇒ 寻仇者, the 4th arts one ⇒ 特战术师, being blocked ⇒ 幽灵 — once ("仅可变化一次"); + * the change takes TRANSLATOR_CHANGE (2) s, standing still and still in the original form [ASSUMED: immobile, damage + * still cancelled]; then the form's flat stat changes apply (move speed relative to the data's 1.0, attack interval + * +N s, 重量等级 +N): + * 寻仇者 (form B): melee only, physical; ATK +100 % while HP < 50 % (Mode_Fuchou_Anger.atk); + * 特战术师 (form D): ranged only (the data's 2.4 radius), arts, 2 targets at once — flyers included [ASSUMED: the + * translator's text "仅进行远程攻击,同时攻击2个目标,造成法术伤害" names no exception; the standalone PRTS 特战术师's talent + * "不会攻击飞行单位" may carry over — then `canTarget: (u) => !u.isFlying` in finish()]; + * 幽灵 (form C): no stat change, unblockable, no attack (PRTS 转译基底·α "幽灵形态 无属性变化;无法被阻挡" — no attack line, + * unlike the armed forms — and the linked PRTS 幽灵 天赋 "不进行普通攻击,无法被阻挡", 攻击方式 不攻击). + * The armed forms attack through the engine's enemy attack (ai.js enemyAttack, `profile.dmgType`), so attack clips, + * projectiles and the attack hooks are the normal ones. Before its change ends it cannot be killed at all (damage is + * cancelled; an HP loss stops at 1 HP) — user report after 0.1.0 (#5): its model never changed (no FORMS clip set), so + * it died on the manifest's die clip, the 寻仇者's B_Die, from its first-form look; and the v2.5 kit let damage through, + * so some lineups killed it before either counter reached 4. fx 'phase' {id, kind: translator_fuchou | _shushi | _youling} starts the model's 2 s + * change clip (render/units.js FORMS). Form letters: B / C / D follow the talents' order Fuchou / Youling / Shushi — C, + * the only clip set without an attack, is the non-attacking 幽灵 (skills ChangeToB / C / D). + */ +function kitTranslator(ab, e) { + const t = (k) => T(ab, k) ?? 0; + const P = { phys: 0, arts: 0, form: null, done: false, anger: false }; + const PRE = { fuchou: 'Mode_Fuchou_Passive', shushi: 'Mode_Shushi_Passive', youling: 'Mode_Youling_Passive' }; + const mods = (pre) => ({ + hpFlat: t(`${pre}.max_hp`), atkFlat: t(`${pre}.atk`), defFlat: t(`${pre}.def`), resFlat: t(`${pre}.magic_resistance`), + moveMul: Math.max(0, 1 + t(`${pre}.move_speed`) / (e.def.moveSpeed || 1)), batPct: t(`${pre}.base_attack_time`) / (e.base.bat || 1), + massFlat: t(`${pre}.mass_level`), + }); + const finish = (b, e2) => { + if (!e2.alive || P.done) return; + P.done = true; + b.removeBuff(e2, 'ab:origin'); + ab.immune = null; + b.addBuff(e2, { key: 'ab:form', persist: true, visible: true, mods: mods(PRE[P.form]), flags: P.form === 'youling' ? { unblockable: true } : null }); + if (P.form !== 'youling') { + const melee = P.form === 'fuchou'; + Object.assign(e2.profile, { noAttack: false, melee, dmgType: melee ? 'phys' : 'arts', maxTargets: melee ? 1 : 2 }); + e2.atkCd = 0; + } + if (e2.route) e2.route.pts = null; + }; + const change = (b, e2, form) => { + if (P.form || !e2.alive) return; + P.form = form; + b.addBuff(e2, { key: 'ab:change', duration: TRANSLATOR_CHANGE, persist: true, flags: { noMove: true }, onExpire: ({ battle }) => finish(battle, e2) }); + setForm(b, e2, `translator_${form}`, 'phase', { dur: TRANSLATOR_CHANGE }); + }; + return [{ + spawn(b, e2) { + b.addBuff(e2, { key: 'ab:origin', persist: true, flags: { noDisplace: true } }); + ab.immune = new Set(TRANSLATOR_IMMUNE); + }, + hitIn(c, b, e2) { + if (P.done) return; + const s = c.source || c.credit, ty = c.dmg.type; + if (!P.form && s && s.side === 'ally') { + if (ty === 'phys' && ++P.phys >= (t('Passive.phy_max_count') || 4)) change(b, e2, 'fuchou'); + else if (ty === 'arts' && ++P.arts >= (t('Passive.magic_max_count') || 4)) change(b, e2, 'shushi'); + } + c.dmg.cancel = true; + }, + // an HP loss (流失 — no 伤害 instance, so not cancelled: 隐德来希's 心烛 hand-over …) cannot knock it out before its + // change ends: its original form has no death clip of its own (A_Die_B / _C / _D are the changes) [ASSUMED floor 1 HP] + killed(c, b, e2) { + if (P.done) return false; + e2.hp = Math.max(1, e2.hp); + return true; + }, + blocked(c, b, e2) { change(b, e2, 'youling'); }, + tick(b, e2) { + // 寻仇者: "生命值低于50%时,攻击力+100%" + if (P.form !== 'fuchou' || !P.done) return; + const on = e2.hpRatio < 0.5; + if (on === P.anger) return; + P.anger = on; + if (on) b.addBuff(e2, { key: 'ab:anger', persist: true, visible: true, mods: { atkPct: t('Mode_Fuchou_Anger.atk') } }); + else b.removeBuff(e2, 'ab:anger'); + }, + }]; +} + +/** 乌顶巨角卢鲁 · 【角力对决】 when blocked: charges `duration` s (no normal attacks; a stun or 失衡 interrupts), then hits its + * blocker; operators hold fixed tiles, so the push always fails ⇒ extra damage and a fail_duration stun. */ +function kitElk(ab) { + const s = ab.sk.skill; + const P = { ch: null }; + const stop = (b, e) => { P.ch = null; b.removeBuff(e, 'ab:elkCharge'); }; + return [{ + tick(b, e) { + const ch = P.ch; + if (!ch) return; + if (e.s.flags.stun || !ch.t.alive || e.blockedBy !== ch.t) { stop(b, e); b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'chargeBroken' }); return; } + if (b.time + 1e-9 < ch.until) return; + stop(b, e); + b.fx('explode', { x: ch.t.x, y: ch.t.y, r: 0.5, kind: 'elkClash' }); + hurt(b, e, ch.t, e.s.atk * ((s && s.bb.atk_scale_s) ?? 1) * ELK_FAIL_SCALE, 'phys'); + const st = T(ab, 'data.attack@fail_duration') ?? 0; + if (ch.t.alive && st > 0) b.applyStatus(ch.t, 'stun', { duration: st, source: e }); + }, + }, unbalanced((b, e) => { if (P.ch) stop(b, e); }), + skill(s, (b, e) => { + const dur = s.bb.duration ?? 0; + P.ch = { t: e.blockedBy, until: b.time + dur }; + b.addBuff(e, { key: 'ab:elkCharge', duration: dur + 0.1, visible: true, flags: { disarm: true } }); + b.fx('telegraph', { x: e.blockedBy.x, y: e.blockedBy.y, r: 0.5, dur, kind: 'elkCharge', id: e.id }); + }, { cond: (b, e) => !P.ch && !!(e.blockedBy && e.blockedBy.alive) })]; +} + +// --------------------------------------------------------------------------------------------------------------- +// this family's part of KITS (content/enemies.js spreads the parts in this order) + +export const LEADER_KITS = Object.freeze({ + // --- bounty (悬赏) leaders with a single form (kitLeaderMisc) + enemy_1050_lslime: (ab, e) => kitLeaderMisc('enemy_1050_lslime', ab, e), // “庞贝” · 4 targets, burning DoT, self-blast when blocked, ASPD up below half + enemy_1500_skulsr: (ab, e) => kitLeaderMisc('enemy_1500_skulsr', ab, e), // 碎骨 · unblocked grenades (splash + DEF down); ATK up below half + enemy_1502_crowns: (ab, e) => kitLeaderMisc('enemy_1502_crowns', ab, e), // 弑君者 · blinks past its blocker + enemy_1504_cqbw: (ab, e) => kitLeaderMisc('enemy_1504_cqbw', ab, e), // W · C4 (2 below half) + enemy_1509_mousek: (ab, e) => kitLeaderMisc('enemy_1509_mousek', ab, e), // 鼠王 · opening barrier with DEF up; enrage below half + enemy_1511_mdrock: (ab, e) => kitLeaderMisc('enemy_1511_mdrock', ab, e), // 泥岩 · stacking ATK, refreshed barrier (+HP/ASPD while up) + enemy_1513_dekght: (ab, e) => kitLeaderMisc('enemy_1513_dekght', ab, e), // 腐败骑士 · plus-shaped hits; rage when 凋零骑士 dies + enemy_1513_dekght_2: (ab, e) => kitLeaderMisc('enemy_1513_dekght_2', ab, e), // 凋零骑士 · 2 targets; rage when 腐败骑士 dies + enemy_1539_reid: (ab, e) => kitLeaderMisc('enemy_1539_reid', ab, e), // “复仇者” · ATK up below half; revives once at 50 % + enemy_2003_rockman: (ab, e) => kitLeaderMisc('enemy_2003_rockman', ab, e), // 迷路的巨像 · long-stun boulder on a non-stunned unit + enemy_2004_balloon: (ab, e) => kitLeaderMisc('enemy_2004_balloon', ab, e), // 喷气人 · takes off when blocked: 7 s hovering, unblockable, 失衡免疫 + enemy_2005_axetro: (ab, e) => kitLeaderMisc('enemy_2005_axetro', ab, e), // “遗弃者” · attack stacks, reset after 4 s idle + enemy_2008_flking: (ab, e) => kitLeaderMisc('enemy_2008_flking', ab, e), // “墓碑” · operators' ATK/DEF halved; periodic barrier + enemy_2048_smgrd: (ab, e) => kitLeaderMisc('enemy_2048_smgrd', ab, e), // “邪魔的利刃” · 国度 ASPD-down around it, ×2.5 inside + enemy_2050_smsha: (ab, e) => kitLeaderMisc('enemy_2050_smsha', ab, e), // 陷落雪祀 · cold + 3-target chain + enemy_2052_smgia: (ab, e) => kitLeaderMisc('enemy_2052_smgia', ab, e), // 纠缠藤蔓 · every 3rd attack stuns 15 s; fragile after terrain damage + + // --- multi-form leaders and other bounty (悬赏) targets + enemy_1512_mcmstr: kitUglyThing, // “巨大的丑东西” · melee ×3; KO ⇒ stun blast, fleeing 大祭司 (true HP loss/s) + enemy_1516_jakill: kitWarden, // 杰斯顿·威廉姆斯 · ranged arts + multi-stun ⇒ killer form (frees prisoners, armour piercing) + enemy_1517_xi: kitXi, // “自在” · cross arts, 破桎而出 barrier blast ⇒ form 2 (double hits, 2 crosses) + enemy_1525_blkswb: kitMace, // 锏 · 抵抗, DEF pen, 速杀 blink, AoE ⇒ form 2 (stealth, double hits, SP clear) + enemy_1535_wlfmster: kitWolfLord, // 扎罗 · −30 % damage, 溶血骇惧 ⇒ 远古威慑 rebirth ⇒ ranged double hits + enemy_10081_mpplai: kitTranslator, // 转译基底·α · damage cancelled; 4th phys / 4th arts hit or blocked ⇒ 2 s change ⇒ 寻仇者 / 特战术师 / 幽灵 + enemy_10144_xdelk_2: kitElk, // 乌顶巨角卢鲁 · 角力对决 charge on its blocker (push fails ⇒ damage + stun) + // 圆仔 · PRTS 天赋 "无法攻击/被阻挡;受到来源于正面的物理和法术伤害-80%;自身始终朝向我方干员数量最多的方向" (no attack: applyWay + // NONE; its 倒走 / 【炫耀】 is "仅用于演出,无额外效果"): unblockable, faces the bigger crowd, front damage −80 % + enemy_2085_skzjxd: (ab) => [unblockable(), frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)], + enemy_2085_skzjxd_2: (ab) => [unblockable(), frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)], // (鸭爵 strategy) same + // 失衡 (pushed / pulled by operators) + enemy_1328_cbjedi: (ab) => [unbalanced((b, e, a, d) => { // 弧光锋卫 · takes damage in proportion to the distance moved while unbalanced + // PRTS 修正 "失衡移动时持续受到真实伤害", "处于失衡状态时,每0.066s受到400点无来源真实持续伤害" (伤害分类: 弧光锋卫失衡状态下的自残 + // 伤害 is BUFF damage): damage, not a 流失 (player report D1 audit) + const v = T(ab, 'unbalanced_bleed.damage') ?? 0, iv = T(ab, 'unbalanced_bleed.interval') ?? 1; + if (v > 0 && iv > 0) b.dealDamage(null, e, { ...periodicDamage((v * d) / (iv * UNBALANCE_SPEED)), tags: ['dot', 'periodic', 'unbalanced'] }); + })], + enemy_10112_ymgds: (ab) => [unbalanced((b, e) => { // 冒失的小弟 · stunned after being unbalanced + const st = T(ab, 'StunAfterUnbalance.stun') ?? 0; + if (st > 0) b.applyStatus(e, 'stun', { duration: st, source: null }); + })], + enemy_10138_xdsnow: (ab) => [unbalanced((b, e, a) => { // 雪孩子 · pushed / pulled into high ground ⇒ hitWall.value damage + const dx = e.x - a.lx, dy = e.y - a.ly, d = Math.hypot(dx, dy); + if (!(d > 0)) return; + const r = Math.round(e.y + (dy / d) * 0.6), c = Math.round(e.x + (dx / d) * 0.6); + if (b.grid.isLow(r, c) && b.grid.groundPassable(r, c)) return; // stopped by nothing: no collision + b.fx('explode', { x: e.x, y: e.y, r: 0.4, kind: 'wallHit', id: e.id }); + hurt(b, null, e, T(ab, 'hitWall.value') ?? 0, 'true', { tags: ['wallHit'] }); + })], + // 拥霜羽兽 · PRTS 天赋 "不会攻击飞行单位"; unbalanced once ⇒ 失去蛋的模式 "不进行普通攻击,不可阻挡,移动速度最终提升至200%" (until + // 0.1.3 it kept its ranged attack without the egg and stood for each attack clip — ai.js attackStand —, and shot the 炎佑 dragon) + enemy_10141_xdpeng_2: (ab) => [noAirTargets(), unbalanced((b, e, a) => { + if (a.done) return; + a.done = true; + e.profile.noAttack = true; + b.addBuff(e, { key: 'ab:noEgg', persist: true, visible: true, flags: { unblockable: true }, mods: { moveMul: 1 + (T(ab, 'speed.move_speed') ?? 0) } }); + })], +}); diff --git a/server/sim/content/enemies/reflection.js b/server/sim/content/enemies/reflection.js new file mode 100644 index 00000000..ce18d489 --- /dev/null +++ b/server/sim/content/enemies/reflection.js @@ -0,0 +1,57 @@ +// server/sim/content/enemies/reflection.js — REFLECTION 折射 kits (深池 enemies, 守墓石像) and their part of KITS (split from +// content/enemies.js). + +import { T, auraAllies, auraBuff } from './helpers.js'; +import { refraction, statue, kitRefraction } from './archetypes.js'; + +// --------------------------------------------------------------------------------------------------------------- +// kits + +function kitPhalanx(ab, e) { + const def = T(ab, 'auraDefup.def') ?? 0; + const r = (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || 1.5; + return [...kitRefraction(ab), { + iv: 0.5, + tick(b, e2) { + let n = 0; + for (const o of b.enemiesInRadius(e2.x, e2.y, r)) if (o !== e2 && o.defId === e2.defId) n++; + if (n > 0) auraBuff(b, e2, 'ab:phalanx', 0.5, { defFlat: def * n }); + }, + }]; +} + +function kitHolyGuard(ab) { + const r = T(ab, 'traitAbility.range_radius') ?? 1.1, aspd = T(ab, 'traitAbility.attack_speed') ?? 0; + return [...kitRefraction(ab), { + iv: 0.5, + tick(b, e) { + const on = b.enemiesInRadius(e.x, e.y, r).some((o) => /enemy_1175_dushdo/.test(o.defId)); + if (!on) return; + // a buff aura (auraAllies): no 隐匿 operator, the blocker included (PRTS 作战机制 §隐匿与Buff的关系 — no 无视 note on + // its page), but an airborne 起飞 one still [ASSUMED, as §21.22's ground-enemy auras] + for (const u of auraAllies(b, e, e.x, e.y, r)) auraBuff(b, u, 'ab:forceField', 0.5, { aspd }, null, true); + }, + }]; +} + +// --------------------------------------------------------------------------------------------------------------- +// this family's part of KITS (content/enemies.js spreads the parts in this order) + +export const REFLECTION_KITS = Object.freeze({ + // --- REFLECTION 折射 + enemy_1165_duhond: kitRefraction, // 深池侦察犬 · refraction + enemy_1165_duhond_2: kitRefraction, // 深池侦察犬pro · refraction + enemy_1166_dusbr: kitRefraction, // 深池侦察兵 · refraction + enemy_1166_dusbr_2: kitRefraction, // 深池侦察队长 · refraction + enemy_1168_dumage: kitRefraction, // 深池暗影术师 · refraction + enemy_1168_dumage_2: kitRefraction, // 深池暗影术师队长 · refraction + enemy_1170_dushld: kitRefraction, // 深池重甲卫士 · refraction + enemy_1170_dushld_2: kitRefraction, // 深池重甲卫士队长 · refraction + enemy_1169_duphlx: kitPhalanx, // 深池方阵步兵 · refraction + DEF +200 per nearby same unit + enemy_1169_duphlx_2: kitPhalanx, // 深池方阵指挥官 · same + enemy_1172_dugago: (ab) => [...kitRefraction(ab), statue(ab)], // 守墓石像 · refraction; melee when blocked; 1st KO → unblockable statue 10 s → arts flyer + enemy_1172_dugago_2: (ab) => [...kitRefraction(ab), statue(ab)], // 愤怒的守墓石像 · same + enemy_1174_duholy: kitHolyGuard, // 深池伙友卫队 · refraction; ASPD-down field next to 影刃 (taunt from data) + enemy_1174_duholy_2: kitHolyGuard, // 深池伙友卫队精英 · same + enemy_9011_acrefr: (ab) => [refraction(T(ab, 'Refracting.magic_resistance') ?? 0, T(ab, 'Refracting.max_hp') ?? 0)], // 假想敌:镜膜 · refraction + max HP while active +}); diff --git a/server/sim/content/enemies/special.js b/server/sim/content/enemies/special.js new file mode 100644 index 00000000..858f5ae7 --- /dev/null +++ b/server/sim/content/enemies/special.js @@ -0,0 +1,390 @@ +// server/sim/content/enemies/special.js — SPECIAL 特异 kits (prisoners, 穿刺手, 暴虐兵长, 镜卫, chimeras, 圣杯, the act-2 specials +// …) and their part of KITS (split from content/enemies.js). + +import { ALLY_COLLIDER_RADIUS } from '../../constants.js'; +import { canTargetAlly, areaSelectable } from '../../targeting.js'; +import { periodicDamage } from '../../damage.js'; +import { + BOOM_RADIUS, nthOf, stOf, T, elem, hurt, targetsNear, allTargets, areaAllies, areaAlliesInTiles, byPriority, + stayRoute, spawnChildren, expose, auraBuff, watchDeaths, onTerrain, +} from './helpers.js'; +import { + unblockable, runWhenHit, blockWeight, taunt, maxTargets, ep, nthAttackPower, lowHpBuff, deathBoom, setFloat, float, + reborn, frontGuard, faceMove, unbalanced, skill, kitEp, kitPrisoner, kitStun3, +} from './archetypes.js'; + +// --------------------------------------------------------------------------------------------------------------- +// constants (numbers that exist nowhere in the data) + +/** 孽罪奇美拉 污染模式 aura (PRTS 天赋 "自身半径1.2范围内的所有单位…每0.5秒受到50真实持续伤害(同类效果取最高)"): radius, + * damage period (s). */ +const CHIMERA_AURA_RADIUS = 1.2, CHIMERA_AURA_EVERY = 0.5; + +/** 自行炮 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']); + +/** Radius of 骸骨拷打者's "周围" death sensing when its talent has no Attack.range_radius [ASSUMED]. */ +const TORTURER_RADIUS = 1; + +/** Barrel zone radius of 咸鳞汁推荐者 [ASSUMED]. */ +const BARREL_RADIUS = 1.5; + +// --------------------------------------------------------------------------------------------------------------- +// kits + +/** 源石污染区 (act1 m04 infection tiles). */ +function infectionArts() { + return { hitOut(c, b, e) { if (c.dmg.isAttack && onTerrain(b, e, 'infection')) c.dmg.type = 'arts'; } }; +} + +function kitChimera(ab) { + const dmg = T(ab, 'OrigAura.damage') ?? 0, spr = T(ab, 'OrigAura.sp_recover_ratio') ?? 0; + const activate = (b, e, a) => { + if (a.on || !onTerrain(b, e, 'infection')) return; + a.on = true; + e.mem.ab.atkType = 'arts'; + b.fx('telegraph', { x: e.x, y: e.y, r: CHIMERA_AURA_RADIUS, kind: 'chimera', id: e.id }); + }; + return [{ + hitOut(c, b, e, a) { if (c.dmg.isAttack && !a.on) { activate(b, e, a); if (a.on) c.dmg.type = 'arts'; } }, + iv: 0.25, + tick(b, e, a) { + activate(b, e, a); + if (!a.on) return; + a.acc = (a.acc ?? 0) + 0.25; + const pulse = a.acc >= CHIMERA_AURA_EVERY - 1e-9; + if (pulse) a.acc -= CHIMERA_AURA_EVERY; + for (const u of b.alliesInRadius(e.x, e.y, CHIMERA_AURA_RADIUS)) { + auraBuff(b, u, 'ab:originium', 0.25, { spRecoveryMul: Math.max(0, 1 + spr) }, null, true); + // PRTS "持续视为受到源石污染区影响…每0.5秒受到50真实持续伤害": a damage instance (受击回复, the 重装 trigger), not a + // 流失 — 无来源 like the 源石污染区 terrain it stands for [ASSUMED], the chimera keeps the credit; "同类效果取最高": one + // tick per unit per period however many chimeras reach it (`mem.chimeraAt`; every chimera's is 50). A terrain + // effect, not an area selection: 隐匿 allies inside are reached too ("所有单位" [ASSUMED], DESIGN §22.12) + if (!pulse || !(dmg > 0) || b.time - (u.mem.chimeraAt ?? -Infinity) < CHIMERA_AURA_EVERY - 1e-6) continue; + u.mem.chimeraAt = b.time; + b.dealDamage(e, u, { ...periodicDamage(dmg), tags: ['dot', 'pollution'] }); + } + }, + }]; +} + +function kitNazg(ab, e) { + const r = (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || e.base.rangeRadius || 1.3; + return [{ + before(c, b, e2) { + const l = targetsNear(b, e2, r); + if (onTerrain(b, e2, 'infection')) for (const u of b.allies()) if (!l.includes(u) && onTerrain(b, u, 'infection') && canTargetAlly(e2, u, true)) l.push(u); + if (l.length) c.targets = byPriority(e2, l); + }, + }]; +} + +/** 吉兆飞鳞 【失温坠落】: the stun when a freeze ends (PRTS "冻结结束时,获得0.05秒晕眩"). */ +const PARROT_CHILL_STUN = 0.05; + +/** + * 吉兆飞鳞 (PRTS): 近地悬浮 (失衡免疫), no normal attack; 受晕眩/无法行动/沉睡/缚地影响后进入晕眩模式 — the float is off, it + * cannot be blocked and is stunned `Stun.duration` (8) s — and floats again once none of those effects holds it + * (初始模式); 冻结 grounds it only when the freeze ends (【失温坠落】: 0.05 s stun). 首次受到伤害后 its speed is × + * `M0SpeedUp.move_speed` for `M0SpeedUp.duration` s ("最终提升至300%"; never in 晕眩模式, dropped when its mode + * changes). 搬运模式 (carrying a 寻险水手) needs a level checkpoint (`ThrowEnemy.checkpoint` 0 = never) — not in this mode. + */ +function kitParrot(ab) { + const down = T(ab, 'Stun.duration') ?? 0; + return [float(), { + taken(c, b, e, a) { + if (a.ran || a.down) return; + a.ran = true; + b.addBuff(e, { key: 'ab:parrotRun', duration: T(ab, 'M0SpeedUp.duration') ?? 0, mods: { moveMul: T(ab, 'M0SpeedUp.move_speed') ?? 1 } }); + }, + status(c, b, e, a) { + if (c.status === 'freeze') { a.chill = true; return; } + if (a.down || !(c.status === 'stun' || c.status === 'sleep')) return; + a.down = true; + setFloat(b, e, false); + b.removeBuff(e, 'ab:parrotRun'); + b.addBuff(e, { key: 'ab:parrotDown', flags: { unblockable: true }, persist: true }); + if (down > 0) b.applyStatus(e, 'stun', { duration: down, source: null }); + b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'grounded' }); + }, + tick(b, e, a) { + if (a.chill && !e.s.flags.freeze) { a.chill = false; b.applyStatus(e, 'stun', { duration: PARROT_CHILL_STUN, source: null }); } + // "离开上述异常效果影响后进入初始模式": s.flags.stun also holds while asleep / frozen (units.js), so a sleep that + // outlasts the mode stun keeps it down + if (a.down && !e.s.flags.stun) { + a.down = false; + b.removeBuff(e, 'ab:parrotDown'); + b.removeBuff(e, 'ab:parrotRun'); + setFloat(b, e, true); + b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'float' }); + } + }, + }]; +} + +function kitRush(ab) { + const ms = T(ab, 'rush.dlancer_t[trigger].move_speed') ?? 0, iv = T(ab, 'rush.dlancer_t[trigger].interval') ?? 0.5; + const max = T(ab, 'rush.dlancer_t[trigger].trig_cnt') ?? 0, first = T(ab, 'firstattack.atk_scale') ?? 0; + return [{ + iv, + spawn(b, e, a) { a.n = 0; }, + tick(b, e, a) { + if (!e.moving || e.blockedBy || a.n >= max) return; + a.n++; + b.addBuff(e, { key: 'ab:rush', mods: { moveMul: 1 + ms * a.n }, persist: true }); + }, + blocked(c, b, e, a) { a.charge = a.n; }, + hitOut(c, b, e, a) { + if (!c.dmg.isAttack || !(a.charge > 0) || !(max > 0)) return; + c.dmg.amount *= 1 + (first / 100) * (a.charge / max); // 被阻挡后的首次攻击: extra damage from the built-up speed + }, + attack(c, b, e, a) { if (a.charge > 0) { a.charge = 0; a.n = 0; b.removeBuff(e, 'ab:rush'); } }, + }]; +} + +/** + * 萨卡兹悖谬暴虐兵长: blocked only by a blocker with ≥ 3 free block; its first hit also strikes the units around its target + * (PRTS 技能0 暴击 "对目标和周围4格的我方单位造成攻击力150%的物理普通伤害(无视无法选择,无视迷彩)※此技能仅能触发一次"): + * the splash ignores 无法选择, so it reaches an airborne 起飞 ally too (`ignoreSelect`). + */ +function kitFirstAoe(ab) { + const scale = T(ab, 'AOEAttack.atk_scale') ?? 0; + return [blockWeight(3), { + dealt(c, b, e, a) { + if (a.done) return; + a.done = true; + b.fx('explode', { x: c.target.x, y: c.target.y, r: 1, kind: 'aoeAttack' }); + for (const u of b.alliesInRadius(c.target.x, c.target.y, 1)) { + if (u !== c.target) hurt(b, e, u, e.s.atk * scale, 'phys', { ignoreSelect: true, tags: ['aoeAttack'] }); + } + }, + }]; +} + +function kitDefDecay(ab) { + const max = T(ab, 'def_reduce.max_stack_cnt') ?? 0, def = T(ab, 'def_reduce.def') ?? 0, res = T(ab, 'def_reduce.magic_resistance') ?? 0; + return [{ + taken(c, b, e) { + if (!(max > 0)) return; + b.addBuff(e, { key: 'ab:defDecay', refresh: 'stack', stacks: 1, maxStacks: max, persist: true, mods: { defFlat: def / max, resFlat: res / max } }); + }, + }]; +} + +function kitExposeOnHit(ab) { + const dur = T(ab, 'Expose.weak[limit]') ?? 0, scale = T(ab, 'Expose.damage_scale') ?? 1; + return [{ sil: true, taken(c, b) { const s = c.source; if (s && s.side === 'ally' && s.alive && s.kind !== 'device') expose(b, s, dur, scale); } }]; +} + +// --------------------------------------------------------------------------------------------------------------- +// this family's part of KITS (content/enemies.js spreads the parts in this order) + +export const SPECIAL_KITS = Object.freeze({ + // --- SPECIAL 特异 / others + enemy_1045_hammer: kitStun3, // 粉碎攻坚手 · every 3rd attack stuns + enemy_1045_hammer_2: kitStun3, // 粉碎攻坚组长 · same + enemy_1116_liprr: kitPrisoner(), // 普通囚犯 · confined (ASPD −50) → freed after 4 attacks (ATK +50 %) + enemy_1116_liprr_2: kitPrisoner(), // 老练囚犯 · same + enemy_1118_lidbox_2: kitPrisoner(), // 拳师囚犯 · + DEF penetration when freed + enemy_1119_vofsd: kitPrisoner(), // 强壮囚犯 · + RES and regen when freed + enemy_1121_lifbos: kitPrisoner(true), // 重犯 · confined DEF up; first liberation frees every prisoner + enemy_1121_lifbos_2: kitPrisoner(true), // 传奇重犯 · same + enemy_1072_dlancer: kitRush, // 萨卡兹穿刺手 · accelerates while walking; first hit after block ×speed + enemy_1320_wdrrl_2: kitFirstAoe, // 萨卡兹悖谬暴虐兵长 · block ≥3 only; first attack splashes + enemy_1302_ymtro_2: () => [blockWeight(4)], // “越长尘” · block ≥4 only (passengers n/a) + enemy_1329_cbshld: kitDefDecay, // 弧光镜卫 · DEF/RES drop with every damage instance + enemy_1329_cbshld_2: kitDefDecay, // 弧光镜卫长 · same + enemy_1249_lysdb_2: (ab) => [{ spawn(b, e, a, ab2) { ab2.hitShield = T(ab, 'Shield.max_block_damage_cnt') ?? 0; } }], // 莱茵生命防卫科高级成员 · blocks one phys/arts hit + enemy_1402_tgshd_2: kitExposeOnHit, // 重装侦察兵 · exposes its attackers 5 s + enemy_1081_sotisd: (ab) => [taunt(T(ab, 'taunt.taunt_level') ?? 0)], // 游击队盾卫 · taunt +1 + enemy_1427_lrnazg: kitNazg, // “灵幛” · attack hits everyone around it (+ on infection tiles) + enemy_1422_lrsldr: () => [infectionArts()], // 萨卡兹枯朽前锋 · arts attacks on 源石污染区 (infection tiles) + enemy_1422_lrsldr_2: () => [infectionArts()], // 萨卡兹枯朽辟路前锋 · same + enemy_1425_lrcmra: kitChimera, // 孽罪奇美拉 · activated on infection: arts attacks + pollution aura + enemy_1425_lrcmra_2: kitChimera, // 渎罪奇美拉 · same + enemy_1430_lrrook: (ab) => [{ // 愧悔魂灵圣杯 · shares damage taken by nearby ground enemies + spawn(b, e, a) { a.r = T(ab, 'takeDmg.range_radius') ?? 1.7; a.share = T(ab, 'takeDmg.damage_scale') ?? 0; stOf(b).chalices = (stOf(b).chalices ?? 0) + 1; }, + death(c, b) { stOf(b).chalices = Math.max(0, (stOf(b).chalices ?? 1) - 1); }, + }], + enemy_1025_reveng: (ab) => [lowHpBuff(0.5, { atkPct: T(ab, 'atkup.atk') ?? 0 })], // 寻仇者 · ATK up below half HP + enemy_1021_bslime: (ab) => [deathBoom({ scale: T(ab, 'boom.atk_scale') ?? 0 })], // 高能源石虫 · death: phys blast + enemy_1067_snslime: (ab, e) => [deathBoom({ scale: T(ab, 'boom.atk_scale') ?? 0, r: (e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || BOOM_RADIUS, status: { key: 'cold', dur: T(ab, 'boom.freeze') ?? 0 } })], // 冰爆源石虫 · death: phys blast + cold + enemy_1069_icebrk_2: (ab) => [{ hitOut(c, b, e) { if (c.dmg.isAttack && c.target.s.flags.freeze) c.dmg.amount *= T(ab, 'atkup.atk_scale') ?? 1; } }], // 雪怪小队破冰者 · ×3 vs frozen + 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) => { // 高准度伦蒂尼姆城防自行炮 · 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; + // each shot picks inside the 9-tile zone (格子判定): an area selection — a 迷彩 ally may be shot, an unblocking 隐匿 + // one not (areaSelectable; it used to skip 迷彩 like a normal attack) + const t = b.allies().filter((a) => areaSelectable(e, a) && 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) => [{ + death(c, b, e) { + const key = ab.tS['Summon.enemy_key'], n = T(ab, 'Summon.cnt') ?? 0; + if (c.reason !== 'killed' || !key || !(n > 0)) return; + spawnChildren(b, e, key, n, { route: stayRoute(e), countInTotal: false, mods: null }); + }, + }, { + spawn(b, e) { watchDeaths(b, e); }, + otherDeath(c, b, e) { + const u = c.unit; + if (!u || u === e || c.reason !== 'killed' || Math.hypot(u.x - e.x, u.y - e.y) > (T(ab, 'Attack.range_radius') ?? TORTURER_RADIUS) + 1e-9) return; + b.heal(e, e, e.s.maxHp * (T(ab, 'Attack.hp_ratio') ?? 0), { self: true }); + b.addBuff(e, { key: 'ab:torture', refresh: 'stack', stacks: 1, maxStacks: T(ab, 'Attack.max_stack_cnt') ?? 1, persist: true, mods: { atkPct: T(ab, 'Attack.atk') ?? 0 } }); + }, + hitOut(c, b, e) { if (c.dmg.isAttack && c.target !== e.blockedBy) c.dmg.amount *= T(ab, 'Attack.attack@ranged_atk_scale') ?? 1; }, + }], + enemy_1501_demonk: () => [maxTargets(2)], // 萨卡兹百夫长 · attacks 2 targets + enemy_10018_sgrobh: () => [maxTargets(2)], // “独轮车玩具” · attacks 2 targets + enemy_2001_duckmi: (ab) => [unblockable(), runWhenHit(T(ab, 'run.attack@move_speed') ?? 0)], // 鸭爵 · unblockable, no attack; runs +400 % once hurt + enemy_2001_duckmi_2: (ab) => [unblockable(), runWhenHit(T(ab, 'run.attack@move_speed') ?? 0)], // 鸭爵 (鸭爵 strategy) · same, +300 % + enemy_10159_mntrjn: () => [unblockable()], // 伊利昂的木驮兽 · unblockable (passengers n/a) + enemy_2009_csaudc: kitEp('neural', 'combat.attack@ep_damage_ratio'), // 骇笑看客 · neural on hit + enemy_2010_csdcr: (ab) => [ep('neural', T(ab, 'attack.attack@ep_damage_ratio') ?? 0), { // 绯红歌伶 · neural on hit; every 20 hits taken: global enemy ASPD up + taken(c, b, e, a) { + a.n = (a.n ?? 0) + 1; + if (a.n < (T(ab, 'AttackSpeedUp.stack_cnt') ?? Infinity)) return; + a.n = 0; + b.fx('telegraph', { x: e.x, y: e.y, r: 99, kind: 'songOfWar', id: e.id }); + for (const o of b.aliveEnemies()) b.addBuff(o, { key: 'ab:songOfWar', duration: T(ab, 'AttackSpeedUp.duration') ?? 0, refresh: 'extend', mods: { aspd: T(ab, 'AttackSpeedUp.attack_speed') ?? 0 }, visible: true }); + }, + }], + enemy_10001_trslim: (ab) => [skill(ab.sk.StartRun, (b, e) => { // 简饲源石虫 · below half: runs (faster, unblockable 3 s) + const s = ab.sk.StartRun.bb; + b.addBuff(e, { key: 'ab:run', duration: s.block_free_time ?? 0, mods: { moveMul: 1 + (s.move_speed ?? 0) }, flags: { unblockable: true }, visible: true }); + }, { sil: true, cond: (b, e) => e.hpRatio < 0.5 })], + enemy_10027_vtsk: (ab) => { // “帝国的甲胄” · entrance barrage on the highest-HP unit; ranged ×0.8; 3-hit charge attack + const ap = ab.sk.Appear, mc = ab.sk.MultiCombat; + return [{ + spawn(b, e) { + const n = ap ? ap.bb.times ?? 0 : 0; + b.after(0.5, () => { + if (!e.alive || !n) return; + const lock = allTargets(b, e).sort((p, q) => q.hp - p.hp)[0]; + if (!lock) return; + b.fx('telegraph', { x: lock.x, y: lock.y, r: 1, kind: 'barrage', id: e.id }); + for (let i = 0; i < n; i++) { + // "对碰撞范围内的1名当前生命值最高的我方单位" — an area selection: never an airborne 起飞 ally nor an unblocking + // 隐匿 one (PRTS “帝国的甲胄”; areaAlliesInTiles) + const t = areaAlliesInTiles(b, e, lock.tileR, lock.tileC, 'box', 1).sort((p, q) => q.hp - p.hp)[0]; + if (t) hurt(b, e, t, e.s.atk, 'phys'); + } + }, { owner: e }); + }, + hitOut(c, b, e) { if (c.dmg.isAttack && c.target !== e.blockedBy) c.dmg.amount *= T(ab, 'range.attack@atk_scale_range') ?? 1; }, + }, mc ? { + cd: mc.cd, icd: mc.icd, fire(b, e) { e.mem.ab.multi = mc.bb.times ?? 1; }, + dealt(c, b, e) { + const m = e.mem.ab.multi; + if (!(m > 1)) return; + e.mem.ab.multi = 0; + for (let i = 1; i < m; i++) hurt(b, e, c.target, e.s.atk, 'phys'); + }, + } : null]; + }, + enemy_10044_wintun: (ab) => { // 咸鳞汁推荐者 · fast with the barrel; first attack (SILENCE): big hit + buff zone for enemies + const s = ab.sk.BlockedBoom ? ab.sk.BlockedBoom.bb : {}; + return [{ + sil: true, // silenced: the barrel is kept (and its speed) until an unsilenced attack + spawn(b, e) { b.addBuff(e, { key: 'ab:barrel', persist: true, mods: { moveMul: T(ab, '1.move_speed') ?? 1 } }); }, + hitOut(c, b, e, a) { if (!a.done && c.dmg.isAttack) c.dmg.amount *= s.blockee_atk_scale ?? 1; }, + attack(c, b, e, a) { + if (a.done) return; + a.done = true; + b.removeBuff(e, 'ab:barrel'); + const x = e.x, y = e.y, life = s.fixed_duration ?? 0; + b.fx('zone', { x, y, r: BARREL_RADIUS, dur: life, kind: 'barrel' }); + let left = life; + const h = b.every(0.5, () => { + for (const o of b.enemiesInRadius(x, y, BARREL_RADIUS)) auraBuff(b, o, 'ab:barrelZone', 0.5, { aspd: s.attack_speed ?? 0, dodgePhys: s.prob ?? 0 }); + left -= 0.5; + if (left <= 0) h.cancel(); + }); + }, + }]; + }, + enemy_10045_parrot: kitParrot, // 吉兆飞鳞 · 近地悬浮 (grounded 8 s when stunned); sprints when first hit + enemy_10087_hlchgr: (ab) => [{ // 圣堂剑士 · spends ammo (every 6 s, max 5) for permanent speed/ATK + iv: T(ab, 'SkillTrigger.interval') ?? 6, + tick(b, e, a) { + a.n = (a.n ?? 0) + 1; + if (a.n > 5) return; + const s = ab.sk.ForeverEnhance ? ab.sk.ForeverEnhance.bb : {}; + b.addBuff(e, { key: 'ab:enhance', refresh: 'stack', stacks: 1, maxStacks: 5, persist: true, mods: { moveFlat: (s.move_speed_add ?? 0) * e.base.moveSpeed, atkPct: s.atk_add ?? 0 } }); + }, + }], + enemy_10094_crstf: kitEp('neural', 'ep.ep_damage_ratio'), // 临时收音师 · neural on hit (filming zones n/a → always) + enemy_10097_crshd: (ab) => [ // 心虚设计师 · neural to its blocker every s; front damage −80 % (faces its walk) + { iv: 1, tick(b, e) { if (e.blockedBy) elem(b, e, e.blockedBy, 'neural', e.s.atk * (T(ab, 'block.ep_damage_ratio') ?? 0)); } }, + frontGuard(T(ab, 'weakness.damage_resistance') ?? 0, faceMove), // 减少来自正面的物理/法术伤害 (cameras absent: it never turns) + ], + enemy_10098_crhro: (ab) => [ep('neural', T(ab, 'inside.attack@ep_damage_ratio') ?? 0), // 主角阵营角色 · neural on hit; 重生 once (reborn.duration s, full HP) + reborn({ dur: T(ab, 'reborn.duration') ?? 0, invincible: T(ab, 'reborn.invincible') ?? 0 })], + enemy_10099_crvln: kitEp('neural', 'inside.attack@ep_damage_ratio'), // 反派阵营角色 · neural on hit + enemy_10116_ymgtop: (ab) => { // 水遁忍者 · spinning phase (SILENCE): phys AoE every s; 失衡 stops it + const spin = { + sil: true, cd: ab.sk.SwitchModeTrigger ? ab.sk.SwitchModeTrigger.cd : 60, icd: ab.sk.SwitchModeTrigger ? ab.sk.SwitchModeTrigger.icd : 10, + fire(b, e, a) { a.until = b.time + (T(ab, 'EndRotate.rotate_duration') ?? 0); b.fx('telegraph', { x: e.x, y: e.y, r: 1, kind: 'spin', id: e.id }); }, + iv: T(ab, 'RotateDamage.interval') ?? 1, + // "每秒对半径1.0范围内的所有我方单位造成…(无视迷彩,不可对空),自身受阻止攻击类异常效果影响期间无法造成此伤害": an area + // selection, skipped while 晕眩 / 冻结 / 浮空 (flag stun), 沉睡 or 缴械 hold it (PRTS 异常效果 §阻止攻击; until 0.1.3 it spun on) + tick(b, e, a) { + if (!(a.until > b.time) || e.s.flags.stun || e.s.flags.sleep || e.s.flags.disarm) return; + for (const u of areaAllies(b, e, e.x, e.y, T(ab, 'RotateDamage.attack@range_radius') ?? 1)) hurt(b, e, u, e.s.atk * (T(ab, 'RotateDamage.attack@atk_scale') ?? 1), 'phys'); + }, + }; + // 漩涡形态 "不进行普通攻击" (PRTS 天赋): no blocked attack while it spins (until 0.1.3 its blocker took both) + const noAtk = { tick(b, e) { e.profile.noAttack = spin.until > b.time; } }; + return [spin, noAtk, unbalanced((b, e) => { if (spin.until > b.time) { spin.until = 0; b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'spinStop' }); } })]; + }, + enemy_10118_ymgprc: (ab) => { // 澪 · double hits; 寒晖: every 4th attack (spCost 3) is a ×1.5 double hit + const scale = (ab.sk.PowerAttack && ab.sk.PowerAttack.bb.atk_scale) || 1; + const pw = nthAttackPower(nthOf(ab.sk.PowerAttack), scale); + return [pw, { dealt(c, b, e) { hurt(b, e, c.target, e.s.atk * (pw.power ? scale : 1), 'phys', { isSkill: false }); } }]; + }, + enemy_10127_rkmbst_2: (ab) => [taunt(1), { spawn(b, e) { // 异质裂兽·α · M0 shield (damage ×0.5, ASPD +100, barrier) + b.addBuff(e, { key: 'ab:m0', persist: true, visible: true, mods: { dmgTakenMul: 1 - (T(ab, 'M0Shield.damage_resistance') ?? 0), aspd: T(ab, 'M0Shield.attack_speed') ?? 0 } }); + const sh = e.s.maxHp * (T(ab, 'M0Shield.init_shield_hp_ratio') ?? 0); + if (sh > 0) b.addBuff(e, { key: 'ab:m0barrier', shield: sh, persist: true }); + } }], + enemy_10156_mncrer: (ab) => [{ sil: true, death(c, b, e) { // 新手祭司学徒 · death: heals nearby enemies + if (c.reason !== 'killed') return; + const r = T(ab, 'Boom.projectile_range') ?? 1, amt = e.s.atk * (T(ab, 'Boom.heal_scale') ?? 0); + b.fx('explode', { x: e.x, y: e.y, r, kind: 'heal' }); + for (const o of b.enemiesInRadius(e.x, e.y, r)) if (o !== e) b.heal(e, o, amt); + } }], + enemy_10162_mnctpt: (ab) => [{ // 自制投石机 · 3-hit attacks with small splash + dealt(c, b, e) { + const n = T(ab, 'Attack.attack@times') ?? 1, r = T(ab, 'Attack.attack@projectile_range') ?? 0; + const t = c.target; + for (let i = 1; i < n; i++) hurt(b, e, t, e.s.atk, 'phys'); + // the stone's splash ("碰撞无视迷彩"): an area selection — no unblocking 隐匿 ally + if (r > 0) for (const u of areaAllies(b, e, t.x, t.y, r)) if (u !== t) for (let i = 0; i < n; i++) hurt(b, e, u, e.s.atk, 'phys'); + }, + }], +}); diff --git a/server/sim/content/enemies/times.js b/server/sim/content/enemies/times.js new file mode 100644 index 00000000..963b1158 --- /dev/null +++ b/server/sim/content/enemies/times.js @@ -0,0 +1,123 @@ +// server/sim/content/enemies/times.js — TIMES 频次 kits (hit-count units and the enemies that create them) and their part +// of KITS (split from content/enemies.js). + +import { TEA_SPLASH_RADIUS, abOf, T, hurt, areaAllies, spawnChildren } from './helpers.js'; +import { splashAttack, husk, skill, kitTimes, kitDeathSpawn, kitEmber } from './archetypes.js'; + +// --------------------------------------------------------------------------------------------------------------- +// constants (numbers that exist nowhere in the data) + +/** 假想敌:再生 shield aura radius (PRTS 假想敌:再生 天赋 "进入此形态时,使半径1.8范围内的其他敌方单位(无视其可选性)获得5层…护盾"). */ +const ACPUPP_AURA_RADIUS = 1.8; + +/** 烹泉 / 沏虹 死亡爆炸 radius (PRTS 天赋 "死亡爆炸(爆炸半径1.25,造成攻击力100%法术溅射伤害并施加15s【烹泉减益】…)"; until + * 0.1.3 the attack range, 2). */ +const TEA_BOOM_RADIUS = 1.25; + +// --------------------------------------------------------------------------------------------------------------- +// kits + +function kitBlades(ab) { + const cnt = T(ab, 'DeadSpawn.cnt') ?? 0, add = T(ab, 'DeadSpawn.cnt_add') ?? 0, atk = T(ab, 'Atkup.atk', 'AtkUp.atk') ?? 0; + const key = ab.tS['DeadSpawn.enemy_key']; + return [{ + spawn(b, e, a) { a.used = 0; }, + attack(c, b, e, a) { + if (a.used >= cnt) return; + a.used++; + b.addBuff(e, { key: 'ab:blade', refresh: 'stack', stacks: 1, maxStacks: Math.max(1, cnt), mods: { atkPct: atk }, persist: true, visible: true }); + }, + death(c, b, e, a) { if (c.reason === 'killed' && key) spawnChildren(b, e, key, Math.max(1, cnt + add * a.used)); }, + }]; +} + +function kitInvisShield(ab, e) { + const s = ab.sk.InvisibleShield; + const r = (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || 2; + return [...kitDeathSpawn()(ab), skill(s, (b, e2) => { + // 辉光照耀 "使【范围隐匿】生效3s" (PRTS 清明 / 堂皇) = the talent's InvisibleShield.duration (3 for both); 清明's skill + // blackboard says 5 — until 0.1.3 that 5 was used + const dur = T(ab, 'InvisibleShield.duration') ?? s.bb.duration ?? 0; + b.fx('telegraph', { x: e2.x, y: e2.y, r, kind: 'invisShield', id: e2.id }); + // "获得隐匿(解除阻挡0秒后恢复)" (PRTS 清明 / 堂皇 天赋): the veil's 隐匿 is back as soon as a block ends + for (const o of b.enemiesInRadius(e2.x, e2.y, r)) if (o !== e2 && dur > 0) b.addBuff(o, { key: 'ab:veiled', duration: dur, flags: { stealth: true }, visible: true, data: { stealthRestore: 0 } }); + })]; +} + +/** 假想敌:再生 (PRTS 假想敌:再生 天赋) · knock-out ⇒ 1 s 重生 ⇒ 再生状态: a 傀儡 of prop_max_hp (15) hits that is 不可阻挡 for + * `interval` (15) s and walks on ("1s内不移动": only the 重生 stands; its model has a 傀儡 walk cycle, B_Move), back with full + * HP if still standing; entering it gives the other enemies within 1.8 (targetable or not) max_damage_block_cnt (5) + * hit shields. */ +function kitRegen(ab) { + const hits = T(ab, 'Revive[Trigger].prop_max_hp'), delay = T(ab, 'Revive[Trigger].interval'), block = T(ab, 'Aura.max_damage_block_cnt') ?? 0; + return [husk({ + hits, delay, stealthy: false, unblock: true, key: 'ab:regen', + onHusk(b, e) { + // "进入此形态时,使半径1.8范围内的其他敌方单位(无视其可选性)获得5层吸收物理/法术伤害的护盾" + b.fx('telegraph', { x: e.x, y: e.y, r: ACPUPP_AURA_RADIUS, kind: 'regenShield', id: e.id }); + for (const o of b.enemiesInRadius(e.x, e.y, ACPUPP_AURA_RADIUS)) if (o !== e && block > 0) { const oab = abOf(b, o); oab.hitShield = Math.max(oab.hitShield, block); } + }, + })]; +} + +/** + * 烹泉 / 沏虹 (PRTS 天赋): every normal attack hits its target and every unit within TEA_SPLASH_RADIUS of it ("法术普通伤害 + * (无视迷彩,不可对空)" — splashAttack, blocked or not); 死亡爆炸 "(爆炸半径1.25,造成攻击力100%法术溅射伤害并施加15s【烹泉减益】, + * 无视迷彩,不可对空)", 【烹泉减益】 "攻击速度-40,且固定每3秒额外-0(可被抵抗,可叠加,每层持续时间和效果独立计算)" — DeadBoom. + * attack_speed / duration; one layer per blast, each its own buff. Until 0.1.3: no attack splash; the blast took the attack + * radius (2) and left a 15 s steam zone re-applying the ASPD cut to whoever stood in it [ASSUMED]. The attack splash, a 法术普通 + * 伤害, can be dodged like the attack itself; the blast (法术溅射伤害) cannot. + */ +function kitTeapot(ab) { + return kitDeathSpawn([splashAttack({ unblocked: false, radius: TEA_SPLASH_RADIUS, noAir: true, dodge: true, fxKind: 'artsSplash' }), { + sil: true, + death(c, b, e) { + if (c.reason !== 'killed') return; + const aspd = T(ab, 'DeadBoom.attack_speed') ?? 0, dur = T(ab, 'DeadBoom.duration') ?? 0; + const r = TEA_BOOM_RADIUS, atk = e.s.atk, x = e.x, y = e.y; + // an area selection (no 无视无法选择 note): an airborne 起飞 ally and an unblocking 隐匿 one are skipped, flyers too + b.fx('explode', { x, y, r, kind: 'teaBoom', id: e.id }); + for (const u of areaAllies(b, e, x, y, r)) { + if (u.isFlying) continue; + hurt(b, null, u, atk, 'arts', { tags: ['teaBoom'] }); + if (u.alive && dur > 0 && aspd) b.addBuff(u, { key: `ab:teaBoom:${e.id}`, duration: dur, refresh: 'replace', mods: { aspd }, visible: true }); + } + }, + }])(ab); +} + +// --------------------------------------------------------------------------------------------------------------- +// this family's part of KITS (content/enemies.js spreads the parts in this order) + +export const TIMES_KITS = Object.freeze({ + // --- TIMES 频次 (hit-count units + their creators) + enemy_1196_msfyin: kitTimes(), // 木制瑞印 · 2 hits, unblockable + enemy_1196_msfyin_2: kitTimes(), // 红木瑞印 · 3 hits + enemy_1198_msfshu: kitTimes(), // 小说卷轴 · 3 hits + enemy_1198_msfshu_2: kitTimes(), // 诗画卷轴 · 4 hits + enemy_1200_msfjin: kitTimes(), // 青铜镜 · 30 hits + enemy_1200_msfjin_2: kitTimes(), // 黄铜镜 · 35 hits + enemy_1202_msfzhi: kitTimes(), // 木制镇纸 · 3 hits + enemy_1202_msfzhi_2: kitTimes(), // 红木镇纸 · 4 hits + enemy_1204_msfhu: kitTimes(true), // 青瓷茶器 · 4 arts/true hits + enemy_1204_msfhu_2: kitTimes(true), // 彩瓷茶器 · 6 arts/true hits + enemy_1208_msfji: kitTimes(), // 铜矛头 · 25 hits + enemy_1208_msfji_2: kitTimes(), // 铁矛头 · 30 hits + enemy_1210_msfden: kitTimes(), // 铜灯盘 · 35 hits + enemy_1210_msfden_2: kitTimes(), // 铁灯盘 · 45 hits + enemy_1195_sfyin: kitDeathSpawn(), // 磨砻 · death: 2 木制瑞印 + enemy_1195_sfyin_2: kitDeathSpawn(), // 明鉴 · death: 2 红木瑞印 + enemy_1197_sfshu: kitDeathSpawn(), // 俗心 · death: 3 小说卷轴 + enemy_1197_sfshu_2: kitDeathSpawn(), // 雅气 · death: 3 诗画卷轴 + enemy_1199_sfjin: kitDeathSpawn(), // 身观 · death: 1 青铜镜 (taunt from data) + enemy_1207_sfji: kitBlades, // 沉沙 · each attack spends a blade (+ATK); death: unspent blades → 铜矛头 (≥1) + enemy_1207_sfji_2: kitBlades, // 新硎 · same (铁矛头) + enemy_1209_sfden: kitInvisShield, // 清明 · veils nearby enemies (stealth) every 15 s; death: 铜灯盘 + enemy_1209_sfden_2: kitInvisShield, // 堂皇 · same (铁灯盘) + enemy_1203_sfhu: kitTeapot, // 烹泉 · death: 4 青瓷茶器 + ASPD-down arts blast zone + enemy_1203_sfhu_2: kitTeapot, // 沏虹 · death: 4 彩瓷茶器 + blast zone + enemy_1288_duskls: kitEmber, // 深池逐火战士 · every KO: 1 s 重生 ⇒ walking 隐匿 5-hit ember (block it to hit it), back after 10 s + enemy_1288_duskls_2: kitEmber, // 深池逐火精锐战士 · same + enemy_1292_duskld: kitEmber, // 深池逐火护卫 · 10-hit 火灰 + enemy_9010_acpupp: kitRegen, // 假想敌:再生 · every KO: 1 s 重生 ⇒ walking unblockable 15-hit puppet (15 s) + 5-hit shields within 1.8 +}); diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js index 2b0c86a5..b53f6019 100644 --- a/server/sim/snapshot.js +++ b/server/sim/snapshot.js @@ -2,7 +2,7 @@ // // b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total } // UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, -// form?, skillIndex?, moduleId?, items? } (form = the unit's current model form — an enemy's, content/enemies.js setForm: +// form?, skillIndex?, moduleId?, items? } (form = the unit's current model form — an enemy's, content/enemies/helpers.js setForm: // 掠海漂移体 'crawl', 暴鸰 'bombed', 转译基底·α's forms …; a 傀儡师 fighting as its 替身 'doll', professions.js — a view built // after the change, a field opened mid-battle, draws it: render/units.js FORMS) // dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1). @@ -39,7 +39,7 @@ export function unitInfo(u) { maxHp: Math.max(1, Math.round(u.s.maxHp)), motion: u.motion === 'FLY' ? 'FLY' : undefined, boss: u.isBoss ? true : undefined, - // the unit's current model form (an enemy's content/enemies.js setForm, a 傀儡师's 替身 — render/units.js FORMS): a + // the unit's current model form (an enemy's content/enemies/helpers.js setForm, a 傀儡师's 替身 — render/units.js FORMS): a // view built mid-battle (fieldMeta — a watched teammate's field, 联防 observers, a reconnect) starts on that clip set form: typeof u.form === 'string' ? u.form : undefined, uid: u.uid ?? undefined, diff --git a/server/sim/targeting.js b/server/sim/targeting.js index 286cb695..5d2ebcf9 100644 --- a/server/sim/targeting.js +++ b/server/sim/targeting.js @@ -107,7 +107,7 @@ export function canTargetAlly(e, a, ranged) { /** * May an AREA effect of enemy-side `src` select ally `a` — a splash, a blast, an area skill or status, a pulse, a zone it - * leaves, a chain / bounce jump, a 周围四格 addition (content/enemies.js areaAllies / areaAlliesInTiles / fieldAllies)? + * leaves, a chain / bounce jump, a 周围四格 addition (content/enemies/helpers.js areaAllies / areaAlliesInTiles / fieldAllies)? * PRTS 作战机制 §AOE伤害判定 "AOE的判定是对攻击范围内的每个可以被选中的敌人进行判定"; §隐匿 "隐匿效果使得获得该效果的单位无法被 * 任何敌方的能力索敌选中"; PRTS 异常效果 §无法选择: with 隐匿, 不可选中, 无敌, 塔不可选中 or 对地规避 "常见的、来自不同阵营的 * “选择”行为将无视这些单位进行(如同范围内不存在这个单位)", and the abilities PRTS marks "无视可选性" are those that skip @@ -133,9 +133,9 @@ export function areaSelectable(src, a) { /** * May a BUFF AURA of enemy-side `src` (a 光环 refreshed on whoever stands in it — 深池伙友卫队's force field, 扎罗's - * 远古威慑; content/enemies.js auraAllies) take ally `a`? PRTS 作战机制 §隐匿与Buff的关系 "隐匿状态下的单位一般无法被敌方的 + * 远古威慑; content/enemies/helpers.js auraAllies) take ally `a`? PRTS 作战机制 §隐匿与Buff的关系 "隐匿状态下的单位一般无法被敌方的 * 索敌机制和Buff选择器选中为目标", "目前明日方舟中使用能选中隐匿状态单位的Buff效果一定是无视隐匿状态起作用的" (its example: - * 寒霜's 攻速下降 Debuff — content/enemies.js allyAura keeps that one on every ally): no 隐匿 ally, the one blocking `src` + * 寒霜's 攻速下降 Debuff — content/enemies/archetypes.js allyAura keeps that one on every ally): no 隐匿 ally, the one blocking `src` * included (GitHub #97), no untargetable or sleeping one; 迷彩 does not protect ("所有光环类能力…均不受迷彩制约"). Unlike areaSelectable it does not * apply 对地规避: a ground enemy's aura still reaches an airborne 起飞 ally [ASSUMED, DESIGN §21.20 / §21.22]. */ diff --git a/server/sim/units.js b/server/sim/units.js index 7be21141..25e07a06 100644 --- a/server/sim/units.js +++ b/server/sim/units.js @@ -85,7 +85,7 @@ export class Unit { this.stats = { dmg: 0, kills: 0, heal: 0, taken: 0, attacks: 0 }; this.hidden = false; // enemies inside a DISAPPEAR segment this.moving = false; // enemies: walked this tick (drawn on the move clip; ai.js updateEnemy) - this.form = null; // the model's current form (content/enemies.js setForm, a 傀儡师's 替身 'doll' → snapshot.js unitInfo) + this.form = null; // the model's current form (content/enemies/helpers.js setForm, a 傀儡师's 替身 'doll' → snapshot.js unitInfo) this.anim = 0; this.persist = { redeployMul: 1, freeRedeploys: 0 }; this.isBoss = false; diff --git a/shared/constants.js b/shared/constants.js index d4e566b1..8ae63403 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -81,7 +81,7 @@ export const SKILL_SUMMON_START_DEPLOY = true; * 12534 "每把都能999层", 12316 "999謝"; research 02 §layers). The only implementation of the cap (DESIGN §20.12). Every * writer of a bond's layers goes through `layerGainRoom`: the prep-side gains (server/match/PlayerState.js addLayers — * 特质, items, bands, 机变 cards, bonds), the settle of the in-battle gains (server/match/Match.js) and the live in-battle - * copy (server/sim/Battle.js addLayers, as the client's AddBondCount) — and the dev tools' direct writes (tools/matchrun.mjs + * copy (server/sim/battle/economy.js addLayers, as the client's AddBondCount) — and the dev tools' direct writes (tools/matchrun.mjs * --layers, tools/balance.mjs applyBoard); a gain at the cap adds 0 (no onLayers, no 'layer' * event), and the client-result check (server/match/fields.js) bounds a reported gain by the room left. Milestones paid * per N layers (远见, 奇迹, 维多利亚 …) stop with the count. 0 / Infinity = no cap. diff --git a/shared/protocol.js b/shared/protocol.js index 3dd2ec4c..73bbb0ab 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -346,7 +346,7 @@ export const EV = Object.freeze({ }); /** - * The model form a `b.ev` 'fx' tuple ['fx', kind, x, y, extra] puts its unit in: `extra.form` (sim content/enemies.js + * The model form a `b.ev` 'fx' tuple ['fx', kind, x, y, extra] puts its unit in: `extra.form` (sim content/enemies/helpers.js * setForm — 转译基底·α's forms, a 逐火 余烬 and its revival, a leader's 重生, 守墓石像's modes, 掠海漂移体's crawl; a 傀儡师's 替身, * sim professions.js; a string is that clip set, null the base one), undefined for any other tuple. A form is state, not decoration: a view that misses * the fx keeps drawing the old model (player report #5 after 0.1.0), so the client's catch-up frames, its hidden-tab diff --git a/test/docs-consistency.test.js b/test/docs-consistency.test.js index 8b739d06..d86dfa30 100644 --- a/test/docs-consistency.test.js +++ b/test/docs-consistency.test.js @@ -23,7 +23,7 @@ // 重装 skills (§21.29, GitHub issue #4 / PR #12). import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { GameData } from '../server/match/gamedata.js'; @@ -49,6 +49,8 @@ import { DATA, makeMatch } from './match/harness.js'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const doc = (p) => readFileSync(join(ROOT, p), 'utf8'); +/** server/sim/Battle.js with its method modules (server/sim/battle/*.js) as one text. */ +const battleText = () => [doc('server/sim/Battle.js'), ...readdirSync(join(ROOT, 'server/sim/battle')).sort().map((f) => doc(`server/sim/battle/${f}`))].join('\n'); const DESIGN = doc('docs/DESIGN.md'); const META = doc('docs/META.md'); const DATA_MD = doc('docs/DATA.md'); @@ -449,7 +451,7 @@ test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push fo assert.match(SIM, /\*\*every blocker\*\* — melee units/); assert.ok(!/targets by its range alone: no/.test(SIM), 'SIM: the melee-only rule is gone'); assert.match(PLAYING, /\*\*阻挡了就一定能打到\*\*/); - const battleSrc = readFileSync(join(ROOT, 'server/sim/Battle.js'), 'utf8'); + const battleSrc = battleText(); assert.ok(!/meleeUnit/.test(battleSrc) && !/export function meleeUnit/.test(readFileSync(join(ROOT, 'server/sim/targeting.js'), 'utf8')), 'no melee gate in the engine'); // #15 / #16: skill triggers and the 3 s operation cooldown (code = §5.6 = §19.2 superseded) assert.equal(AUTO_OP_COOLDOWN, 3); @@ -552,7 +554,7 @@ test('playtest6b follow-up (DESIGN §20.10–§20.13): leader HP, 直接乘算, assert.equal(BOND_LAYER_CAP, 999); assert.equal(layerGainRoom(995, 10), 4); assert.ok(!('BOND_LAYER_CAP' in SIM_CONST) && !('layerRoom' in SIM_CONST), 'no second cap in server/sim/constants.js'); - for (const f of ['server/match/PlayerState.js', 'server/match/Match.js', 'server/sim/Battle.js']) { + for (const f of ['server/match/PlayerState.js', 'server/match/Match.js', 'server/sim/battle/economy.js']) { const src = doc(f); assert.match(src, /layerGainRoom/, `${f} clamps with layerGainRoom`); assert.ok(!/layerRoom\b/.test(src), `${f}: no layerRoom`); @@ -764,14 +766,14 @@ test('batch 6 QA residuals (DESIGN §21.21–§21.25): the lock per deployment f // F3: 卢西恩 / 锏 count only the allies they can hurt — since 0.1.2 (§22.12) the targets of their trigger selection // (targetsNear → canTargetAlly, which skips an airborne 起飞 ally for a ground enemy); the player text keeps auras and counters assert.match(doc('server/sim/content/bosses.js'), /cond: \(b\) => targetsNear\(b, e, LUCIEN_AOE_RADIUS\)\.length > 0/); - assert.match(doc('server/sim/content/enemies.js'), /const inR = \(b, e, s\) => targetsNear\(b, e, [^\n]*\)\.length > 0/); + assert.match(doc('server/sim/content/enemies/leaders.js'), /const inR = \(b, e, s\) => targetsNear\(b, e, [^\n]*\)\.length > 0/); assert.match(doc('server/sim/targeting.js'), /if \(f\.liftoff && evadesGround\(e, a\)\) return false;/); assert.match(sub(22), /they count only the allies they can hurt \(`!evadesGround`\)/); assert.match(sub(20), /an area skill cast because allies are near counts only those it can hurt/); assert.ok(!/燃烧区域和减益都落不到她身上/.test(PLAYING), 'PLAYING: no blanket 减益 claim'); assert.match(PLAYING, /地面敌人的光环和全场效果[^\n]*照常生效/); // F5: rule 3 counts every board piece's home, removed or not - const battleSrc = doc('server/sim/Battle.js'); + const battleSrc = battleText(); assert.match(battleSrc, /a\.uid != null && \(a\.kind === 'op' \|\| a\.kind === 'token'\) && a\.homeR === r && a\.homeC === c/); assert.match(sub(24), /Every board piece's home counts now, on the field or not/); assert.match(SIM, /the piece on the field or not — a summon leaves its home free only once it has expired or been\nkilled/);