Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed - #2 two-tap confirm on 机变 cards (like the shop) - #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles) - #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band - #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from - #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others) - #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats) - #8 official element gauges on units; burst cooldown (阻回) per PRTS - #9 knocked-down operators stay on their tile with a redeploy countdown ring - #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼) - #11 赫默's drone only from her skill; placeable summons limited to talent tokens - #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel - #13 the occasional giant bond icon (loading texture scaled ×46) fixed - docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
This commit is contained in:
1 parent
0aba602ecf
commit
d4f868cd19
107 files changed
+4510
-1653
No files matched your search
+47
-5
@@ -18,13 +18,13 @@
|
||||
// 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.
|
||||
|
||||
import { TICK, ROWS, COLS, DP_DEFAULTS, 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 } from './constants.js';
|
||||
import { TICK, ROWS, COLS, DP_DEFAULTS, DOWN_STATE, 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 } 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 } from './damage.js';
|
||||
import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff, elementView } from './damage.js';
|
||||
import { absoluteRangeKeys, canTargetEnemy } from './targeting.js';
|
||||
import { normDir, mirrorDir, localOrder, localBefore } from './dir.js';
|
||||
import { ProjectileSystem } from './projectiles.js';
|
||||
@@ -1824,7 +1824,12 @@ export class Battle {
|
||||
return ev;
|
||||
}
|
||||
|
||||
/** Compact full snapshot of this field (DESIGN §8.2 b.snap). */
|
||||
/**
|
||||
* Compact full snapshot of this field (DESIGN §8.2 b.snap), plus (only when non-empty):
|
||||
* down: [[id, respawnAt, respawnTime, state]] — knocked-out operators waiting to redeploy on their own tile
|
||||
* (isDown): the game time their respawn timer ends, its length (s) and constants.js DOWN_STATE;
|
||||
* elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView).
|
||||
*/
|
||||
snapshot() {
|
||||
const snap = {
|
||||
fieldId: this.fieldId,
|
||||
@@ -1839,14 +1844,51 @@ export class Battle {
|
||||
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)]);
|
||||
}
|
||||
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[] } */
|
||||
/**
|
||||
* A knocked-out operator waiting to redeploy on its own tile (DESIGN §5.5: after its respawn time, when the tile is
|
||||
* free and DP ≥ cost): killed — not withdrawn, not removed for good — after it was deployed. The client keeps its
|
||||
* model on the field 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 === 'killed'
|
||||
&& 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 (_checkRedeploys). */
|
||||
_downState(u) {
|
||||
if (this.time + 1e-9 < u.respawnAt) return DOWN_STATE.COUNTING;
|
||||
const occ = this._occ[u.homeR * COLS + u.homeC];
|
||||
if (occ && occ.alive && occ !== 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).map(unitInfo),
|
||||
units: this.units.filter((u) => (u.alive && u.deployed && !u.hidden) || this.isDown(u)).map(unitInfo),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -618,8 +618,13 @@ function enemyAttack(b, e) {
|
||||
const attackId = ++b._attackSeq;
|
||||
const type = def.dmgType === 'heal' ? 'arts' : def.dmgType;
|
||||
const rangedShot = radius > 0 && !(e.blockedBy && targets[0] === e.blockedBy && radius < 1);
|
||||
// content-resolved attacks (`e.profile.deferHit`: 帝国炮火先兆者's shell landing 3 s later): the attack itself happens
|
||||
// — the 'atk' event (kind `e.profile.shot`, drawn by the content's own fx), cooldown, pause, the 'attack' hook — and
|
||||
// the content's 'attack' handler deals its damage
|
||||
const deferred = !!(e.profile && e.profile.deferHit);
|
||||
for (const t of targets) {
|
||||
b._ev(['atk', e.id, t.id, rangedShot ? 'enemy' : 'none']);
|
||||
b._ev(['atk', e.id, t.id, deferred ? (e.profile.shot || 'none') : rangedShot ? 'enemy' : 'none']);
|
||||
if (deferred) continue;
|
||||
const hit = (tt) => {
|
||||
if (!tt || !tt.alive || !e.alive && !rangedShot) return;
|
||||
b.dealDamage(e, tt, { amount: e.s.atk * (e.profile?.atkScale ?? 1), type, isAttack: true, attackId });
|
||||
|
||||
+15
-1
@@ -39,6 +39,9 @@ export const ELEMENTS = Object.freeze(['burn', 'neural', 'necrosis', 'apoptosis'
|
||||
* `ally` = an operator/summon hit by enemy damage (ba.dt.burning / neural / apoptosis / erosion),
|
||||
* `enemy` = an enemy hit by operators (the "·我方" terms ba.dt.burning2 / neural2 / apoptosis2 / erosion2;
|
||||
* `elemDamage` is 元素伤害: HP damage ignoring DEF/RES, × elemTakenMul).
|
||||
* `duration` = the burst's 爆发冷却 (PRTS 元素 table "持续时间": 10 s, 凋亡 15 s, 侵蚀 on enemies 8 s — the operators'
|
||||
* 侵蚀 burst has its 10 s cooldown too): while it runs NO element of the unit fills or recovers, and when it ends every
|
||||
* gauge of the unit resets (damage.js).
|
||||
* `necrosis` keeps the engine's earlier invented burst (no official counterpart) for legacy content.
|
||||
*/
|
||||
export const ELEMENT = Object.freeze({
|
||||
@@ -58,11 +61,16 @@ export const ELEMENT = Object.freeze({
|
||||
enemy: Object.freeze({ elemDps: 800, weaken: 0.5, duration: 15 }),
|
||||
}),
|
||||
erosion: Object.freeze({
|
||||
ally: Object.freeze({ damage: 800, type: 'phys', defDown: 100, duration: 0 }),
|
||||
ally: Object.freeze({ damage: 800, type: 'phys', defDown: 100, duration: 10 }),
|
||||
enemy: Object.freeze({ elemDamage: 5000, defDown: 120, duration: 8 }),
|
||||
}),
|
||||
necrosis: Object.freeze({ dps: 100, duration: 12, atkDownPct: 0.2 }),
|
||||
});
|
||||
/**
|
||||
* Official element order (PRTS 元素: SANITY 1 神经, WATER 2 侵蚀, FIRE 3 灼燃, DARK 4 凋亡; the legacy `necrosis` last):
|
||||
* the tie-break of the "当前损伤元素" a unit shows — the fullest gauge, then the lower id (damage.js elementView).
|
||||
*/
|
||||
export const ELEMENT_ORDER = Object.freeze(['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']);
|
||||
/** 麻痹 (ba.palsy): each stack cancels one normal attack of an enemy; at most 3 stacks, lasts until consumed. */
|
||||
export const PALSY_MAX = 3;
|
||||
|
||||
@@ -79,6 +87,12 @@ export const FREEZE_RES_DOWN = 15;
|
||||
|
||||
/** Default DP rules (DESIGN §5.5), overridable by Battle opts.flags. */
|
||||
export const DP_DEFAULTS = Object.freeze({ dpInit: 10, dpPerSec: 1, dpMax: 99 });
|
||||
/**
|
||||
* State of a knocked-out operator waiting to redeploy on its own tile (b.snap `down` entries, Battle.snapshot): its
|
||||
* respawn timer runs (COUNTING), then it waits for the player's DP to reach its cost (WAIT_DP) or for its tile to be
|
||||
* free (WAIT_TILE). render/units.js mirrors these codes.
|
||||
*/
|
||||
export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
|
||||
|
||||
/** Safety cap for battles with an infinite time limit (boss rounds are force-ended by the match). */
|
||||
export const MAX_BATTLE_TIME = 3600;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// server/sim/content/bonds/core.js — the 8 core bonds (核心盟约, research 02 §3.1–3.8), battle side + prep side.
|
||||
//
|
||||
// 炎 yanShip members ATK ×(1 + base_atk + atk_per_stack·L); 6: one “炎佑” (30 % of the 炎 ATK / max HP sums
|
||||
// at battle start); 9: two 炎佑, ATK ×atk, damage taken ×(1 − damage_resistance)
|
||||
// 炎 yanShip members ATK ×(1 + base_atk + atk_per_stack·L); 6: one “炎佑” (its template ATK / max HP + 30 %
|
||||
// of the 炎 ATK / max HP sums at battle start — PRTS "增加…(最终加算)"); 9: two 炎佑, ATK ×atk,
|
||||
// damage taken ×(1 − damage_resistance)
|
||||
// 萨尔贡 sargonShip member skill start → every member on the field gets an independent stack (ASPD +base_attack_speed
|
||||
// for base_time + time_per_stack·L s, ≤ max_buff_stack_cnt); 6: each stack also ATK +base_atk
|
||||
// (additive, one atkMul); 6 + band_narant: instead lends the caster's equipment (tier ≤
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
// Attribute bonuses are 直接乘算 (support/index.js convention): ×(1 + x) multipliers; flat values stay flat.
|
||||
|
||||
import { gameData, num, buffsOf, passiveBuff, effectRecord } from './support/index.js';
|
||||
import { isShopItem } from '../simdata.js';
|
||||
|
||||
// =====================================================================================================================
|
||||
// data helpers
|
||||
@@ -166,10 +167,8 @@ export function choiceCardIds(data = gameData()) {
|
||||
for (const c of Array.isArray(cards.bounty) ? cards.bounty : []) if (c && c.effectId && !bounty.includes(c.effectId)) bounty.push(c.effectId);
|
||||
for (const c of Array.isArray(cards.tactic) ? cards.tactic : []) if (c && c.effectId && !tactic.includes(c.effectId)) tactic.push(c.effectId);
|
||||
const items = [];
|
||||
for (const [id, it] of Object.entries((data && data.items) || {})) {
|
||||
if (!it || it.isGolden || it.hideInShop || it.itemType !== 'EQUIP' || !Number.isInteger(it.tier)) continue;
|
||||
items.push(id);
|
||||
}
|
||||
// the 道具补给 / 机密商店 cards draw shop items only (never the effect-only special 维式重锤 / 突变细胞)
|
||||
for (const [id, it] of Object.entries((data && data.items) || {})) if (isShopItem(it)) items.push(id);
|
||||
return { bounty: bounty.sort(), tactic: tactic.sort(), items: items.sort() };
|
||||
}
|
||||
|
||||
|
||||
+101
-26
@@ -69,8 +69,14 @@ const ACBUNN_TARGETS = 3;
|
||||
const ACPUPP_AURA_RADIUS = 2;
|
||||
/** 重弩突袭者 直击 reach along a row/column [ASSUMED]. */
|
||||
const CROSS_REACH = 6;
|
||||
/** 暴鸰 bomb splash radius [ASSUMED]. */
|
||||
const BOMB_RADIUS = 1;
|
||||
/** 暴鸰 投弹: the target's tile and its 8 neighbours (PRTS "对目标及其周围八格的我方单位造成100%物理伤害"). */
|
||||
const BOMB_REACH = 1;
|
||||
/** 帝国炮火先兆者 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 radius of an activated 奇美拉 [ASSUMED]. */
|
||||
const CHIMERA_AURA_RADIUS = 1;
|
||||
/** Bombardments per 自行炮 Cannon cast ("数次") [ASSUMED]. */
|
||||
@@ -616,8 +622,9 @@ const selfFear = (ab) => ({
|
||||
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: 1 + ms }, flags: { unblockable: true } });
|
||||
if (ms > 0 && sd > 0) b.addBuff(e, { key: 'ab:fearRun', duration: sd, mods: { moveMul: ms }, flags: { unblockable: true } });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1226,25 +1233,50 @@ function kitBoneSpike() {
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* 假想敌:黑云 (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, a) => {
|
||||
const prey = b.enemiesInRadius(e2.x, e2.y, k.bb.range_radius ?? 1.5).filter((o) => o !== e2 && o.isFlying && o.def.rank === 'NORMAL' && !o.isBoss);
|
||||
for (const o of prey) {
|
||||
b.addBuff(o, { key: 'ab:devoured', duration: k.bb.duration ?? 0, flags: { bind: true, noMove: true, disarm: true } });
|
||||
b.after(k.bb.duration ?? 0, () => { if (o.alive && e2.alive) { b.kill(o, null); e2.mem.ab.ammo = (e2.mem.ab.ammo ?? 0) + (k.bb.sp ?? 1); } }, { owner: e2 });
|
||||
}
|
||||
if (prey.length) b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: prey[0].id, kind: 'devour' });
|
||||
}, { sil: true }),
|
||||
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 t = byPriority(e2, targetsNear(b, e2, f.bb.range_radius ?? e2.base.rangeRadius))[0];
|
||||
const ammo = e2.mem.ab.ammo ?? 0;
|
||||
if (!t || !(ammo > 0)) return;
|
||||
const hits = Math.floor(e2.mem.ab.ammo ?? 0);
|
||||
if (!(hits > 0)) return;
|
||||
e2.mem.ab.ammo = 0;
|
||||
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'fireWeapon', n: ammo });
|
||||
hurt(b, e2, t, e2.s.atk * (f.bb.atk_scale ?? 1) * ammo, 'phys');
|
||||
}, { sil: true, cond: (b, e2) => (e2.mem.ab.ammo ?? 0) > 0 && targetsNear(b, e2, f.bb.range_radius ?? e2.base.rangeRadius).length > 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 }),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1263,6 +1295,12 @@ function kitRegen(ab) {
|
||||
function kitSteal(ab) {
|
||||
const n = T(ab, 'DamageOrBullet.attack@minus_bullet') ?? 1;
|
||||
return [selfFear(ab), {
|
||||
// PRTS “萨科塔之眼”: "不会攻击飞行单位" (the 炎佑 is one) — its next ground target in range instead
|
||||
before(c, b, e) {
|
||||
if (!c.targets || !c.targets.some((t) => t.isFlying)) return;
|
||||
const l = targetsNear(b, e, e.base.rangeRadius).filter((t) => !t.isFlying);
|
||||
c.targets = byPriority(e, l).slice(0, 1);
|
||||
},
|
||||
hitOut(c, b, e) {
|
||||
if (!c.dmg.isAttack) return;
|
||||
const t = c.target;
|
||||
@@ -1287,13 +1325,48 @@ function kitRoar(ab) {
|
||||
}, { sil: true })];
|
||||
}
|
||||
|
||||
/**
|
||||
* 暴鸰 (PRTS): "不进行普通攻击"; 投弹 (boomb, cooldown / initCooldown 1) "仅攻击范围内存在我方单位时可触发:对目标及其周围八格
|
||||
* 的我方单位造成100%物理伤害(对主目标造成物理普通伤害,对溅射目标造成物理溅射伤害,伤害无视迷彩)技能结束后移速最终提升至200%
|
||||
* ※此技能仅能触发一次,不可沉默" — one bomb on the ranged target (engine priority), then move speed ×boomb.move_speed.
|
||||
*/
|
||||
function kitBombd(ab) {
|
||||
const ms = (ab.sk.boomb && ab.sk.boomb.bb.move_speed) || 0;
|
||||
const s = ab.sk.boomb;
|
||||
const ms = (s && s.bb.move_speed) || 0;
|
||||
const reach = (e) => e.base.rangeRadius || 2;
|
||||
return [
|
||||
{ spawn(b, e) { e.profile.noAttack = true; } },
|
||||
skill(s, (b, e, a) => {
|
||||
const t = byPriority(e, targetsNear(b, e, reach(e)))[0];
|
||||
if (!t) return;
|
||||
a.cd = Infinity; a.left = Infinity; // 仅能触发一次
|
||||
b.fx('explode', { x: t.x, y: t.y, r: BOMB_REACH + 0.5, kind: 'bomb', tiles: 'box', id: e.id });
|
||||
hurt(b, e, t, e.s.atk, 'phys');
|
||||
for (const u of alliesInTiles(b, t.tileR, t.tileC, 'box', BOMB_REACH)) if (u !== t) hurt(b, e, u, e.s.atk, 'phys', { tags: ['splash'] });
|
||||
if (ms > 0) b.addBuff(e, { key: 'ab:bombRun', mods: { moveMul: ms }, persist: true }); // 移速最终提升至200%
|
||||
}, { 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; camouflage does not matter). 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 [{
|
||||
dealt(c, b, e, a) {
|
||||
b.fx('explode', { x: c.target.x, y: c.target.y, r: BOMB_RADIUS, kind: 'bomb' });
|
||||
for (const u of b.alliesInRadius(c.target.x, c.target.y, BOMB_RADIUS)) if (u !== c.target) hurt(b, e, u, e.s.atk, 'phys');
|
||||
if (!a.fast && ms > 0) { a.fast = true; b.addBuff(e, { key: 'ab:bombRun', mods: { moveMul: 1 + ms }, persist: true }); } // 投掷之后移动速度大幅度提升
|
||||
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 b.alliesInRadius(x, y, SHELL_RADIUS)) hurt(b, null, u, atk, 'phys', { isSkill: false, tags: ['shell'] });
|
||||
});
|
||||
}
|
||||
},
|
||||
}];
|
||||
}
|
||||
@@ -2026,12 +2099,14 @@ export const KITS = Object.freeze({
|
||||
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, // 暴鸰 · bomb splash; speeds up after throwing
|
||||
enemy_1040_bombd: kitBombd, // 暴鸰 · no normal attack: ONE bomb (target + 8 tiles), then ×2 speed
|
||||
enemy_10083_hlbird: kitSelfFear, // “萨科塔之翼” · fear + flee below half HP
|
||||
enemy_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 normal flyers for ammo, fires it all at once
|
||||
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; },
|
||||
@@ -2288,8 +2363,8 @@ export const STATS_ONLY = Object.freeze({
|
||||
enemy_1006_shield_2: 'heavy defender', enemy_1006_shield_3: 'heavy defender', enemy_1007_slime: 'plain',
|
||||
enemy_1010_demon: 'plain', enemy_1010_demon_2: 'plain', enemy_1041_lazerd: 'FLY arts (engine)', enemy_1041_lazerd_2: 'FLY arts (engine)',
|
||||
enemy_1043_zomsbr: 'regen is data (hpRecoveryPerSec 80)', enemy_1061_zomshd: 'regen is data (hpRecoveryPerSec 200)',
|
||||
enemy_1046_agent: 'plain', enemy_1071_dftman: 'plain', enemy_1092_mdgint: 'plain (bounty)', enemy_1112_emppnt: 'FLY ranged (engine)',
|
||||
enemy_1112_emppnt_2: 'FLY ranged (engine)', enemy_1251_lysyta: 'R-series armour interaction absent', enemy_1251_lysyta_2: 'same',
|
||||
enemy_1046_agent: 'plain', enemy_1071_dftman: 'plain', enemy_1092_mdgint: 'plain (bounty)',
|
||||
enemy_1251_lysyta: 'R-series armour interaction absent', enemy_1251_lysyta_2: 'same',
|
||||
enemy_1252_lysytb_2: 'same', enemy_1254_lypa_2: 'no listed ability', enemy_1325_cbgpro: 'plain', enemy_1325_cbgpro_2: 'plain',
|
||||
enemy_1367_dseed: 'altar pulse absent (骸骨拷打者 leaves them inert, uncounted, in place)',
|
||||
enemy_1381_winman: 'plain', enemy_1381_winman_2: 'plain', enemy_1387_winshd: '封冻/供暖器 zones absent', enemy_1415_mmkabi_2: 'chains absent',
|
||||
|
||||
@@ -1149,7 +1149,7 @@ const KITS = {
|
||||
},
|
||||
|
||||
// ---- 3_15 巫恋 · 削弱者 (hidden) — S2 诅咒娃娃: places the doll (auto-placed on the tile of the range covering most
|
||||
// enemies; replaces any doll of hers still standing, the prep-phase board piece included) whose 3×3 aura lowers
|
||||
// enemies; replaces any doll of hers still standing — the doll is never a prep hand card) whose 3×3 aura lowers
|
||||
// enemy ATK/DEF, gone after 15 s; 溃败暗示: low-HP enemies fragile
|
||||
chess_char_3_15_a: (bb, chess, def) => {
|
||||
const d = defOf(chess, def);
|
||||
@@ -1171,8 +1171,8 @@ const KITS = {
|
||||
const life = num(tok?.skill?.duration, 0) > 0 ? tok.skill.duration : textNum(d.skill?.description, /(\d+)秒后自动销毁/, 15);
|
||||
const grid = tok?.rangeGrid ?? NINE;
|
||||
const reach = Math.max(1, ...grid.map((p) => Math.max(Math.abs(p[0]), Math.abs(p[1]))));
|
||||
// "最多可库存1个" / deploy limit 1: the new doll replaces any doll of hers still standing (the board piece
|
||||
// placed in the prep phase included) — withdrawn first so its tile is available again
|
||||
// "最多可库存1个" / deploy limit 1: the new doll replaces any doll of hers still standing — withdrawn first
|
||||
// so its tile is available again
|
||||
for (const t of tokensOf(battle, unit, tokenId)) if (t.alive) battle.retreat(t, { reason: 'expired', permanent: true });
|
||||
const foes = battle.aliveEnemies().filter((e) => !e.hidden);
|
||||
let best = null, bs = -1, bd = Infinity;
|
||||
|
||||
+105
-58
@@ -35,7 +35,8 @@
|
||||
// do not stack), refilled by shield_ratio_each_trigger /s when the target was not hit for `interval`
|
||||
// s (always while 凯瑟琳's skill runs, owner bb overwrite_ratio)
|
||||
// 投递坐标 / 风雪之眼 / 保护目标 inert pieces (no attack; 风雪之眼 untargetable) — their effects belong to the owner kit
|
||||
// 炎佑 (enemy_9012_acloon) flying ally AI (see spawnYanyou)
|
||||
// 炎佑 (enemy_9012_acloon) flying ally: flies after the highest-aggro enemy of the field and hovers over it, stays
|
||||
// put when there is none; 3-target arts + burn on every hit, 元素脆弱 aura, 祛恶之焰 channel (yanyouKit)
|
||||
// 预备干员-医疗 / Touch (band map characters) generic kit / 恳切福音 kit
|
||||
// Common rules: deploy limit per owner for tokens running these kits (data `deployLimit`; the oldest is withdrawn),
|
||||
// `summonKill` hook ({ token, owner, victim }) whenever a token kills, lifetimes never outlive a shorter
|
||||
@@ -874,30 +875,64 @@ function yanyouFallbackDef(battle) {
|
||||
return battle.data.rawEnemy?.(TOKEN_IDS.yanyou) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 炎佑 (PRTS “炎佑” 级别0(卫戍协议), enemy_database enemy_9012_acloon; user playtest #4 item 12):
|
||||
* * moves on its own — "持续追踪全场范围内仇恨值最高的敌方单位": it flies after the highest-aggro targetable enemy of
|
||||
* the whole field (the operator order: taunt, least remaining path, earliest spawned; while 祛恶之焰 channels, its
|
||||
* locked target) and hovers over it (YANYOU_STOP short of its position; a 2nd 炎佑 of 9 炎 hovers 0.8 row beside the
|
||||
* first); with no enemy on the field it stays where it is (user playtest #4: "停留在最后在的位置,而不是往一个固定位置
|
||||
* 返回"; PRTS says it then tracks a random tile — the user's first-hand account wins);
|
||||
* * normal attack: rangeRadius 2.0, arts, 3 targets ("可同时攻击3个目标", talent 1.attack@max_target);
|
||||
* * every damage it deals adds burn = ATK × 2.ep_damage_ratio (20 %: "自身造成伤害时,附加攻击力20%的灼燃损伤"; the
|
||||
* official 下半 notice: "祛恶之焰附带的灼燃损伤调整为基于炎佑的攻击力") — normal attacks AND flame ticks;
|
||||
* * 元素脆弱 aura: enemies within YANYOU_FRAGILE_RADIUS (1.5, PRTS "自身<固定半径>半径1.5范围内") take element damage
|
||||
* × 2.damage_scale (1.2);
|
||||
* * immune to element damage ("即将受到元素损伤前取消此元素损伤");
|
||||
* * 祛恶之焰 [模式乙] (the 炎 bond's mode, Skill_2; cooldown / initCooldown 15, atk_scale 0.6, hit_duration 20,
|
||||
* range_radius 1.0): "触发索敌和普通攻击相同:锁定1个目标持续施法,最多持续20秒,每秒对目标周围半径1.0范围内的所有
|
||||
* 敌方单位造成攻击力60%的法术伤害(范围伤害为中点判定…对被锁定的目标也不例外)※施法期间受到沉默影响后,立即结束技能".
|
||||
* It fires when a normal attack would (SP = the 15 s cooldown), locks that attack's first target, channels on it
|
||||
* (no normal attacks meanwhile), deals the 1 s ticks (the first at once) and ends after 20 s, when the locked
|
||||
* target is gone (dead, left, untargetable — "锁定1个目标…最多持续") or when silenced; the cooldown then runs again
|
||||
* [ASSUMED: the cooldown counts from the end of the channel, like the engine's timed skills]. While stunned /
|
||||
* frozen the channel keeps its lock but deals nothing.
|
||||
*/
|
||||
const YANYOU_STOP = 0.25; // [ASSUMED] how close it hovers to the tracked enemy (tiles)
|
||||
const YANYOU_FRAGILE_RADIUS = 1.5; // PRTS "在场期间自身<!--固定半径-->半径1.5范围内的敌方单位受到20%的元素脆弱"
|
||||
const YANYOU_AURA_EVERY = 0.2; // aura refresh period (s); the status lasts two periods
|
||||
const YANYOU_PROFILE = Object.freeze({ canHitFly: true });
|
||||
|
||||
/** Highest-aggro targetable enemy of the whole field for `u` (operator order, targeting.js sortEnemyTargets). */
|
||||
function topAggroEnemy(battle, u) {
|
||||
let best = null, bk = null;
|
||||
for (const e of battle.enemies) {
|
||||
if (!canTargetEnemy(u, e, YANYOU_PROFILE)) continue;
|
||||
const k = [-(e.s.taunt || 0), battle.remainingDistance(e), e.spawnSeq];
|
||||
if (!bk || k[0] < bk[0] || (k[0] === bk[0] && (k[1] < bk[1] - 1e-9 || (Math.abs(k[1] - bk[1]) <= 1e-9 && k[2] < bk[2])))) { best = e; bk = k; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function yanyouKit(bb, raw) {
|
||||
// enemy-shaped record: `talents` is one { bb } object, `skills` the enemy skill list
|
||||
const tal = raw?.talents && !Array.isArray(raw.talents) ? raw.talents.bb ?? {} : raw?.talent ?? {};
|
||||
const skills = Array.isArray(raw?.skills) ? raw.skills : [];
|
||||
const sk1 = skills.find((s) => s.prefabKey === 'Skill') ?? skills[0] ?? {};
|
||||
const sk2 = skills.find((s) => s.prefabKey === 'Skill_2') ?? sk1;
|
||||
// the 炎 bond summons 模式乙 (Skill_2, PRTS: "由核心盟约-炎召唤时为模式乙"); 模式甲 (Skill) hits the target only
|
||||
const sk = skills.find((s) => s.prefabKey === 'Skill_2') ?? skills.find((s) => s.prefabKey === 'Skill') ?? skills[0] ?? {};
|
||||
const maxTargets = Math.max(1, Math.floor(num(tal['1.attack@max_target'], 1)));
|
||||
const epRatio = num(tal['2.ep_damage_ratio'], 0);
|
||||
const fragR = num(tal['2.range_radius'], 0);
|
||||
const fragMul = num(tal['2.damage_scale'], 1);
|
||||
const radius = num(raw?.stats?.rangeRadius, 0) > 0 ? num(raw.stats.rangeRadius, 0) : 2;
|
||||
const moveSpeed = num(raw?.stats?.moveSpeed, 1);
|
||||
const cd = num(sk1.cooldown, 0);
|
||||
const initCd = num(sk1.initCooldown, cd);
|
||||
const flameScale = num(sk2.bb?.atk_scale ?? sk1.bb?.atk_scale, 0);
|
||||
const flameDur = num(sk2.bb?.hit_duration ?? sk1.bb?.hit_duration, 0);
|
||||
const flameR = num(sk2.bb?.range_radius, 0);
|
||||
const FLAME_PROFILE = { canHitFly: true };
|
||||
const inReach = (u, e, pad = 0) => e && e.alive && !e.hidden && Math.hypot(e.x - u.x, e.y - u.y) <= radius + pad + 1e-9;
|
||||
const bestInReach = (battle, u) => {
|
||||
const list = battle.enemies.filter((e) => inReach(u, e) && canTargetEnemy(u, e, FLAME_PROFILE));
|
||||
if (!list.length) return null;
|
||||
sortEnemyTargets(battle, u, list, null);
|
||||
return list[0];
|
||||
const cd = num(sk.cooldown, 0);
|
||||
const initCd = num(sk.initCooldown, cd);
|
||||
const flameScale = num(sk.bb?.atk_scale, 0);
|
||||
const flameDur = num(sk.bb?.hit_duration, 0);
|
||||
const flameR = num(sk.bb?.range_radius, 0);
|
||||
/** The channel's locked target while it is still valid (alive, on the field, targetable), else null. */
|
||||
const lockOf = (u) => {
|
||||
const t = u.mem.flame?.target;
|
||||
return t && t.alive && canTargetEnemy(u, t, YANYOU_PROFILE) ? t : null;
|
||||
};
|
||||
const refreshKeys = (u) => {
|
||||
// cached while it hovers in place — unless the engine rebuilt the range from the (dummy) grid meanwhile
|
||||
@@ -917,63 +952,73 @@ function yanyouKit(bb, raw) {
|
||||
return {
|
||||
skill: cd > 0 && flameDur > 0 ? {
|
||||
kind: 'duration', duration: flameDur, spType: 'time', spCost: cd, initSp: Math.max(0, cd - initCd), trigger: 'DEFAULT',
|
||||
onStart({ unit }) { unit.mem.flame = { target: null, acc: 1 }; },
|
||||
attack: { noAttack: true }, // channelling: no normal attacks
|
||||
onStart({ battle, unit }) {
|
||||
// "触发索敌和普通攻击相同": the normal attack's first target
|
||||
const list = battle.enemiesInKeys(unit.rangeKeys, unit, YANYOU_PROFILE);
|
||||
sortEnemyTargets(battle, unit, list, null);
|
||||
unit.mem.flame = { target: list[0] ?? null, acc: 1 };
|
||||
if (!list[0]) unit.skill.end('noTarget');
|
||||
},
|
||||
onTick({ battle, unit, dt }) {
|
||||
const f = unit.mem.flame || (unit.mem.flame = { target: null, acc: 1 });
|
||||
const f = unit.mem.flame;
|
||||
if (!f) return;
|
||||
if (unit.s.flags.silence) { unit.skill.end('silence'); return; }
|
||||
const t = lockOf(unit);
|
||||
if (!t) { unit.skill.end('targetLost'); return; }
|
||||
if (!unit.canAct) return; // stunned / frozen: the lock holds, no damage
|
||||
f.acc += dt ?? battle.dt;
|
||||
if (f.acc < 1 - 1e-9) return;
|
||||
f.acc -= 1;
|
||||
if (!inReach(unit, f.target, 0.5) || !canTargetEnemy(unit, f.target, FLAME_PROFILE)) f.target = bestInReach(battle, unit);
|
||||
const t = f.target;
|
||||
if (!t) { unit.skill.end('noTarget'); return; }
|
||||
// 祛恶之焰 (Skill_2 range_radius): the channelled target and every enemy within range_radius of it
|
||||
// (research 02 §3.1; the talent's 3-target cap is for normal attacks)
|
||||
const others = flameR > 0 ? battle.enemiesInRadius(t.x, t.y, flameR).filter((e) => e !== t) : [];
|
||||
for (const e of [t, ...others]) battle.dealDamage(unit, e, { amount: unit.s.atk * flameScale, type: 'arts', isSkill: true, tags: ['flame'] });
|
||||
battle.fx('yanyouFlame', { x: t.x, y: t.y, id: unit.id, target: t.id, n: 1 + others.length });
|
||||
// centre-point AoE around the locked target, searched again every second (the target itself included)
|
||||
const hit = flameR > 0 ? battle.enemiesInRadius(t.x, t.y, flameR) : [];
|
||||
if (!hit.includes(t)) hit.unshift(t);
|
||||
for (const e of hit) battle.dealDamage(unit, e, { amount: unit.s.atk * flameScale, type: 'arts', isSkill: true, tags: ['flame'] });
|
||||
battle.fx('yanyouFlame', { x: t.x, y: t.y, id: unit.id, target: t.id, r: flameR, n: hit.length, dur: 1 });
|
||||
},
|
||||
onEnd({ unit }) { unit.mem.flame = null; },
|
||||
} : null,
|
||||
trait: {
|
||||
attack: 'ranged', dmgType: 'arts', projectile: 'bolt', canHitFly: true, maxTargets, heal: null, noAttack: false,
|
||||
afterHit: (b, u, target, info) => {
|
||||
// 元素脆弱 (same-name statuses keep the strongest); it lasts until shortly after its next attack [ASSUMED: no data]
|
||||
if (fragR > 0 && fragMul > 1) {
|
||||
for (const e of b.enemiesInRadius(info.x, info.y, fragR)) b.applyStatus(e, 'elemFragile', { duration: u.s.interval + 0.5, value: fragMul - 1, source: u });
|
||||
}
|
||||
if (epRatio > 0 && target && target.alive) b.dealDamage(u, target, { type: 'element', element: 'burn', amount: u.s.atk * epRatio, tags: ['yanyou'] });
|
||||
},
|
||||
},
|
||||
trait: { attack: 'ranged', dmgType: 'arts', projectile: 'bolt', canHitFly: true, maxTargets, heal: null, noAttack: false },
|
||||
install(battle, unit) {
|
||||
unit.motion = 'FLY';
|
||||
if (!(unit.base.spRecovery > 0)) unit.base.spRecovery = 1; // the cooldown ticks as time SP (data spRecovery 0)
|
||||
unit.base.blockCnt = 0;
|
||||
const speed = moveSpeed * MOVE_SCALE;
|
||||
onDeploy(battle, unit, () => { unit.mem.keysAt = null; refreshKeys(unit); });
|
||||
// every damage it deals adds burn (not the burn itself, not element bursts)
|
||||
if (epRatio > 0) {
|
||||
battle.on('damaged', (ctx) => {
|
||||
if (ctx.source !== unit || !ctx.target || ctx.target.side !== 'enemy' || !ctx.target.alive) return;
|
||||
if (ctx.type === 'element' || ctx.type === 'elemental' || !(ctx.amount > 0)) return;
|
||||
battle.dealDamage(unit, ctx.target, { type: 'element', element: 'burn', amount: unit.s.atk * epRatio, tags: ['yanyou'] });
|
||||
}, { owner: unit });
|
||||
}
|
||||
// "即将受到元素损伤前取消此元素损伤"
|
||||
battle.on('elementHit', (ctx) => { if (ctx.target === unit) ctx.dmg.cancel = true; }, { owner: unit });
|
||||
let auraAcc = YANYOU_AURA_EVERY;
|
||||
battle.on('tick', ({ dt }) => {
|
||||
if (!unit.alive || !unit.deployed) return;
|
||||
if (unit.canAct && !unit.s.flags.noMove && speed > 0) {
|
||||
// chase the leading enemy (or the flame target) and hover over the middle of the enemies around it, so
|
||||
// its multi-target attacks reach as many as possible
|
||||
const tgt = unit.mem.flame?.target && unit.mem.flame.target.alive ? unit.mem.flame.target : mostAdvancedEnemy(battle);
|
||||
let tx = unit.homeC, ty = unit.homeR;
|
||||
const tgt = lockOf(unit) ?? topAggroEnemy(battle, unit);
|
||||
if (tgt) {
|
||||
let sx = 0, sy = 0, n = 0;
|
||||
for (const e of battle.enemiesInRadius(tgt.x, tgt.y, radius)) { sx += e.x; sy += e.y; n++; }
|
||||
tx = n ? sx / n : tgt.x; ty = n ? sy / n : tgt.y;
|
||||
ty += num(unit.mem.hoverDy, 0); // a 2nd 炎佑 hovers beside the first one
|
||||
}
|
||||
const dx = tx - unit.x, dy = ty - unit.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
const stop = tgt ? 0.25 : 0;
|
||||
if (d > stop + 1e-6) {
|
||||
const step = Math.min(d - stop, speed * dt);
|
||||
const R = battle.rect;
|
||||
unit.x = Math.max(R.c0, Math.min(R.c1, unit.x + (dx / d) * step));
|
||||
unit.y = Math.max(R.r0, Math.min(R.r1, unit.y + (dy / d) * step));
|
||||
const dx = tgt.x - unit.x, dy = tgt.y + num(unit.mem.hoverDy, 0) - unit.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > YANYOU_STOP + 1e-6) {
|
||||
const step = Math.min(d - YANYOU_STOP, speed * dt);
|
||||
const R = battle.rect;
|
||||
unit.x = Math.max(R.c0, Math.min(R.c1, unit.x + (dx / d) * step));
|
||||
unit.y = Math.max(R.r0, Math.min(R.r1, unit.y + (dy / d) * step));
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshKeys(unit);
|
||||
auraAcc += dt;
|
||||
if (fragMul > 1 && auraAcc >= YANYOU_AURA_EVERY - 1e-9) {
|
||||
auraAcc = 0;
|
||||
for (const e of battle.enemiesInRadius(unit.x, unit.y, YANYOU_FRAGILE_RADIUS)) {
|
||||
battle.applyStatus(e, 'elemFragile', { duration: 2 * YANYOU_AURA_EVERY, value: fragMul - 1, source: unit });
|
||||
}
|
||||
}
|
||||
}, { owner: unit });
|
||||
},
|
||||
};
|
||||
@@ -1003,8 +1048,9 @@ function airTile(battle, playerId, taken) {
|
||||
|
||||
/**
|
||||
* 炎 bond summon: spawn `count` “炎佑” for `playerId` (flying ally that picks its own targets; bonds.js computes the
|
||||
* numbers). atk / hp replace the template stats (0.3 × 炎 sums at battle start), atkMul (9 炎: ×1.5) and
|
||||
* dmgTakenMul (9 炎: 0.1) are applied as a persistent buff. Returns the spawned units.
|
||||
* numbers). atk / hp (0.3 × the 炎 sums at battle start) are ADDED to the template stats (600 / 12000): PRTS "登场时使
|
||||
* 自身攻击力、生命值增加召唤自身的玩家场地上的所有【炎】盟约干员攻击力、生命值的30%(最终加算)". atkMul (9 炎: ×1.5,
|
||||
* [ASSUMED] on the whole ATK) and dmgTakenMul (9 炎: 0.1) are applied as a persistent buff. Returns the spawned units.
|
||||
*/
|
||||
export function spawnYanyou(battle, playerId, { atk, hp, atkMul = 1, dmgTakenMul = 1, count = 1 } = {}) {
|
||||
const rec = battle.data.rawToken?.(TOKEN_IDS.yanyou) ?? battle.data.rawEnemy?.(TOKEN_IDS.yanyou) ?? null;
|
||||
@@ -1013,9 +1059,10 @@ export function spawnYanyou(battle, playerId, { atk, hp, atkMul = 1, dmgTakenMul
|
||||
const n = Math.max(0, Math.min(Math.floor(num(count, 1)), Math.max(1, Math.floor(num(rec.deployLimit ?? rec.stats?.deployLimit, 2)))));
|
||||
const out = [];
|
||||
const taken = new Set();
|
||||
const base = rec.stats || {};
|
||||
const stats = {};
|
||||
if (num(atk, -1) >= 0) stats.atk = num(atk, 0);
|
||||
if (num(hp, 0) > 0) stats.maxHp = num(hp, 1);
|
||||
if (num(atk, -1) >= 0) stats.atk = num(base.atk, 0) + num(atk, 0);
|
||||
if (num(hp, 0) > 0) stats.maxHp = num(base.maxHp, 0) + num(hp, 1);
|
||||
const ps = battle.getPlayer(playerId);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const tile = airTile(battle, playerId, taken);
|
||||
@@ -1027,7 +1074,7 @@ export function spawnYanyou(battle, playerId, { atk, hp, atkMul = 1, dmgTakenMul
|
||||
if (num(atkMul, 1) !== 1 && num(atkMul, 1) >= 0) mods.atkMul = num(atkMul, 1);
|
||||
if (num(dmgTakenMul, 1) !== 1 && num(dmgTakenMul, 1) >= 0) mods.dmgTakenMul = num(dmgTakenMul, 1);
|
||||
if (Object.keys(mods).length) battle.addBuff(u, { key: 'bond:yanyou', persist: true, allowDead: true, mods });
|
||||
u.mem.hoverDy = out.length === 0 ? 0 : (out.length % 2 ? 0.8 : -0.8);
|
||||
u.mem.hoverDy = out.length === 0 ? 0 : (out.length % 2 ? 0.8 : -0.8); // the 2nd one hovers beside the first
|
||||
u.hp = u.s.maxHp;
|
||||
battle.fx('yanyouSummon', { x: u.x, y: u.y, id: u.id, playerId });
|
||||
out.push(u);
|
||||
|
||||
+74
-27
@@ -7,22 +7,25 @@
|
||||
// → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
|
||||
// Phys: max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %·A); Arts: max(A×(1 − R′/100), 5 %·A) with
|
||||
// R′ = max(0, R×(1−resIgnorePct) − resIgnoreFlat); True: A.
|
||||
// Element damage ('element' type + element) fills a gauge instead of HP (1000; enemy leaders 2000). A full gauge
|
||||
// bursts with the official term-table effects (constants.js ELEMENT), which differ by the side hit:
|
||||
// operators (enemy damage): burn 1200 arts + RES −20 10 s · neural 1000 true + stun 10 s · apoptosis 15 s: no skill
|
||||
// activation, −1 SP/s, 100 arts/s · erosion 800 phys + permanent DEF −100.
|
||||
// enemies (operator damage, "·我方"): burn 7000 元素伤害 + RES −20 (10 s) · neural 6000 元素伤害 + 3 麻痹 (10 s) ·
|
||||
// Element damage ('element' type + element) fills a gauge instead of HP (1000; enemy leaders 2000), reduced by the
|
||||
// target's 损伤抵抗 (data `epResistance`, a percentage: PRTS 元素 "受到的元素损伤 = 损伤值 × (1 − 损伤抵抗 × 0.01)").
|
||||
// A full gauge bursts with the official term-table effects (constants.js ELEMENT), which differ by the side hit:
|
||||
// operators (enemy damage): burn 1200 arts + RES −20 10 s · neural stun 10 s, then 1000 true · apoptosis 15 s: 阻回
|
||||
// (noSp) + 静默 (no skill activation), −1 SP/s, 100 arts/s · erosion permanent DEF −100, then 800 phys (10 s).
|
||||
// enemies (operator damage, "·我方"): burn 7000 元素伤害 + RES −20 (10 s) · neural 3 麻痹, then 6000 元素伤害 (10 s) ·
|
||||
// apoptosis 15 s: 50 % weaken recovering over the burst + 800 元素伤害/s · erosion 5000 元素伤害 + permanent DEF −120 (8 s).
|
||||
// necrosis (legacy spare gauge): 12 s of 100 true dmg/s and ATK −20 %.
|
||||
// The gauge stays locked (full) for the burst duration ("冷却") and resets to 0 when it ends. A burst that is still
|
||||
// resolving (its `elementBurst` hook runs before the `<el>Burst` lock exists; erosion on operators has no lock at all)
|
||||
// already counts as locked (`unit.burstPending[el]`): same-element fills of that unit are refused until it has resolved,
|
||||
// so a hook that spreads the element back (淤困 parasite hosts next to each other) cannot re-burst it recursively.
|
||||
// 爆发冷却 (PRTS 元素, "阻回" after a burst): for the burst's duration (the `<el>Burst` buff) NO element of the unit can
|
||||
// fill or be recovered (reduceElement) — the bursting gauge shows full — and when it ends EVERY gauge of the unit
|
||||
// resets to 0. A burst that is still resolving (its `elementBurst` hook runs before the `<el>Burst` lock exists) already
|
||||
// counts as locked (`unit.burstPending[el]`), so a hook that spreads an element back (淤困 parasite hosts next to each
|
||||
// other) cannot re-burst the unit recursively. The unit's shown gauge (the fullest, "当前损伤元素") and its cooldown
|
||||
// travel in b.snap `elem` (elementView).
|
||||
// 元素伤害 (HP damage of an element) is the DamageInfo type 'elemental' (+ optional `element` for display): no DEF/RES,
|
||||
// no dodge, × source dmgDealtMul × target dmgTakenMul × elemTakenMul. Sleeping units (沉睡: 无敌) take no damage
|
||||
// unless the attacker's profile has `hitSleep` or the damage carries `ignoreSleep`.
|
||||
|
||||
import { MIN_DAMAGE_RATIO, ELEMENT, PALSY_MAX } from './constants.js';
|
||||
import { MIN_DAMAGE_RATIO, ELEMENT, ELEMENT_ORDER, PALSY_MAX } from './constants.js';
|
||||
|
||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
|
||||
@@ -201,9 +204,46 @@ export function applyHpLoss(battle, source, target, amount, dmg) {
|
||||
return dealt;
|
||||
}
|
||||
|
||||
/** Is `target`'s `el` gauge locked: its `<el>Burst` buff is up, or its burst is resolving right now (see header). */
|
||||
export function burstLocked(target, el) {
|
||||
return !!(target.burstPending && target.burstPending[el]) || !!target.findBuff(el + 'Burst');
|
||||
/**
|
||||
* Are `target`'s element gauges locked (爆发冷却, see header): a burst's `<el>Burst` lock is up (any element — the lock
|
||||
* holds every gauge of the unit), or a burst of the unit is resolving right now. The element argument of older callers
|
||||
* is ignored.
|
||||
*/
|
||||
export function burstLocked(target) {
|
||||
const p = target.burstPending;
|
||||
if (p) for (const k in p) if (p[k]) return true;
|
||||
return !!target.s.flags.burstLock;
|
||||
}
|
||||
|
||||
/** The burst lock buff (爆发冷却) running on `u`, or null. */
|
||||
function burstLockOf(u) {
|
||||
let best = null;
|
||||
for (const b of u.buffs) if (b.flags && b.flags.burstLock && (!best || b.timeLeft > best.timeLeft)) best = b;
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The element gauge a unit shows (b.snap `elem`, render/units.js): null when every gauge is empty and no burst
|
||||
* cooldown runs. Official display (PRTS 元素): one icon, the "当前损伤元素" — the fullest gauge (ties: the lower
|
||||
* official element id, constants.js ELEMENT_ORDER) — with its remaining EP; during a 爆发冷却 the bursting element,
|
||||
* refilling over the cooldown. Returns `[el, fill 0..1 (2 decimals), cooldownEnd (game s), cooldown (s)]`; the two
|
||||
* cooldown numbers are 0 outside a cooldown. `now` = the battle time.
|
||||
*/
|
||||
export function elementView(u, now) {
|
||||
const e = u.elem;
|
||||
if (!e) return null;
|
||||
const lock = u.s.flags.burstLock ? burstLockOf(u) : null;
|
||||
if (lock) {
|
||||
const el = lock.key.endsWith('Burst') ? lock.key.slice(0, -5) : 'burn';
|
||||
const dur = Number.isFinite(lock.duration) && lock.duration > 0 ? lock.duration : 0;
|
||||
const left = Number.isFinite(lock.timeLeft) ? Math.max(0, lock.timeLeft) : 0;
|
||||
return [el in e ? el : 'burn', 1, Math.round((now + left) * 100) / 100, Math.round(dur * 100) / 100];
|
||||
}
|
||||
let best = null, bv = 0;
|
||||
for (const k of ELEMENT_ORDER) { const v = e[k]; if (v > bv) { bv = v; best = k; } }
|
||||
if (!best) return null;
|
||||
const fill = Math.min(1, Math.max(0.01, Math.round((bv / u.gaugeMax) * 100) / 100));
|
||||
return [best, fill, 0, 0];
|
||||
}
|
||||
|
||||
/** Element gauge accumulation + burst. Fires `elementHit` { source, target, dmg } first (mutable amount/mul, cancel). */
|
||||
@@ -218,7 +258,8 @@ export function applyElement(battle, source, target, dmg) {
|
||||
if (dmg.type !== 'element') return dealDamage(battle, source, target, dmg); // a handler converted it
|
||||
}
|
||||
const max = target.gaugeMax;
|
||||
let amt = dmg.amount * dmg.mul * target.s.elemTakenMul * (1 - clamp01(target.def?.epDamageResistance ?? 0));
|
||||
// 损伤抵抗 (official EP_RESISTANCE = data epResistance, a percentage)
|
||||
let amt = dmg.amount * dmg.mul * target.s.elemTakenMul * (1 - clamp01((target.def?.epResistance ?? 0) / 100));
|
||||
if (!(amt > 0) || !Number.isFinite(amt)) return 0;
|
||||
target.elem[el] = Math.min(max, target.elem[el] + amt);
|
||||
battle._ev(['dmg', target.id, Math.round(amt), el]);
|
||||
@@ -229,11 +270,12 @@ export function applyElement(battle, source, target, dmg) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A burst (see header): side-aware official effects; the `<el>Burst` buff locks the gauge for its duration. The unit
|
||||
* is marked `burstPending[el]` while the burst resolves (hook, lock, hits), so nothing re-bursts it in the meantime.
|
||||
* A burst (see header): side-aware official effects; the `<el>Burst` buff is the 爆发冷却 that locks every gauge of the
|
||||
* unit for its duration. The unit is marked `burstPending[el]` while the burst resolves (hook, lock, hits), so nothing
|
||||
* re-bursts it in the meantime.
|
||||
*/
|
||||
export function elementBurst(battle, source, target, el) {
|
||||
if (!ELEMENT[el] || !target.alive || burstLocked(target, el)) return;
|
||||
if (!ELEMENT[el] || !target.alive || burstLocked(target)) return;
|
||||
const pending = target.burstPending || (target.burstPending = {});
|
||||
pending[el] = true;
|
||||
try { resolveBurst(battle, source, target, el); } finally { pending[el] = false; }
|
||||
@@ -242,7 +284,8 @@ export function elementBurst(battle, source, target, el) {
|
||||
function resolveBurst(battle, source, target, el) {
|
||||
const cfg = ELEMENT[el];
|
||||
target.elem[el] = target.gaugeMax;
|
||||
const reset = () => { target.elem[el] = 0; };
|
||||
// the cooldown's end restores every element of the unit ("随后单位所有种类的元素值恢复至最大值")
|
||||
const reset = () => { for (const k of ELEMENT_ORDER) if (k in target.elem) target.elem[k] = 0; };
|
||||
battle.fx('burst', { x: target.x, y: target.y, id: target.id, element: el });
|
||||
if (battle._hooks.elementBurst) battle.emit('elementBurst', { source, target, element: el });
|
||||
if (!target.alive) { reset(); return; }
|
||||
@@ -265,13 +308,14 @@ function resolveBurst(battle, source, target, el) {
|
||||
lock(c.duration, { mods: { resFlat: -c.resDown } });
|
||||
if (enemy) hit(c.elemDamage, 'elemental'); else hit(c.damage, c.type);
|
||||
} else if (el === 'neural') {
|
||||
// PRTS 元素: the status first ("立刻获得等时长的眩晕" / "获得3层麻痹"), then the hit
|
||||
lock(c.duration);
|
||||
if (enemy) {
|
||||
hit(c.elemDamage, 'elemental');
|
||||
if (target.alive) battle.applyStatus(target, 'palsy', { value: c.palsy, source });
|
||||
battle.applyStatus(target, 'palsy', { value: c.palsy, source });
|
||||
if (target.alive) hit(c.elemDamage, 'elemental');
|
||||
} else {
|
||||
hit(c.damage, c.type);
|
||||
if (target.alive) battle.applyStatus(target, 'stun', { duration: c.stun, source, force: true });
|
||||
battle.applyStatus(target, 'stun', { duration: c.stun, source, force: true });
|
||||
if (target.alive) hit(c.damage, c.type);
|
||||
}
|
||||
} else if (el === 'apoptosis') {
|
||||
if (enemy) {
|
||||
@@ -285,9 +329,9 @@ function resolveBurst(battle, source, target, el) {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 15 s: no skill activation, −1 SP per second, 100 arts damage per second
|
||||
// 15 s of 阻回 ("停止并阻止任意形式的技力回复": noSp) + 静默 (no skill activation), −1 SP and 100 arts damage per second
|
||||
lock(c.duration, {
|
||||
flags: { silence: true }, interval: 1,
|
||||
flags: { silence: true, noSp: true }, interval: 1,
|
||||
onTick: () => {
|
||||
const sk = target.skill;
|
||||
if (sk && !sk.noSkill && sk.kind !== 'passive' && !(sk.active && sk.isTimed) && sk.sp > 0) sk.sp = Math.max(0, sk.sp - c.spLossPerSec);
|
||||
@@ -296,7 +340,7 @@ function resolveBurst(battle, source, target, el) {
|
||||
});
|
||||
}
|
||||
} else if (el === 'erosion') {
|
||||
// the permanent DEF cut lands first, then the hit
|
||||
// the permanent DEF cut lands first, then the hit; both sides then have their cooldown (operators 10 s, enemies 8 s)
|
||||
lock(c.duration);
|
||||
battle.addBuff(target, { key: 'erosionDown', refresh: 'stack', stacks: 1, maxStacks: 1e6, mods: { defFlat: -c.defDown }, visible: true });
|
||||
if (enemy) hit(c.elemDamage, 'elemental'); else hit(c.damage, c.type);
|
||||
@@ -309,12 +353,15 @@ export function palsyBuff(n) {
|
||||
return { key: 'palsy', refresh: 'stack', stacks, maxStacks: PALSY_MAX, visible: true, status: 'palsy' };
|
||||
}
|
||||
|
||||
/** Reduce an element gauge (e.g. wandermedic "回复元素损伤"). Returns the amount removed. */
|
||||
/**
|
||||
* Reduce an element gauge (e.g. wandermedic "回复元素损伤"). Returns the amount removed. Nothing recovers during a
|
||||
* burst cooldown ("爆发冷却状态下,单位所有类型的元素值均无法损失、无法被其他手段回复").
|
||||
*/
|
||||
export function reduceElement(target, amount, el = null) {
|
||||
let removed = 0;
|
||||
if (!target || !target.elem || burstLocked(target)) return 0;
|
||||
const els = el ? [el] : Object.keys(target.elem);
|
||||
for (const k of els) {
|
||||
if (burstLocked(target, k)) continue;
|
||||
const take = Math.min(target.elem[k], amount - removed);
|
||||
if (take > 0) { target.elem[k] -= take; removed += take; }
|
||||
if (removed >= amount) break;
|
||||
|
||||
@@ -44,6 +44,16 @@ export function flattenBlackboard(bb) {
|
||||
|
||||
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
|
||||
|
||||
/**
|
||||
* Is an items.json record a shop item — what the 调度中心 item slot, the 道具补给 / 机密商店 cards and the "random
|
||||
* item" pools (pool_equip_normal …) draw from: a normal EQUIP item with a tier, neither hidden by the official shop
|
||||
* table (`hideInShop`: the Arts) nor one of the effect-only items build-data marks `shopExcluded` (the special
|
||||
* 维式重锤 of 维多利亚 / 洛洛, 突变细胞 — tools/build-data.mjs SHOP_EXCLUDED_ITEMS).
|
||||
*/
|
||||
export function isShopItem(it) {
|
||||
return !!it && typeof it === 'object' && !it.isGolden && !it.hideInShop && !it.shopExcluded && it.itemType === 'EQUIP' && Number.isInteger(it.tier);
|
||||
}
|
||||
|
||||
function toArrayOfPairs(grid) {
|
||||
if (!Array.isArray(grid)) return null;
|
||||
const out = [];
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// server/sim/skills.js — skill runtime: SP, charges, trigger rules, kinds, SkillSpec interpretation (DESIGN §5.6).
|
||||
//
|
||||
// SP types: 'time' (+spRecovery/s), 'attack' (+1 per attack), 'hurt' (+1 per hit taken), 'none'.
|
||||
// No SP gain while a duration/ammo/toggle skill is active, while stunned, or while the unit has the noSp flag.
|
||||
// No SP gain while a duration/ammo/toggle skill is active, while stunned, or while the unit has the noSp flag (阻回: no SP
|
||||
// gain of any kind — time, attack, hurt or granted).
|
||||
// Charges (maxCharges > 1): SP fills to spCost → +1 charge (SP restarts) until charges == max (SP stays full).
|
||||
// Trigger rules: DEFAULT (ready + about to attack/heal + enemy / injured ally in the INITIAL range — or, checked every
|
||||
// tick, an enemy inside one of the content trigger ranges added with addTriggerRange: 海嗣, 流形),
|
||||
@@ -176,6 +177,8 @@ export class SkillRuntime {
|
||||
gainSp(amount, reason = 'time', silent = false) {
|
||||
if (this.noSkill || this.kind === 'passive' || !(amount > 0)) return 0;
|
||||
if (this.active && this.isTimed && reason !== 'init') return 0;
|
||||
// 阻回 (the operators' 凋亡 burst, damage.js): "停止并阻止任意形式的技力回复" — no SP of any kind (time, attack, hurt, gifts)
|
||||
if (reason !== 'init' && this.unit.s.flags.noSp) return 0;
|
||||
let cost = this.spCost;
|
||||
if (this.charges >= this.maxCharges && this.sp >= cost) return 0;
|
||||
let amt = amount;
|
||||
|
||||
Reference in new issue
Block a user