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
+154
-27
@@ -75,6 +75,10 @@
|
||||
// Live LP (user playtest #3 item 2): during COMBAT / 联防 m.public players[].pendingLp = min(lpCapPerRound, the counted
|
||||
// leaks of the player's own battle so far) (_pendingLpView; omitted when 0) — the teammates' rows of the team panel
|
||||
// show lp − pendingLp; the settlement lands with the SETTLE view, where it is gone.
|
||||
// User playtest #4: a match with a single human (独立模拟, or a 同盟 room started alone / with AI teammates) times no
|
||||
// phase outside its battles (soloUntimed); the co-op strategy draft has ONE countdown — BAND_TURN_SECONDS per turn,
|
||||
// published as m.public.deadline — AI seats pick at once and a turn that runs out takes the highlighted strategy
|
||||
// (g.bandFocus → timeoutBand); g.unitStats answers m.unitStats: the stats the board's units start their next battle with.
|
||||
// opts.clientCombat default true (env SP_COMBAT=server → false: the legacy server-run + snapshot streaming mode)
|
||||
// opts.verify 'off' | 'sample' | 'all' (env SP_VERIFY, default 'off'): re-simulate accepted client results
|
||||
// ('sample': ~1 in 8, in a later callback, mismatches logged; 'all': before accepting — the
|
||||
@@ -108,7 +112,7 @@
|
||||
// pairing and the boss pool; its own running normal battle is force-ended. The seat shows status 'left'. When no
|
||||
// human is left at all the match ends ('abandoned'); when nobody alive is left it ends as 'eliminated'.
|
||||
|
||||
import { C2S } from '../../shared/protocol.js';
|
||||
import { C2S, unitStatsEntry } from '../../shared/protocol.js';
|
||||
import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO, modeIdFor } from '../../shared/constants.js';
|
||||
import { Battle } from '../sim/Battle.js';
|
||||
import { DataSource } from '../sim/simdata.js';
|
||||
@@ -182,6 +186,14 @@ export const DELAYS = Object.freeze({
|
||||
PUBLIC_THROTTLE: 100,
|
||||
});
|
||||
|
||||
/**
|
||||
* Seconds of one turn of the co-op strategy draft (user playtest #4 item 4: the old 12 s per turn — research 06 §724,
|
||||
* itself [ASSUMED] — inside the 50 s step was far too little and counted apart from the header's 50 s). [ASSUMED]: the
|
||||
* official data only gives the whole BAND_CHECK step (autoChessData.enterStepList: 50 s, hint 15 s); the turn clock is
|
||||
* the remake's. It is also the step's only countdown (m.public.deadline = draft.turnDeadline). × timerScale.
|
||||
*/
|
||||
export const BAND_TURN_SECONDS = 30;
|
||||
|
||||
const dsCache = new WeakMap();
|
||||
function dataSourceFor(data) {
|
||||
if (!data || typeof data !== 'object' || !data.chess) return undefined;
|
||||
@@ -270,6 +282,12 @@ export class Match {
|
||||
}
|
||||
if (!this.players.size) throw new TypeError('Match: seats required');
|
||||
this.order = [...this.players.values()].sort((a, b) => a.seat - b.seat);
|
||||
/**
|
||||
* Exactly one human seat at the start (独立模拟, or a 同盟 room started alone / with AI teammates only): nobody waits
|
||||
* on anybody, so no phase outside a battle is timed — soloUntimed (user playtest #4 item 3). The mode's own rules
|
||||
* (draft order and skip, 6 机变 cards, 联防 …) stay.
|
||||
*/
|
||||
this.loneHuman = this.order.filter((p) => !p.isBot).length === 1;
|
||||
|
||||
// per-match setup (DESIGN §6.5)
|
||||
const setup = setupMatchWaves(this.gd, this.rngSetup);
|
||||
@@ -559,9 +577,11 @@ export class Match {
|
||||
* Solo timers (research 01 §843 / 06 §3: 下半 独立模拟 has no time limit on 休整期 / 机变 — "休整期及机变阶段没有时间
|
||||
* 限制"; the strategy draft is free too): a solo match publishes a deadline ONLY for its battles. INFO_CHECK waits
|
||||
* for 准备就绪 (co-op keeps the official 25 s guard), BAND_DRAFT / SP_DRAFT / PREP are untimed, and the fixed
|
||||
* presentation steps (BATTLE_CHECK, ROUND_START, SETTLE) run silently (no countdown).
|
||||
* presentation steps (BATTLE_CHECK, ROUND_START, SETTLE) run silently (no countdown). The same holds for any match
|
||||
* with a single human (loneHuman: a 同盟 room started alone or with AI teammates only — user playtest #4 item 3):
|
||||
* the timers only ever made humans wait on each other; AI seats act at once.
|
||||
*/
|
||||
get soloUntimed() { return this.isSolo; }
|
||||
get soloUntimed() { return this.isSolo || this.loneHuman; }
|
||||
|
||||
nextUid() { return ++this.uidSeq; }
|
||||
|
||||
@@ -772,7 +792,11 @@ export class Match {
|
||||
if (this.bossPool) v.bossHp = { hp: Math.max(0, Math.round(this.bossPool.hp)), max: Math.round(this.bossPool.maxHp) };
|
||||
if (this.phase === PHASE.BAND_DRAFT && this.draft) {
|
||||
const d = this.draft;
|
||||
v.draft = { order: d.order.slice(), turn: this.draftTurn(), picks: { ...d.picks }, skipsLeft: { ...d.skipsLeft }, turnDeadline: d.turnDeadline || 0, untimed: !!d.untimed };
|
||||
// turnSeconds: the length of a turn (the countdown gauge's total; 0 when untimed) — deadline = turnDeadline
|
||||
v.draft = {
|
||||
order: d.order.slice(), turn: this.draftTurn(), picks: { ...d.picks }, skipsLeft: { ...d.skipsLeft }, turnDeadline: d.turnDeadline || 0,
|
||||
turnSeconds: d.untimed ? 0 : this.bandTurnMs() / 1000, untimed: !!d.untimed,
|
||||
};
|
||||
}
|
||||
if (this.phase === PHASE.SP_DRAFT && this.sp) {
|
||||
const s = this.sp;
|
||||
@@ -856,6 +880,8 @@ export class Match {
|
||||
return OK;
|
||||
case 'g.band': return this.pickBand(ps, msg.bandId);
|
||||
case 'g.bandSkip': return this.skipBand(ps);
|
||||
// the strategy highlighted in the draft screen (what a timed-out turn takes, timeoutBand)
|
||||
case 'g.bandFocus': return this.bandFocus(ps, msg.bandId ?? null);
|
||||
case 'g.buy': return ps.buy(msg.slot);
|
||||
case 'g.refresh': return ps.refresh();
|
||||
case 'g.freeze': return ps.freeze();
|
||||
@@ -873,6 +899,8 @@ export class Match {
|
||||
case 'g.watch': return this.watch(ps, msg.fieldId);
|
||||
case 'g.autoplay': return this.setAutoplay(ps, !!msg.on);
|
||||
case 'g.pause': return this.setPause(ps, !!msg.on);
|
||||
// the stats the board's units start their next battle with (the detail card in prep, user playtest #4 item 7)
|
||||
case 'g.unitStats': return this.unitStats(ps, msg.seq ?? null);
|
||||
case 'g.leave': this.onLeave(ps.playerId); return OK;
|
||||
case 'b.progress': return this._onProgress(ps, msg);
|
||||
case 'b.result': return this._onResult(ps, msg);
|
||||
@@ -924,6 +952,55 @@ export class Match {
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* g.unitStats { seq? } (user playtest #4 item 7: the detail card showed fixed record stats): the stats every unit of
|
||||
* the player's board will fight with at the start of its next battle — equipment, bonds and their layers, 特质, the
|
||||
* band and 机变 effects — computed exactly by the shared sim. The player's battle input after the onBattleStart meta
|
||||
* handlers (`ev.preview: true`, no enemies — those handlers must not change the match for a preview) builds a Battle
|
||||
* of the battle's options that is started (initial deployment + battleStart hooks), read and dropped: it is never
|
||||
* stepped, so skills and timed effects do not show. Pushed to the player as `m.unitStats { seq, round, units }`
|
||||
* (units: shared/protocol.js unitStatsEntry, board operators and summons by uid); cached per input (a build costs
|
||||
* ≈ 0.3–0.7 ms). Prep phases only (ROUND_START, 机变, PREP); a battle's live stats come from the browser's own sim.
|
||||
* @param {PlayerState} ps @param {number|null} seq echoed (the client keeps the newest answer)
|
||||
*/
|
||||
unitStats(ps, seq = null) {
|
||||
if (!ps.alive) return fail(ERR.ELIMINATED);
|
||||
if (this.phase !== PHASE.ROUND_START && this.phase !== PHASE.SP_DRAFT && this.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
|
||||
const units = this._unitStatsOf(ps);
|
||||
this.sendTo(ps.playerId, { t: 'm.unitStats', seq: Number.isInteger(seq) ? seq : null, round: this.round, units });
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** The start-of-battle stats of a player's board units (see unitStats); [] when the preview battle cannot be built. */
|
||||
_unitStatsOf(ps) {
|
||||
const input = ps.battleInput({ side: 'L', colOffset: 0 });
|
||||
const ev = { input, kind: 'normal', round: this.round, preview: true };
|
||||
this.dispatch(ps, 'onBattleStart', ev);
|
||||
const players = [ev.input && typeof ev.input === 'object' ? ev.input : input];
|
||||
let key = null;
|
||||
try { key = JSON.stringify([this.round, this.stageId, this.battleContent, players]); } catch { key = null; }
|
||||
if (!this._unitStatsCache) this._unitStatsCache = new WeakMap(); // PlayerState → { key, units } (the last preview)
|
||||
const cached = this._unitStatsCache.get(ps);
|
||||
if (key && cached && cached.key === key) return cached.units;
|
||||
const units = [];
|
||||
// the flags of the battle it previews: a normal round gains IN_BATTLE layers from its start (a <战斗开始时> layer gain
|
||||
// raises bond stats at t = 0 there too); the Final Assault / Hidden Core fight without (previewed as a normal field)
|
||||
const bossRound = this.round === this.gd.bossRound || this.round === this.gd.hiddenRound;
|
||||
const b = this.newBattle({
|
||||
seed: deriveSeed(this.seed, `preview:${this.round}:${ps.seat}`), kind: 'normal', modeId: this.modeId, round: this.round,
|
||||
stageId: this.stageId, rect: { ...GEO.NORMAL_RECT }, timeLimit: 60, players, spawns: [], routes: this.wave ? this.wave.routes : [],
|
||||
sharedBoss: null, flags: { layerGainsEnabled: !bossRound, ...this.gd.dp }, fieldId: `n:${ps.playerId}`, recordEvents: false,
|
||||
});
|
||||
try {
|
||||
if (typeof b.start === 'function') b.start();
|
||||
for (const u of Array.isArray(b.allyUnits) ? b.allyUnits : []) {
|
||||
if (u && Number.isInteger(u.uid) && (u.kind === 'op' || u.kind === 'token')) units.push(unitStatsEntry(u, u.s));
|
||||
}
|
||||
} catch (e) { this.reportError('unitStats', e); }
|
||||
this._unitStatsCache.set(ps, { key, units });
|
||||
return units;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solo pause (official PauseUp / ResumeUp; DESIGN §14 "Solo pause"): g.pause { on } freezes the running battle — the
|
||||
* field clock (b.start `elapsed`, the boss budgets and overtime), the result deadline / server release timers, the
|
||||
@@ -1022,20 +1099,26 @@ export class Match {
|
||||
// ===================================================================================================
|
||||
// BAND_DRAFT
|
||||
|
||||
/**
|
||||
* The strategy draft (user playtest #4 item 4): ONE countdown — every turn has the same clock, BAND_TURN_SECONDS, and
|
||||
* m.public.deadline is the current turn's end (= draft.turnDeadline; the step header and the turn indicator show the
|
||||
* same number). No separate step cap: the turns bound the step (≤ (seats + skips) × turn). AI seats pick at once. A
|
||||
* turn that runs out takes the strategy the player has highlighted (g.bandFocus) while it is free, else the default
|
||||
* (timeoutBand). Solo, and any single-human match (soloUntimed): untimed. Solo also keeps seat order and has no skip.
|
||||
*/
|
||||
enterBandDraft() {
|
||||
if (this.phase !== PHASE.INFO_CHECK) return;
|
||||
this.phase = PHASE.BAND_DRAFT;
|
||||
const order = this.order.map((p) => p.playerId);
|
||||
const untimed = this.isSolo;
|
||||
if (!untimed) this.rngDraft.shuffle(order);
|
||||
const skips = this.gd.bandDraft.skipsPerPlayer;
|
||||
this.draft = { order, idx: 0, picks: {}, skipsLeft: Object.fromEntries(order.map((pid) => [pid, untimed ? 0 : skips])), untimed, turnDeadline: 0 };
|
||||
if (!untimed) {
|
||||
// overall step cap (50 s): everyone still unpicked gets the default
|
||||
this.setDeadline(this.gd.timer('bandDraft'), () => this.finishBandDraft(true));
|
||||
} else {
|
||||
this.setDeadline(0);
|
||||
}
|
||||
if (!this.isSolo) this.rngDraft.shuffle(order);
|
||||
const skips = this.isSolo ? 0 : this.gd.bandDraft.skipsPerPlayer;
|
||||
const untimed = this.soloUntimed;
|
||||
this.draft = {
|
||||
order, idx: 0, picks: {}, skipsLeft: Object.fromEntries(order.map((pid) => [pid, skips])), untimed, turnDeadline: 0,
|
||||
/** playerId → the strategy highlighted in the draft screen (g.bandFocus) */
|
||||
focus: new Map(),
|
||||
};
|
||||
this.setDeadline(0);
|
||||
this.startDraftTurn();
|
||||
this.markPublic();
|
||||
}
|
||||
@@ -1046,30 +1129,44 @@ export class Match {
|
||||
return d.order[d.idx] ?? null;
|
||||
}
|
||||
|
||||
/** Real ms of one strategy-draft turn (BAND_TURN_SECONDS × timerScale). */
|
||||
bandTurnMs() { return this.scaled(BAND_TURN_SECONDS * 1000); }
|
||||
|
||||
startDraftTurn() {
|
||||
const d = this.draft;
|
||||
this.cancel(this._turnTimer);
|
||||
this._turnTimer = null;
|
||||
while (d.idx < d.order.length && d.picks[d.order[d.idx]]) d.idx++;
|
||||
if (d.idx >= d.order.length) { this.later(0, () => this.finishBandDraft(false)); return; }
|
||||
if (d.idx >= d.order.length) {
|
||||
d.turnDeadline = 0;
|
||||
this.deadline = 0;
|
||||
this.later(0, () => this.finishBandDraft(false));
|
||||
return;
|
||||
}
|
||||
const token = ++this._turnToken;
|
||||
if (!d.untimed) {
|
||||
const ms = this.scaled(this.gd.timer('bandTurn') * 1000);
|
||||
const ms = this.bandTurnMs();
|
||||
d.turnDeadline = this.sched.now() + ms;
|
||||
// the step's countdown IS the turn's (one number everywhere)
|
||||
this.deadline = d.turnDeadline;
|
||||
this._turnTimer = this.later(ms, () => {
|
||||
if (this.phase !== PHASE.BAND_DRAFT || token !== this._turnToken) return;
|
||||
const pid = this.draftTurn();
|
||||
if (pid) this._applyBand(this.players.get(pid), this.defaultBand(pid));
|
||||
if (pid) this._applyBand(this.players.get(pid), this.timeoutBand(pid));
|
||||
});
|
||||
} else {
|
||||
d.turnDeadline = 0;
|
||||
this.deadline = 0;
|
||||
}
|
||||
const cur = this.players.get(this.draftTurn());
|
||||
if (cur && cur.botControlled) this.scheduleBandBot();
|
||||
this.markPublic();
|
||||
}
|
||||
|
||||
/** An AI seat's (or an AI 托管 seat's) turn: it picks at once (user playtest #4 item 4 — nobody waits on the AI). */
|
||||
scheduleBandBot() {
|
||||
const token = this._turnToken;
|
||||
this.later(this.scaled(DELAYS.BOT_ACTION), () => {
|
||||
this.later(0, () => {
|
||||
if (this.phase !== PHASE.BAND_DRAFT || token !== this._turnToken) return;
|
||||
const ps = this.players.get(this.draftTurn());
|
||||
if (!ps || !ps.botControlled) return;
|
||||
@@ -1084,7 +1181,7 @@ export class Match {
|
||||
/**
|
||||
* Whether `bandId` was already picked by another player of this draft. Research 09 §5 / DESIGN §14 corrections:
|
||||
* the strategy draft marks a teammate's pick as 队友已选 and it cannot be chosen again (co-op). The automatic
|
||||
* assignments — the 12 s turn timeout, the 50 s step cap and a departing seat — obey the same rule: see defaultBand.
|
||||
* assignments — a turn that runs out and a departing seat — obey the same rule: see timeoutBand / defaultBand.
|
||||
*/
|
||||
bandTaken(bandId, playerId) {
|
||||
const picks = this.draft?.picks || {};
|
||||
@@ -1093,10 +1190,11 @@ export class Match {
|
||||
}
|
||||
|
||||
/**
|
||||
* The strategy an automatic assignment gives `playerId` (12 s turn timeout, 50 s step cap, departing seat): the official
|
||||
* default 「华法琳」 (bandDraft.timeoutBandId) while no teammate holds it, else the first strategy of the mode (sortId
|
||||
* order, gd.bandIds) that nobody else picked — never a duplicate (队友已选; the client shows the same choice:
|
||||
* public/js/screens/bandDraft.js timeoutBand). Solo drafts have no teammates, so it is always the default.
|
||||
* The strategy an automatic assignment gives `playerId` (a departing seat; a timed-out turn without a usable
|
||||
* highlight, timeoutBand): the official default 「华法琳」 (bandDraft.timeoutBandId) while no teammate holds it, else the
|
||||
* first strategy of the mode (sortId order, gd.bandIds) that nobody else picked — never a duplicate (队友已选; the
|
||||
* client shows the same choice: public/js/screens/bandDraft.js timeoutBand). Solo drafts have no teammates, so it is
|
||||
* always the default.
|
||||
* @param {string} playerId
|
||||
*/
|
||||
defaultBand(playerId) {
|
||||
@@ -1105,6 +1203,33 @@ export class Match {
|
||||
return this.gd.bandIds().find((b) => !this.bandTaken(b, playerId)) || def;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a turn that runs out assigns (user playtest #4 item 4): the strategy the player has highlighted in the draft
|
||||
* screen (g.bandFocus — the detail pane's band, the one 确认选择 would take) while it is allowed and no teammate holds
|
||||
* it, else defaultBand.
|
||||
* @param {string} playerId
|
||||
*/
|
||||
timeoutBand(playerId) {
|
||||
const f = this.draft && this.draft.focus instanceof Map ? this.draft.focus.get(playerId) : null;
|
||||
if (typeof f === 'string' && this.gd.bandAllowed(f) && !this.bandTaken(f, playerId)) return f;
|
||||
return this.defaultBand(playerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* g.bandFocus { bandId? }: the strategy the player highlights in the draft screen (any time before its pick; also
|
||||
* while waiting for its turn). A missing / null bandId clears it. Only a timed-out turn reads it (timeoutBand).
|
||||
*/
|
||||
bandFocus(ps, bandId) {
|
||||
if (this.phase !== PHASE.BAND_DRAFT || !this.draft) return fail(ERR.WRONG_PHASE);
|
||||
const d = this.draft;
|
||||
if (d.picks[ps.playerId]) return fail(ERR.ALREADY);
|
||||
if (!(d.focus instanceof Map)) d.focus = new Map();
|
||||
if (bandId == null) { d.focus.delete(ps.playerId); return OK; }
|
||||
if (typeof bandId !== 'string' || !this.gd.bandAllowed(bandId)) return fail(ERR.BAD_TARGET);
|
||||
d.focus.set(ps.playerId, bandId);
|
||||
return OK;
|
||||
}
|
||||
|
||||
pickBand(ps, bandId) {
|
||||
if (this.phase !== PHASE.BAND_DRAFT || !this.draft) return fail(ERR.WRONG_PHASE);
|
||||
if (this.draft.picks[ps.playerId]) return fail(ERR.ALREADY);
|
||||
@@ -1131,7 +1256,7 @@ export class Match {
|
||||
skipBand(ps) {
|
||||
if (this.phase !== PHASE.BAND_DRAFT || !this.draft) return fail(ERR.WRONG_PHASE);
|
||||
const d = this.draft;
|
||||
if (d.untimed) return fail(ERR.WRONG_PHASE, 'no skip in solo');
|
||||
if (this.isSolo) return fail(ERR.WRONG_PHASE, 'no skip in solo');
|
||||
if (d.picks[ps.playerId]) return fail(ERR.ALREADY);
|
||||
if (this.draftTurn() !== ps.playerId) return fail(ERR.NOT_YOUR_TURN);
|
||||
if (!(d.skipsLeft[ps.playerId] > 0)) return fail(ERR.ALREADY, 'no skip left');
|
||||
@@ -1218,8 +1343,9 @@ export class Match {
|
||||
if (!draft || !alive.length) { this.enterPrep(); return; }
|
||||
this.phase = PHASE.SP_DRAFT;
|
||||
const order = alive.map((p) => p.playerId);
|
||||
const untimed = this.isSolo;
|
||||
if (!untimed) this.rngDraft.shuffle(order);
|
||||
if (!this.isSolo) this.rngDraft.shuffle(order);
|
||||
// untimed: solo and any single-human match (soloUntimed); the co-op order / 6 cards stay
|
||||
const untimed = this.soloUntimed;
|
||||
this.sp = { ...draft, order, idx: 0, picks: {}, taken: {}, untimed, turnDeadline: 0 };
|
||||
this.setDeadline(0);
|
||||
this.startSpTurn();
|
||||
@@ -1404,7 +1530,8 @@ export class Match {
|
||||
this.dispatch(ps, 'onPrepStart', { round: this.round });
|
||||
ps.recompute();
|
||||
}
|
||||
const secs = this.isSolo ? null : this.gd.prepTime(this.round);
|
||||
// solo / single-human matches: untimed (soloUntimed); co-op: the round's prepTime
|
||||
const secs = this.soloUntimed ? null : this.gd.prepTime(this.round);
|
||||
this.setDeadline(secs, () => this.prepDeadline());
|
||||
let i = 0;
|
||||
for (const ps of alive) if (ps.botControlled) this.scheduleBotPrep(ps, i++);
|
||||
|
||||
@@ -28,8 +28,9 @@
|
||||
// absent; equipped items are otherwise locked: g.destroy refuses them),
|
||||
// 2 identical normal items (hand/temp/equipped) merge into the golden item in the hand, items are never sold
|
||||
// (destroy for 0). consume-on-equip items resolve through the effect registry and never take a slot.
|
||||
// * Tokens: placing an owner with placeable summons (tokens.json displayType DEFAULT) sends one stack
|
||||
// (deployLimit copies) to the hand; withdrawing/selling/merging the owner removes its tokens.
|
||||
// * Tokens: placing an owner with placeable summons (tokens.json `placeable`: the talent summons 海嗣 / 狼群 / 流形)
|
||||
// sends one stack (deployLimit copies) to the hand; withdrawing/selling/merging the owner removes its tokens. A
|
||||
// skill's summon (赫默 医疗无人机, 巫恋 诅咒娃娃) is never a hand card: it appears in battle when the skill fires.
|
||||
// * Facing (DESIGN §3, research 09 §1.2): every board piece has `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js), set
|
||||
// by g.move {…, dir} (absent ⇒ RIGHT) and kept across rounds. g.move onto the piece's OWN tile re-orients it in
|
||||
// place; a swap keeps the occupant's dir; pieces put on the board by effects (a merge elite taking a consumed
|
||||
@@ -39,8 +40,7 @@
|
||||
// equal to the defaults dropped) is re-checked against this match's data (shared/protocol.js checkLoadout; a
|
||||
// mismatch falls back to the defaults) and kept frozen; bots always use the defaults. Match.setLoadout may replace
|
||||
// it during INFO_CHECK only. battleInput() resolves every chess unit to `skillIndex` + `moduleId` (resolveLoadout:
|
||||
// normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`. A placeable summon of a
|
||||
// skill (赫默 S2 医疗无人机) is granted only with that skill equipped (grantTokensFor).
|
||||
// normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`.
|
||||
|
||||
import { ERR, GEO, PHASE } from '../../shared/constants.js';
|
||||
import { checkLoadout, resolveLoadout } from '../../shared/protocol.js';
|
||||
@@ -345,18 +345,13 @@ export class PlayerState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner placed on the board: send its placeable summons to the hand (one stack per token type). A summon that comes
|
||||
* from a skill (SkillRecord.overrideTokenKey, e.g. 赫默 S2 医疗无人机 "获得一个医疗无人机") is granted only while that
|
||||
* skill is the one equipped under the player's loadout (DESIGN §16) — 赫默 on S1 has no drone.
|
||||
* Owner placed on the board: send its placeable summons to the hand (one stack per token type). Only talent summons
|
||||
* are placeable (gamedata.placeableTokens / tokens.json `placeable`); a summon a skill makes ("获得一个医疗无人机",
|
||||
* 赫默 S2) appears in battle when that skill fires (user playtest #4: not on the field from the start).
|
||||
*/
|
||||
grantTokensFor(owner) {
|
||||
const rec = this.gd.chess(owner.id);
|
||||
const skills = rec && Array.isArray(rec.skills) ? rec.skills.filter(Boolean) : [];
|
||||
const sel = skills.length ? this.loadoutFor(rec).skillIndex : null;
|
||||
for (const { tokenId, count } of this.gd.placeableTokens(owner.id)) {
|
||||
if (this._hasTokensOf(owner.uid, tokenId)) continue;
|
||||
const bySkill = skills.filter((s) => s.overrideTokenKey === tokenId);
|
||||
if (bySkill.length && !bySkill.some((s) => s.index === sel)) continue;
|
||||
const t = this.newPiece('token', tokenId, { count, ownerUid: owner.uid });
|
||||
this.stow(t, { allowTemp: true });
|
||||
}
|
||||
|
||||
+15
-6
@@ -29,14 +29,18 @@
|
||||
// after a win when hiddenEligible() holds
|
||||
// result each title ≤ once, ≤ 1 title per player, onlyOnWin titles only on a win, roundsPassed per player,
|
||||
// Σ alive players' LP = the merged team LP after the Final Assault
|
||||
// deadlines every timed phase's m.public deadline equals its configured duration × timerScale (a solo match
|
||||
// runs BATTLE_CHECK / ROUND_START / SETTLE silently: deadline 0, Match.soloUntimed)
|
||||
// deadlines every timed phase's m.public deadline equals its configured duration × timerScale; the co-op
|
||||
// strategy draft has one countdown: the deadline is the current turn's (Match.BAND_TURN_SECONDS). A match
|
||||
// with a single human (solo, or a 同盟 room with AI teammates only: Match.soloUntimed) times nothing
|
||||
// outside its battles — no INFO_CHECK / draft / 机变 / prep deadline, BATTLE_CHECK / ROUND_START / SETTLE
|
||||
// silent (deadline 0)
|
||||
// Checks never throw into the match: an exception inside a check is itself recorded as a violation.
|
||||
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { collectViolations } from './invariants.js';
|
||||
import { pairPlayers, bossPoolHp, hiddenEligible } from './finalAssault.js';
|
||||
import { helperOrder } from './unite.js';
|
||||
import { BAND_TURN_SECONDS } from './Match.js';
|
||||
|
||||
/**
|
||||
* @param {import('./Match.js').Match} m
|
||||
@@ -236,7 +240,8 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
||||
wrap(m, 'enterInfoCheck', function (orig) {
|
||||
const r = orig();
|
||||
runInvariants();
|
||||
if (m.phase === PHASE.INFO_CHECK && m.deadline) expectDeadline(gd.timer('infoCheck'), 'INFO_CHECK');
|
||||
if (m.phase === PHASE.INFO_CHECK && m.soloUntimed) check('briefing', () => { if (m.deadline) fail('untimed briefing is timed'); });
|
||||
else if (m.phase === PHASE.INFO_CHECK && m.deadline) expectDeadline(gd.timer('infoCheck'), 'INFO_CHECK');
|
||||
return r;
|
||||
});
|
||||
wrap(m, 'enterBandDraft', function (orig) {
|
||||
@@ -247,7 +252,11 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
||||
const d = m.draft;
|
||||
const ids = m.order.map((p) => p.playerId).sort();
|
||||
if (JSON.stringify(d.order.slice().sort()) !== JSON.stringify(ids)) fail(`draft order ${d.order} != seats ${ids}`);
|
||||
if (m.isSolo) { if (m.deadline) fail('solo band draft is timed'); } else expectDeadline(gd.timer('bandDraft'), 'BAND_DRAFT');
|
||||
// one countdown (user playtest #4 item 4): the step's deadline IS the current turn's, BAND_TURN_SECONDS long
|
||||
if (m.soloUntimed) { if (m.deadline || d.turnDeadline) fail('untimed band draft is timed'); } else {
|
||||
if (m.deadline !== d.turnDeadline) fail(`BAND_DRAFT: deadline ${m.deadline} is not the turn's ${d.turnDeadline}`);
|
||||
expectDeadline(BAND_TURN_SECONDS, 'BAND_DRAFT turn');
|
||||
}
|
||||
});
|
||||
return r;
|
||||
});
|
||||
@@ -280,7 +289,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
||||
if (m.phase !== PHASE.SP_DRAFT || !m.sp) return;
|
||||
const s = m.sp;
|
||||
if (s.idx >= s.order.length) return;
|
||||
if (m.isSolo) { if (m.deadline) fail('solo 机变 is timed'); } else expectDeadline(s.idx === 0 ? gd.timer('spFirst') : gd.timer('spTurn'), `SP_DRAFT turn ${s.idx}`);
|
||||
if (m.soloUntimed) { if (m.deadline) fail('untimed 机变 is timed'); } else expectDeadline(s.idx === 0 ? gd.timer('spFirst') : gd.timer('spTurn'), `SP_DRAFT turn ${s.idx}`);
|
||||
});
|
||||
return res;
|
||||
});
|
||||
@@ -306,7 +315,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
||||
runInvariants();
|
||||
check('prep', () => {
|
||||
if (m.phase !== PHASE.PREP) return;
|
||||
if (m.isSolo) { if (m.deadline) fail('solo prep is timed'); } else if (!m._prepEndQueued) expectDeadline(gd.prepTime(m.round), 'PREP');
|
||||
if (m.soloUntimed) { if (m.deadline) fail('untimed prep is timed'); } else if (!m._prepEndQueued) expectDeadline(gd.prepTime(m.round), 'PREP');
|
||||
});
|
||||
return res;
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// that still carries them is ignored).
|
||||
|
||||
import { getConfig, getMode } from '../data.js';
|
||||
import { isShopItem } from '../sim/simdata.js';
|
||||
|
||||
const own = (map, id) => (map && typeof map === 'object' && typeof id === 'string' && Object.hasOwn(map, id) && map[id] && typeof map[id] === 'object' ? map[id] : null);
|
||||
const numOr = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||||
@@ -44,7 +45,7 @@ export const DEFAULTS = Object.freeze({
|
||||
hiddenCore: { single: 350, multi: 1200, minTeamLpExclusive: 1, difficulties: ['NORMAL', 'HARD', 'ABYSS'] },
|
||||
dp: { init: 10, perSec: 1, max: 99 },
|
||||
unite: { maxHelpers: 2, templates: { 1: 'act1autochess_escaped_single', 2: 'act1autochess_escaped_multi' } },
|
||||
timers: { infoCheck: 25, bandDraft: 50, bandTurn: 12, battleCheck: 3, spFirst: 30, spTurn: 16 },
|
||||
timers: { infoCheck: 25, bandDraft: 50, bandTurn: 30, battleCheck: 3, spFirst: 30, spTurn: 16 },
|
||||
bans: { FUNNY: { core: 0, addon: 1 }, NORMAL: { core: 3, addon: 4 }, HARD: { core: 3, addon: 4 }, ABYSS: { core: 3, addon: 4 } },
|
||||
bandDraft: { skipsPerPlayer: 1, timeoutBandId: 'band_bldsk' },
|
||||
leftoverFundsKeptByBands: ['band_cannot'],
|
||||
@@ -76,10 +77,14 @@ export class GameData {
|
||||
const c = chess[id];
|
||||
return c && c.visible && !c.isGolden && !c.isDiy && !c.isHidden && Number.isInteger(c.tier);
|
||||
}).sort();
|
||||
/** shop-eligible normal EQUIP item ids by tier */
|
||||
/**
|
||||
* Shop item ids by tier (sim/simdata.js isShopItem: normal EQUIP, not hidden, not effect-only — the special
|
||||
* 维式重锤 and 突变细胞 are never sold). Every "shop item" draw uses it: the shop item slot (pool.js), the 道具补给 /
|
||||
* 机密商店 cards (choices.js) and the shop-eligible item pools (Match.rollItemId).
|
||||
*/
|
||||
this.shopItemsByTier = {};
|
||||
for (const [id, it] of Object.entries(this._items)) {
|
||||
if (!it || it.isGolden || it.hideInShop || it.itemType !== 'EQUIP' || !Number.isInteger(it.tier)) continue;
|
||||
if (!isShopItem(it)) continue;
|
||||
(this.shopItemsByTier[it.tier] ||= []).push(id);
|
||||
}
|
||||
for (const k of Object.keys(this.shopItemsByTier)) this.shopItemsByTier[k].sort();
|
||||
@@ -434,7 +439,11 @@ export class GameData {
|
||||
return b && Number.isInteger(b.totalHp) && b.totalHp > 0 ? b.totalHp : this.defaultStartLp;
|
||||
}
|
||||
|
||||
/** Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }]. */
|
||||
/**
|
||||
* Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }] — its TALENT
|
||||
* summons (tokens.json `placeable`: 海嗣 / 狼群 / 流形); a skill's summon (赫默's drone, 巫恋's doll) only appears in
|
||||
* battle when the skill fires (tools/build-data.mjs buildTokens).
|
||||
*/
|
||||
placeableTokens(chessId) {
|
||||
const c = this.chess(chessId);
|
||||
if (!c || !Array.isArray(c.tokens)) return [];
|
||||
|
||||
+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