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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user