Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player; placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call) - #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿 - #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text - #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool, and the team LP reaching 0 first is a defeat (PRTS) - #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones - #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10) - #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range - #9 official per-enemy model scales (威龙) - #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding - #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider - #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes - #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20 - #18 炎佑 is 孤立: no heals or ally buffs - #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators - DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites green; dev recordings regenerated
This commit is contained in:
1 parent
e6628f0cfe
commit
6464e4b46a
138 files changed
+7946
-1379
No files matched your search
+96
-15
@@ -66,7 +66,9 @@
|
||||
// boss fields in real time without snapshots (they share the pool). A silent authority (deadline = time limit + 15 s)
|
||||
// or a disconnected one loses the field to the server, which re-simulates the spec; an implausible b.result is
|
||||
// replaced by the server's own simulation (fields.js validateClientResult). The shared boss pool, the team LP and the
|
||||
// overtime drain live here (b.pool ≤ 4 Hz, b.end; m.public follows a boss fight at ~1 Hz). No b.snap / b.ev is sent in
|
||||
// overtime drain live here (b.pool ≤ 4 Hz, b.end; m.public follows a boss fight at ~1 Hz); the first end condition the
|
||||
// server registers decides the Final Assault (pool 0 → victory, team LP 0 → defeat; nothing reported after a defeat is
|
||||
// credited — _finalEnding, user playtest #6 item 5). No b.snap / b.ev is sent in
|
||||
// this mode; g.watch hands out specs. battleIds are `<seed36>[-<matchNo36>].<round>.<seq>[.<fieldId>]` (≤ 64 chars):
|
||||
// unique across the matches of a room, so a report that crossed into the next match is ignored like any stale one; a
|
||||
// duplicate b.result (a browser re-sending one it believes lost) is answered ok and changes nothing.
|
||||
@@ -74,7 +76,11 @@
|
||||
// clock and the server pacers; m.public.paused; resume shifts every clock / deadline by the pause.
|
||||
// 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.
|
||||
// show lp − pendingLp; the settlement lands with the SETTLE view, where it is gone. 联防 (user playtest #6 item 7): a
|
||||
// leaker's players[].uniteLeft = its enemies still standing on the 联防 field (not spawned yet, alive, or through again;
|
||||
// uncapped, live: the authority's b.progress `left`, the server-run timeline or battle; exact once the field has its
|
||||
// result — _uniteLeft) and pendingLp = min(lpCapPerRound, uniteLeft): the counter falls as the helpers kill them (and
|
||||
// rises when one of them splits or summons — the children are billed to the same leaker).
|
||||
// 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
|
||||
@@ -124,15 +130,16 @@ import { PlayerState } from './PlayerState.js';
|
||||
import { buildDeployMap, boardOrder, pieceDir } from './board.js';
|
||||
import { bondList } from './bondsMeta.js';
|
||||
import { EffectDispatcher, getDefaultRegistry } from './effectsMeta.js';
|
||||
import { generateDraft, applyCard, cardView } from './choices.js';
|
||||
import { generateDraft, applyCard, cardView, bountyBattles, isMultiRoundBounty } from './choices.js';
|
||||
import { setupMatchWaves, buildNormalWave, buildBossWave, bountySpawns, withBounties, previewOf, weightedPick } from './waves.js';
|
||||
import { planUnite, uniteBattleOpts, uniteSurvivors } from './unite.js';
|
||||
import { pairPlayers, bossPoolHp, SharedBossPool, hiddenEligible, BOSS_HIT_STEPS } from './finalAssault.js';
|
||||
import {
|
||||
FieldRunner, DeadBattle, GAME_SPEED, snapFrame, runHeadless, timelineAt, HeadlessPacer, syntheticResult,
|
||||
validateClientResult, RESULT_GRACE_MS, BOSS_SILENCE_MS, HARD_CAP_SECONDS, HeadlessJob, HEADLESS_SLICE_MS, CATCHUP_TICKS_PER_INTERVAL,
|
||||
uniteBillBounds,
|
||||
} from './fields.js';
|
||||
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult as compactForVerify, battleProgress } from '../sim/spec.js';
|
||||
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult as compactForVerify, battleProgress, uniteLeft } from '../sim/spec.js';
|
||||
import { CreditPool } from './finalAssault.js';
|
||||
import { buildResult } from './results.js';
|
||||
import { botPrepBeginSteps, botPrepEndSteps, botPickBand, botPickCard } from './bot.js';
|
||||
@@ -338,6 +345,10 @@ export class Match {
|
||||
this.watchers = new Map();
|
||||
this.lastResults = new Map();
|
||||
this.unitePlan = null;
|
||||
/** server-run 联防: the leakers' counts last published (_uniteTick) */
|
||||
this._uniteLeftKey = null;
|
||||
/** 联防: { plan, bounds } — per leaker the most survivors settlement can bill (_uniteLeft's clamp) */
|
||||
this._uniteBounds = null;
|
||||
this.teamLp = null;
|
||||
this.bossPool = null;
|
||||
this.hiddenLayerSum = 0;
|
||||
@@ -1465,10 +1476,11 @@ export class Match {
|
||||
|
||||
addBounty(ps, card) {
|
||||
if (!ps || !card || !this.gd.enemy(card.enemyKey)) return null;
|
||||
const rounds = Math.max(1, Math.min(99, Number.isInteger(card.rounds) ? card.rounds : 1));
|
||||
// a multi-round card lasts MULTI_ROUND_BOUNTY_BATTLES battles (choices.js; the user's call after playtest #6)
|
||||
const rounds = bountyBattles(card);
|
||||
const b = {
|
||||
id: `bounty:${this.nextUid()}`,
|
||||
card: { effectId: card.effectId ?? card.id ?? null, name: card.name ?? '悬赏', desc: card.desc ?? '', tier: card.tier ?? 1, coin: Math.max(0, Math.trunc(Number(card.coin) || 0)), payout: card.payout === 'perfect' ? 'perfect' : 'kill', rounds, enemyKey: card.enemyKey, count: Math.max(1, Math.min(20, Number.isInteger(card.count) ? card.count : 1)) },
|
||||
card: { effectId: card.effectId ?? card.id ?? null, name: card.name ?? '悬赏', desc: card.desc ?? '', tier: card.tier ?? 1, coin: Math.max(0, Math.trunc(Number(card.coin) || 0)), payout: card.payout === 'perfect' ? 'perfect' : 'kill', rounds, multiRound: isMultiRoundBounty(card), enemyKey: card.enemyKey, count: Math.max(1, Math.min(20, Number.isInteger(card.count) ? card.count : 1)) },
|
||||
roundsLeft: rounds,
|
||||
};
|
||||
ps.bounties.push(b);
|
||||
@@ -1822,7 +1834,9 @@ export class Match {
|
||||
this._defaultWatch();
|
||||
this.markPublic();
|
||||
this.tickerText(`联防阶段:${plan.helpers.map((p) => p.name).join('、')} 迎战突破防线的敌人`);
|
||||
this._uniteLeftKey = null;
|
||||
this.runner = new FieldRunner(this, this.fields, {
|
||||
onTick: (runner) => this._uniteTick(runner),
|
||||
onDone: (runner) => {
|
||||
if (this.phase !== PHASE.UNITE) return;
|
||||
const res = runner.resultOf(this.fields[0]);
|
||||
@@ -1944,14 +1958,21 @@ export class Match {
|
||||
* LP a player's own battle of this normal round will cost at settlement so far — settle()'s min(lpCapPerRound,
|
||||
* counted leaks) — for the teammates' live LP (m.public players[].pendingLp, user playtest #3 item 2; the own client
|
||||
* counts its local battle itself). COMBAT: the recorded result once every field is done, else the field's result, else
|
||||
* the authority's b.progress leaks (a server-run field reports none before its result is released); 联防: the own
|
||||
* battle's count — the most the 联防 can charge (settle: min(cap, survivors) ≤ min(cap, counted)). Omitted when 0 and
|
||||
* in every other phase (boss rounds charge the merged team LP live).
|
||||
* @returns {{ pendingLp?: number }}
|
||||
* the authority's b.progress leaks (a server-run field reports none before its result is released); 联防: a leaker's
|
||||
* enemies still standing on the 联防 field (_uniteLeft, uncapped in `uniteLeft`, user playtest #6 item 7), anyone
|
||||
* else's own battle count (0: they were perfect). Omitted when 0 and in every other phase (boss rounds charge the
|
||||
* merged team LP live).
|
||||
* @returns {{ pendingLp?: number, uniteLeft?: number }}
|
||||
*/
|
||||
_pendingLpView(ps) {
|
||||
if (!ps || !ps.alive || (this.phase !== PHASE.COMBAT && this.phase !== PHASE.UNITE)) return {};
|
||||
const counted = (r) => (r && Array.isArray(r.leaked) ? r.leaked.filter((l) => l && l.counted !== false).length : 0);
|
||||
// 联防: a leaker's enemies still standing on the 联防 field (uncapped), the loss capped like settle()
|
||||
const left = this._uniteLeft(ps);
|
||||
if (left != null) {
|
||||
const loss = Math.min(this.gd.lpCapPerRound, left);
|
||||
return loss > 0 ? { uniteLeft: left, pendingLp: loss } : { uniteLeft: left };
|
||||
}
|
||||
let n = 0;
|
||||
if (this.lastResults.has(ps.playerId)) n = counted(this.lastResults.get(ps.playerId));
|
||||
else if (this.phase === PHASE.COMBAT) {
|
||||
@@ -1963,6 +1984,58 @@ export class Match {
|
||||
return loss > 0 ? { pendingLp: loss } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 联防 (user playtest #6 item 7; PRTS 卫戍协议/帮助 "防卫失败的玩家可通过上方信息栏确认自身所属敌人的剩余数量"): how many of
|
||||
* a leaker's enemies are still standing on the 联防 field — not spawned yet, alive, or through the objective again —
|
||||
* plus its leaks that could not re-enter: what settle() charges it (before the per-round cap) if the 联防 ended now.
|
||||
* It falls as the helpers strike them down and rises when one splits or summons (the children carry the leaker).
|
||||
* Live from the field (client run: the authority's b.progress `left`; server run: the headless timeline on the field
|
||||
* clock, or the streamed battle itself), clamped to what settlement can bill that leaker (fields.js uniteBillBounds:
|
||||
* sent in + the offspring bound, validateClientResult's budget); exact once the field has its result (unite.js
|
||||
* uniteSurvivors; a synthetic result charges the own leaks, as settle()). null for anyone but a leaker of the running
|
||||
* 联防.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
_uniteLeft(ps) {
|
||||
const plan = this.unitePlan;
|
||||
if (this.phase !== PHASE.UNITE || !plan || !ps || !plan.leakers.includes(ps)) return null;
|
||||
const pid = ps.playerId;
|
||||
const f = this.fields.find((x) => x && x.kind === 'unite') || null;
|
||||
let res = null;
|
||||
if (f && f.cc) res = f.done ? f.result : null;
|
||||
else if (f && f.battle && f.battle.finished) { try { res = f.battle.result(); } catch { res = null; } }
|
||||
if (res && res.synthetic) {
|
||||
const own = this.lastResults.get(pid);
|
||||
return own && Array.isArray(own.leaked) ? own.leaked.filter((l) => l && l.counted !== false).length : 0;
|
||||
}
|
||||
if (res) return uniteSurvivors(plan, res).get(pid) || 0;
|
||||
const sent = plan.leaked.filter((l) => l.sourcePlayerId === pid).length;
|
||||
let live = null;
|
||||
if (f && f.cc) {
|
||||
if (f.mode === 'server' && f.timeline) {
|
||||
const sample = timelineAt(f.timeline, this._fieldElapsed(f));
|
||||
live = sample && sample[3] && typeof sample[3] === 'object' ? sample[3] : null;
|
||||
} else live = f.progress && f.progress.left && typeof f.progress.left === 'object' ? f.progress.left : null;
|
||||
} else if (f && f.battle) {
|
||||
try { live = uniteLeft(f.battle); } catch { live = null; }
|
||||
}
|
||||
if (!this._uniteBounds || this._uniteBounds.plan !== plan) this._uniteBounds = { plan, bounds: uniteBillBounds(plan.leaked, this.gd) };
|
||||
const bound = this._uniteBounds.bounds.get(pid) ?? sent;
|
||||
const standing = live ? Math.min(bound, Math.max(0, Math.trunc(Number(live[pid]) || 0))) : sent;
|
||||
return standing + (plan.notReentered.get(pid) || 0);
|
||||
}
|
||||
|
||||
/** Server-run 联防 (streaming mode): refresh m.public about once a game second when a leaker's count moved. */
|
||||
_uniteTick(runner) {
|
||||
const f = runner && runner.fields ? runner.fields[0] : null;
|
||||
if (!f || !f.battle || runner.ticks % 30 !== 0) return;
|
||||
let key = '';
|
||||
try { key = JSON.stringify(uniteLeft(f.battle)); } catch { key = ''; }
|
||||
if (key === this._uniteLeftKey) return;
|
||||
this._uniteLeftKey = key;
|
||||
this.markPublic();
|
||||
}
|
||||
|
||||
/** The connected human who simulates a field: lowest seat among its players (normal: the owner). */
|
||||
_authorityFor(f, exclude = null) {
|
||||
let best = null;
|
||||
@@ -2223,6 +2296,8 @@ export class Match {
|
||||
return OK;
|
||||
}
|
||||
if (Number.isFinite(msg.leaks)) p.leaks = Math.max(p.leaks, msg.leaks);
|
||||
// 联防: the leakers' enemies still standing (the latest report; clamped where it is read, _uniteLeft)
|
||||
if (f.kind === 'unite' && msg.left && typeof msg.left === 'object') p.left = { ...msg.left };
|
||||
this.markPublic();
|
||||
return OK;
|
||||
}
|
||||
@@ -2250,9 +2325,10 @@ export class Match {
|
||||
for (const pid of f.players) { const d = Number(result.perPlayer[pid] && result.perPlayer[pid].bossDamage) || 0; by[pid] = d; sum += d; }
|
||||
if (sum > f.bossAcked) this._creditBoss(f, sum, by);
|
||||
// the client emptied the pool as it saw it (server hp − its unacknowledged damage): what is left on the server is
|
||||
// float dust from summing the fields' reports in another order — the boss is down
|
||||
// float dust from summing the fields' reports in another order — the boss is down (pools never hold less than
|
||||
// BOSS_POOL_MIN_HP, finalAssault.js: this only catches a noise-level disagreement at that boundary)
|
||||
const pool = this.bossPool;
|
||||
if (result.reason === 'cleared' && pool && pool.hp > 0 && pool.hp < 1) pool.damage(f.players[0] ?? null, pool.hp);
|
||||
if (result.reason === 'cleared' && this._finalEnding !== 'forced' && pool && pool.hp > 0 && pool.hp < 1 + 1e-6) pool.damage(f.players[0] ?? null, pool.hp);
|
||||
// a boss field ends only when the shared pool is empty (the client saw it reach 0) or the match forced the end
|
||||
// (b.end): any other result — 'forced' / 'timeout' at t = 0, 'cleared' while the pool still holds — would stop
|
||||
// the pair's fight (and, with every field done, end the Final Assault as a defeat). The field is handed to the
|
||||
@@ -2399,7 +2475,9 @@ export class Match {
|
||||
_creditBoss(f, cum, by) {
|
||||
const pool = this.bossPool;
|
||||
const reported = Number(cum);
|
||||
if (!pool || !Number.isFinite(reported)) return;
|
||||
// the team LP ran out first: the run failed at that moment (PRTS "…使目标生命值扣除至0,则无视倒计时直接失败"), so
|
||||
// damage reported afterwards — in flight, or rounded up in the final b.result — changes nothing (user playtest #6)
|
||||
if (!pool || !Number.isFinite(reported) || this._finalEnding === 'forced') return;
|
||||
if (!f.bossReported || reported > f.bossReported.cum) f.bossReported = { cum: reported, by: by && typeof by === 'object' ? { ...by } : null };
|
||||
const c = Math.min(reported, this._bossDmgBudget(f));
|
||||
if (!(c > f.bossAcked)) return;
|
||||
@@ -2700,7 +2778,8 @@ export class Match {
|
||||
const wave = reuse ? this.bossWaves[i].wave : buildBossWave(this.gd, this.rngWaves, this.factions, this.round, { bossId, solo });
|
||||
// one spawn list per field, shared by the field's players' onBattleStart handlers (edit it in place)
|
||||
const spawns = wave.spawns.map((s) => ({ ...s, mods: s.mods ? { ...s.mods } : undefined }));
|
||||
// multi-round bounties ("之后的每场作战") follow their player into the boss field, on the player's half
|
||||
// bounties with battles left (a multi-round card lasts MULTI_ROUND_BOUNTY_BATTLES) follow their player into the
|
||||
// boss field, on the player's half
|
||||
g.forEach((ps, j) => {
|
||||
for (const b of bountySpawns(this.gd, this.round, wave, ps.bounties, ps.playerId, { solo: this.isSolo, side: j === 0 ? 'L' : 'R' })) spawns.push(b);
|
||||
});
|
||||
@@ -2874,7 +2953,9 @@ export class Match {
|
||||
if (pp && ps) this.dispatch(ps, 'onBattleResult', { result: pp, lpLoss: 0, perfect: !!pp.perfect, boss: true });
|
||||
}
|
||||
}
|
||||
const victory = this.bossPool.hp <= 0;
|
||||
// the end condition the server registered first decides (client-side combat: _endFinal — pool 0 → victory, team LP 0
|
||||
// → defeat); a boss field's final result may never turn a defeat into a victory (user playtest #6 item 5)
|
||||
const victory = this._finalEnding ? this._finalEnding === 'cleared' : this.bossPool.hp <= 0;
|
||||
this._syncTeamLp();
|
||||
this.deadline = 0;
|
||||
this.overtimeAt = 0;
|
||||
|
||||
+80
-23
@@ -5,7 +5,8 @@
|
||||
// * Hand (整备区) 10 slots filled right→left, temp (临时整备区) 5 slots. A full hand refuses buys / withdrawals,
|
||||
// except a purchase that completes a merge and a withdrawal whose own summon stack frees a slot. Passive gains (merge
|
||||
// results, grants, returned equipment) overflow into temp; temp blocks Ready ("直到溢出情况排除才可开始进行作战").
|
||||
// A temp piece is resolved (chess sold back to the pool, items / summon stacks destroyed) at the deadline of the
|
||||
// A temp piece is resolved (chess sold back to the pool, items destroyed, summon stacks removed — they come back at
|
||||
// the next round start, grantTokensFor) at the deadline of the
|
||||
// first prep in which the player could act on it (tempDue): a piece that overflowed during a prep before Ready
|
||||
// expires at that prep's end; one that arrived after Ready, at the prep end (<休整期结束时> grants), in COMBAT /
|
||||
// SETTLE (battle-result grants, merges, returned equipment) or at the next round start / 机变 stays visible and
|
||||
@@ -21,7 +22,8 @@
|
||||
// refreshes first), one toggle freezes all unsold slots until the next round start (a manual refresh while frozen
|
||||
// rerolls everything and the new slots stay frozen), level-up price = base − rounds elapsed (floor 0).
|
||||
// * Merge: 3 normal copies (风丸 2) on board/hand/temp → 1 elite to the hand; equipment returns to the hand; a
|
||||
// reward offer of 3 free chess of tier min(level+1, 6) is queued (pick 1, expires at prep end; an offer earned
|
||||
// reward offer of 3 different free chess of tier min(level+1, 6) is queued (never one operator twice — user
|
||||
// playtest #6 item 19; a short tier tops up from the tier below; pick 1, expires at prep end; an offer earned
|
||||
// after the prep — SETTLE / Final Assault effects — is kept for the next prep). The elite goes to the hand
|
||||
// (research 01 A1 "1 elite goes to the hand, not to a board tile"), overflow temp — also outside PREP (a
|
||||
// SETTLE merge's elite waits in temp for the next prep); only with the hand and temp both full does it take a
|
||||
@@ -30,9 +32,14 @@
|
||||
// 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 `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.
|
||||
// * Tokens (PRTS 卫戍协议/帮助 §战斗部署, user playtest #6): placing an owner with manually deployable summons
|
||||
// (tokens.json `placeable`: 赫默's 医疗探机 and 巫恋's 诅咒娃娃 with their S2, 凯瑟琳's 爬行号·防护单元, 海嗣 / 狼群 /
|
||||
// 流形) sends one stack (deployLimit copies — 凯瑟琳 2) to the hand, placed by hand like any piece (no deploy slot);
|
||||
// withdrawing/selling/merging the owner removes its tokens, moving it on the board (also when a summon dragged onto
|
||||
// it swaps it away) sends its placed summons back onto their stack ("移动干员时,其所属召唤物全部退场并重置至手牌区");
|
||||
// a summon stack removed from temp at a prep deadline comes back at the next round start (startRound tops every
|
||||
// board owner's summons up to the deploy limit, "干员所属召唤物会于下一回合返还"). In battle a skill's summon takes its
|
||||
// tile when the skill fires (sim/content/tokens.js dockSkillSummons).
|
||||
// * 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
|
||||
@@ -50,6 +57,7 @@ import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder,
|
||||
import { offsetTile } from '../sim/dir.js';
|
||||
import { computeBonds, bondList, bondSnapshot, activatedLayers } from './bondsMeta.js';
|
||||
import { itemKey } from './gamedata.js';
|
||||
import { bountyText } from './choices.js';
|
||||
|
||||
const HAND_SIZE = GEO.HAND_SIZE;
|
||||
const TEMP_SIZE = GEO.TEMP_SIZE;
|
||||
@@ -352,22 +360,46 @@ export class PlayerState {
|
||||
for (let i = 0; i < this.temp.length; i++) if (this.temp[i] && this.temp[i].kind === 'token' && this.temp[i].ownerUid === ownerUid) this.temp[i] = null;
|
||||
}
|
||||
|
||||
_hasTokensOf(ownerUid, tokenId) {
|
||||
for (const p of this.board.values()) if (p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId) return true;
|
||||
for (const p of this.hand) if (p && p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId) return true;
|
||||
for (const p of this.temp) if (p && p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId) return true;
|
||||
return false;
|
||||
/**
|
||||
* An owner that changes its board tile (moved, swapped): its summons on the board go back onto its stack (PRTS
|
||||
* 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场并重置至手牌区"); overflow temp when no stack or slot is left (a
|
||||
* stack lost there comes back at the next round start). `keep`: a summon the player just placed (the one dragged
|
||||
* onto its owner, which swapped the owner away) stays where it was put.
|
||||
*/
|
||||
_liftTokensOf(ownerUid, keep = null) {
|
||||
for (const [k, p] of [...this.board]) {
|
||||
if (p.kind !== 'token' || p.ownerUid !== ownerUid || p === keep) continue;
|
||||
this.board.delete(k);
|
||||
if (!this._returnToken(p, null, { allowTemp: true })) this.board.set(k, p); // nowhere to go: it stays put
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies of one summon type an owner has (placed pieces + stacks in the hand / temp). */
|
||||
_tokenCountOf(ownerUid, tokenId) {
|
||||
const mine = (p) => !!p && p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId;
|
||||
let n = 0;
|
||||
for (const p of this.board.values()) if (mine(p)) n += p.count || 1;
|
||||
for (const p of this.hand) if (mine(p)) n += p.count || 1;
|
||||
for (const p of this.temp) if (mine(p)) n += p.count || 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Owner on the board: send its placeable summons to the hand (one stack per token type, topped up to the deploy limit;
|
||||
* gamedata.placeableTokens / tokens.json `placeable`, user playtest #6) — those its equipped skill / module makes
|
||||
* (DESIGN §16: 赫默 S2 医疗无人机 a drone, 赫默 S1 none). Called when the owner is placed and at every round start, which
|
||||
* returns a stack removed from temp at the last prep deadline (PRTS 卫戍协议/帮助 §手牌区 "干员所属召唤物会于下一回合
|
||||
* 返还"); copies the owner still has (placed or stacked) are not granted again.
|
||||
*/
|
||||
grantTokensFor(owner) {
|
||||
for (const { tokenId, count } of this.gd.placeableTokens(owner.id)) {
|
||||
if (this._hasTokensOf(owner.uid, tokenId)) continue;
|
||||
const t = this.newPiece('token', tokenId, { count, ownerUid: owner.uid });
|
||||
const rec = owner && owner.kind === 'chess' ? this.gd.chess(owner.id) : null;
|
||||
if (!rec) return;
|
||||
for (const { tokenId, count } of this.gd.placeableTokens(owner.id, this.loadoutFor(rec))) {
|
||||
const missing = count - this._tokenCountOf(owner.uid, tokenId);
|
||||
if (missing <= 0) continue;
|
||||
const stack = [...this.hand, ...this.temp].find((p) => p && p.kind === 'token' && p.ownerUid === owner.uid && p.id === tokenId);
|
||||
if (stack) { stack.count = (stack.count || 1) + missing; continue; }
|
||||
const t = this.newPiece('token', tokenId, { count: missing, ownerUid: owner.uid });
|
||||
this.stow(t, { allowTemp: true });
|
||||
}
|
||||
}
|
||||
@@ -539,16 +571,23 @@ export class PlayerState {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Queue a reward offer: `count` chess of tier min(level + offset, maxTier) at price 0 (pick 1). */
|
||||
/**
|
||||
* Queue a reward offer: `count` DIFFERENT chess of tier min(level + offset, maxTier) at price 0 (pick 1). Each is a
|
||||
* copy-weighted roll from the shared pool excluding the ones already drawn; a tier left without another chess tops
|
||||
* up from the tier below (user playtest #6 item 19: the official promotion reward never offers one operator twice —
|
||||
* the user's first-hand report; the normal shop's slots may repeat). The offer reserves no copies (the pick takes one).
|
||||
*/
|
||||
pushRewardOffer(source = 'merge', { tier = null, ids = null } = {}) {
|
||||
const ro = this.gd.rewardOffer();
|
||||
const t = Number.isInteger(tier) ? tier : Math.min(this.shop.level + ro.tierOffset, ro.maxTier);
|
||||
let list = Array.isArray(ids) ? ids.filter((id) => this.gd.chess(id)) : null;
|
||||
// an offer never shows one operator twice, whoever built the list (user playtest #6 item 19)
|
||||
let list = Array.isArray(ids) ? [...new Set(ids)].filter((id) => this.gd.chess(id)) : null;
|
||||
if (!list) {
|
||||
list = [];
|
||||
const fresh = (id) => !list.includes(id);
|
||||
for (let i = 0; i < ro.count; i++) {
|
||||
let id = null;
|
||||
for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt });
|
||||
for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt, filter: fresh });
|
||||
if (id) list.push(id);
|
||||
}
|
||||
}
|
||||
@@ -899,16 +938,19 @@ export class PlayerState {
|
||||
const occ = this.board.get(key) || null;
|
||||
if (occ === piece) return this._reorient(piece, dir);
|
||||
if (loc.area === 'board') {
|
||||
// board → board: move or swap (the occupant must be legal on the source tile and keeps its own facing)
|
||||
// board → board: move or swap (the occupant must be legal on the source tile and keeps its own facing); an
|
||||
// operator that changes its tile takes its summons off the board (back onto their stacks, _liftTokensOf)
|
||||
if (occ) {
|
||||
const [sr, sc] = parseKey(loc.key);
|
||||
if (!this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE);
|
||||
this.board.set(loc.key, occ);
|
||||
if (occ.kind === 'chess') this._liftTokensOf(occ.uid);
|
||||
} else {
|
||||
this.board.delete(loc.key);
|
||||
}
|
||||
piece.dir = dir;
|
||||
this.board.set(key, piece);
|
||||
this._liftTokensOf(piece.uid);
|
||||
this.recompute();
|
||||
return OK;
|
||||
}
|
||||
@@ -966,6 +1008,8 @@ export class PlayerState {
|
||||
const occ = this.board.get(key) || null;
|
||||
if (occ === piece) return this._reorient(piece, dir);
|
||||
if (loc.area === 'board') {
|
||||
// board → board: move or swap; an operator swapped onto the summon's old tile changed its tile, so its other
|
||||
// summons go back onto their stacks like any moved operator (_liftTokensOf; the summon just placed stays)
|
||||
if (occ) {
|
||||
const [sr, sc] = parseKey(loc.key);
|
||||
if (!this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE);
|
||||
@@ -975,6 +1019,7 @@ export class PlayerState {
|
||||
}
|
||||
piece.dir = dir;
|
||||
this.board.set(key, piece);
|
||||
if (occ && occ.kind === 'chess') this._liftTokensOf(occ.uid, piece);
|
||||
this.recompute();
|
||||
return OK;
|
||||
}
|
||||
@@ -1216,8 +1261,10 @@ export class PlayerState {
|
||||
|
||||
/**
|
||||
* Prep deadline: every temp piece due at this prep (tempDue ≤ prepsEnded) is resolved — a chess is sold back (its
|
||||
* pool copies return, its summons are removed), items and summon stacks are destroyed. Pieces that overflowed after
|
||||
* the player could no longer act on them (after Ready, at the prep end) are kept for the next prep.
|
||||
* pool copies return, its summons are removed), items are destroyed, the summon stack of an owner still on the board
|
||||
* is removed and comes back at the next round start (startRound → grantTokensFor; PRTS "干员所属召唤物会于下一回合
|
||||
* 返还"). Pieces that overflowed after the player could no longer act on them (after Ready, at the prep end) are kept
|
||||
* for the next prep.
|
||||
*/
|
||||
resolveTemp() {
|
||||
let changed = false;
|
||||
@@ -1252,6 +1299,8 @@ export class PlayerState {
|
||||
// Likewise reward offers of the last prep already expired at its end; what is still queued was earned after it —
|
||||
// a merge completed during SETTLE / the Final Assault (突变细胞, battle-result grants) — and is shown in this prep
|
||||
this.ready = false;
|
||||
// summon stacks removed from temp at the last prep deadline come back (PRTS 卫戍协议/帮助 §手牌区); full hand ⇒ temp
|
||||
for (const p of [...this.board.values()]) if (p.kind === 'chess') this.grantTokensFor(p);
|
||||
this.rollShop({ keepFrozen: true });
|
||||
this.shop.frozen = false;
|
||||
for (const s of this.shop.slots) if (s) s.frozen = false;
|
||||
@@ -1369,7 +1418,15 @@ export class PlayerState {
|
||||
out.push(v);
|
||||
}
|
||||
for (const b of this.bounties) {
|
||||
out.push({ id: b.id, name: b.card.name || '悬赏', desc: b.card.desc || '', iconKind: 'choice', iconId: b.card.effectId || 'bounty', counter: b.roundsLeft >= 90 ? null : b.roundsLeft });
|
||||
// the battles the bounty's enemies still come for (an official multi-round card: every battle, no counter) and
|
||||
// the card's official rich text (blue "下场作战" / "两场作战", red "每场"; a multi-round card reads as long as it
|
||||
// lasts — choices.js bountyText, MULTI_ROUND_BOUNTY_BATTLES) — user playtest #6 item 4
|
||||
const left = b.roundsLeft >= 90 ? null : b.roundsLeft;
|
||||
const eff = b.card.effectId ? this.gd.effect(b.card.effectId) : null;
|
||||
out.push({
|
||||
id: b.id, name: b.card.name || '悬赏', desc: bountyText((eff && eff.descRaw) || b.card.desc || '', b.card), iconKind: 'choice', iconId: b.card.effectId || 'bounty',
|
||||
counter: left, counterText: left == null ? '之后的每场作战' : `还剩 ${left} 场作战`,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+55
-9
@@ -31,6 +31,8 @@
|
||||
// botPrepBeginSteps / botPrepEndSteps (and planLayoutSteps, arrangeSteps, createRehearsalSteps) are step
|
||||
// generators that yield between whole actions (never with a transient board) — the same actions in the same
|
||||
// order as the one-shot functions (runSteps), hence the same rng draws and decisions.
|
||||
// The summon cards of the placed operators (赫默's 医疗探机, 伺夜's 狼群 …; user playtest #6) are placed after
|
||||
// them on the best remaining tiles, 凯瑟琳's 支援装置 next to the best operator no device faces yet, facing it.
|
||||
// 5. equip items on the strongest deployed damage dealers (consume-on-equip items / Arts only with a handler)
|
||||
// 6. resolve the temp slots, keep one hand slot free, then Ready.
|
||||
// Placement quality (tools/matchrun sweeps, research-faithful waves): the planner beats random layouts by ≈ 8 points
|
||||
@@ -38,7 +40,8 @@
|
||||
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
import { deriveSeed } from '../sim/rng.js';
|
||||
import { freeSlot, legalTiles, positionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
|
||||
import { ASPD_MIN } from '../sim/constants.js';
|
||||
import { freeSlot, legalTiles, canPlace, positionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
|
||||
import { rotateOffset, normDir, mirrorDir, oppositeDir } from '../sim/dir.js';
|
||||
import { itemKey } from './gamedata.js';
|
||||
import { computeBonds } from './bondsMeta.js';
|
||||
@@ -497,7 +500,7 @@ export function rangeTiles(rec, r, c, dir = 'RIGHT') {
|
||||
function dpsOf(rec) {
|
||||
const st = rec && rec.stats;
|
||||
if (!st || isHealer(rec)) return 0;
|
||||
const interval = Math.max(0.2, (st.bat || 1) * 100 / Math.max(10, st.aspd || 100));
|
||||
const interval = Math.max(0.2, (st.bat || 1) * 100 / Math.max(ASPD_MIN, st.aspd || 100));
|
||||
const d = (st.atk || 0) / interval;
|
||||
return rec.attackKind === 'none' ? d * 0.4 : d;
|
||||
}
|
||||
@@ -920,11 +923,7 @@ export function botPrepEnd(m, ps, job = null) {
|
||||
/** botPrepEnd as a step generator (see botPrepBeginSteps). */
|
||||
export function* botPrepEndSteps(m, ps, job = null) {
|
||||
if (!ps.alive || ps.ready) return;
|
||||
if (job && job.done && job.best !== job.plans[0]) {
|
||||
// summons placed around the default plan would sit on the new plan's tiles: back to their stacks, re-placed after
|
||||
liftTokens(ps);
|
||||
yield* applyPlanSteps(m, ps, job.chosen, job.best);
|
||||
}
|
||||
if (job && job.done && job.best !== job.plans[0]) yield* applyPlanSteps(m, ps, job.chosen, job.best);
|
||||
// 5. temp → hand / sell / destroy; keep one hand slot free for next round's merges
|
||||
resolveTemp(m, ps);
|
||||
if (freeSlot(ps.hand) < 0) sellWeakestHand(m, ps);
|
||||
@@ -1055,8 +1054,13 @@ export function* arrangeSteps(m, ps, { final = false, defer = false } = {}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Put the chosen pieces on their planned tiles, fill what is still off the board, then place summons. */
|
||||
/**
|
||||
* Put the chosen pieces on their planned tiles, fill what is still off the board, then place summons. Placed summons go
|
||||
* back to their stacks first: they would sit on the plan's tiles (an operator moved onto one swaps it elsewhere), and a
|
||||
* 凯瑟琳 device left beside a tile its operator moved away from would face nothing (QA, playtest #6).
|
||||
*/
|
||||
function* applyPlanSteps(m, ps, chosen, target) {
|
||||
liftTokens(ps);
|
||||
// move pieces onto their targets (board → board moves swap; hand → board may swap an occupant back to the hand)
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
let moved = false;
|
||||
@@ -1097,12 +1101,53 @@ function liftTokens(ps) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Summons that help the operator on the tile they face (range 1-1: their own tile + the one in front) instead of
|
||||
* covering the enemy path: 凯瑟琳's 爬行号·防护单元 (a hand piece since user playtest #6).
|
||||
*/
|
||||
const FRONT_SUPPORT_TOKENS = new Set(['token_10041_cathy_catsld']);
|
||||
/** Device tile offsets around an operator (behind it — enemies come from the gates on the right —, above, below, in front) and the facing that points back at it. */
|
||||
const SUPPORT_SPOTS = Object.freeze([[0, -1, 'RIGHT'], [1, 0, 'DOWN'], [-1, 0, 'UP'], [0, 1, 'LEFT']]);
|
||||
|
||||
/**
|
||||
* A tile + direction for a front-support device: next to the most valuable operator no device faces yet (blockers
|
||||
* first — they take the hits —, then DPS), pointing at it; null when none is free.
|
||||
*/
|
||||
function supportSpot(m, ps, p) {
|
||||
const rec = m.gd.token(p.id);
|
||||
if (!rec) return null;
|
||||
const map = ps.deployMap();
|
||||
const pos = positionClass(rec);
|
||||
const faced = new Set();
|
||||
for (const [k, q] of ps.board) {
|
||||
if (q.kind !== 'token' || !FRONT_SUPPORT_TOKENS.has(q.id)) continue;
|
||||
const [r, c] = parseKey(k);
|
||||
const [dr, dc] = rotateOffset(0, 1, pieceDir(q));
|
||||
faced.add(tileKey(r + dr, c + dc));
|
||||
}
|
||||
const ops = [...ps.board.entries()].filter(([k, q]) => q.kind === 'chess' && !faced.has(k)).map(([k, q]) => [k, m.gd.chess(q.id)]).filter(([, c]) => c)
|
||||
.sort((a, b) => Number(isBlocker(b[1])) - Number(isBlocker(a[1])) || dpsOf(b[1]) - dpsOf(a[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||||
for (const [k] of ops) {
|
||||
const [r, c] = parseKey(k);
|
||||
for (const [dr, dc, dir] of SUPPORT_SPOTS) {
|
||||
const t = tileKey(r + dr, c + dc);
|
||||
if (!ps.board.has(t) && canPlace(map, pos, r + dr, c + dc)) return [r + dr, c + dc, dir];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Placeable summons from the hand / temp onto the best free tiles (one per stack count). */
|
||||
function* placeTokensSteps(m, ps) {
|
||||
for (const p of [...ps.hand, ...ps.temp]) {
|
||||
if (!p || p.kind !== 'token') continue;
|
||||
for (let n = p.count || 1; n > 0; n--) {
|
||||
yield;
|
||||
if (FRONT_SUPPORT_TOKENS.has(p.id)) {
|
||||
const spot = supportSpot(m, ps, p);
|
||||
if (!spot || !tryDo(() => ps.move(p.uid, { area: 'board', row: spot[0], col: spot[1] }, spot[2]))) break;
|
||||
continue;
|
||||
}
|
||||
const plan = yield* planLayoutSteps(m, ps, [p], LAYOUT_PARAMS, { occupied: new Set(ps.board.keys()) });
|
||||
const k = plan.get(p.uid);
|
||||
if (!k) break;
|
||||
@@ -1156,7 +1201,8 @@ function resolveTemp(m, ps) {
|
||||
tryDo(() => ps.destroy(p.uid));
|
||||
}
|
||||
}
|
||||
// anything left (e.g. sells failed): final sweep — tokens cannot be sold, they are dropped with the temp slot
|
||||
// anything left (e.g. sells failed): final sweep — tokens cannot be sold, they are dropped with the temp slot (and
|
||||
// come back at the next round start, PlayerState.startRound)
|
||||
for (let i = 0; i < ps.temp.length; i++) {
|
||||
const p = ps.temp[i];
|
||||
if (!p) continue;
|
||||
|
||||
@@ -35,9 +35,9 @@ const paramsOf = (ctx, item) => {
|
||||
};
|
||||
|
||||
/** random chess sharing at least one bond with `bonds`, tier ≤ maxTier */
|
||||
function rollSameBond(ctx, bonds, maxTier) {
|
||||
function rollSameBond(ctx, bonds, maxTier, exclude = null) {
|
||||
const set = new Set(bonds);
|
||||
return ctx.rollChess({ maxTier, filter: (id) => { const c = ctx.gd.chess(id); return !!(c && Array.isArray(c.bonds) && c.bonds.some((b) => set.has(b))); } });
|
||||
return ctx.rollChess({ maxTier, filter: (id) => { const c = ctx.gd.chess(id); return !!(c && Array.isArray(c.bonds) && c.bonds.some((b) => set.has(b))) && !(exclude && exclude.includes(id)); } });
|
||||
}
|
||||
|
||||
const ITEM_HANDLERS = {
|
||||
@@ -127,7 +127,8 @@ const ITEM_HANDLERS = {
|
||||
const n = Math.max(1, int(paramsOf(ctx, ev.item).refresh_cnt, 3));
|
||||
const bonds = ctx.pieceBonds(ev.target.uid);
|
||||
const ids = [];
|
||||
for (let k = 0; k < n; k++) { const id = rollSameBond(ctx, bonds, ctx.shopLevel()); if (id) ids.push(id); }
|
||||
// a pick-one offer never shows one operator twice (user playtest #6 item 19); fewer cards when the pool runs out
|
||||
for (let k = 0; k < n; k++) { const id = rollSameBond(ctx, bonds, ctx.shopLevel(), ids); if (id) ids.push(id); }
|
||||
if (ids.length) ctx.offerChess(ids, { source: 'item' });
|
||||
},
|
||||
},
|
||||
@@ -141,7 +142,12 @@ const ITEM_HANDLERS = {
|
||||
const original = ctx.gd.baseIdOf(target.id);
|
||||
ctx.destroyPiece(target.uid);
|
||||
const ids = [];
|
||||
for (let k = 0; k < n; k++) { const id = ctx.rollChess({ tier }); if (id) ids.push(id); }
|
||||
// different operators of the target's tier, topped up from the tier below like the promotion reward (item 19)
|
||||
for (let k = 0; k < n; k++) {
|
||||
let id = null;
|
||||
for (let t = tier; t >= 1 && !id; t--) id = ctx.rollChess({ tier: t, filter: (x) => !ids.includes(x) });
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
if (ids.length) ctx.offerChess(ids, { source: 'item', tier });
|
||||
// co-op: next prep, send the original chess to the teammate with the most members of its bonds
|
||||
const mates = ctx.teammates();
|
||||
|
||||
+59
-7
@@ -3,7 +3,13 @@
|
||||
//
|
||||
// Generation (generateDraft): the family is a weighted pick from choices.schedule[modeId].rounds[r].families; the card
|
||||
// count is `cards` (co-op 6, solo 3):
|
||||
// bounty 悬赏决策 distinct cards.bounty entries with tier ∈ bountyTiers whose enemy exists and is active in the mode
|
||||
// bounty 悬赏决策 distinct cards.bounty entries with tier ∈ bountyTiers whose enemy exists and is active in the mode,
|
||||
// draft cards only (`draftBounty`, choices.json `draft`: the PRTS 下半 记录 §机变阶段 "敌人轮选" table —
|
||||
// never 战术特训, which only 法术教鞭 creates, nor the hidden 鸭爵 set; the 7 multi-round "之后 / 后续
|
||||
// 的每场作战" cards stay in, as the table lists them); each card carries its official rich text
|
||||
// `descRaw` (the battles in blue "下场作战" / "两场作战"); a multi-round card lasts
|
||||
// MULTI_ROUND_BOUNTY_BATTLES battles and says so (`bountyBattles` / `bountyText`: the user's call
|
||||
// after playtest #6 — "我不记得有过多轮悬赏")
|
||||
// supply 道具补给 random normal EQUIP shop items with tier in supplyTiers [lo, hi] (duplicates allowed)
|
||||
// shop 机密商店 random normal EQUIP shop items of any tier I–VI (duplicates allowed); FREE — the official card
|
||||
// text is "无需消耗资金,获得装备补给" (research 01 A4/04 addendum), so the price is 0
|
||||
@@ -32,6 +38,38 @@ import { weightedPick } from './waves.js';
|
||||
|
||||
export const FAMILY_NAMES = { bounty: '悬赏决策', supply: '道具补给', shop: '机密商店', tactic: '战术决策' };
|
||||
|
||||
/**
|
||||
* Battles a multi-round bounty card lasts (data `rounds` 99, official text "之后 / 后续的<@ba.vdown>每场</>作战":
|
||||
* 山海众头目·多轮悬赏, 多轮悬赏·假想敌 ×6, 法术大师A2·多轮战术特训). The user does not remember any multi-round bounty
|
||||
* (playtest #6 answer, "我不记得有过多轮悬赏"): until that is confirmed otherwise every such card lasts two battles,
|
||||
* exactly like the "接下来两场作战" cards, and its text says so in the same blue (`bountyText`). `null` restores the
|
||||
* official "每场" (every later battle, red text) everywhere: the draft, 教鞭 / 神秘顾客, the bounty list and the effects
|
||||
* column (DESIGN §20).
|
||||
*/
|
||||
export const MULTI_ROUND_BOUNTY_BATTLES = 2;
|
||||
/** A multi-round bounty card ("之后 / 后续的每场作战"; choices.json `multiRound`, or data `rounds` ≥ 90). */
|
||||
export const isMultiRoundBounty = (c) => !!c && (c.multiRound === true || Number(c.rounds) >= 90);
|
||||
/** The battles a bounty card's enemies come for (MULTI_ROUND_BOUNTY_BATTLES for a multi-round card), 1–99. */
|
||||
export function bountyBattles(c) {
|
||||
if (isMultiRoundBounty(c) && Number.isInteger(MULTI_ROUND_BOUNTY_BATTLES)) return MULTI_ROUND_BOUNTY_BATTLES;
|
||||
const r = Number(c && c.rounds);
|
||||
return Math.max(1, Math.min(99, Number.isInteger(r) ? r : 1));
|
||||
}
|
||||
const N_ZH = ['', '一', '两', '三', '四', '五'];
|
||||
/**
|
||||
* A bounty text as the card lasts: a multi-round card's "之后的 / 后续每场作战" (rich `<@ba.vdown>每场</>` or plain) reads
|
||||
* "接下来<@ba.vup>两场作战</>" like the official two-battle cards while MULTI_ROUND_BOUNTY_BATTLES is set; any other
|
||||
* text is returned as is.
|
||||
*/
|
||||
export function bountyText(text, c) {
|
||||
if (typeof text !== 'string' || !text || !isMultiRoundBounty(c) || !Number.isInteger(MULTI_ROUND_BOUNTY_BATTLES)) return text;
|
||||
const n = MULTI_ROUND_BOUNTY_BATTLES;
|
||||
const battles = `${N_ZH[n] || n}场作战`;
|
||||
return text
|
||||
.replace(/(?:之后的|后续的?)<@ba\.vdown>每场<\/>作战/g, `接下来<@ba.vup>${battles}</>`)
|
||||
.replace(/(?:之后的|后续的?)每场作战/g, `接下来${battles}`);
|
||||
}
|
||||
|
||||
function scheduleFor(gd, round) {
|
||||
const sch = gd.choices.schedule && gd.choices.schedule[gd.modeId];
|
||||
const r = sch && sch.rounds && sch.rounds[String(round)];
|
||||
@@ -104,17 +142,31 @@ export function cardTargetBonds(gd, effectId) {
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bounty card the 悬赏决策 draft may offer (choices.json cards.bounty `draft`, tools/build-data.mjs
|
||||
* bountyDraftExclusion: the PRTS "敌人轮选" table — no 战术特训, no 鸭爵 set; user playtest #6 item 4). Data without the
|
||||
* flag (older builds, test fixtures) falls back to the kill bounties.
|
||||
*/
|
||||
export function draftBounty(c) {
|
||||
if (!c) return false;
|
||||
if (typeof c.draft === 'boolean') return c.draft;
|
||||
return c.payout !== 'perfect';
|
||||
}
|
||||
|
||||
function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = null } = {}) {
|
||||
const cardsData = gd.choices.cards || {};
|
||||
if (family === 'bounty') {
|
||||
const tiers = Array.isArray(sch.bountyTiers) && sch.bountyTiers.length ? sch.bountyTiers : [1, 2];
|
||||
const pool = (Array.isArray(cardsData.bounty) ? cardsData.bounty : []).filter((c) => c && tiers.includes(c.tier) && gd.enemy(c.enemyKey) && !gd.inactiveEnemies.has(c.enemyKey));
|
||||
const pool = (Array.isArray(cardsData.bounty) ? cardsData.bounty : []).filter((c) => c && draftBounty(c) && tiers.includes(c.tier) && gd.enemy(c.enemyKey) && !gd.inactiveEnemies.has(c.enemyKey));
|
||||
const pick = pool.slice();
|
||||
rng.shuffle(pick);
|
||||
return pick.slice(0, n).map((c) => ({
|
||||
kind: 'bounty', id: c.effectId, name: c.name, desc: c.desc || '', tier: c.tier, coin: c.coin, payout: c.payout,
|
||||
rounds: c.rounds, enemyKey: c.enemyKey, count: c.count,
|
||||
}));
|
||||
return pick.slice(0, n).map((c) => {
|
||||
const eff = typeof gd.effect === 'function' ? gd.effect(c.effectId) : null;
|
||||
return {
|
||||
kind: 'bounty', id: c.effectId, name: c.name, desc: bountyText(c.desc || '', c), descRaw: bountyText((eff && eff.descRaw) || null, c), tier: c.tier, coin: c.coin,
|
||||
payout: c.payout, rounds: bountyBattles(c), enemyKey: c.enemyKey, count: c.count,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (family === 'supply' || family === 'shop') {
|
||||
let lo = 1;
|
||||
@@ -144,7 +196,7 @@ function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = n
|
||||
/** Public card view. */
|
||||
export function cardView(c) {
|
||||
const v = { idx: c.idx, kind: c.kind, id: c.id, name: c.name, desc: c.desc, tier: c.tier ?? null };
|
||||
if (c.kind === 'bounty') Object.assign(v, { coin: c.coin, payout: c.payout, rounds: c.rounds, enemyKey: c.enemyKey, count: c.count });
|
||||
if (c.kind === 'bounty') Object.assign(v, { descRaw: c.descRaw ?? null, coin: c.coin, payout: c.payout, rounds: c.rounds, enemyKey: c.enemyKey, count: c.count });
|
||||
if (c.kind === 'item') v.price = 0;
|
||||
if (c.kind === 'tactic') Object.assign(v, { team: c.team, tacticKind: c.tacticKind });
|
||||
return v;
|
||||
|
||||
+43
-7
@@ -18,14 +18,19 @@
|
||||
// HeadlessJob / runHeadless(battle) step a battle to its end — in wall-clock-bounded slices (bots, takeovers
|
||||
// under a real scheduler) or at once (verification, virtual time) — and keep a
|
||||
// progress timeline [[gt, killed, total]] for the teammates' waiting UI
|
||||
// (联防: [gt, killed, total, left] — left = spec.js uniteLeft, the leakers'
|
||||
// enemies still standing, for their live counter; user playtest #6 item 7)
|
||||
// HeadlessPacer real-time pacing of a dynamic set of server-run battles without snapshots
|
||||
// (boss fields that share the pool while other fields run on clients); a
|
||||
// takeover's fast-forward is spread over the pacing intervals
|
||||
// specBounds(spec, gd) / validateClientResult(spec, result, { gd })
|
||||
// a client's b.result is accepted only when it is plausible for its spec;
|
||||
// the returned result is rebuilt from whitelisted fields (never the raw object)
|
||||
// uniteBillBounds(spawns, gd) 联防: per leaker, the most survivors settlement can bill (sent in + the
|
||||
// offspring bound) — the live counter's clamp
|
||||
// syntheticResult(players, progress) stand-in when a boss field's client never reported
|
||||
import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js';
|
||||
import { uniteLeft } from '../sim/spec.js';
|
||||
|
||||
export const MAX_TICKS_PER_INTERVAL = 8;
|
||||
export const INTERVAL_MS = 1000 / 30;
|
||||
@@ -251,20 +256,34 @@ export const HEADLESS_SLICE_MS = 8;
|
||||
export const CATCHUP_TICKS_PER_INTERVAL = 240;
|
||||
const perfNow = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
|
||||
|
||||
/**
|
||||
* A timeline sample of a battle: [gt, killed, total], plus — 联防 — the leakers' enemies still standing
|
||||
* (spec.js uniteLeft; omitted when unknown).
|
||||
*/
|
||||
export function timelineSample(b) {
|
||||
const s = [Number(b && b.time) || 0, Number(b && b.killed) || 0, Number(b && b.total) || 0];
|
||||
if (b && b.kind === 'unite') {
|
||||
let left = null;
|
||||
try { left = uniteLeft(b); } catch { left = null; }
|
||||
if (left) s.push(left);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* A server-run battle stepped to its end, in one go or in wall-clock-bounded slices (a low-power host must not stall
|
||||
* its event loop for the ~0.1–1 s a whole battle takes, several times at once when bots fight): `run(budgetMs)` steps
|
||||
* until the battle ends (→ true) or the budget is used (→ false; checked every 16 ticks). `timeline` grows while it
|
||||
* runs ([[gt, killed, total]] every TIMELINE_EVERY game seconds, then the final state); `output()` once done gives
|
||||
* `{ battle, result, timeline, crashed }`. A throwing battle is force-ended, then replaced by a DeadBattle; the run is
|
||||
* bounded by HARD_CAP_SECONDS.
|
||||
* runs (timelineSample: [gt, killed, total(, left)] every TIMELINE_EVERY game seconds, then the final state);
|
||||
* `output()` once done gives `{ battle, result, timeline, crashed }`. A throwing battle is force-ended, then replaced by
|
||||
* a DeadBattle; the run is bounded by HARD_CAP_SECONDS.
|
||||
*/
|
||||
export class HeadlessJob {
|
||||
constructor(battle, { onError = null, players = [] } = {}) {
|
||||
this.battle = battle;
|
||||
this.onError = onError;
|
||||
this.players = players;
|
||||
this.timeline = [[0, 0, Number(battle && battle.total) || 0]];
|
||||
this.timeline = [timelineSample(battle)];
|
||||
this.every = Math.max(1, Math.round(TIMELINE_EVERY / TICK));
|
||||
this.cap = Math.ceil(HARD_CAP_SECONDS / TICK);
|
||||
this.n = 0;
|
||||
@@ -284,7 +303,7 @@ export class HeadlessJob {
|
||||
b.step();
|
||||
this.n++;
|
||||
k++;
|
||||
if (this.n % this.every === 0) this.timeline.push([Number(b.time) || 0, Number(b.killed) || 0, Number(b.total) || 0]);
|
||||
if (this.n % this.every === 0) this.timeline.push(timelineSample(b));
|
||||
if (timed && (k & 15) === 0 && !b.finished && now() - t0 >= budgetMs) return false;
|
||||
}
|
||||
if (!b.finished) b.forceEnd('timeout');
|
||||
@@ -307,7 +326,7 @@ export class HeadlessJob {
|
||||
for (const pid of this.players) perPlayer[pid] = emptyPerPlayer();
|
||||
result = { time: Number(b.time) || 0, reason: 'forced', perPlayer, killed: 0, total: 0, errors: 1, synthetic: true };
|
||||
}
|
||||
this.timeline.push([Number(b.time) || 0, Number(b.killed) || 0, Number(b.total) || 0]);
|
||||
this.timeline.push(timelineSample(b));
|
||||
this.done = true;
|
||||
this._out = { battle: b, result, timeline: this.timeline, crashed: this.crashed };
|
||||
}
|
||||
@@ -324,7 +343,7 @@ export function runHeadless(battle, opts = {}) {
|
||||
return job.output();
|
||||
}
|
||||
|
||||
/** Timeline sample at game time `gt`: [gt, killed, total] of the last sample ≤ gt. */
|
||||
/** Timeline sample at game time `gt`: [gt, killed, total(, left)] of the last sample ≤ gt. */
|
||||
export function timelineAt(timeline, gt) {
|
||||
if (!Array.isArray(timeline) || !timeline.length) return [0, 0, 0];
|
||||
let lo = 0, hi = timeline.length - 1;
|
||||
@@ -626,6 +645,23 @@ export function specBounds(spec, gd = null) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 联防: the most enemies settlement can bill each leaker — what it sent in plus what those enemies can leave behind
|
||||
* (splits / summons within offspringPerParent, maxTotal without a data bound): validateClientResult's (key, leaker)
|
||||
* budgets summed per leaker. Bounds the live counter (Match._uniteLeft; user playtest #6 item 7).
|
||||
* @param {object[]} spawns the 联防 spawns (unite.js plan.leaked: { enemyKey, sourcePlayerId, … })
|
||||
* @returns {Map<string, number>} leaker id → bound
|
||||
*/
|
||||
export function uniteBillBounds(spawns, gd = null) {
|
||||
const B = specBounds({ spawns: Array.isArray(spawns) ? spawns : [] }, gd);
|
||||
const out = new Map();
|
||||
for (const [ks, n] of [...B.keySourceCounts, ...B.derivedSourceCounts]) {
|
||||
const pid = ks.slice(ks.lastIndexOf('|') + 1);
|
||||
if (pid) out.set(pid, (out.get(pid) || 0) + n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const finiteIn = (v, lo, hi) => typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi;
|
||||
const sameMods = (a, b) => {
|
||||
const ka = a && typeof a === 'object' ? Object.keys(a).filter((k) => a[k] !== undefined).sort() : [];
|
||||
|
||||
@@ -15,13 +15,19 @@
|
||||
// * Overtime: bossTurnHpReduceTime counts REAL seconds like the level's 120 s maxPlayTime (which runs out first; the
|
||||
// battle goes on): from 150 real s (300 game s on the 2× field clock) the team loses bossOvertimeDrainPerSec (1) LP
|
||||
// per real second (gamedata.js bossOvertimeDue); m.public.deadline = the 120 s countdown, m.public.overtimeAt = the
|
||||
// drain start. Minion / boss leaks cost their `lpr`; team LP 0 ⇒ defeat (all fields force-ended); pool 0 ⇒ victory.
|
||||
// drain start. Minion / boss leaks cost their `lpr`; team LP 0 ⇒ defeat (all fields force-ended; PRTS 卫戍协议:盟约 下半
|
||||
// "…使目标生命值扣除至0,则无视倒计时直接失败"); pool 0 ⇒ victory. The first of the two the server registers decides
|
||||
// (Match._finalEnding): a report that arrives after the team LP ran out credits nothing (user playtest #6 item 5).
|
||||
// * Pool HP is a float that never keeps float dust: the hit that would leave less than BOSS_POOL_MIN_HP (1) takes the
|
||||
// rest, so the pool is 0 or ≥ 1 and a leader shown at 0 HP is down (sim/constants.js; the browser's LocalBossPool
|
||||
// follows the same rule, server/sim/spec.js). Before, the per-player crediting of a report could leave 3.6e-12 that
|
||||
// the browser could never deal (user playtest #6 item 5).
|
||||
// * No IN_BATTLE layer gains (flags.layerGainsEnabled = false).
|
||||
// * Hidden Core eligibility (after an R14 win): difficulty in hiddenCore.difficulties, the mode has a hidden round,
|
||||
// Σ activated layers of the alive players measured at the end of the boss round's prep > threshold (solo 350 /
|
||||
// co-op 1200) and team LP > minTeamLpExclusive (1).
|
||||
|
||||
import { BOSS_ROW_OFFSET, COLS } from '../sim/constants.js';
|
||||
import { BOSS_ROW_OFFSET, COLS, BOSS_POOL_MIN_HP } from '../sim/constants.js';
|
||||
import { mirrorDir, normDir } from '../sim/dir.js';
|
||||
|
||||
export const BOSS_HIT_STEPS = [0.2, 0.5, 0.8];
|
||||
@@ -63,7 +69,10 @@ export function bossPoolHp(gd, bossId, aliveCount) {
|
||||
return Math.max(1, Math.round(base * tune));
|
||||
}
|
||||
|
||||
/** The one HP pool every boss field damages. `damage(playerId, amount)` is called by the sim. */
|
||||
/**
|
||||
* The one HP pool every boss field damages. `damage(playerId, amount)` is called by the sim (server-run fields) and by
|
||||
* Match._creditBoss (client reports, per player); the hit that would leave less than BOSS_POOL_MIN_HP takes the rest.
|
||||
*/
|
||||
export class SharedBossPool {
|
||||
constructor(hp, { onHit = null } = {}) {
|
||||
this.maxHp = Math.max(1, hp);
|
||||
@@ -76,8 +85,8 @@ export class SharedBossPool {
|
||||
damage(playerId, amount) {
|
||||
const a = Number(amount);
|
||||
if (!Number.isFinite(a) || a <= 0 || this.hp <= 0) return 0;
|
||||
const dealt = Math.min(this.hp, a);
|
||||
this.hp -= dealt;
|
||||
const dealt = this.hp - a < BOSS_POOL_MIN_HP ? this.hp : a;
|
||||
this.hp -= dealt; // exactly 0 when the rest is taken (x − x): no float dust
|
||||
if (playerId != null) this.byPlayer.set(playerId, (this.byPlayer.get(playerId) || 0) + dealt);
|
||||
if (this.onHit) {
|
||||
try { this.onHit(playerId, dealt); } catch { /* reported by the caller */ }
|
||||
|
||||
@@ -440,18 +440,28 @@ export class GameData {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }] — its manually
|
||||
* deployable summons (tokens.json `placeable`: 医疗探机, 诅咒娃娃, 海嗣, 狼群, 流形, 爬行号·防护单元; user playtest #6)
|
||||
* that the chess makes under `loadout` ({ skillIndex } from shared/protocol.js resolveLoadout; absent ⇒ its default
|
||||
* skill): the owner variant's `sources` (`bySkill[skillIndex]` for a non-default skill) name a talent or a skill —
|
||||
* 赫默 / 巫恋 on S1 make no drone / doll. `count` = the summon's deploy limit (PRTS 卫戍协议/帮助 "根据召唤物部署数量
|
||||
* 上限(非初始持有量),发送等量召唤物至手牌区": 凯瑟琳 2 of her 3 devices).
|
||||
*/
|
||||
placeableTokens(chessId) {
|
||||
placeableTokens(chessId, loadout = null) {
|
||||
const c = this.chess(chessId);
|
||||
if (!c || !Array.isArray(c.tokens)) return [];
|
||||
const out = [];
|
||||
for (const tid of c.tokens) {
|
||||
const t = this.token(tid);
|
||||
if (!t || t.kind !== 'summon' || t.displayType !== 'DEFAULT' || t.placeable !== true) continue;
|
||||
const count = posIntOr(t.deployLimit, 1);
|
||||
if (!t || t.kind !== 'summon' || t.placeable !== true) continue;
|
||||
const vs = t.variants && typeof t.variants === 'object' ? t.variants : {};
|
||||
const v = vs[chessId] ?? vs[String(chessId).replace(/_b$/, '_a')] ?? null;
|
||||
if (v) {
|
||||
const alt = loadout && Number.isInteger(loadout.skillIndex) && v.bySkill ? v.bySkill[loadout.skillIndex] : null;
|
||||
const src = Array.isArray(alt?.sources) ? alt.sources : Array.isArray(v.sources) ? v.sources : [];
|
||||
if (!src.includes('talent') && !src.includes('skill')) continue;
|
||||
}
|
||||
const count = posIntOr(v?.stats?.deployLimit, posIntOr(t.deployLimit, 1));
|
||||
out.push({ tokenId: tid, count: Math.min(count, 9) });
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -455,8 +455,9 @@ function hostAction(gd, wave, { fly, token = false }) {
|
||||
* alone when the host spawns nothing, e.g. a walker bounty in a FLY round), then the whole list is spread over the
|
||||
* action window: unit j at preDelay + j·max(W/total, 0.05·W). Returned here: the bounty specs only (a bounty's
|
||||
* consecutive units share a spec); the host action's own units are NOT re-timed — `withBounties` gives the exact
|
||||
* combined list. Multi-round bounties ("之后的每场作战", `rounds` > 1) spawn in every battle of the player while they
|
||||
* last — the Final Assault and the Hidden Core included (`side`: the player's half of the boss field, see routeByMotion).
|
||||
* combined list. Bounties of several battles (`rounds` > 1: "接下来两场作战", and the multi-round cards, which last
|
||||
* choices.js MULTI_ROUND_BOUNTY_BATTLES) spawn in every battle of the player while they last — the Final Assault and the
|
||||
* Hidden Core included (`side`: the player's half of the boss field, see routeByMotion).
|
||||
* @param {Array<{ id: string, card: object }>} bounties
|
||||
*/
|
||||
export function bountySpawns(gd, round, wave, bounties, playerId, { solo = false, side = null } = {}) { // eslint-disable-line no-unused-vars
|
||||
|
||||
+176
-27
@@ -6,9 +6,10 @@
|
||||
//
|
||||
// Construction creates every ally unit (undeployed) and installs content (kits + domain modules). The first
|
||||
// step() (or an explicit start()) deploys everything (operators top→bottom, left→right — mirrored for the right boss
|
||||
// side —, then the summon pieces the same way; the players of a shared field side by side), fires `deploy` (initial)
|
||||
// for each unit, forces out the operators that enter already knocked out (`carryState.down`, 联防: constants.js
|
||||
// FORCED_EXIT — down on their tile, redeploy timer running) and then fires `battleStart`.
|
||||
// side —, then the summon pieces the same way — except a piece content flags `deferDeploy`, which waits on its tile;
|
||||
// the players of a shared field side by side), fires `deploy` (initial) for each unit, forces out the operators that
|
||||
// enter already knocked out (`carryState.down`, 联防: constants.js FORCED_EXIT — down on their tile, redeploy timer
|
||||
// running) and then fires `battleStart`.
|
||||
// Tick order: scheduled callbacks → spawns → DP → buffs → enemies (attack, move, block) → enemy index →
|
||||
// allies (skill tick, attack) → projectiles → redeploys → boss sync → `tick` hook → release hooks of removed units →
|
||||
// time += TICK → end checks. A forceEnd() requested mid-step ends the step after the current phase (docs/SIM.md §1.4).
|
||||
@@ -20,17 +21,18 @@
|
||||
// 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, BLOCK_RADIUS_SQ, DP_DEFAULTS, DOWN_STATE, FORCED_EXIT, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY } from './constants.js';
|
||||
import { TICK, ROWS, COLS, BLOCK_RADIUS_SQ, DP_DEFAULTS, DOWN_STATE, FORCED_EXIT, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST, AUTO_OP_COOLDOWN } from './constants.js';
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
import { createRng } from './rng.js';
|
||||
import { Grid } from './grid.js';
|
||||
import { Unit } from './units.js';
|
||||
import { makeBuff, STATUS, RESIST_STATUSES } from './buffs.js';
|
||||
import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff, elementView } from './damage.js';
|
||||
import { absoluteRangeKeys, canTargetEnemy, meleeUnit } from './targeting.js';
|
||||
import { absoluteRangeKeys, canTargetEnemy } from './targeting.js';
|
||||
import { bodyKeys, bodyInKeys, bodyInRadius } from './body.js';
|
||||
import { normDir, mirrorDir, localOrder, localBefore } from './dir.js';
|
||||
import { ProjectileSystem } from './projectiles.js';
|
||||
import { stampFear } from './fear.js';
|
||||
import { SkillRuntime } from './skills.js';
|
||||
import { updateAlly, updateEnemy, compileRoute, remainingDistance, effectiveProfile, performAttack, acquireTargets } from './ai.js';
|
||||
import { resolveProfile } from './professions.js';
|
||||
@@ -44,6 +46,15 @@ let schedSeq = 0;
|
||||
|
||||
/** Finite number or the default (content may pass undefined/NaN/Infinity/strings to helpers). */
|
||||
const fin = (v, d) => { const n = typeof v === 'number' ? v : (v == null || v === '' ? NaN : Number(v)); return Number.isFinite(n) ? n : d; };
|
||||
/**
|
||||
* Official push distance (tiles) of a 受力等级 (constants.js PUSH_TILES: ≤ −3 → 0, ≥ 3 → the 3 value); `effect` = a 特效
|
||||
* push (PRTS 推与拉's 特效 column, PUSH_TILES_EFFECT: 见行者), else the 弹道 column.
|
||||
*/
|
||||
export function pushTiles(level, effect = false) {
|
||||
const l = Math.round(fin(level, -99));
|
||||
if (l <= -3) return 0;
|
||||
return (effect ? PUSH_TILES_EFFECT : PUSH_TILES)[Math.min(3, l)];
|
||||
}
|
||||
|
||||
function clone(v) {
|
||||
if (v == null || typeof v !== 'object') return v;
|
||||
@@ -90,7 +101,10 @@ export class Battle {
|
||||
// non-boss kinds fall back to 60 s; only boss/hidden (or an explicit Infinity) run without a limit.
|
||||
const tl = Number(opts.timeLimit);
|
||||
this.timeLimit = opts.timeLimit == null || !(tl > 0) ? (bossLike ? Infinity : 60) : tl;
|
||||
this.flags = { layerGainsEnabled: this.kind === 'normal', ...DP_DEFAULTS, ...(opts.flags || {}) };
|
||||
// startOpCooldown: the operation cooldown of the battle-start deployment (skills.js AUTO_OP_COOLDOWN)
|
||||
this.flags = { layerGainsEnabled: this.kind === 'normal', ...DP_DEFAULTS, startOpCooldown: AUTO_OP_COOLDOWN, ...(opts.flags || {}) };
|
||||
const soc = Number(this.flags.startOpCooldown);
|
||||
this.flags.startOpCooldown = this.flags.startOpCooldown != null && Number.isFinite(soc) && soc >= 0 ? soc : AUTO_OP_COOLDOWN;
|
||||
// DP knobs may arrive as undefined/null/strings (e.g. a template without `dp`): never let them poison DP with NaN.
|
||||
for (const k of ['dpInit', 'dpPerSec', 'dpMax']) {
|
||||
const v = Number(this.flags[k]);
|
||||
@@ -302,6 +316,15 @@ export class Battle {
|
||||
u.kit = kit || {};
|
||||
u.profile = resolveProfile(u.def, u.kit.trait || null);
|
||||
if (u.def.untargetable) this.addBuff(u, { key: 'trait:untargetable', flags: { untargetable: true }, persist: true, allowDead: true });
|
||||
// abnormal effects a summon holds (tokens.json `abnormal`, PRTS; user playtest #6 item 18): 禁疗 — no heal reaches it;
|
||||
// 孤立 ("无法被同阵营选中") — no ally heal or ally selection (auras over a range) reaches it
|
||||
const ab = u.def.abnormal;
|
||||
if (Array.isArray(ab) && ab.length) {
|
||||
const flags = {};
|
||||
if (ab.includes('healFree')) flags.noHeal = true;
|
||||
if (ab.includes('isolated')) { flags.isolated = true; flags.noHeal = true; }
|
||||
if (Object.keys(flags).length) this.addBuff(u, { key: 'trait:abnormal', flags, persist: true, allowDead: true });
|
||||
}
|
||||
const spec = u.kit.skill || null;
|
||||
u.skill = new SkillRuntime(this, u, u.def.skill, spec, u.def.skill?.bb ?? {});
|
||||
if (u.profile.install && !u._profInstalled) {
|
||||
@@ -330,8 +353,11 @@ export class Battle {
|
||||
const seq0 = this._deploySeq;
|
||||
const lists = this.players.map((ps) => ps.units.filter((u) => u.kind === 'op' || u.kind === 'token').slice().sort((a, b) =>
|
||||
b.homeR - a.homeR || (ps.mirror ? b.homeC - a.homeC : a.homeC - b.homeC) || a.id - b.id));
|
||||
// a summon piece flagged `deferDeploy` by content (one its owner's loadout does not make — 赫默 on S1 with a drone
|
||||
// piece —, or a skill's summon when shared/constants.js SKILL_SUMMON_START_DEPLOY is off: content/tokens.js
|
||||
// dockSkillSummons) stays off the field, its tile reserved (isReservedTile), until content deploys it
|
||||
for (const kind of ['op', 'token']) {
|
||||
const per = lists.map((l) => l.filter((u) => u.kind === kind));
|
||||
const per = lists.map((l) => l.filter((u) => u.kind === kind && !u.deferDeploy));
|
||||
const n = Math.max(0, ...per.map((l) => l.length));
|
||||
for (let i = 0; i < n; i++) for (const l of per) if (l[i]) this._safe(() => this._deploy(l[i], { initial: true }), 'initialDeploy', l[i]);
|
||||
}
|
||||
@@ -851,6 +877,8 @@ export class Battle {
|
||||
if (!u.skill) this._setupUnit(u);
|
||||
u.skill.reset(cs);
|
||||
const sk = u.skill;
|
||||
// "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却" (PRTS 卫戍协议/帮助; skills.js)
|
||||
if (initial) sk.opReadyAt = this.time + this.flags.startOpCooldown;
|
||||
if (keepSp && !sk.noSkill && sk.kind !== 'passive' && !sk.active) {
|
||||
sk.charges = Math.max(0, Math.min(sk.maxCharges, Math.floor(fin(keepSp.charges, 0))));
|
||||
sk.sp = Math.max(0, Math.min(sk.spCost, fin(keepSp.sp, 0)));
|
||||
@@ -1072,14 +1100,15 @@ export class Battle {
|
||||
}
|
||||
|
||||
/**
|
||||
* The enemies ally `u` blocks that it may target with `profile` — always selectable by a melee blocker, inside its
|
||||
* range or not ("可以选择且优先选择阻挡单位", PRTS 选择器; ai.js acquireTargets, the skills' DEFAULT trigger). A ranged
|
||||
* operator on a melee tile gets none: it attacks what its range holds (targeting.js meleeUnit, PRTS 索敌的概念
|
||||
* "阻挡(近战限定)").
|
||||
* The enemies ally `u` blocks that it may target with `profile` — always selectable by their blocker, inside its range
|
||||
* or not, whatever its facing ("可以选择且优先选择阻挡单位", PRTS 选择器; ai.js acquireTargets, the skills' DEFAULT
|
||||
* trigger). That holds for a ranged operator on a melee tile too: the user's rule after playtest #6, "阻挡了就一定要能
|
||||
* 打到" — officially the collision pushes a blocked enemy to its blocker's front, so whatever blocks an enemy can hit
|
||||
* it (DESIGN §20; it replaces the playtest #5 QA's melee-only restriction).
|
||||
*/
|
||||
blockedTargets(u, profile) {
|
||||
const out = [];
|
||||
if (!u || !u.blocking || !u.blocking.length || !meleeUnit(u)) return out;
|
||||
if (!u || !u.blocking || !u.blocking.length) return out;
|
||||
for (const e of u.blocking) if (e.blockedBy === u && canTargetEnemy(u, e, profile)) out.push(e);
|
||||
return out;
|
||||
}
|
||||
@@ -1241,7 +1270,8 @@ export class Battle {
|
||||
* than LEVITATE_HALF_WEIGHT (current massLevel); 冻结's RES cut hits enemies only; 麻痹 adds stacks; "同名效果取最高"
|
||||
* statuses (`valued`) keep the strongest value — a weaker application only extends past the stronger one's end (it
|
||||
* then resumes); other statuses refresh to the longer duration. 诱导 (`attract`) walks the enemy to `point`
|
||||
* ([r, c] or {x, y}; default the source's tile — a new application moves the point). A stunned/sleeping operator
|
||||
* ([r, c] or {x, y}; default the source's tile — a new application moves the point); 恐惧 (`fear`) stamps where it
|
||||
* was applied and from where (fear.js stampFear: the fan of 恐惧可达地块 its movement uses). A stunned/sleeping operator
|
||||
* releases the enemies it blocks; a feared/levitated/unblockable/attracted enemy is released by its blocker.
|
||||
* `statusApplied` reports the final duration and `entered` (the target did not carry the status before).
|
||||
*/
|
||||
@@ -1286,6 +1316,8 @@ export class Battle {
|
||||
const mods = tpl.enemyOnlyMods && target.side !== 'enemy' ? null : typeof tpl.mods === 'function' ? tpl.mods(value) : (tpl.mods || null);
|
||||
const b = this.addBuff(target, { key, duration, refresh: opts.refresh ?? 'extend', mods, flags: tpl.flags || null, status: key, visible: true, source });
|
||||
if (tpl.attract && b) this._setAttractPoint(target, b, opts.point ?? value, source);
|
||||
// 恐惧: the hit position and the source's position of every application (fear.js — the fan of reachable tiles)
|
||||
if (key === 'fear' && b && target.side === 'enemy') stampFear(this, target, b, source);
|
||||
}
|
||||
const f = tpl.flags;
|
||||
if (f && target.side === 'enemy' && (f.levitate || f.unblockable || f.fear)) this._unblock(target);
|
||||
@@ -1417,6 +1449,19 @@ export class Battle {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A living enemy whose body is on any of `keys` — targetable or not (stealthed, untargetable, asleep, flying): the
|
||||
* 技能范围 trigger "技能范围内存在敌人(无视其不可选中)" (skills.js SKILL_RANGE).
|
||||
*/
|
||||
anyEnemyInKeys(keys) {
|
||||
if (!keys) return false;
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const b = this._eb[keys[i]];
|
||||
if (b && b.length) for (const e of b) if (e.alive && e.deployed) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Allies (not devices) within tiles `keys`, sorted by HP ratio (lowest first) that need healing. */
|
||||
injuredAlliesInKeys(keys, healer, includeElement = false) {
|
||||
const out = [];
|
||||
@@ -1453,10 +1498,25 @@ export class Battle {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Allies inside `unit`'s current range (for auras). */
|
||||
/** Allies inside `unit`'s current range (for auras) — not the 孤立 ones ("无法被同阵营选中": 炎佑), `unit` aside. */
|
||||
alliesInGrid(unit) {
|
||||
const set = unit.rangeKeySet || new Set(unit.rangeKeys || []);
|
||||
return this.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC));
|
||||
return this.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC) && this.allySelectable(a, unit));
|
||||
}
|
||||
|
||||
/**
|
||||
* May an ability of ally `by` select ally `a`? Never a 孤立 unit (炎佑, 从不混淆的方向; flag `isolated`) other than `by`
|
||||
* itself — PRTS 选择器: selectors decide "天赋是否能给予某个干员Buff" and every heal / buff / aura target, and their 可选判定
|
||||
* rejects a 孤立 target of a friendly selector ("若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"; user playtest #6
|
||||
* item 18). Enemies still target it (a hostile selector). 禁疗 only keeps heals off (heal pipeline, injuredAlliesInKeys).
|
||||
*/
|
||||
allySelectable(a, by = null) {
|
||||
return !!a && (a === by || !a.s.flags.isolated);
|
||||
}
|
||||
|
||||
/** Deployed allies (no devices) an ability of ally `by` may select: allies(ownerId) without the 孤立 ones. */
|
||||
alliesFor(by, ownerId = null) {
|
||||
return this.allies(ownerId).filter((a) => this.allySelectable(a, by));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1707,26 +1767,115 @@ export class Battle {
|
||||
}
|
||||
|
||||
/**
|
||||
* Displace an enemy (hook/push). dir = {x,y} (normalised internally), distance in tiles. Heavier enemies move
|
||||
* less: effective = distance × clamp(1 − 0.25·(massLevel − force), 0, 1) (current massLevel: 失重 counts).
|
||||
* 失衡免疫 (flag `noDisplace`: 近地悬浮, 浮空 — PRTS 异常效果 "不会被位移影响") ⇒ no movement. The tiles it may cross
|
||||
* follow its movement (`motion`): a hovering enemy walks the ground, so it stays on ground-passable tiles.
|
||||
* 受力等级 of enemy `e` under a push / pull of 力度 `force` (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3, 大力+1 4, 特大力 5):
|
||||
* force − its current 重量等级 (massLevel incl. 失重) — PRTS 游戏数据基础 §重量公式, 推与拉 (user playtest #6 item 14).
|
||||
*/
|
||||
displace(e, dir, distance, { force = 1 } = {}) {
|
||||
if (!e || !e.alive || e.side !== 'enemy' || e.isBoss || e.s.flags.noDisplace) return 0;
|
||||
const len = Math.hypot(dir.x, dir.y);
|
||||
forceLevel(e, force) {
|
||||
return Math.round(fin(force, 0)) - (e && e.s ? e.s.massLevel : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push enemy `e` with 力度 `force` (PRTS 推与拉 §推力): the official distance of its 受力等级 (constants.js PUSH_TILES:
|
||||
* a 中力 push moves a weight-1 enemy 1.7 tiles, a weight-2 one 0.44, weight 3 0.12, weight ≥ 4 not at all). Radial
|
||||
* (the default: away from `from`, the pusher's centre — "沿着干员向自身中心点的射线方向") or directional (`dir` = the
|
||||
* pusher's direction {x, y}: 推击手, "朝部署方向" / "向前推开"); a directional push on a target more than 45° off `dir`
|
||||
* or nearer than 0.25 tile becomes radial with 受力等级 −2 ("特殊修正"). `fixed` waives both corrections (PRTS 圣聆初雪 S1
|
||||
* 备注 "不会因角度过大或距离过近而变化方向与力度"); `fixedAngle` only the angle one (PRTS 见行者 S2 备注 "不会因为角度过大
|
||||
* 而改变推动的方向或削减力度" — the < 0.25 tile rule still applies). `inward` = a radial push towards `from` (薄绿 S2's "拖拽", PRTS 备注 "实际为
|
||||
* 反方向(指向薄绿方向)的推开"), never nearer than PULL_STOP_RADIUS to its centre [ASSUMED: "至面前"]. `effect` = a 特效
|
||||
* push (PRTS 推与拉: one frame less of travel than a 弹道 push — constants.js PUSH_TILES_EFFECT / PUSH_EFFECT_SKILLS).
|
||||
* Returns the tiles moved.
|
||||
*/
|
||||
push(e, force, { from = null, dir = null, fixed = false, fixedAngle = false, inward = false, effect = false } = {}) {
|
||||
if (!this._displaceable(e)) return 0;
|
||||
let level = this.forceLevel(e, force);
|
||||
const fx0 = fin(from?.x, e.x), fy0 = fin(from?.y, e.y);
|
||||
const vx = e.x - fx0, vy = e.y - fy0, d = Math.hypot(vx, vy);
|
||||
let ux = 0, uy = 0;
|
||||
const dirX = dir ? fin(dir.x, 0) : 0, dirY = dir ? fin(dir.y, 0) : 0;
|
||||
const dl = Math.hypot(dirX, dirY);
|
||||
if (dl > 0) {
|
||||
ux = dirX / dl; uy = dirY / dl;
|
||||
if (from && !fixed && (d < PUSH_DIRECTIONAL_MIN_DIST || (!fixedAngle && vx * ux + vy * uy < d * Math.SQRT1_2))) {
|
||||
level -= 2;
|
||||
if (d > 1e-6) { ux = vx / d; uy = vy / d; }
|
||||
}
|
||||
} else if (d > 1e-6) { ux = vx / d; uy = vy / d; }
|
||||
else if (!inward && from && Array.isArray(from.fwd)) { ux = from.fwd[1]; uy = from.fwd[0]; }
|
||||
else return 0;
|
||||
let dist = pushTiles(level, effect);
|
||||
if (inward && !(dl > 0)) { ux = -ux; uy = -uy; dist = Math.min(dist, Math.max(0, d - PULL_STOP_RADIUS)); }
|
||||
return this.displace(e, { x: ux, y: uy }, dist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull enemy `e` with 力度 `force` towards the point `to` (PRTS 推与拉 §拉力 / §捕网): 受力等级 ≥ 0 — all the way, until it
|
||||
* is within `stop` tiles of `center` (急停; `center` defaults to `to`, `stop` to PULL_STOP_RADIUS) or reaches `to`;
|
||||
* −1 — PULL_WEAK_SHARE of its starting distance to `to`; −2 — PULL_CRAWL tiles; ≤ −3 — nothing. `pullToFront` aims at
|
||||
* the official 拉力起点 in front of an operator. Returns the tiles moved.
|
||||
*/
|
||||
pull(e, force, { to, center = null, stop = PULL_STOP_RADIUS } = {}) {
|
||||
if (!this._displaceable(e) || !to) return 0;
|
||||
// an enemy the puller itself blocks already stands in front of it (at contact) [ASSUMED: no pull, no unblocking]
|
||||
if (center && center.side === 'ally' && e.blockedBy === center) return 0;
|
||||
const tx = fin(to.x, e.x), ty = fin(to.y, e.y);
|
||||
const dx = tx - e.x, dy = ty - e.y, d0 = Math.hypot(dx, dy);
|
||||
if (!(d0 > 1e-6)) return 0;
|
||||
const ux = dx / d0, uy = dy / d0;
|
||||
// travel until inside the stop circle around `center` (smaller root of |e + t·u − c| = stop), else up to `to`
|
||||
let full = d0;
|
||||
const cx = fin(center?.x, tx), cy = fin(center?.y, ty), r = Math.max(0, fin(stop, 0));
|
||||
const wx = e.x - cx, wy = e.y - cy, wu = wx * ux + wy * uy, w2 = wx * wx + wy * wy;
|
||||
if (w2 <= r * r) full = 0;
|
||||
else {
|
||||
const disc = wu * wu - w2 + r * r;
|
||||
if (disc >= 0) { const t = -wu - Math.sqrt(disc); if (t >= 0) full = Math.min(full, t); }
|
||||
}
|
||||
const level = this.forceLevel(e, force);
|
||||
const dist = level >= 0 ? full : level === -1 ? Math.min(full, PULL_WEAK_SHARE * d0) : level === -2 ? Math.min(full, PULL_CRAWL) : 0;
|
||||
return dist > 1e-6 ? this.displace(e, { x: ux, y: uy }, dist) : 0;
|
||||
}
|
||||
|
||||
/** Official distance (tiles) a push of 力度 `force` would move `e` on open ground (0 when it cannot be displaced). */
|
||||
pushDistance(e, force, { effect = false } = {}) {
|
||||
return this._displaceable(e) ? pushTiles(this.forceLevel(e, force), effect) : 0;
|
||||
}
|
||||
|
||||
/** Pull `e` "至面前" of ally `unit` (拉力起点 PULL_ORIGIN tiles ahead along its direction, 急停 around its centre). */
|
||||
pullToFront(e, unit, force) {
|
||||
if (!unit) return 0;
|
||||
const f = Array.isArray(unit.fwd) ? unit.fwd : [0, 1];
|
||||
return this.pull(e, force, { to: { x: unit.x + f[1] * PULL_ORIGIN, y: unit.y + f[0] * PULL_ORIGIN }, center: unit, stop: PULL_STOP_RADIUS });
|
||||
}
|
||||
|
||||
/** Can `e` be displaced at all: a living enemy, not a leader part of the boss pool, not 失衡免疫 (近地悬浮, 浮空). */
|
||||
_displaceable(e) {
|
||||
return !!(e && e.alive && e.side === 'enemy' && !e.isBoss && !e.s.flags.noDisplace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an enemy `distance` tiles along `dir` = {x, y} (normalised internally) over passable tiles — the raw mover of
|
||||
* push() / pull(), which apply the official 力度 − 重量 rules (content uses those; the old `force` option is gone).
|
||||
* 失衡免疫 (flag `noDisplace`: 近地悬浮, 浮空 — PRTS 异常效果 "不会被位移影响") and leaders ⇒ no movement. The tiles it may
|
||||
* cross follow its movement (`motion`): a hovering enemy walks the ground, so it stays on ground-passable tiles.
|
||||
*/
|
||||
displace(e, dir, distance) {
|
||||
if (!this._displaceable(e) || !dir) return 0;
|
||||
const dxv = fin(dir.x, 0), dyv = fin(dir.y, 0);
|
||||
const len = Math.hypot(dxv, dyv);
|
||||
if (!(len > 0)) return 0;
|
||||
const eff = distance * Math.max(0, Math.min(1, 1 - 0.25 * Math.max(0, e.s.massLevel - force)));
|
||||
if (eff <= 0) return 0;
|
||||
const ux = dir.x / len, uy = dir.y / len;
|
||||
const eff = Math.min(fin(distance, 0), 2 * COLS);
|
||||
if (!(eff > 0)) return 0;
|
||||
const ux = dxv / len, uy = dyv / len;
|
||||
let moved = 0;
|
||||
const stepLen = 0.1;
|
||||
while (moved + 1e-9 < eff) {
|
||||
const nx = e.x + ux * stepLen, ny = e.y + uy * stepLen;
|
||||
const s = Math.min(stepLen, eff - moved); // (the last step is a partial one: 0.12 tiles moves 0.12, not 0.2)
|
||||
const nx = e.x + ux * s, ny = e.y + uy * s;
|
||||
const r = Math.round(ny), c = Math.round(nx);
|
||||
const ok = e.motion === 'FLY' ? this.grid.inRect(r, c) : this.grid.groundPassable(r, c);
|
||||
if (!ok) break;
|
||||
e.x = nx; e.y = ny; moved += stepLen;
|
||||
e.x = nx; e.y = ny; moved += s;
|
||||
}
|
||||
if (moved > 0) {
|
||||
this._unblock(e);
|
||||
|
||||
+31
-42
@@ -12,17 +12,21 @@
|
||||
// but walks the ground. An unblocked enemy touching an ally with free block capacity — within its block radius (0.7071
|
||||
// ground, 0.8944 air, devices 0.4472; Battle._checkBlock) — is blocked, moving or not, so an enemy overlapping an
|
||||
// operator is taken over once its blocker is gone. Blocked enemies fight their blocker (ranged ones may pick anyone in
|
||||
// range, blocker first); a melee blocker may always target the enemies it blocks, in range or not, a ranged operator on
|
||||
// a melee tile only what its range holds (acquireTargets, targeting.js meleeUnit).
|
||||
// range, blocker first); every blocker — a ranged operator on a melee tile included — may always target the enemies
|
||||
// it blocks, in range or not, whatever its facing, and targets them first (acquireTargets, Battle.blockedTargets;
|
||||
// user playtest #6: "阻挡了就一定要能打到").
|
||||
// Unblocked ranged enemies attack allies within their radius and pause ATTACK_PAUSE seconds after each attack; the
|
||||
// candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered blocker → taunt → latest deployed
|
||||
// (targeting.js sortAllyTargets). Reaching the final leg's end = leak. An `attract` (诱导) status suspends the route:
|
||||
// the enemy walks to the status point instead (moveAttracted) and re-plans its route when released.
|
||||
// (targeting.js sortAllyTargets). Reaching the final leg's end = leak. A `fear` (恐惧) status suspends the route: the
|
||||
// enemy runs between random checkpoints away from the fear's source (fear.js moveFeared; a self-inflicted fear
|
||||
// flutters inside its own tile); an `attract` (诱导) status walks it to the status point instead (moveAttracted);
|
||||
// both re-plan the route when released (恐惧 outranks 诱导).
|
||||
|
||||
import { ATTACK_PAUSE, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, BOOMERANG_RETURN_SPEED, COLS } from './constants.js';
|
||||
import { ATTACK_PAUSE, ALLY_COLLIDER_RADIUS, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, BOOMERANG_RETURN_SPEED, COLS } from './constants.js';
|
||||
import { sortEnemyTargets, sortAllyTargets, canTargetEnemy, canTargetAlly, tileKeyOf } from './targeting.js';
|
||||
import { reduceElement } from './damage.js';
|
||||
import { OB_CRATE } from './grid.js';
|
||||
import { straightClear } from './grid.js';
|
||||
import { moveFeared, endFear } from './fear.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// profiles
|
||||
@@ -99,7 +103,9 @@ export function enforceBlockCapacity(b, u) {
|
||||
/** Collect targets for an ally with profile `prof`. */
|
||||
export function acquireTargets(b, u, prof) {
|
||||
if (prof.heal && prof.dmgType === 'heal') {
|
||||
const cands = b.injuredAlliesInKeys(u.rangeKeys, u, !!prof.heal.elementHealRatio);
|
||||
let cands = b.injuredAlliesInKeys(u.rangeKeys, u, !!prof.heal.elementHealRatio);
|
||||
// a heal restricted to allies at or below an HP ratio (塞雷娅 S1 急救 "血量小于等于一半")
|
||||
if (prof.heal.hpAtMost > 0) cands = cands.filter((a) => a.hpRatio <= prof.heal.hpAtMost + 1e-9);
|
||||
if (!cands.length) return cands;
|
||||
let n = prof.heal.mode === 'multi' ? Math.max(1, prof.heal.count || 3) : 1;
|
||||
if (prof.maxTargets > n) n = Math.floor(prof.maxTargets); // skill targeting override (e.g. heal 2 targets)
|
||||
@@ -119,9 +125,9 @@ export function acquireTargets(b, u, prof) {
|
||||
if (t.length) return t;
|
||||
}
|
||||
const cands = b.enemiesInKeys(u.rangeKeys, u, prof);
|
||||
// "可以选择且优先选择阻挡单位" (PRTS 选择器): the enemies a melee unit blocks are always selectable by it — the block
|
||||
// radius (0.7071) reaches past its own tile, so a blocked enemy may stand outside a short range (user playtest #5 item
|
||||
// 4); a ranged operator on a melee tile gets none (Battle.blockedTargets: "阻挡(近战限定)")
|
||||
// "可以选择且优先选择阻挡单位" (PRTS 选择器): the enemies a unit blocks are always selectable by it — the block radius
|
||||
// (0.7071) reaches past its own tile, so a blocked enemy may stand outside a short range or behind its facing (user
|
||||
// playtest #5 item 4); a ranged operator on a melee tile too (user playtest #6: "阻挡了就一定要能打到")
|
||||
if (u.blocking.length) for (const e of b.blockedTargets(u, prof)) if (!cands.includes(e)) cands.push(e);
|
||||
if (!cands.length) return cands;
|
||||
if (prof.allInRange) return cands;
|
||||
@@ -381,30 +387,6 @@ function planLeg(b, e, leg) {
|
||||
R.suffix = suf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a straight move from (x, y) to the centre of tile p = {x: col, y: row} only crosses ground-walkable,
|
||||
* crate-free tiles (the start and end tiles excepted). Exact grid traversal (tile (r,c) spans c ± 0.5, r ± 0.5); a
|
||||
* line through a tile corner needs both side tiles clear (as the grid's Bresenham smoothing).
|
||||
*/
|
||||
function straightClear(g, x, y, p) {
|
||||
let c = Math.round(x), r = Math.round(y);
|
||||
const dx = p.x - x, dy = p.y - y;
|
||||
const sc = dx > 0 ? 1 : -1, sr = dy > 0 ? 1 : -1;
|
||||
const ddx = dx !== 0 ? Math.abs(1 / dx) : Infinity, ddy = dy !== 0 ? Math.abs(1 / dy) : Infinity;
|
||||
let tx = dx !== 0 ? (c + 0.5 * sc - x) / dx : Infinity;
|
||||
let ty = dy !== 0 ? (r + 0.5 * sr - y) / dy : Infinity;
|
||||
const clear = (rr, cc) => (rr === p.y && cc === p.x) || (g.walkable(rr, cc) && !(g.obstacle[rr * COLS + cc] & OB_CRATE));
|
||||
for (let guard = 4 * COLS; guard > 0 && (r !== p.y || c !== p.x); guard--) {
|
||||
if (tx >= 1 && ty >= 1) break;
|
||||
if (Math.abs(tx - ty) < 1e-9) {
|
||||
if (!clear(r, c + sc) || !clear(r + sr, c)) return false;
|
||||
c += sc; r += sr; tx += ddx; ty += ddy;
|
||||
} else if (tx < ty) { c += sc; tx += ddx; } else { r += sr; ty += ddy; }
|
||||
if (!clear(r, c)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Estimated length of legs after index `idx` (cached per grid version). */
|
||||
function tailLength(b, e, idx) {
|
||||
const R = e.route;
|
||||
@@ -475,23 +457,27 @@ export function updateEnemy(b, e, dt) {
|
||||
if (!e.hidden && b._checkBlock(e)) return;
|
||||
if (b.time < e.pauseUntil) return;
|
||||
if (e.s.flags.noMove) return;
|
||||
// 恐惧 (ba.fear "无法被阻挡并四散逃跑"; PRTS 诱发移动: 恐惧 outranks 诱导): runs to random tiles of the fan away from
|
||||
// its source — a self-inflicted fear flutters inside its own tile (fear.js); the route re-plans once it ends
|
||||
if (e.s.flags.fear && !e.hidden) { moveFeared(b, e, dt); return; }
|
||||
if (e.mem.fearMove) endFear(e);
|
||||
// 诱导 (ba.attract "无法被阻挡并向目标位置移动"): walks to the attract point instead of following its route
|
||||
if (e.s.flags.attract) { moveAttracted(b, e, dt); return; }
|
||||
// 恐惧 (ba.fear "无法被阻挡并四散逃跑"): the enemy flees on the spot instead of advancing along its route
|
||||
if (e.s.flags.fear) { e.moving = false; return; }
|
||||
advanceRoute(b, e, dt, R);
|
||||
}
|
||||
|
||||
/**
|
||||
* 诱导: walk the enemy (own speed; ground: grid path re-planned when obstacles change, flyers: straight) to the point
|
||||
* of its `attract` status (Battle._setAttractPoint) and keep it there. Its route re-plans from wherever it stands
|
||||
* once the status ends (route.pts is reset whenever it moved).
|
||||
* 诱导: walk the enemy (own speed; ground: grid path, flyers: straight) to the point of its `attract` status
|
||||
* (Battle._setAttractPoint) and keep it there. The path re-plans from where the enemy stands when an obstacle changed
|
||||
* or something else moved it since its last step (a push / pull, or a 恐惧 that outranked the 诱导 for a while) —
|
||||
* a stale path would walk through walls. Its route re-plans from wherever it stands once the status ends (route.pts
|
||||
* is reset whenever it moved).
|
||||
*/
|
||||
function moveAttracted(b, e, dt) {
|
||||
let A = null;
|
||||
for (const x of e.buffs) if (x.status === 'attract' && x.data && x.data.attract) { A = x.data.attract; break; }
|
||||
if (!A) { e.moving = false; return; }
|
||||
if (!A.pts || A.ver !== b.grid.version) {
|
||||
if (!A.pts || A.ver !== b.grid.version || e.x !== A.px || e.y !== A.py) {
|
||||
const sr = Math.round(e.y), sc = Math.round(e.x);
|
||||
let pts = null;
|
||||
if (e.motion !== 'FLY') { // (a hovering 近地悬浮 enemy is an air unit but walks the ground: motion decides the path)
|
||||
@@ -517,6 +503,7 @@ function moveAttracted(b, e, dt) {
|
||||
if (d <= dist) { e.x = p.x; e.y = p.y; dist -= d; A.i++; } else { e.x += (dx / d) * dist; e.y += (dy / d) * dist; dist = 0; }
|
||||
moved = true;
|
||||
}
|
||||
A.px = e.x; A.py = e.y;
|
||||
e.moving = moved;
|
||||
if (moved && e.route) e.route.pts = null;
|
||||
}
|
||||
@@ -593,6 +580,8 @@ function enemyAttack(b, e) {
|
||||
// Content may flip it with `e.profile.melee = false`.
|
||||
const melee = e.profile?.melee ?? def.applyWay === 'MELEE';
|
||||
const radius = melee ? 0 : e.base.rangeRadius;
|
||||
// the range circle takes an ally whose 0.25 collider touches it (PRTS 作战机制 §碰撞体积: 索敌 uses the colliders)
|
||||
const reach = radius > 0 ? radius + ALLY_COLLIDER_RADIUS : 0;
|
||||
// `e.profile.canTarget(ally)`: the enemy's own target rule (只攻击地面单位, 不会攻击飞行单位 …; content/enemies.js),
|
||||
// applied to the candidates before the priority sort and the target count
|
||||
const own = e.profile && typeof e.profile.canTarget === 'function' ? e.profile.canTarget : null;
|
||||
@@ -600,11 +589,11 @@ function enemyAttack(b, e) {
|
||||
if (e.blockedBy) {
|
||||
const bl = e.blockedBy;
|
||||
if (radius > 0) {
|
||||
targets = b.alliesInRadius(e.x, e.y, radius, null).filter((a) => a === bl || canTargetAlly(e, a, true));
|
||||
targets = b.alliesInRadius(e.x, e.y, reach, null).filter((a) => a === bl || canTargetAlly(e, a, true));
|
||||
if (!targets.includes(bl) && bl.alive) targets.push(bl);
|
||||
} else if (bl.alive && bl.deployed) targets = [bl];
|
||||
} else if (radius > 0) {
|
||||
targets = b.alliesInRadius(e.x, e.y, radius, null).filter((a) => canTargetAlly(e, a, true));
|
||||
targets = b.alliesInRadius(e.x, e.y, reach, null).filter((a) => canTargetAlly(e, a, true));
|
||||
}
|
||||
if (own && targets.length) targets = targets.filter((a) => own(a));
|
||||
if (targets.length > 1) sortAllyTargets(e, targets);
|
||||
|
||||
+8
-2
@@ -32,7 +32,7 @@ export const MUL_KEYS = Object.freeze([
|
||||
export const FLAG_KEYS = Object.freeze([
|
||||
'stun', 'freeze', 'sleep', 'silence', 'disarm', 'stealth', 'invulnerable', 'unblockable', 'levitate', 'fear',
|
||||
'cold', 'reveal', 'bind', 'noHeal', 'untargetable', 'blockFly', 'noMove', 'noSp', 'burstLock', 'hidden',
|
||||
'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract', 'float', 'noDisplace',
|
||||
'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract', 'float', 'noDisplace', 'isolated', 'camou',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -62,12 +62,18 @@ export const STATUS = Object.freeze({
|
||||
// never the element gauge (元素损伤 has its own multiplier, `elemTakenMul`; damage.js)
|
||||
elemFragile: { mods: (v) => ({ elementalTakenMul: 1 + (v ?? 0.2) }), valued: 0.2 },
|
||||
silence: { flags: { silence: true }, immune: 'silence' },
|
||||
// 恐惧: 无法被阻挡并四散逃跑 (no attacks, unblockable, stops advancing along its route)
|
||||
// 恐惧: 无法被阻挡并四散逃跑 (no attacks, unblockable; leaves its route and runs to random tiles of the fan away from the
|
||||
// source — fear.js, stamped by Battle.applyStatus)
|
||||
fear: { flags: { fear: true, unblockable: true }, immune: 'feared' },
|
||||
// 战栗: 被阻挡后无法进行普通攻击
|
||||
tremble: { flags: { tremble: true }, immune: 'feared' },
|
||||
disarm: { flags: { disarm: true } },
|
||||
// 隐匿 (ba.invisible): 不阻挡时不成为敌方攻击的目标 (an ally: only the enemy it blocks attacks it — targeting.js)
|
||||
stealth: { flags: { stealth: true } },
|
||||
// 迷彩 (ba.camou): "不阻挡时不成为敌方普通攻击的目标(无法躲避溅射类攻击)" — an ally's camouflage keeps the enemies off
|
||||
// it like 隐匿 (only the enemy it blocks attacks it: targeting.js canTargetAlly; shown the same way, snapshot.js), but
|
||||
// it is not 隐匿 (叙拉古 / 家族徽章 read `stealth` only) and has its own buff keys
|
||||
camou: { flags: { camou: true } },
|
||||
reveal: { flags: { reveal: true } },
|
||||
invulnerable: { flags: { invulnerable: true } },
|
||||
// 浮空: 变为空中单位 (Unit.isFlying),无法移动、攻击及使用技能; 对重量大于3的单位持续时间减半; the state holds 不可阻挡 +
|
||||
|
||||
+59
-2
@@ -15,6 +15,14 @@ export const COLS = GEO.COLS;
|
||||
export const MOVE_SCALE = 0.5;
|
||||
/** Ranged enemies stop moving this long after each attack (DESIGN §5.5). */
|
||||
export const ATTACK_PAUSE = 0.35;
|
||||
/**
|
||||
* Collider radius of an allied unit (PRTS 作战机制 §碰撞体积与位置识别: "我方干员碰撞体积基本均为以0.25格为半径的圆形" —
|
||||
* "关于碰撞体积与远程索敌,碰撞箱碰撞是为最常用的方式,索敌抬手等均使用碰撞箱"; the Ifrit 0.1 collider example). A ranged enemy's
|
||||
* normal attack takes an ally whose collider touches its range circle: centre distance ≤ rangeRadius + this
|
||||
* (ai.js enemyAttack; user playtest #6 follow-up — 萨卡兹枯朽战车's 2.2 reaches 2.45). [ASSUMED] every ally, summons
|
||||
* included; enemy skills / zones of content keep their own (point) radius.
|
||||
*/
|
||||
export const ALLY_COLLIDER_RADIUS = 0.25;
|
||||
/**
|
||||
* Block contact (PRTS 游戏数据基础 §阻挡半径; 作战机制 §碰撞体积与位置识别 "中点判定 … 案例: 阻挡"): an unblocked enemy whose
|
||||
* position lies within the blocker's radius of the blocker's centre touches it — compared on squared distances, as the
|
||||
@@ -83,6 +91,31 @@ export const ELEMENT_ORDER = Object.freeze(['neural', 'erosion', 'burn', 'apopto
|
||||
/** 麻痹 (ba.palsy): each stack cancels one normal attack of an enemy; at most 3 stacks, lasts until consumed. */
|
||||
export const PALSY_MAX = 3;
|
||||
|
||||
/**
|
||||
* Push / pull (位移; user playtest #6 item 14). The 受力等级 is the source's 力度 minus the target's current 重量等级
|
||||
* (massLevel, 失重 counts) — PRTS 游戏数据基础 §重量公式 (力度: 微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3, 大力+1 4, 特大力
|
||||
* 5) and PRTS 推与拉. PUSH_TILES = the official push distance per 受力等级 (游戏数据基础 "推力-位移近似对应表": ≤ −3 → 0,
|
||||
* −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles). Pulls (拖拽 / 捕网, "拉力-位移近似对应表"):
|
||||
* ≥ 0 → all the way to the pull point (必定拉至身前), −1 → PULL_WEAK_SHARE of the starting distance, −2 → PULL_CRAWL
|
||||
* tiles, ≤ −3 → nothing. A pull "至面前" aims at the point PULL_ORIGIN tiles in front of the puller (拉力起点 "干员前方0.5格
|
||||
* 距离处") and stops once the target is within PULL_STOP_RADIUS of the puller's centre (急停 "拖拽者中心半径0.6708").
|
||||
* A directional push (推击手 / 朝部署方向) on a target more than 45° off the direction or nearer than
|
||||
* PUSH_DIRECTIONAL_MIN_DIST becomes radial with 受力等级 −2 (推与拉 "特殊修正").
|
||||
* PRTS 推与拉 gives two columns of 理想移动距离: 弹道 (a push carried by a projectile — "温蒂的23技能、阿消的12技能"; equal
|
||||
* to the 游戏数据基础 table above) and 特效 (an effect push, one frame less of travel — "食铁兽的12技能、见行者的12技能"):
|
||||
* PUSH_TILES_EFFECT, used by the skills in PUSH_EFFECT_SKILLS (见行者 S1 护身射击 / S2 惊爆射击, the only 特效 pushers of
|
||||
* the pool PRTS names). [ASSUMED: every other push of the pool (野鬃, 山, 莫斯提马, 琳琅诗怀雅, 圣聆初雪, 薄绿) uses the 弹道
|
||||
* column — PRTS does not classify them; the two columns differ by 7–12 %.]
|
||||
*/
|
||||
export const PUSH_TILES = Object.freeze({ '-2': 0.12, '-1': 0.44, 0: 1.7, 1: 2.14, 2: 2.96, 3: 3.53 });
|
||||
export const PUSH_TILES_EFFECT = Object.freeze({ '-2': 0.085, '-1': 0.374, 0: 1.562, 1: 1.987, 2: 2.773, 3: 3.331 });
|
||||
export const PUSH_EFFECT_SKILLS = Object.freeze(new Set(['skchr_forcer_1', 'skchr_forcer_2']));
|
||||
export const PULL_WEAK_SHARE = 0.35;
|
||||
export const PULL_CRAWL = 0.03;
|
||||
export const PULL_ORIGIN = 0.5;
|
||||
export const PULL_STOP_RADIUS = 0.6708;
|
||||
export const PUSH_DIRECTIONAL_MIN_DIST = 0.25;
|
||||
|
||||
/** Freeze caused by cold on cold (DESIGN §5.3). */
|
||||
export const COLD_FREEZE_DURATION = 3;
|
||||
/** 浮空 (ba.levitate): the duration is halved on units heavier than this weight (massLevel). */
|
||||
@@ -96,6 +129,15 @@ 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 });
|
||||
/**
|
||||
* Cooldown of the automatic skill operations (PRTS 卫戍协议/帮助 §作战阶段 技能操作: "自动操作具有3s冷却,在完成一次操作或作战
|
||||
* 开始时部署的单位将进入冷却"), in battle seconds [ASSUMED: the game clock, like every skill timer]: the engine auto-casts
|
||||
* a MANUAL skill no sooner than this after its previous cast and after the unit's battle-start deployment
|
||||
* (skills.js; Battle._deploy `initial` sets `opReadyAt` from `battle.flags.startOpCooldown`, default this value). Kits
|
||||
* with an automatic cast of their own check `skill.opCooling`. AUTO skills fire by their own rule and are not
|
||||
* operations.
|
||||
*/
|
||||
export const AUTO_OP_COOLDOWN = 3;
|
||||
/**
|
||||
* 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
|
||||
@@ -126,8 +168,13 @@ export const DEPLOY_ANIM_TIME = 0.5;
|
||||
/** Stage devices that act as ground obstacles for pathing (阻隔工事). */
|
||||
export const OBSTACLE_DEVICES = Object.freeze({ trap_1105_accrate: { hp: 100, name: '阻隔工事' } });
|
||||
|
||||
/** Default enemy/op aggregation clamps. */
|
||||
export const ASPD_MIN = 10;
|
||||
/**
|
||||
* ASPD (攻击速度) clamps for every unit. The attribute's floor is 20 (PRTS 数值范围: ATTACK_SPEED 默认下限 20; 游戏数据基础
|
||||
* "攻击速度属性实际被限制了下限为20" — so two −50 slows on base 100 leave ASPD 20, interval = 5 × BAT, not 10 × BAT; user
|
||||
* playtest #6 item 17). The interval formula's own 10–600 clamp ("参与攻击间隔计算时会被限定在10~600以内") then only binds
|
||||
* at the top.
|
||||
*/
|
||||
export const ASPD_MIN = 20;
|
||||
export const ASPD_MAX = 600;
|
||||
|
||||
/** Client event buffer cap (events are dropped oldest-first beyond this when nobody drains). */
|
||||
@@ -143,3 +190,13 @@ export const REGEN_EVENT_MIN = 1;
|
||||
|
||||
/** Row offset mapping board rows (9..12) onto boss-field rows (2..5). */
|
||||
export const BOSS_ROW_OFFSET = -7;
|
||||
|
||||
/**
|
||||
* Shared boss pool (Final Assault / Hidden Core): a pool holding less than this many HP is empty — the hit that would
|
||||
* leave less takes the rest (server SharedBossPool, browser LocalBossPool) and a browser pool reading below it is 0.
|
||||
* Pool HP is a float: the server subtracts each field's reported damage per player, the browser shows `server hp −
|
||||
* unacknowledged local damage`, and their rounding could leave dust (3.6e-12) that no hit could remove — the local
|
||||
* cumulative counter absorbed it — so the leader stood at "0 HP" until the overtime drain ended the run (user playtest
|
||||
* #6 item 5). With the rule a pool always reads 0 or at least 1 HP: 0 on the HUD = the leader is down.
|
||||
*/
|
||||
export const BOSS_POOL_MIN_HP = 1;
|
||||
@@ -38,7 +38,8 @@
|
||||
// create those units (death spawns, embers, blades, 再生).
|
||||
// ELEMENT — element damage on hit = ATK × ep_damage_ratio into the ally's gauge. 侵蚀 (erosion) is an engine
|
||||
// gauge whose burst is the official one (termDescription ba.dt.erosion: "永久降低100点防御力并受到800点物理伤害").
|
||||
// DOT — HP-loss / arts damage zones (污染秽蚀, 毒雾, 燃烧区域), bleeding (removed by healing), pulsing auras.
|
||||
// DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit; 毒雾, 燃烧区域),
|
||||
// bleeding (removed by healing), pulsing auras.
|
||||
// INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed; AoE still hits).
|
||||
// REFLECTION — 折射 (ba.refraction "生效时,法术抗性+70"): RES +refracting.magic_resistance while NOT silenced
|
||||
// (the ability line is SILENCE-flagged: silencing turns it off); 镜膜 also gets max HP +100 % while on.
|
||||
@@ -70,8 +71,11 @@ export const EROSION_BURST = Object.freeze({ defDown: ELEMENT.erosion.ally.defDo
|
||||
const PARASITE_SPREAD = 1000;
|
||||
/** Death-explosion radius when the enemy has no official radius [ASSUMED]. */
|
||||
const BOOM_RADIUS = 1.25;
|
||||
/** Pollution zone radius of 萨卡兹枯朽战车's ranged 污染秽蚀 [ASSUMED]. */
|
||||
const TANK_ZONE_RADIUS = 1;
|
||||
/** 萨卡兹枯朽战车 秽蚀轰击: radius of the 【污染秽蚀】 it leaves at its target (PRTS 萨卡兹枯朽战车 技能 "在目标位置生成半径1.7,
|
||||
* 持续10s的【污染秽蚀】" — the skill blackboard's range_radius 2.2 is its trigger range; the zone's radius is in no table). */
|
||||
const TANK_ZONE_RADIUS = 1.7;
|
||||
/** 【污染秽蚀】 ticks once per second; "同名效果不叠加": a unit inside several zones takes one tick per second. */
|
||||
const POLLUTION_INTERVAL = 1;
|
||||
/** "数个目标" of 假想敌:骨刺 while stealthed [ASSUMED]. */
|
||||
const ACBUNN_TARGETS = 3;
|
||||
/** 假想敌:再生 shield aura radius ("周围一定距离") [ASSUMED]. */
|
||||
@@ -625,7 +629,12 @@ const float = () => ({ spawn(b, e) { setFloat(b, e, true); } });
|
||||
/** The enemies whose kit spawns them hovering (float()): the match's bot counts them as air units (bot.js fieldModel). */
|
||||
export const HOVER_KEYS = Object.freeze(['enemy_2025_syufo', 'enemy_10045_parrot']);
|
||||
|
||||
/** "生命值首次降至一半以下时,在数秒内陷入恐惧" (SelfFear). */
|
||||
/**
|
||||
* "生命值首次降至一半以下时,在数秒内陷入恐惧" (SelfFear; PRTS “萨科塔之翼/之眼/昂首” "生命值首次低于50%时,对自身施加持续5s的恐惧,
|
||||
* 在5s内移动速度最终提升至150%"). The fear's source is the enemy itself, so it has no 恐惧可达地块: for SelfFear.fear s it
|
||||
* flies to random points of its own tile (±0.25) at ×move_speed — the engine's 恐惧 movement (fear.js; user playtest #6
|
||||
* item 13, "小范围乱飞") — then flies on along its route.
|
||||
*/
|
||||
const selfFear = (ab) => ({
|
||||
taken(c, b, e, a) {
|
||||
if (a.done || !(e.hpRatio < 0.5)) return;
|
||||
@@ -875,10 +884,20 @@ function bleed(ab) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 污染秽蚀 zone: HP loss per second, `low` on ground tiles, `high` on elevated tiles. */
|
||||
/**
|
||||
* 【污染秽蚀】 zone (PRTS 萨卡兹枯朽战士 / 萨卡兹枯朽战车: "范围内位于低地/高地的我方干员和召唤物每秒受到50/25点真实普通伤害
|
||||
* (可对空,无视无法选择、迷彩;同名效果不叠加)"): `low` true damage per second on low ground, `high` on high ground, to every
|
||||
* ally inside (flyers, stealthed and untargetable ones included); a unit inside several zones takes one tick per second
|
||||
* (`mem.pollutedAt`: the last tick it took).
|
||||
*/
|
||||
function pollution(b, src, x, y, r, life, low, high) {
|
||||
zone(b, { x, y, r, life, iv: 1, kind: 'pollution', tick(units) {
|
||||
for (const u of units) { const v = u.ground ? low : high; if (v > 0) b.loseHp(u, v, { source: src }); }
|
||||
zone(b, { x, y, r, life, iv: POLLUTION_INTERVAL, kind: 'pollution', tick(units) {
|
||||
for (const u of units) {
|
||||
const v = u.ground ? low : high;
|
||||
if (!(v > 0) || b.time - (u.mem.pollutedAt ?? -Infinity) < POLLUTION_INTERVAL - 1e-6) continue;
|
||||
u.mem.pollutedAt = b.time;
|
||||
hurt(b, src, u, v, 'true', { tags: ['pollution'] });
|
||||
}
|
||||
} });
|
||||
}
|
||||
|
||||
@@ -1104,19 +1123,37 @@ function kitParrot(ab) {
|
||||
}];
|
||||
}
|
||||
|
||||
function kitTank(ab) {
|
||||
const every = (T(ab, 'Empty.sp') ?? 2) + 1, melee = T(ab, 'Empty.attack@chuang_atk_scale') ?? 1;
|
||||
const s = ab.sk.PollutedRangedAtk ? ab.sk.PollutedRangedAtk.bb : {};
|
||||
/**
|
||||
* 萨卡兹枯朽战车 / 尖端 (PRTS; enemy_database spData "初始技力 2 / 技力上限 2 / 攻击回复"): an attack made with full SP is the
|
||||
* skill 秽蚀轰击 — "对目标造成100%物理普通伤害,同时在目标位置生成半径1.7,持续10s的【污染秽蚀】" — and empties the SP; every
|
||||
* other attack gains 1. Initial SP = max, so the FIRST attack is the skill, then every 3rd (attacks 1, 4, 7 …). Talent:
|
||||
* "普通攻击只攻击位于低地的我方单位,且不会攻击飞行单位" (a candidate filter, ai.js enemyAttack: a FLY ally such as the 炎佑 dragon on a
|
||||
* low tile is no target either); "被阻挡时进行近战攻击,造成攻击力200%的伤害" — normal attacks on its blocker ×chuang_atk_scale.
|
||||
* [ASSUMED] the skill also fires on its blocker and deals the skill's own 100 % there (PRTS names no blocked exception:
|
||||
* enemy SP skills fire as soon as they are ready and its condition — a low-ground non-flying ally within 2.2 — holds for
|
||||
* the blocker), so a blocked tank hits 100 / 200 / 200 % … (v2.4.1: 200 % on every attack, the skill included).
|
||||
* Reach: the 2.2 range circle takes an ally whose 0.25 collider touches it (ai.js enemyAttack, constants.js
|
||||
* ALLY_COLLIDER_RADIUS; PRTS 作战机制 §碰撞体积) — 2.45 from the ally's centre, 21 tiles around it instead of 13 (user
|
||||
* playtest #6 follow-up: the user remembers a long-reaching, hard-hitting ranged attack; the data scale stays 100 %).
|
||||
*/
|
||||
function kitTank(ab, e) {
|
||||
const melee = T(ab, 'Empty.attack@chuang_atk_scale') ?? 1;
|
||||
const sk = ab.sk.PollutedRangedAtk || null;
|
||||
const s = sk ? sk.bb : {};
|
||||
const spd = (e.def.raw && e.def.raw.sp) || null;
|
||||
const max = Math.max(1, num(spd && spd.maxSp, 0) || (sk && sk.sp) || (T(ab, 'Empty.sp') ?? 2));
|
||||
const init = Math.min(max, Math.max(0, num(spd && spd.initSp, 0)));
|
||||
return [{
|
||||
// 只攻击地面单位 — PRTS 萨卡兹枯朽战车 天赋 "普通攻击只攻击位于低地的我方单位,且不会攻击飞行单位" (a candidate filter,
|
||||
// ai.js enemyAttack: a FLY ally such as the 炎佑 dragon on a low tile is no target either)
|
||||
spawn(b, e, a) { a.n = 0; e.profile.canTarget = (u) => !!u.ground && !u.isFlying; },
|
||||
hitOut(c, b, e) { if (c.dmg.isAttack && c.target === e.blockedBy) c.dmg.amount *= melee; }, // 近战攻击造成更高伤害
|
||||
attack(c, b, e, a) {
|
||||
a.n++;
|
||||
if (a.n % every) return;
|
||||
spawn(b, e2, a) { a.sp = init; a.skill = false; e2.profile.canTarget = (u) => !!u.ground && !u.isFlying; },
|
||||
// SP full: this attack is 秽蚀轰击 (a blocked hit lands at once, inside this attack, while `a.skill` is set)
|
||||
before(c, b, e2, a) { a.skill = a.sp >= max; },
|
||||
hitOut(c, b, e2, a) { if (c.dmg.isAttack && !a.skill && c.target === e2.blockedBy) c.dmg.amount *= melee; },
|
||||
attack(c, b, e2, a) {
|
||||
if (!a.skill) { a.sp = Math.min(max, a.sp + 1); return; }
|
||||
a.skill = false;
|
||||
a.sp = 0;
|
||||
const t = c.targets[0];
|
||||
if (t) pollution(b, e, t.x, t.y, TANK_ZONE_RADIUS, s.projectile_life_time ?? 0, s.polluted_damage_low ?? 0, s.polluted_damage_high ?? 0);
|
||||
if (t) pollution(b, e2, t.x, t.y, TANK_ZONE_RADIUS, s.projectile_life_time ?? 0, s.polluted_damage_low ?? 0, s.polluted_damage_high ?? 0);
|
||||
},
|
||||
}];
|
||||
}
|
||||
@@ -2147,11 +2184,11 @@ export const KITS = Object.freeze({
|
||||
// --- DOT 持续
|
||||
enemy_1234_dsubrl: kitDsubrl, // 深溟巢涌者 · pulse hits every ally in range + neural; 抵抗, immune 停顿
|
||||
enemy_1234_dsubrl_2: kitDsubrl, // 富营养的巢涌者 · same
|
||||
enemy_1267_nhpbr: kitPolluted, // 萨卡兹枯朽战士 · death: 污染秽蚀 HP-loss zone
|
||||
enemy_1267_nhpbr: kitPolluted, // 萨卡兹枯朽战士 · death: 污染秽蚀 zone (50 / 25 true per second)
|
||||
enemy_1267_nhpbr_2: kitPolluted, // 萨卡兹枯朽战士组长 · same
|
||||
enemy_1270_nhstlk: (ab) => [bleed(ab)], // 逐腐兽 · bleeding (arts/s, cleared by healing)
|
||||
enemy_1270_nhstlk_2: (ab) => [bleed(ab)], // 疯狂的逐腐兽 · same
|
||||
enemy_1272_nhtank: kitTank, // 萨卡兹枯朽战车 · ground targets only, melee ×2, every 3rd attack a pollution zone
|
||||
enemy_1272_nhtank: kitTank, // 萨卡兹枯朽战车 · ground targets only, melee ×2, 秽蚀轰击 on attacks 1, 4, 7 …
|
||||
enemy_1272_nhtank_2: kitTank, // 尖端萨卡兹枯朽战车 · same
|
||||
enemy_9006_actoxi: (ab) => [{ // 假想敌:蚀裂 · death: poison cloud on its killer
|
||||
sil: true,
|
||||
@@ -2201,7 +2238,7 @@ export const KITS = Object.freeze({
|
||||
enemy_1355_mrfly_2: (ab, e) => [enemyAura(e.base.rangeRadius || 2.5, 'ab:mrfly', { resFlat: T(ab, 'magdef_add.magic_resistance') ?? 0 })], // 护障·P · RES aura
|
||||
enemy_1042_frostd: (ab) => [allyAura(T(ab, 'defup.range_radius') ?? 2.5, 'ab:frost', { aspd: (T(ab, 'atkSpeedDown.attack_speed') ?? 0) * 100 })], // 寒霜 · ASPD −50 aura on operators
|
||||
enemy_1040_bombd: kitBombd, // 暴鸰 · no normal attack: ONE bomb (target + 8 tiles), then ×2 speed
|
||||
enemy_10083_hlbird: kitSelfFear, // “萨科塔之翼” · fear + flee below half HP
|
||||
enemy_10083_hlbird: kitSelfFear, // “萨科塔之翼” · below half HP: 5 s self-fear, flutters in its tile ×1.5
|
||||
enemy_10084_hlegle: kitSteal, // “萨科塔之眼” · fear below half; steals 1 ammo instead of hitting
|
||||
enemy_10085_hllevi_2: kitRoar, // “萨科塔昂首” · fear below half; 祈祷邀约 global ASPD −30
|
||||
enemy_1407_hummbd: kitExposeOnDeath, // 远眺 · death: exposes operators around (damage taken ×1.2)
|
||||
|
||||
@@ -30,10 +30,14 @@
|
||||
// "立即流失N%当前生命" + hp_ratio ⇒ self HP loss at start (宴, 风丸); hp_ratio + "恢复/回复…生命" ⇒ self heal at start.
|
||||
// Passive skills only apply stat mods (for bb.duration s when the text says "N秒内": 宴) and the self/counter effects
|
||||
// above — their scales describe procs (bombs, sword rain, counters) that need a hand-authored kit.
|
||||
// force→onHit displacement (pull when the text says 拖拽 or for hookmasters, push otherwise; 0.5+0.5·force tiles).
|
||||
// force→onHit displacement with the official 力度 − 重量 rules (Battle.push / pullToFront): a pull "至面前" when the text says
|
||||
// 拖拽 or for hookmasters, else a push — along the unit's direction when the text says 朝部署方向 / 向前 / 身前方向 or for
|
||||
// 推击手 (directional), otherwise away from the unit (radial); a skill of constants.js PUSH_EFFECT_SKILLS (见行者 S1) pushes
|
||||
// by PRTS 推与拉's 特效 column.
|
||||
|
||||
import { normalizeSkill } from '../simdata.js';
|
||||
import { sortEnemyTargets } from '../targeting.js';
|
||||
import { PUSH_EFFECT_SKILLS } from '../constants.js';
|
||||
|
||||
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v !== '' && Number.isFinite(+v) ? +v : undefined));
|
||||
|
||||
@@ -212,8 +216,10 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
|
||||
// ---- displacement
|
||||
const forceRaw = g('force');
|
||||
const hasForce = !passive && forceRaw !== undefined;
|
||||
const force = Math.max(0, forceRaw ?? 0);
|
||||
const force = forceRaw ?? 0; // 力度 (微小力 −1 … 特大力 5)
|
||||
const pull = /拖拽/.test(desc) || (!/推开|击退/.test(desc) && def && def.subProf === 'hookmaster');
|
||||
const directional = !pull && (/朝部署方向|向前|身前方向/.test(desc) || (def && def.subProf === 'pusher'));
|
||||
const effectPush = PUSH_EFFECT_SKILLS.has(sk.id);
|
||||
const hitElement = element && !counterText;
|
||||
|
||||
const healAlly = (battle, unit) => {
|
||||
@@ -223,7 +229,7 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
|
||||
}
|
||||
let best = null;
|
||||
for (const a of battle.injuredAlliesInKeys(unit.rangeKeys, unit)) if (!(allyHealOthersOnly && a === unit)) { best = a; break; }
|
||||
if (!best) best = battle.alliesInRadius(unit.x, unit.y, 1.5, null).filter((a) => a.hp < a.s.maxHp && !(allyHealOthersOnly && a === unit)).sort((a, b) => a.hpRatio - b.hpRatio)[0] ?? null;
|
||||
if (!best) best = battle.alliesInRadius(unit.x, unit.y, 1.5, null).filter((a) => a.hp < a.s.maxHp && !(allyHealOthersOnly && a === unit) && (a === unit || !(a.s.flags.noHeal || a.profile?.noHeal))).sort((a, b) => a.hpRatio - b.hpRatio)[0] ?? null;
|
||||
if (best) battle.heal(unit, best, unit.s.atk * allyHealScale);
|
||||
};
|
||||
const onHit = hitStatuses.length || hasForce || hitElement || allyHeal
|
||||
@@ -235,11 +241,9 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
|
||||
for (const s of hitStatuses) ctx.battle.applyStatus(ctx.target, s.key, { duration: s.duration, source: ctx.unit });
|
||||
}
|
||||
if (hasForce && ctx.target.alive && ctx.target.side === 'enemy') {
|
||||
const dx = ctx.target.x - ctx.unit.x, dy = ctx.target.y - ctx.unit.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const dir = pull ? { x: -dx / len, y: -dy / len } : { x: dx / len, y: dy / len };
|
||||
const dist = pull ? Math.max(0, Math.min(len - 0.6, 0.5 + 0.5 * force)) : 0.5 + 0.5 * force;
|
||||
if (dist > 0) ctx.battle.displace(ctx.target, dir, dist, { force: force + 1 });
|
||||
const u = ctx.unit;
|
||||
if (pull) ctx.battle.pullToFront(ctx.target, u, force);
|
||||
else ctx.battle.push(ctx.target, force, { from: u, dir: directional && u.fwd ? { x: u.fwd[1], y: u.fwd[0] } : null, effect: effectPush });
|
||||
}
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -10,14 +10,20 @@
|
||||
// golden stays in the hand (the built-in equipped the merged golden on the copy, and its live loop over
|
||||
// the target's items skipped the item after a merge)
|
||||
// 紧急调度券 a shop operator leaves its slot only when it was actually granted (built-in cleared the slot first)
|
||||
// 寻呼模块 the special refresh shows `refresh_cnt` DIFFERENT operators when the pool allows it
|
||||
// 寻呼模块 the special refresh shows `refresh_cnt` DIFFERENT operators (fewer when the pool has no more)
|
||||
// 突变细胞 after the battle the carrier becomes a NORMAL random tier+1 operator; its other equipment goes back to
|
||||
// the hand (built-in kept it on the new operator)
|
||||
// 教鞭 / “神秘顾客” trap_create_self_choice {choice_event: hunter_band_1}: the bounty comes from the band bounty
|
||||
// family `enemyeffect_b_*` (research 04 §7: "offer 3 random enemyeffect_b_* bounties … adds 1 enemy to your
|
||||
// next battle, killer gets `coin` funds"); the built-in drew any tier ≤ II kill bounty (incl. 2-round and
|
||||
// faction bounties). [ASSUMED simplification, engine: no PERSONAL_CHOOSE overlay] 3 are drawn and one of
|
||||
// them is taken at random. Falls back to the built-in when the family is empty.
|
||||
// 教鞭 trap_create_self_choice {choice_event: hunter_band_1}: the bounty is a 战术特训 card (choices.json
|
||||
// cards.bounty payout `perfect`, e.g. 战术特训·飞行I "若各自行动阶段就达成完美作战,获得1资金") — PRTS
|
||||
// 卫戍协议:盟约 下半/PRTS盟约记录 §法术 教鞭 "于3个战术特训的悬赏任务中选择一项", §机变阶段 "※以下悬赏任务仅由
|
||||
// 法术教鞭生成", and 杜宾 加练! "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金" (user
|
||||
// playtest #6 item 4 review; the built-in drew any tier ≤ II kill bounty, and research 04 §7 [ASSUMED]
|
||||
// the band family `enemyeffect_b_*`).
|
||||
// “神秘顾客” the same trap_create_self_choice ("选择一项特殊悬赏任务"; no band grants it in act2, research 04): a band
|
||||
// bounty `enemyeffect_b_*` (research 04 §7 [ASSUMED]: "adds 1 enemy to your next battle, killer gets `coin`
|
||||
// funds").
|
||||
// Both: [ASSUMED simplification, engine: no PERSONAL_CHOOSE overlay] 3 cards whose enemy can appear in the
|
||||
// mode are drawn and one of them is taken at random; the built-in runs when the family is empty.
|
||||
// “神秘顾客” trap_disney_special: when actively destroyed, +count funds and the Art passes to the next alive player
|
||||
// (seat order, cyclic)
|
||||
// 天师古鼎 equip_with_another_gain_coin_when_gain_char: a 【炎】 carrier also holding 炎国短刀 (either quality)
|
||||
@@ -42,14 +48,18 @@ function pieceIsMember(ctx, piece, bondId) {
|
||||
return bonds.includes('maniShip') && isCoreBond(bondId) && ctx.bondActive('maniShip') && ctx.bondActive(bondId);
|
||||
}
|
||||
|
||||
/** Offer size of the personal bounty choice (research 04 §7, [ASSUMED]). */
|
||||
/** Offer size of the personal bounty choice (PRTS 法术 教鞭 "于3个战术特训的悬赏任务中选择一项"). */
|
||||
const OFFER_SIZE = 3;
|
||||
/** Bounty cards of the band bounty family `enemyeffect_b_*` whose enemy can appear in this mode. */
|
||||
function bandBounties(ctx) {
|
||||
/** cards.bounty entries matching `test` whose enemy can appear in this mode. */
|
||||
function bountyCards(ctx, test) {
|
||||
const all = ctx.data && ctx.data.choices && ctx.data.choices.cards && Array.isArray(ctx.data.choices.cards.bounty) ? ctx.data.choices.cards.bounty : [];
|
||||
const inactive = ctx.gd.inactiveEnemies instanceof Set ? ctx.gd.inactiveEnemies : new Set();
|
||||
return all.filter((c) => c && /^enemyeffect_b_\d+$/.test(String(c.effectId)) && ctx.gd.enemy(c.enemyKey) && !inactive.has(c.enemyKey));
|
||||
return all.filter((c) => c && test(c) && ctx.gd.enemy(c.enemyKey) && !inactive.has(c.enemyKey));
|
||||
}
|
||||
/** The 战术特训 cards (perfect payout: the chooser's own phase perfect) — what 教鞭 offers. */
|
||||
const isTraining = (c) => c.payout === 'perfect';
|
||||
/** The band bounty family `enemyeffect_b_*` — what “神秘顾客” offers [ASSUMED]. */
|
||||
const isBandBounty = (c) => /^enemyeffect_b_\d+$/.test(String(c.effectId));
|
||||
|
||||
function wrap(registry, key, hooks) {
|
||||
const base = registry.get(`item:${key}`) || {};
|
||||
@@ -85,7 +95,8 @@ export function registerMeta(registry) {
|
||||
const shares = (id) => { const c = ctx.gd.chess(id); return !!(c && Array.isArray(c.bonds) && c.bonds.some((b) => bonds.has(b))); };
|
||||
const ids = [];
|
||||
for (let k = 0; k < n; k++) {
|
||||
const id = ctx.rollChess({ maxTier, filter: (x) => shares(x) && !ids.includes(x) }) || ctx.rollChess({ maxTier, filter: shares });
|
||||
// never the same operator twice (user playtest #6 item 19): fewer cards when the pool has no other one
|
||||
const id = ctx.rollChess({ maxTier, filter: (x) => shares(x) && !ids.includes(x) });
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
if (ids.length) ctx.offerChess(ids, { source: 'item' });
|
||||
@@ -109,11 +120,11 @@ export function registerMeta(registry) {
|
||||
},
|
||||
}));
|
||||
|
||||
// 教鞭 / “神秘顾客” — trap_create_self_choice {choice_event}: a band bounty (enemyeffect_b_*) for the next battle
|
||||
for (const key of ['chess_item_6_03_m', 'chess_item_6_01_m']) {
|
||||
// 教鞭 / “神秘顾客” — trap_create_self_choice {choice_event}: 教鞭 a 战术特训 card, “神秘顾客” a band bounty (enemyeffect_b_*)
|
||||
for (const [key, family] of [['chess_item_6_03_m', isTraining], ['chess_item_6_01_m', isBandBounty]]) {
|
||||
wrap(registry, key, (base) => ({
|
||||
onArt(ctx, ev) {
|
||||
const cards = bandBounties(ctx);
|
||||
const cards = bountyCards(ctx, family);
|
||||
if (!cards.length) { if (typeof base.onArt === 'function') base.onArt.call(base, ctx, ev); return; }
|
||||
const offer = ctx.rng.shuffle(cards.slice()).slice(0, OFFER_SIZE);
|
||||
const card = ctx.rng.pick(offer);
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
// Operator loadouts (DESIGN §16): every selectable non-default skill of the 16 visible chess is authored in the kit's
|
||||
// `skills: { [skillId]: SkillSpec }` map from its own SkillRecord (skillRec / skillBbOf — Lv4 normal, Lv7 elite);
|
||||
// talents / traits read the resolved record, so a module choice ('none' ⇒ traitBase / talentsBase, module.active
|
||||
// false) is honoured. Per-skill triggers come from data (TANK S1 ⇒ TAKE_DAMAGE); the few spec overrides are documented
|
||||
// false) is honoured. Per-skill triggers come from data (the official 技能策略: every MANUAL 重装 skill ⇒ TAKE_DAMAGE, a
|
||||
// MANUAL skill with its own 技能范围 ⇒ SKILL_RANGE, AUTO skills keep their own rule); the few spec overrides are documented
|
||||
// at the skill (冲锋号令 AUTO ⇒ SP_FULL, 花香疗法 heal-type DEFAULT). Tests: test/content/kits_alt_t1.test.js.
|
||||
//
|
||||
// fx kinds emitted (battle.fx(kind, {x, y, …})): aoe {radius, id, skill} · zone {radius, dur, id, skill} ·
|
||||
@@ -88,10 +89,13 @@ export function enemiesInGrid(battle, unit, grid, { n = 0, priority = null, canH
|
||||
sortEnemyTargets(battle, unit, list, priority ?? unit.profile?.priority ?? null);
|
||||
return n > 0 && list.length > n ? list.slice(0, n) : list;
|
||||
}
|
||||
/** Deployed allies (no devices) whose tile is inside `grid` relative to `unit` (null ⇒ current range). */
|
||||
/**
|
||||
* Deployed allies (no devices) whose tile is inside `grid` relative to `unit` (null ⇒ current range) — never a 孤立 unit
|
||||
* (炎佑: Battle.allySelectable), as Battle.alliesInGrid.
|
||||
*/
|
||||
export function alliesInGridOf(battle, unit, grid = null) {
|
||||
const set = grid ? new Set(absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)) : (unit.rangeKeySet || new Set(unit.rangeKeys || []));
|
||||
return battle.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC));
|
||||
return battle.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC) && battle.allySelectable(a, unit));
|
||||
}
|
||||
/** Any targetable enemy in the unit's current range. */
|
||||
export const enemyInRange = (battle, unit) => battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length > 0;
|
||||
@@ -133,7 +137,7 @@ export function toggleBuff(battle, unit, key, cond, mods, extra = {}) {
|
||||
export function installAura(battle, unit, { key, select, mods, value = 0, interval = 0.5 }) {
|
||||
battle.every(interval, () => {
|
||||
if (!up(unit)) return;
|
||||
for (const a of battle.allies()) {
|
||||
for (const a of battle.alliesFor(unit)) {
|
||||
if (!select(a)) continue;
|
||||
const cur = a.findBuff(key);
|
||||
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > value && cur.timeLeft > 0.05) continue;
|
||||
@@ -356,7 +360,7 @@ export default {
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 1_02 角峰 抗寒体质: HP +max_hp, DEF +def, RES ×(1+magic_resistance). 雪原卫士: RES +magic_resistance (flat).
|
||||
// Alternate S1 体能强化 (TANK S1 ⇒ TAKE_DAMAGE trigger from data): HP +max_hp, +hp_recovery_per_sec HP per second
|
||||
// Alternate S1 体能强化 (重装 ⇒ TAKE_DAMAGE trigger from data): HP +max_hp, +hp_recovery_per_sec HP per second
|
||||
// (the 生命回复速度 attribute). Elite module PRO-Y (block 4) is a stat of the module (attr blockCnt).
|
||||
chess_char_1_02_a: (bb, chess) => {
|
||||
const s1 = skillBbOf(chess, 'skchr_yak_1');
|
||||
@@ -392,7 +396,7 @@ export default {
|
||||
// attack@max_target enemies on the line and cause attack@sluggish s of 停顿.
|
||||
// 细胞活性抑制剂: attacks inflict `damage` arts per `interval` s for `duration` s (damage_seamonster vs 【海怪】).
|
||||
// Elite module (SPT-X): stealth of enemies inside the range is cancelled.
|
||||
// Alternate S1 侵袭破坏应对 (TANK S1 ⇒ TAKE_DAMAGE trigger from data): ATK +atk, DEF +def.
|
||||
// Alternate S1 侵袭破坏应对 (重装 ⇒ TAKE_DAMAGE trigger from data): ATK +atk, DEF +def.
|
||||
chess_char_1_04_a: (bb, chess, def) => {
|
||||
const t = talentBb(chess, 0);
|
||||
const s1 = skillBbOf(chess, 'skchr_udflow_1');
|
||||
@@ -622,14 +626,18 @@ export default {
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 1_10 古米 备用军粮 (TAKE_DAMAGE, ct charges): the next attack heals the most injured ally nearby (skill grid)
|
||||
// for heal_scale × ATK. Elite module (GUA-X): targets below hp_ratio HP get ×heal_scale more.
|
||||
// 1_10 古米 备用军粮 (自动触发, ct charges — an AUTO skill: no 技能策略 applies, it keeps its own rule; PRTS 备注 "此技能
|
||||
// 在存在生命值不满的可治疗角色时可触发;技能触发后古米将切换至治疗模式(普通攻击改为治疗技能范围内的一名友方单位),直至古米
|
||||
// 完成一次普通攻击的治疗"): cast as soon as a healable ally of the skill range (x-4, herself included) is injured —
|
||||
// no enemy needed (user playtest #6: it waited until she engaged) — then her next normal attack heals the most
|
||||
// injured ally of the skill range for heal_scale × ATK instead of hitting an enemy; she makes no normal attack until
|
||||
// that heal is done. Elite module (GUA-X): targets below hp_ratio HP get ×heal_scale more.
|
||||
// 平底锅专精: each attack prob for ATK ×atk_scale and a `stun` s stun (only when that hit lands).
|
||||
// Alternate S2 食粮烹制 (PRTS notes: 10 s 缴械 at the start, the real duration is disarm + 30 s, attacks become heals
|
||||
// with the attack interval +130 %): cooking for `disarm` s — no attacks, DEF +def; then for the skill's `duration` s
|
||||
// she heals the most injured ally inside the skill grid (x-4) for ATK per attack, ATK +atk, base attack time
|
||||
// ×(1 + base_attack_time). The GUA-X heal bonus (below hp_ratio: ×heal_scale) applies to these heals too.
|
||||
// Trigger: DEFAULT (data) with her own profile — an enemy in her initial range (the tile she blocks on).
|
||||
// Trigger: TAKE_DAMAGE (data: the 重装 strategy "不受技能范围影响,受到伤害时释放技能").
|
||||
chess_char_1_10_a: (bb, chess, def) => {
|
||||
const t = talentBb(chess, 0);
|
||||
const tb = traitBb(chess);
|
||||
@@ -638,6 +646,7 @@ export default {
|
||||
const b2 = r2?.bb ?? {};
|
||||
const cook = Math.max(0, num(b2.disarm, 10));
|
||||
const cookKey = 'sunbr:cook', serveKey = 'sunbr:serve';
|
||||
const healGrid = def?.skill?.rangeGrid ?? null;
|
||||
return {
|
||||
skills: {
|
||||
[S2]: {
|
||||
@@ -661,9 +670,9 @@ export default {
|
||||
},
|
||||
},
|
||||
install(battle, unit) {
|
||||
if (unit.skill?.id !== S2 || tb.hp_ratio == null || num(tb.heal_scale, 1) === 1) return;
|
||||
// (S1 applies the module bonus inside its own heal): her S2 heals — attack heals while it runs (herself included,
|
||||
// she stands in the grid), never her natural / self regeneration
|
||||
if (tb.hp_ratio == null || num(tb.heal_scale, 1) === 1) return;
|
||||
// GUA-X on her skill heals (S1's heal-mode attack, S2's attack heals while it runs — herself included, she stands
|
||||
// in the grid), never her natural / self regeneration
|
||||
battle.on('heal', (ctx) => {
|
||||
if (ctx.source !== unit || !unit.skill?.active || ctx.opts?.regen || ctx.opts?.self) return;
|
||||
if (ctx.target.hpRatio < num(tb.hp_ratio)) ctx.amount *= num(tb.heal_scale, 1);
|
||||
@@ -671,18 +680,11 @@ export default {
|
||||
},
|
||||
skill: {
|
||||
kind: instantKind(def),
|
||||
attack: {
|
||||
onHit({ battle, unit }) {
|
||||
const grid = def?.skill?.rangeGrid;
|
||||
const keys = grid ? absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0) : unit.rangeKeys;
|
||||
const ally = battle.injuredAlliesInKeys(keys, unit)[0];
|
||||
if (!ally) return;
|
||||
let amount = unit.s.atk * num(bb.heal_scale, 1);
|
||||
if (tb.hp_ratio != null && ally.hpRatio < num(tb.hp_ratio)) amount *= num(tb.heal_scale, 1);
|
||||
battle.heal(unit, ally, amount, { tags: ['skill'] });
|
||||
battle.fx('heal', { x: ally.x, y: ally.y, id: ally.id });
|
||||
},
|
||||
},
|
||||
heal: true,
|
||||
...(healGrid ? { trigger: { rule: 'SKILL_RANGE', grid: healGrid, allies: true }, targeting: { rangeGrid: healGrid } } : {}),
|
||||
// the heal-mode attack: the most injured ally of the skill range (acquireTargets, heal profile)
|
||||
attack: { dmgType: 'heal', heal: { mode: 'single' }, healScale: num(bb.heal_scale, 1), projectile: 'none' },
|
||||
onHit({ battle, target }) { if (target) battle.fx('heal', { x: target.x, y: target.y, id: target.id }); },
|
||||
},
|
||||
talents: [{ install(battle, unit) {
|
||||
// the proc is rolled per attack hit; the stun only follows a hit that landed (not dodged / cancelled)
|
||||
@@ -757,7 +759,7 @@ export default {
|
||||
if (unit.skill?.id !== S1) return;
|
||||
battle.on('tick', () => {
|
||||
const sk = unit.skill;
|
||||
if (!up(unit) || !unit.canAct || !sk.ready || sk.active || unit.s.flags.silence) return;
|
||||
if (!up(unit) || !unit.canAct || !sk.ready || sk.active || sk.opCooling || unit.s.flags.silence) return;
|
||||
if (battle.injuredAlliesInKeys(unit.baseRangeKeys || unit.rangeKeys, unit).length) sk.activate('DEFAULT');
|
||||
}, { owner: unit });
|
||||
},
|
||||
@@ -832,9 +834,10 @@ export default {
|
||||
},
|
||||
},
|
||||
trait: {
|
||||
// with only bound enemies in range she holds her fire (the mystic trait stores the energy meanwhile)
|
||||
// with only bound enemies in range (or blocked by her — always her targets, Battle.blockedTargets) she holds her
|
||||
// fire (the mystic trait stores the energy meanwhile)
|
||||
canAttack(battle, u) {
|
||||
const ok = battle.enemiesInKeys(u.rangeKeys, u, u.profile).some((e) => !bound(e));
|
||||
const ok = battle.enemiesInKeys(u.rangeKeys, u, u.profile).some((e) => !bound(e)) || battle.blockedTargets(u, u.profile).some((e) => !bound(e));
|
||||
if (!ok) u.trait.hadTarget = false;
|
||||
return ok;
|
||||
},
|
||||
@@ -861,8 +864,11 @@ export default {
|
||||
talents: [{ install(battle, unit) {
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
if (ctx.attacker !== unit || !ctx.targets.some(bound)) return;
|
||||
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile || unit.profile).filter((e) => !bound(e));
|
||||
sortEnemyTargets(battle, unit, cands, (ctx.profile || unit.profile).priority);
|
||||
const prof = ctx.profile || unit.profile;
|
||||
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof);
|
||||
for (const e of battle.blockedTargets(unit, prof)) if (!cands.includes(e)) cands.push(e);
|
||||
for (let i = cands.length - 1; i >= 0; i--) if (bound(cands[i])) cands.splice(i, 1);
|
||||
sortEnemyTargets(battle, unit, cands, prof.priority);
|
||||
ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length));
|
||||
}, { owner: unit });
|
||||
} }],
|
||||
@@ -921,7 +927,9 @@ export default {
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 1_19 野鬃 夹枪冲锋: wider range (skill grid), ATK +atk, hits push the target away (attack@force, 中等力度).
|
||||
// 1_19 野鬃 夹枪冲锋: wider range (skill grid), ATK +atk, hits push the target away (attack@force 1 = 中力; radial — away
|
||||
// from her centre, PRTS 推与拉: every push but the 推击手' is radial) by the official 力度 − 重量 distance (Battle.push:
|
||||
// weight 0 → 2.14 tiles, 1 → 1.7, 2 → 0.44, 3 → 0.12, ≥ 4 → none; user playtest #6 item 14).
|
||||
// 一致向前: after deploying (normal flag 0: first deployment only; elite flag 1: every deployment) every undeployed
|
||||
// 【近卫】 operator of the player costs `value` less DP to deploy (≤ max_stack_cnt per operator until it deploys).
|
||||
// Alternate S1 骑枪刺击 (PASSIVE, ON_DEPLOY): for `duration` s after every deployment ASPD +attack_speed.
|
||||
@@ -946,9 +954,7 @@ export default {
|
||||
attack: {
|
||||
onHit({ battle, unit, target }) {
|
||||
if (!target || !target.alive || target.side !== 'enemy') return;
|
||||
let dx = target.x - unit.x, dy = target.y - unit.y;
|
||||
if (Math.hypot(dx, dy) < 1e-6) { dx = unit.fwd[1]; dy = unit.fwd[0]; }
|
||||
battle.displace(target, { x: dx, y: dy }, 0.5 + 0.5 * force, { force: force + 1 });
|
||||
battle.push(target, force, { from: unit });
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -981,9 +987,9 @@ export default {
|
||||
// attack@buff_prob to stun attack@stun s; afterwards 雷蛇 is stunned `stun` s.
|
||||
// 战术防御: when attacked, +sp SP to herself and to one random ally in the talent grid. Elite 雷抗: RES +magic_resistance.
|
||||
// Elite module (SPT-X): stealth of enemies inside the range is cancelled.
|
||||
// Alternate S1 充能防御 (AUTO, hurt SP; TANK S1 ⇒ TAKE_DAMAGE from data — the hit that fills SP sets it off): PRTS
|
||||
// "技能拥有8s的持续时间": for bb.duration s DEF +def, and the next damage instance taken is blocked (cancelled — a
|
||||
// blocked hit "导致第一天赋无法触发": no 战术防御 SP, no hurt SP).
|
||||
// Alternate S1 充能防御 (AUTO, hurt SP, "技能自动开启" — an AUTO skill takes no 技能策略: SP_FULL, so the hit that fills SP
|
||||
// sets it off): PRTS "技能拥有8s的持续时间": for bb.duration s DEF +def, and the next damage instance taken is blocked
|
||||
// (cancelled — a blocked hit "导致第一天赋无法触发": no 战术防御 SP, no hurt SP).
|
||||
chess_char_1_20_a: (bb, chess) => {
|
||||
const t = talentBb(chess, 0);
|
||||
const grid = talentGrid(chess, 0);
|
||||
@@ -993,7 +999,7 @@ export default {
|
||||
return {
|
||||
skills: {
|
||||
[S1]: {
|
||||
kind: 'duration', duration: num(b1.duration, 8), mods: { defPct: num(b1.def) },
|
||||
kind: 'duration', duration: num(b1.duration, 8), mods: { defPct: num(b1.def) }, trigger: 'SP_FULL',
|
||||
onStart({ battle, unit, skill }) {
|
||||
battle.addBuff(unit, { key: blockKey, duration: skill.duration + 0.05, tags: ['skill'], visible: true });
|
||||
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id });
|
||||
|
||||
@@ -18,36 +18,10 @@ import { canTargetEnemy } from '../../targeting.js';
|
||||
import { bodyInKeys } from '../../body.js';
|
||||
import {
|
||||
num, talentBb, moduleBb, traitBb, up, cheb, byEnemyAttack, onHitBy, onHitOn, onDamagedOn, enemiesInGrid,
|
||||
alliesInGridOf, enemyInRange, statBuff, toggleBuff, installAura, spTimeBonus, freeTileAround, summonTileFree, instantKind,
|
||||
alliesInGridOf, enemyInRange, statBuff, toggleBuff, installAura, spTimeBonus, freeTileAround, instantKind,
|
||||
tinmanKit, batMod,
|
||||
} from './tier1.js';
|
||||
|
||||
/**
|
||||
* The tokens.json variant a summon of `owner` gets — its chess (else the normal `_a` sibling, else the first variant)
|
||||
* with the owner's selected skill / module merged, as simdata getToken does — for fields normalizeToken drops (the
|
||||
* withdraw skill's duration). Exact when two players of one field give the same chess different loadouts (DESIGN §16).
|
||||
*/
|
||||
function ownTokenVariant(battle, tokId, owner) {
|
||||
const vs = battle.tokenDef(tokId, owner)?.raw?.variants;
|
||||
if (!vs || typeof vs !== 'object') return null;
|
||||
const oid = String(owner?.defId ?? '');
|
||||
let v = vs[oid] ?? vs[oid.replace(/_b$/, '_a')] ?? Object.values(vs)[0] ?? null;
|
||||
const lo = owner?.def?.loadout;
|
||||
if (v && lo && !lo.isDefault) {
|
||||
if (!lo.skillIsDefault && v.bySkill?.[lo.skillIndex]) v = { ...v, ...v.bySkill[lo.skillIndex] };
|
||||
if (!lo.moduleIsDefault && v.byModule?.[lo.moduleId]) v = { ...v, ...v.byModule[lo.moduleId] };
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Lifetime (s) of a token `owner` summons: its own withdraw skill duration, else "N秒后自动销毁" in the owner's skill text. */
|
||||
function tokenLifetime(battle, tokId, owner, desc) {
|
||||
const raw = battle.tokenDef(tokId, owner)?.raw;
|
||||
const v = num(ownTokenVariant(battle, tokId, owner)?.skill?.duration, num(raw?.skill?.duration));
|
||||
if (v > 0) return v;
|
||||
const m = String(desc || '').match(/(\d+(?:\.\d+)?)秒后自动销毁/);
|
||||
return m ? +m[1] : 0;
|
||||
}
|
||||
import { releaseSkillSummon } from '../tokens.js';
|
||||
|
||||
/** Largest element gauge of a unit (every element — 侵蚀 included: the bosses' attacks fill it on operators). */
|
||||
const elemLoad = (a) => Math.max(a.elem.burn, a.elem.neural, a.elem.necrosis, a.elem.apoptosis, a.elem.erosion ?? 0);
|
||||
@@ -92,10 +66,12 @@ export default {
|
||||
}),
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 2_02 赫默 医疗无人机 (AUTO, heal trigger): deploy a medical drone (overrideTokenKey, stats from tokens.json variants)
|
||||
// next to the most injured ally in range; it heals around itself and self-destructs after its lifetime; at most `cnt`
|
||||
// drones at once. 强化注射: every 【医疗】 operator on the field ASPD +attack_speed. Elite module (PHY-Y): heals on
|
||||
// ground units ×heal_scale (module 'none': no bonus).
|
||||
// 2_02 赫默 医疗无人机 (AUTO, heal trigger): "获得一个医疗无人机 / 最多可库存1个无人机;无人机投入战场后治疗周围友军,
|
||||
// 10秒后自动销毁" — the 医疗探机 is a hand piece the player places (user playtest #6; PRTS 卫戍协议/帮助): each cast gives
|
||||
// one (stock ≤ cnt) and the placed piece takes the field on its own tile (tokens.js releaseSkillSummon: not at the
|
||||
// battle start — user playtest #4; not placed ⇒ no drone); it heals around itself and self-destructs after 10 s (token
|
||||
// kit). 强化注射: every 【医疗】 operator on the field ASPD +attack_speed. Elite module (PHY-Y): heals on ground units
|
||||
// ×heal_scale (module 'none': no bonus).
|
||||
// S1 治疗强化·γ型 (alt): ATK +atk for its duration (no drone).
|
||||
chess_char_2_02_a: (bb, chess) => {
|
||||
const tokId = chess?.skill?.overrideTokenKey ?? (chess?.tokens ?? [])[0] ?? 'token_10000_silent_healrb';
|
||||
@@ -105,23 +81,7 @@ export default {
|
||||
return {
|
||||
skill: {
|
||||
kind: 'instant', heal: true,
|
||||
onStart({ battle, unit }) {
|
||||
const focus = battle.injuredAlliesInKeys(unit.rangeKeys, unit)[0] ?? unit;
|
||||
let best = null, bd = Infinity;
|
||||
for (const k of unit.rangeKeys || []) {
|
||||
const r = (k / COLS) | 0, c = k % COLS;
|
||||
if (!summonTileFree(battle, r, c) || !battle.grid.canStand(r, c, { ranged: true })) continue;
|
||||
const d = Math.max(Math.abs(r - focus.tileR), Math.abs(c - focus.tileC)) + 0.01 * (Math.abs(r - unit.tileR) + Math.abs(c - unit.tileC));
|
||||
if (d < bd) { bd = d; best = [r, c]; }
|
||||
}
|
||||
if (!best) return;
|
||||
const drones = (unit.mem.drones ?? []).filter((d) => d.alive);
|
||||
while (drones.length >= cnt) battle.retreat(drones.shift(), { reason: 'expired', permanent: true });
|
||||
const life = tokenLifetime(battle, tokId, unit, chess?.skill?.desc);
|
||||
const d = battle.spawnToken(unit, tokId, best[0], best[1], life > 0 ? { duration: life } : {});
|
||||
if (d) { drones.push(d); battle.fx('summon', { x: d.x, y: d.y, id: d.id, token: tokId }); }
|
||||
unit.mem.drones = drones;
|
||||
},
|
||||
onStart({ battle, unit }) { releaseSkillSummon(battle, unit, tokId, { cap: cnt }); },
|
||||
},
|
||||
skills: { 'skcom_heal_up[3]': { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } } },
|
||||
talents: [{ install(battle, unit) {
|
||||
@@ -135,8 +95,9 @@ export default {
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 2_03 崖心 (hidden) 束缚链: up to max_target enemies in the large front grid are dragged (force 1 = 中等) to the tile in front
|
||||
// of her, take atk_scale × ATK true damage and are stunned `stun` s. 雪境猎手: ATK/DEF +atk/+def while not blocking.
|
||||
// 2_03 崖心 (hidden) 束缚链: up to max_target enemies in the large front grid are dragged (force 1 = 中力; Battle.pullToFront:
|
||||
// the official 力度 − 重量 pull — weight ≤ 1 all the way in front of her, 2 a third of the way, 3 barely, ≥ 4 not at all),
|
||||
// take atk_scale × ATK true damage and are stunned `stun` s. 雪境猎手: ATK/DEF +atk/+def while not blocking.
|
||||
// Elite module (HOK-X, trait value/dist): dragged enemies take `value` arts per `dist` tiles travelled.
|
||||
chess_char_2_03_a: (bb, chess, def) => {
|
||||
const t = talentBb(chess, 0);
|
||||
@@ -147,11 +108,9 @@ export default {
|
||||
kind: 'instant',
|
||||
onStart({ battle, unit }) {
|
||||
const foes = enemiesInGrid(battle, unit, def?.skill?.rangeGrid ?? null, { n: Math.max(1, Math.floor(num(bb.max_target, 1))) });
|
||||
const fx0 = unit.tileC + unit.fwd[1], fy0 = unit.tileR + unit.fwd[0];
|
||||
for (const e of foes) {
|
||||
const sx = e.x, sy = e.y;
|
||||
const dx = fx0 - e.x, dy = fy0 - e.y, dist = Math.hypot(dx, dy);
|
||||
const moved = dist > 0.05 ? battle.displace(e, { x: dx, y: dy }, dist, { force: force + 1 }) : 0; // same force mapping as generic.js
|
||||
const moved = battle.pullToFront(e, unit, force);
|
||||
battle.fx('pull', { x: e.x, y: e.y, id: e.id, fromX: sx, fromY: sy });
|
||||
if (moved > 0 && num(tb.value) > 0) {
|
||||
battle.dealDamage(unit, e, { amount: num(tb.value) * moved / Math.max(0.01, num(tb.dist, 1)), type: 'arts', isSkill: true, canDodge: false, tags: ['drag'] });
|
||||
@@ -698,7 +657,7 @@ export default {
|
||||
battle.every(1, () => {
|
||||
if (!up(unit)) return;
|
||||
const amt = unit.s.atk * r;
|
||||
for (const a of battle.allies()) if (a.hp < a.s.maxHp) battle.heal(unit, a, amt, { aura: true, tags: ['talent'] });
|
||||
for (const a of battle.alliesFor(unit)) if (a.hp < a.s.maxHp) battle.heal(unit, a, amt, { aura: true, tags: ['talent'] });
|
||||
}, { owner: unit });
|
||||
} }],
|
||||
};
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
// Profession defaults (server/sim/professions.js) are kept and extended: fastshot fly bonus, instructor, reaperrange,
|
||||
// hunter ammo, funnel ramp, phalanx guard, merchant drain, tactician reinforcement, underminer module weaken.
|
||||
// Summons: the match hands 伺夜's 狼群 and 巫恋's 诅咒娃娃 to the player as placeable board pieces (tokens.js kits);
|
||||
// these kits adopt/replace such pieces instead of adding a second pack/doll, and every summon tile avoids the home
|
||||
// tile of an ally that has not (re)deployed yet (freeTile). Offensive skills whose own range differs from the attack
|
||||
// 伺夜's kit adopts his pack piece instead of adding a second pack, 巫恋's S2 brings her placed doll onto its tile
|
||||
// (tokens.js releaseSkillSummon), and every summon tile avoids the home tile of an ally that has not (re)deployed yet
|
||||
// (freeTile). Offensive skills whose own range differs from the attack
|
||||
// range trigger on that skill range (CUSTOM_RANGE: 松果 shortened range, 见行者 push row — research 03 §1.4).
|
||||
//
|
||||
// Client VFX (battle.fx kinds used here, all `{x, y, id, …extra}`): 'aoe' (radius, dmgType), 'strike' (special single
|
||||
@@ -20,18 +21,19 @@
|
||||
// selectable skill of the visible chess, each built from its OWN record at the chess's level (skillData: Lv4 normal /
|
||||
// Lv7 elite blackboard, range, charges) — the loader picks the selected one. Talents / trait / install are shared: the
|
||||
// parts that belong to one skill only (琳琅诗怀雅 bombs, 菲莱 counters, 雪猎 special bullets…) check the selected skill id.
|
||||
// Triggers come from the data record of each skill (skills[i].trigger), except offensive skills with their own range
|
||||
// (CUSTOM_RANGE on that range, research 03 §1.4) and 薄绿 S1 (阵法术师 技能1 row SEARCH = "初始攻击范围内出现敌人后自动释放",
|
||||
// research 03 C1 ⇒ DEFAULT). Non-default modules: 能天使 MAR-Y (ASPD vs ground), 琳琅诗怀雅 MER-Y (ATK per payment),
|
||||
// Triggers come from the data record of each skill (skills[i].trigger: tools/build-data.mjs resolveTrigger — the official
|
||||
// 技能策略 incl. SKILL_RANGE for a MANUAL skill's own 技能范围 and the class rows for every MANUAL skill), except
|
||||
// 薄绿 S1 (the 阵法术师 row SEARCH = "在初始攻击范围内存在敌人时" ⇒ the engine's DEFAULT for a phalanx, checked every
|
||||
// tick) and the kits' own automatic casts (雪猎 special bullets). Non-default modules: 能天使 MAR-Y (ASPD vs ground), 琳琅诗怀雅 MER-Y (ATK per payment),
|
||||
// 斯卡蒂 DRE-X (× vs blocked), 瑕光 GUA-X (heal × under 50 %), 伺夜 TAC-Y (×165 % trait, pack-blocked enemies taunt +1),
|
||||
// 空弦 MAR-X (fly ×, profession layer). No in-battle effect here: 忍冬 SOL-Y "首次部署时部署费用-4" (the initial
|
||||
// deployment is free) and the 集成战略-only ISW-A modules of 琳琅诗怀雅 / 空弦 (their stats and trait cost still apply).
|
||||
|
||||
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../../targeting.js';
|
||||
import { COLS } from '../../constants.js';
|
||||
import { bodyDist, bodyInKeys, bodyOnTile, bodyTileReach } from '../../body.js';
|
||||
import { COLS, PUSH_DIRECTIONAL_MIN_DIST } from '../../constants.js';
|
||||
import { bodyDist, bodyInKeys, bodyOnTile } from '../../body.js';
|
||||
import { normalizeChess, normalizeSkill } from '../../simdata.js';
|
||||
import { tacticalPoint as sharedTacticalPoint } from '../tokens.js';
|
||||
import { tacticalPoint as sharedTacticalPoint, releaseSkillSummon } from '../tokens.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// helpers
|
||||
@@ -113,6 +115,8 @@ const copyGrid = (g) => (Array.isArray(g) && g.length ? g.map((p) => [p[0], p[1]
|
||||
const NINE = [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]];
|
||||
/** Two enemy bodies touch within this distance (tiles) — 见行者 collision stun. */
|
||||
const COLLIDE = 0.6;
|
||||
/** 忍冬 S3's 迷彩 (until her next cast): its own buff key, never merged with another unit status. */
|
||||
const CAMOU_KEY = 'vulpis:camou';
|
||||
const CN_NUM = { 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 };
|
||||
/** Number captured by `re` (arabic or a single chinese numeral) in `text`, else `fallback`. */
|
||||
function textNum(text, re, fallback) {
|
||||
@@ -174,6 +178,7 @@ function aura(battle, unit, o) {
|
||||
const list = o.side === 'ally' ? battle.allyUnits : battle.enemies;
|
||||
for (const x of list) {
|
||||
if (!x.alive || !x.deployed || x.hidden || x.kind === 'device') continue;
|
||||
if (o.side === 'ally' && !battle.allySelectable(x, unit)) continue; // never a 孤立 unit (炎佑)
|
||||
if (!onTiles(x, set) || (o.filter && !o.filter(x))) continue;
|
||||
const mods = typeof o.mods === 'function' ? o.mods(x) : o.mods;
|
||||
if (!mods) continue;
|
||||
@@ -447,6 +452,7 @@ const KITS = {
|
||||
let best = null;
|
||||
for (const a of battle.allyUnits) {
|
||||
if (a === unit || !alive(a) || a.hidden || a.kind === 'device' || a.hpRatio >= healRatio) continue;
|
||||
if (a.s.flags.noHeal || a.profile?.noHeal) continue; // 禁疗 / 孤立: never a heal target
|
||||
if (Math.max(Math.abs(a.tileR - unit.tileR), Math.abs(a.tileC - unit.tileC)) !== 1) continue; // 周围八格
|
||||
if (!best || a.hpRatio < best.hpRatio || (a.hpRatio === best.hpRatio && a.deploySeq < best.deploySeq)) best = a;
|
||||
}
|
||||
@@ -486,7 +492,8 @@ const KITS = {
|
||||
if (!e) break;
|
||||
spent++;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * cash, type: 'phys', isSkill: true, tags: ['skill', 'swire2Cash'] });
|
||||
if (e.alive) battle.displace(e, { x: unit.fwd[1], y: unit.fwd[0] }, 0.5 + 0.5 * force, { force: force + 1 });
|
||||
// "将目标小力地向前推开": a directional push along her direction (Battle.push, official 力度 − 重量 distance)
|
||||
if (e.alive) battle.push(e, force, { from: unit, dir: { x: unit.fwd[1], y: unit.fwd[0] } });
|
||||
}
|
||||
fx(battle, 'coin', unit, { n: 0, spent, skill: 'swire2_3' });
|
||||
},
|
||||
@@ -719,10 +726,14 @@ const KITS = {
|
||||
const keys = new Set(gridKeys(skillGrid ?? unit.rangeGrid, unit));
|
||||
const victims = battle.enemies.filter((e) => e.alive && !e.hidden && !e.s.flags.untargetable && onTiles(e, keys));
|
||||
const pushed = new Set(victims);
|
||||
const dist = 0.5 + 0.5 * force; // engine displacement convention (docs/SIM.md §12)
|
||||
for (const e of victims) {
|
||||
const expect = e.isBoss ? 0 : dist * Math.max(0, Math.min(1, 1 - 0.25 * Math.max(0, e.s.massLevel - (force + 1))));
|
||||
const moved = battle.displace(e, { x: unit.fwd[1], y: unit.fwd[0] }, dist, { force: force + 1 });
|
||||
// "往身前方向…推开": a directional push whose angle is fixed — PRTS 备注 "不会因为角度过大而改变推动的方向或削减力度"
|
||||
// (only the angle: a target nearer than 0.25 tile still turns radial at 受力等级 −2) — by the official
|
||||
// 力度 − 重量 distance of a 特效 push (PRTS 推与拉 names 见行者's skills 特效类; Battle.push / pushDistance);
|
||||
// stopped short of it ⇒ it hit a wall
|
||||
const near = Math.hypot(e.x - unit.x, e.y - unit.y) < PUSH_DIRECTIONAL_MIN_DIST;
|
||||
const expect = battle.pushDistance(e, near ? force - 2 : force, { effect: true });
|
||||
const moved = battle.push(e, force, { from: unit, dir: { x: unit.fwd[1], y: unit.fwd[0] }, fixedAngle: true, effect: true });
|
||||
const wall = expect > 0 && moved + 0.05 < expect;
|
||||
battle.applyStatus(e, 'stun', { duration: wall ? sWall : sDirect, source: unit });
|
||||
}
|
||||
@@ -735,9 +746,8 @@ const KITS = {
|
||||
}
|
||||
fx(battle, 'push', unit, { skill: 'forcer_2', n: victims.length });
|
||||
},
|
||||
// "立即将范围内所有敌人…": fires once an enemy is inside the skill range (research 03 §1.4), not only when one
|
||||
// stands on her 2-tile attack range
|
||||
...(skillGrid ? { trigger: { rule: 'CUSTOM_RANGE', grid: skillGrid } } : {}),
|
||||
// "立即将范围内所有敌人…": the data rule SKILL_RANGE fires it once an enemy is inside the skill range (PRTS 技能
|
||||
// 策略 "仅在技能范围内存在敌人(无视其不可选中)时释放技能"), not only when one stands on her 2-tile attack range
|
||||
},
|
||||
talents: [{ install(battle, unit) {
|
||||
battle.on('hit', (ctx) => {
|
||||
@@ -758,14 +768,14 @@ const KITS = {
|
||||
return kit;
|
||||
},
|
||||
|
||||
// ---- 3_08 薄绿 · 阵法术师 — S2 聚能涡旋: each hit pulls the target in front of her (splash arts), end-of-skill burst on
|
||||
// ---- 3_08 薄绿 · 阵法术师 — S2 聚能涡旋: each hit pushes the target towards her (splash arts), end-of-skill burst on
|
||||
// every enemy in range; 地质学者: DEF aura (skill off) / less likely targeted (skill on);
|
||||
// 精锐 module PLX-X: keeps part of the guard (DEF/RES) while the skill runs
|
||||
// S1 风语: wider range (skill grid), attacks at attack@atk_scale (群体 arts splash of the trait). Auto-cast: the data
|
||||
// rule SEARCH is the 阵法术师 技能1 row, officially "初始攻击范围内出现敌人后自动释放" (research 03 Addendum C1,
|
||||
// BWIKI) = an enemy inside her INITIAL range — the engine's DEFAULT rule, checked every tick for a unit that does
|
||||
// not attack while its skill is off — not any enemy on the field (the sim's generic SEARCH, research 03 §1.4
|
||||
// [ASSUMED]): she would burn the skill on enemies that just spawned.
|
||||
// rule SEARCH is the 阵法术师 row (PRTS 卫戍协议/帮助 "不受基础策略影响,在初始攻击范围内存在敌人时释放技能"; it
|
||||
// covers every MANUAL skill of the class — user playtest #6) = an enemy inside her INITIAL range — the engine's
|
||||
// DEFAULT rule, checked every tick for a unit that does not attack while its skill is off — not any enemy on the
|
||||
// field: she would burn the skill on enemies that just spawned.
|
||||
chess_char_3_08_a: (bb, chess, def) => {
|
||||
const d = defOf(chess, def);
|
||||
const t0 = talentBb(d, 0);
|
||||
@@ -805,10 +815,9 @@ const KITS = {
|
||||
atkScale: num(bb['attack@atk_scale'], 1),
|
||||
onHit({ battle, unit, target }) {
|
||||
if (!target || !target.alive || target.side !== 'enemy') return;
|
||||
const dx = unit.tileC + unit.fwd[1] - target.x, dy = unit.tileR + unit.fwd[0] - target.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < 0.05) return;
|
||||
if (battle.displace(target, { x: dx, y: dy }, Math.min(len, 0.5 + 0.5 * pullForce), { force: pullForce + 1 }) > 0) fx(battle, 'pull', target, { src: unit.id });
|
||||
// PRTS 备注: "此技能的“拖拽”机制实际为反方向(指向薄绿方向)的推开" — a radial push towards her by the
|
||||
// official 力度 − 重量 push distance (小力 vs weight 1: 0.44 tiles), never past her (Battle.push inward)
|
||||
if (battle.push(target, pullForce, { from: unit, inward: true }) > 0) fx(battle, 'pull', target, { src: unit.id });
|
||||
},
|
||||
},
|
||||
onStart: guardOn,
|
||||
@@ -976,7 +985,7 @@ const KITS = {
|
||||
if (sel === S2 || sel == null) {
|
||||
battle.on('tick', () => {
|
||||
const sk = unit.skill;
|
||||
if (!alive(unit) || !unit.canAct || !sk || !sk.ready || unit.s.flags.silence || (unit.trait.ammo ?? 1) > 0) return;
|
||||
if (!alive(unit) || !unit.canAct || !sk || !sk.ready || sk.opCooling || unit.s.flags.silence || (unit.trait.ammo ?? 1) > 0) return;
|
||||
if (battle.enemiesInKeys(unit.baseRangeKeys || unit.rangeKeys, unit, unit.profile).length) sk.activate('DEFAULT');
|
||||
}, { owner: unit });
|
||||
}
|
||||
@@ -994,7 +1003,7 @@ const KITS = {
|
||||
// ---- 3_12 瑕光 · 守护者 — S3 先贤化身: ATK/DEF +, bonus arts per hit, heals another nearby ally per attack;
|
||||
// 剑盾骑士: hurt-SP skills of the team also gain SP on attack; 仁慈: attacks sleeping enemies (first, ×atk_scale);
|
||||
// 精锐 module GUA-Y: damage taken −15 %
|
||||
// S1 光芒涌动 (TAKE_DAMAGE, charges): next attack ×atk_scale phys + heals the most injured ally of the 3×3 (herself
|
||||
// S1 光芒涌动 (自动触发 ⇒ DEFAULT, charges): next attack ×atk_scale phys + heals the most injured ally of the 3×3 (herself
|
||||
// included) for heal_scale × ATK; S2 慑敌辉光: ATK +, puts every ground enemy on her own tile and every enemy she
|
||||
// blocks to sleep (PRTS 备注; for the skill's duration: no own value in the data) and heals every ally of the
|
||||
// skill range by ATK × ratio each second;
|
||||
@@ -1176,47 +1185,18 @@ const KITS = {
|
||||
return kit;
|
||||
},
|
||||
|
||||
// ---- 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 doll is never a prep hand card) whose 3×3 aura lowers
|
||||
// enemy ATK/DEF, gone after 15 s; 溃败暗示: low-HP enemies fragile
|
||||
// ---- 3_15 巫恋 · 削弱者 (hidden) — S2 诅咒娃娃 "获得一个诅咒娃娃(最多可库存1个)": the doll is a hand piece the player
|
||||
// places (user playtest #6; PRTS 卫戍协议/帮助); each cast gives one and the placed piece takes the field on its
|
||||
// own tile (tokens.js releaseSkillSummon: not at the battle start; not placed ⇒ no doll); its token kit keeps the
|
||||
// 3×3 ATK/DEF aura (the token skill's bb) for 15 s; 溃败暗示: low-HP enemies fragile
|
||||
chess_char_3_15_a: (bb, chess, def) => {
|
||||
const d = defOf(chess, def);
|
||||
const t0 = talentBb(d, 0);
|
||||
const tokenId = chess?.skill?.overrideTokenKey ?? (d.tokens || []).find((t) => /doll/.test(String(t))) ?? 'token_10006_vodfox_doll';
|
||||
const debuff = { atkPct: num(bb.atk), defPct: num(bb.def) };
|
||||
const dollKit = () => ({
|
||||
skill: null,
|
||||
trait: { noAttack: true },
|
||||
install(battle, doll) {
|
||||
aura(battle, doll, { key: 'skill:vodfox_doll', side: 'enemy', interval: 0.1, tiles: () => doll.rangeKeySet, mods: debuff });
|
||||
},
|
||||
});
|
||||
return {
|
||||
skill: {
|
||||
kind: 'instant',
|
||||
onStart({ battle, unit }) {
|
||||
const tok = battle.tokenDef(tokenId, unit); // the owner's skill / module variant (DESIGN §16)
|
||||
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 — 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;
|
||||
for (const k of unit.rangeKeys || []) {
|
||||
const r = (k / COLS) | 0, c = k % COLS;
|
||||
if (!freeTile(battle, r, c) || !battle.grid.canStand(r, c, { ranged: true })) continue;
|
||||
let s = 0;
|
||||
for (const e of foes) if (bodyTileReach(e, r, c) <= reach) s++;
|
||||
const dd = Math.abs(r - unit.tileR) + Math.abs(c - unit.tileC);
|
||||
if (s > bs || (s === bs && dd < bd)) { best = [r, c]; bs = s; bd = dd; }
|
||||
}
|
||||
if (!best) return;
|
||||
const doll = battle.spawnToken(unit, tokenId, best[0], best[1], { duration: life, untargetable: true, kit: dollKit() });
|
||||
unit.mem.doll = doll;
|
||||
if (doll) fx(battle, 'summon', doll, { src: unit.id, token: tokenId });
|
||||
},
|
||||
onStart({ battle, unit }) { releaseSkillSummon(battle, unit, tokenId); },
|
||||
},
|
||||
talents: [{ install(battle, unit) {
|
||||
aura(battle, unit, {
|
||||
@@ -1283,10 +1263,15 @@ const KITS = {
|
||||
},
|
||||
|
||||
// ---- 3_18 忍冬 · 尖兵 — S3 隐狐之艺: +DP, range +1, ATK +, decaying ASPD, hits all blocked, 0.2 s stun per hit,
|
||||
// camouflage after a kill until the next cast; 追凶: bonus arts on marked enemies; 蓄势: DP regen + out-of-combat
|
||||
// after a kill during the skill 迷彩 from its end until the next cast ("进入迷彩状态,直至下一次开启技能") — the
|
||||
// `camou` status under its own key (ba.camou "不阻挡时不成为敌方普通攻击的目标": only the enemy she blocks attacks
|
||||
// her, as with 隐匿; it used to be the generic `stealth` = 隐匿 under the key `stealth`, shared with 伪装服's timed
|
||||
// 隐匿, which the unending one then made permanent, and it counted as 隐匿 for 叙拉古 / 家族徽章 — user playtest #6
|
||||
// "叙拉古阵营隐身不会结束": the bond's 隐匿 ends on time, this 迷彩 officially lasts); 追凶: bonus arts on marked
|
||||
// enemies; 蓄势: DP regen + out-of-combat
|
||||
// regen; 精锐 module SOL-X: ATK/DEF + while blocking
|
||||
// S1 小施惩戒 (charges): next attack + extra_damage_ratio × ATK arts and +cost DP; S2 坠刃拷问 (charges, fires
|
||||
// once an enemy is inside its own range 3-12): +cost DP, ≤ max_target enemies of that range take atk_scale arts and
|
||||
// S1 小施惩戒 (charges): next attack + extra_damage_ratio × ATK arts and +cost DP; S2 坠刃拷问 (charges, the data rule
|
||||
// SKILL_RANGE: fires once an enemy is inside its own range 3-12, charges 3 s apart): +cost DP, ≤ max_target enemies of that range take atk_scale arts and
|
||||
// 停顿 — the ones already 停顿 are also stunned. (SOL-Y "首次部署时部署费用-4": the initial deployment is free.)
|
||||
chess_char_3_18_a: (bb, chess, def) => {
|
||||
const d = defOf(chess, def);
|
||||
@@ -1300,7 +1285,7 @@ const KITS = {
|
||||
mods: { atkPct: num(bb.atk) },
|
||||
attack: { hitAllBlocked: true, onHitStatus: { key: 'stun', duration: num(bb['attack@stun'], 0.2) } },
|
||||
onStart({ battle, unit, skill }) {
|
||||
battle.removeStatus(unit, 'stealth');
|
||||
battle.removeBuff(unit, CAMOU_KEY);
|
||||
unit.mem.vulpisKill = false;
|
||||
const dp = num(bb.cost, 0);
|
||||
if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); }
|
||||
@@ -1315,7 +1300,7 @@ const KITS = {
|
||||
onEnd({ battle, unit, reason }) {
|
||||
battle.removeBuff(unit, 'skill:vulpis_aspd');
|
||||
if (reason !== 'death' && unit.alive && unit.mem.vulpisKill) {
|
||||
battle.applyStatus(unit, 'stealth', { source: unit });
|
||||
battle.addBuff(unit, { key: CAMOU_KEY, flags: { camou: true }, status: 'camou', source: unit });
|
||||
fx(battle, 'camouflage', unit);
|
||||
}
|
||||
},
|
||||
@@ -1329,12 +1314,11 @@ const KITS = {
|
||||
if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); }
|
||||
} },
|
||||
}),
|
||||
skchr_vulpis_2: (s) => { // 坠刃拷问 — air units too (PRTS 备注 "※可对空")
|
||||
skchr_vulpis_2: (s) => { // 坠刃拷问 — air units too (PRTS 备注 "※可对空"); data trigger SKILL_RANGE (its 3-12 range)
|
||||
const grid = copyGrid(s.rangeGrid);
|
||||
const n = Math.max(1, Math.floor(num(s.bb.max_target, 6)));
|
||||
return {
|
||||
kind: instantKindOf(s),
|
||||
...(grid ? { trigger: { rule: 'CUSTOM_RANGE', grid } } : {}),
|
||||
onStart({ battle, unit }) {
|
||||
const dp = num(s.bb.cost, 0);
|
||||
if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); }
|
||||
@@ -1575,8 +1559,11 @@ const KITS = {
|
||||
},
|
||||
},
|
||||
skills: altSkills(chess, d, bb, {
|
||||
// (自动触发: an AUTO skill takes no 技能策略 — the 战术家 row is for MANUAL skills — and this DP skill fires as soon
|
||||
// as it is ready, as before)
|
||||
skchr_vigil_1: (s) => ({
|
||||
kind: 'instant',
|
||||
trigger: 'SP_FULL',
|
||||
onStart({ battle, unit }) {
|
||||
dpGain(battle, unit, num(s.bb.cost, 0));
|
||||
const w = wolfOf(unit);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// (精锐 `_b`) id: `bb` is the Lv4 / Lv7 skill blackboard, talent/trait blackboards come from `def` (module talent and
|
||||
// trait upgrades of elites are already merged into the data), `def.raw.module.active` tells whether the module runs.
|
||||
// Every number comes from a blackboard (skill `bb`, `def.talents[i].bb`, `def.traitBb`, token data); literals below
|
||||
// are only fall-backs for missing keys or documented [ASSUMED] shapes (tornado radius, device placement …).
|
||||
// are only fall-backs for missing keys or documented [ASSUMED] shapes (tornado radius …).
|
||||
//
|
||||
// Notes shared by several kits:
|
||||
// - `base_attack_time` in skill blackboards is a FLAT change of the base attack time in seconds (白面鸮 −1.8 on 2.85 s
|
||||
@@ -30,7 +30,7 @@ import { bodyDist, bodyInKeys } from '../../body.js';
|
||||
import { absoluteRangeKeys, canTargetEnemy, sortEnemyTargets } from '../../targeting.js';
|
||||
import { normalizeChess } from '../../simdata.js';
|
||||
import { aggregateMods } from '../../buffs.js';
|
||||
import { dirFromDelta, frontOf, offsetTile } from '../../dir.js';
|
||||
import { CAT_SHIELD_KEY } from '../tokens.js';
|
||||
|
||||
const TICK_EPS = 0.01; // minimal status duration (s)
|
||||
const AURA = 0.2; // aura refresh period (s)
|
||||
@@ -53,24 +53,6 @@ const nationOf = (u) => u?.def?.raw?.nationId ?? null;
|
||||
const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId ?? u?.def?.raw?.charId);
|
||||
const enemyHasTag = (e, tag) => !!e && e.side === 'enemy' && Array.isArray(e.def?.tags) && e.def.tags.includes(tag);
|
||||
const keySet = (unit) => unit.rangeKeySet || new Set(unit.rangeKeys || []);
|
||||
/**
|
||||
* The tokens.json variant a summon of `owner` gets — its chess (else the normal `_a` sibling, else the first variant)
|
||||
* with the owner's selected skill / module merged, as simdata getToken does — for fields normalizeToken drops
|
||||
* (stats.deployLimit). Exact when two players of one field give the same chess different loadouts (DESIGN §16).
|
||||
*/
|
||||
function ownTokenVariant(battle, tokenId, owner) {
|
||||
const vs = battle.tokenDef(tokenId, owner)?.raw?.variants;
|
||||
if (!vs || typeof vs !== 'object') return null;
|
||||
const oid = String(owner?.defId ?? '');
|
||||
let v = vs[oid] ?? vs[oid.replace(/_b$/, '_a')] ?? Object.values(vs)[0] ?? null;
|
||||
const lo = owner?.def?.loadout;
|
||||
if (v && lo && !lo.isDefault) {
|
||||
if (!lo.skillIsDefault && v.bySkill?.[lo.skillIndex]) v = { ...v, ...v.bySkill[lo.skillIndex] };
|
||||
if (!lo.moduleIsDefault && v.byModule?.[lo.moduleId]) v = { ...v, ...v.byModule[lo.moduleId] };
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Every living, visible enemy standing on the unit's range (ignores stealth/untargetable: auras, reveals). */
|
||||
function enemiesOnRange(battle, unit, keys = null) {
|
||||
const set = keys || keySet(unit);
|
||||
@@ -102,7 +84,7 @@ function toggleBuff(battle, unit, key, on, mods) {
|
||||
/** "技力光环(同类效果取最高)": shared key, highest value wins. */
|
||||
function spAura(battle, source, value, filter) {
|
||||
whileDeployed(battle, source, AURA, () => {
|
||||
for (const a of battle.allies(source.ownerId)) {
|
||||
for (const a of battle.alliesFor(source, source.ownerId)) {
|
||||
if (!filter(a)) continue;
|
||||
const cur = a.findBuff('aura:spRecovery');
|
||||
if (cur && cur.source !== source && cur.source?.alive && (cur.data?.v ?? 0) > value) continue;
|
||||
@@ -128,16 +110,12 @@ function inspire(battle, target, val, src, stat = 'atk') {
|
||||
}
|
||||
/** Reveal stealthed enemies on the given tiles. */
|
||||
const reveal = (battle, enemies) => { for (const e of enemies) if (e.s.flags.stealth) pulse(battle, e, 'aura:reveal', null, { flags: { reveal: true } }); };
|
||||
/** Knock-back / pull (0.5 + 0.5·force tiles, like generic.js). `toward` = {x,y} point for pulls. */
|
||||
function shove(battle, e, from, force, { toward = null, maxDist = Infinity } = {}) {
|
||||
if (!e.alive) return 0;
|
||||
const f = Math.max(0, num(force, 0));
|
||||
const dx = toward ? toward.x - e.x : e.x - from.x, dy = toward ? toward.y - e.y : e.y - from.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (!(len > 1e-6)) return 0;
|
||||
const dist = Math.min(0.5 + 0.5 * f, maxDist);
|
||||
if (!(dist > 0)) return 0;
|
||||
return battle.displace(e, { x: dx, y: dy }, dist, { force: f + 1 });
|
||||
/**
|
||||
* Knock-back of 力度 `force` away from `from` (a radial push, Battle.push: the official 力度 − 重量 distance — PRTS 推与拉;
|
||||
* user playtest #6 item 14). Returns the tiles moved.
|
||||
*/
|
||||
function shove(battle, e, from, force) {
|
||||
return e && e.alive ? battle.push(e, num(force, 0), { from }) : 0;
|
||||
}
|
||||
/** Free tile for a summon/device: in the rect, standable, empty and not the home tile of any ally (dead ones redeploy there). */
|
||||
function freeTile(battle, r, c, { ranged = false, ground = false } = {}) {
|
||||
@@ -203,12 +181,12 @@ function moduleRangeGrid(def) {
|
||||
return null;
|
||||
}
|
||||
const applyModuleRange = (battle, unit, def) => { const g = moduleRangeGrid(def); if (g) { unit.rangeGrid = g; battle.refreshRange(unit); } };
|
||||
/** Pull `e` towards the unit, to the tile in front of it ("拖拽至面前"); returns the distance moved. */
|
||||
/**
|
||||
* Pull `e` "至面前" of the unit with 力度 `force` (Battle.pullToFront: the official 拉力起点 half a tile ahead, 急停 0.6708
|
||||
* around it, 力度 − 重量 — weight ≤ force all the way, one heavier a third of the way, …); returns the distance moved.
|
||||
*/
|
||||
function pullToFront(battle, unit, e, force) {
|
||||
if (!e || !e.alive) return 0;
|
||||
const d = Math.hypot(unit.x - e.x, unit.y - e.y);
|
||||
if (!(d > 1 + 1e-6)) return 0;
|
||||
return shove(battle, e, unit, force, { toward: { x: unit.x, y: unit.y }, maxDist: d - 1 });
|
||||
return e && e.alive ? battle.pullToFront(e, unit, num(force, 0)) : 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
@@ -216,7 +194,7 @@ function pullToFront(battle, unit, e, force) {
|
||||
|
||||
const kits = {
|
||||
// ===== 信仰搅拌机 (shotprotector) S3 退休前布道 — ammo 30 counters; talents 扫射迎宾仪礼 / 架盾送客仪礼; module: reveal
|
||||
// S1 铳骑主考官 (TAKE_DAMAGE: next attack ×3 hits + reload an adjacent 拉特兰 ammo skill), S2 八臂电锯侠 (ammo;
|
||||
// S1 铳骑主考官 (自动触发 ⇒ DEFAULT: next attack ×3 hits + reload an adjacent 拉特兰 ammo skill), S2 八臂电锯侠 (ammo;
|
||||
// a fatal hit is blocked for `ammo_cost` bullets); module SPT-Y 老朋友: range +1
|
||||
chess_char_4_01_a: (bb, chess, def) => {
|
||||
const t0 = tbb(def, 0), t1 = tbb(def, 1);
|
||||
@@ -655,8 +633,7 @@ const kits = {
|
||||
},
|
||||
|
||||
// ===== 寒芒克洛丝 (fastshot) S2 封喉 — double shot → 4 shots after 40 hits; talent 中的 (20 % ×1.5 + 0.2 s stun)
|
||||
// S1 无痕 (ATK up, double shot, 迷彩 = the engine's ally `stealth`: ranged enemies cannot target her unless she
|
||||
// blocks them)
|
||||
// S1 无痕 (ATK up, double shot, 迷彩 = the engine's `camou` flag: only an enemy she blocks targets her)
|
||||
chess_char_4_06_a: (bb, chess, def) => {
|
||||
const t0 = tbb(def, 0);
|
||||
const need = num(bb['attack@max_stack_count'], 40);
|
||||
@@ -666,7 +643,7 @@ const kits = {
|
||||
skchr_kroos2_1: () => ({
|
||||
kind: 'duration',
|
||||
mods: { atkPct: num(bb.atk) },
|
||||
flags: { stealth: true },
|
||||
flags: { camou: true },
|
||||
attack: { hits: 2 },
|
||||
onStart({ battle, unit }) { battle.fx('camouflage', { x: unit.x, y: unit.y, id: unit.id }); },
|
||||
}),
|
||||
@@ -902,108 +879,35 @@ const kits = {
|
||||
},
|
||||
|
||||
// ===== 凯瑟琳 (craftsman) S2 战火淬炼 — stops attacking, HP/DEF up, devices give 6 %/s shields; talent 定向支援信号
|
||||
// [ASSUMED] the devices are not board pieces in this mode (data `placeable: false`): at battle start (and when she
|
||||
// redeploys, while stock lasts) she auto-places up to deployLimit of them on a free tile orthogonally next to her best
|
||||
// blockers, each pointing at that operator's tile.
|
||||
// "携带3个支援装置(最多部署2个)": the 爬行号·防护单元 are hand pieces (2 = the deploy limit) the player places and
|
||||
// turns towards an operator (user playtest #6, PRTS 卫戍协议/帮助 §战斗部署); they deploy with the board and the
|
||||
// token kit (tokens.js catShield) gives the shields — S2 overwrite_ratio per second while this skill runs — so
|
||||
// this kit places nothing (none placed ⇒ no device).
|
||||
// S1 岁月锻打 (passive: she and every other operator holding a device shield ATK/DEF +8 %/+11 %); module CRA-X
|
||||
// carries one more device (talent cnt, data)
|
||||
chess_char_4_11_a: (bb, chess, def) => {
|
||||
const t0 = tbb(def, 0);
|
||||
const tokenId = (def.talents || []).find((t) => t.tokenKey)?.tokenKey ?? (def.tokens || [])[0] ?? 'token_10041_cathy_catsld';
|
||||
const carried = Math.max(0, Math.floor(num(t0.cnt, 3)));
|
||||
const overwrite = num(bb.overwrite_ratio, 0.06);
|
||||
const S2 = isSel(def, 'skchr_cathy_2');
|
||||
return {
|
||||
skills: alt(def, {
|
||||
skchr_cathy_1: () => ({
|
||||
kind: 'passive',
|
||||
mods: { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) },
|
||||
onStart({ battle, unit }) {
|
||||
unit.mem.forgeAura?.cancel();
|
||||
unit.mem.forgeAura = whileDeployed(battle, unit, AURA, () => {
|
||||
for (const a of battle.allies(unit.ownerId)) {
|
||||
if (a === unit || a.kind !== 'op' || !a.findBuff('cathy:shield')) continue;
|
||||
pulse(battle, a, `cathy:forge:${unit.id}`, { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) });
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
// carries one more device (talent cnt, data — a spare the hand never shows)
|
||||
chess_char_4_11_a: (bb, chess, def) => ({
|
||||
skills: alt(def, {
|
||||
skchr_cathy_1: () => ({
|
||||
kind: 'passive',
|
||||
mods: { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) },
|
||||
onStart({ battle, unit }) {
|
||||
unit.mem.forgeAura?.cancel();
|
||||
unit.mem.forgeAura = whileDeployed(battle, unit, AURA, () => {
|
||||
for (const a of battle.allies(unit.ownerId)) {
|
||||
if (a === unit || a.kind !== 'op' || !a.findBuff(CAT_SHIELD_KEY)) continue;
|
||||
pulse(battle, a, `cathy:forge:${unit.id}`, { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) });
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
skill: {
|
||||
kind: 'duration',
|
||||
mods: { hpPct: num(bb.max_hp), defPct: num(bb.def) },
|
||||
attack: { noAttack: true },
|
||||
onStart({ battle, unit }) { battle.fx('overclock', { x: unit.x, y: unit.y, id: unit.id }); },
|
||||
},
|
||||
install(battle, unit) {
|
||||
const tok = battle.tokenDef(tokenId, unit); // the owner's skill / module variant (DESIGN §16)
|
||||
const tb = tok?.talents?.[0]?.bb || {};
|
||||
const cap = num(tb.max_shield_ratio, 0.2), each = num(tb.shield_ratio_each_trigger, 0.06);
|
||||
const idle = num(tb.interval, 5), period = Math.max(0.1, num(tb['catsld_t_1[timer][interval].interval'], num(bb.interval, 1)));
|
||||
const limit = Math.max(1, Math.floor(num(ownTokenVariant(battle, tokenId, unit)?.stats?.deployLimit, num(tok?.raw?.deployLimit, 2))));
|
||||
unit.mem.devicesLeft = carried;
|
||||
unit.mem.devices = [];
|
||||
// a device points at ONE tile (AK: range [0,0],[0,1] turned towards the chosen operator) and shields whichever
|
||||
// of her operators stands there — the same operator again after it redeploys on its tile ("装置效果不叠加":
|
||||
// one device per operator)
|
||||
const covered = (a) => unit.mem.devices.some((d) => d.alive && d.mem.tr === a.tileR && d.mem.tc === a.tileC);
|
||||
const giveShield = (target, amount, capAmt) => {
|
||||
const cur = target.findBuff('cathy:shield');
|
||||
const v = Math.min(capAmt, (cur ? cur.shield : 0) + amount);
|
||||
if (!cur || v > cur.shield + 1e-9) battle.addBuff(target, { key: 'cathy:shield', shield: v, visible: true, source: unit });
|
||||
};
|
||||
const deviceKit = (tr, tc) => ({
|
||||
skill: null, talents: [], trait: { noAttack: true },
|
||||
install(b, dev) {
|
||||
dev.mem.tr = tr; dev.mem.tc = tc;
|
||||
dev.mem.target = null;
|
||||
// token trait "不会受到攻击": untargetable (spawn option) AND immune — enemy AoE abilities that sweep every ally
|
||||
// in a radius would otherwise destroy the 100-HP device and silently end its shields
|
||||
b.addBuff(dev, { key: 'cathy:device', flags: { invulnerable: true }, persist: true, allowDead: true });
|
||||
b.every(period, () => {
|
||||
if (!dev.alive) return;
|
||||
const o = b.unitAt(tr, tc);
|
||||
const t = o && o.kind === 'op' && o.ownerId === dev.ownerId && o.deployed ? o : null;
|
||||
if (t !== dev.mem.target) { // newly connected operator: full talent shield at once
|
||||
dev.mem.target = t;
|
||||
if (t) { giveShield(t, unit.s.maxHp * cap, unit.s.maxHp * cap); b.fx('shield', { x: t.x, y: t.y, id: t.id }); }
|
||||
return;
|
||||
}
|
||||
if (!t) return;
|
||||
const capAmt = unit.s.maxHp * cap;
|
||||
if (S2 && unit.alive && skillActive(unit)) giveShield(t, unit.s.maxHp * overwrite, capAmt);
|
||||
else if (b.time - t.lastHitAt >= idle - 1e-9) giveShield(t, unit.s.maxHp * each, capAmt);
|
||||
}, { owner: dev, immediate: true });
|
||||
},
|
||||
});
|
||||
const place = () => {
|
||||
if (!unit.alive || !unit.deployed) return;
|
||||
unit.mem.devices = unit.mem.devices.filter((d) => d.alive);
|
||||
// front-most on the player's own board first (a board position; the mirrored Final Assault side counts from
|
||||
// the field's right)
|
||||
const boardCol = (x) => (x.player && x.player.mirror ? -x.tileC : x.tileC);
|
||||
const cands = battle.allies(unit.ownerId).filter((a) => a.kind === 'op' && !covered(a))
|
||||
.sort((a, b) => (b.s.blockCnt > 0) - (a.s.blockCnt > 0) || b.s.taunt - a.s.taunt || boardCol(b) - boardCol(a) || a.id - b.id);
|
||||
for (const a of cands) {
|
||||
if (unit.mem.devices.length >= limit || unit.mem.devicesLeft <= 0) break;
|
||||
const r = a.tileR, c = a.tileC;
|
||||
// orthogonal neighbours only (a diagonal device could never have the operator in its range): behind the
|
||||
// operator first (along its direction), then its two sides, then in front; each device faces the operator
|
||||
const tiles = [frontOf(r, c, a.dir, -1), offsetTile(r, c, -1, 0, a.dir), offsetTile(r, c, 1, 0, a.dir), frontOf(r, c, a.dir, 1)];
|
||||
const t = tiles.find(([rr, cc]) => freeTile(battle, rr, cc, { ranged: true }));
|
||||
if (!t) continue;
|
||||
const dev = battle.spawnToken(unit, tokenId, t[0], t[1], { kit: deviceKit(r, c), untargetable: true, dir: dirFromDelta(r - t[0], c - t[1]) });
|
||||
if (!dev) continue;
|
||||
unit.mem.devicesLeft--;
|
||||
unit.mem.devices.push(dev);
|
||||
battle.fx('device', { x: dev.x, y: dev.y, id: dev.id, target: a.id });
|
||||
}
|
||||
};
|
||||
battle.on('battleStart', place, { owner: unit });
|
||||
battle.on('deploy', (c) => { if (c.unit === unit && !c.initial) place(); }, { owner: unit });
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
skill: {
|
||||
kind: 'duration',
|
||||
mods: { hpPct: num(bb.max_hp), defPct: num(bb.def) },
|
||||
attack: { noAttack: true },
|
||||
onStart({ battle, unit }) { battle.fx('overclock', { x: unit.x, y: unit.y, id: unit.id }); },
|
||||
},
|
||||
}),
|
||||
|
||||
// ===== 歌蕾蒂娅 (hookmaster) S3 缺水的碎漩狂舞 — bind a far target, tornado: slow, 85 % arts pulses + pull, final pull
|
||||
// S1 缺水的大洋裂断 (charges: next attack pulls the target to her front, 150 %/180 %); S2 缺水的掌握怒海 (BAT +0.5 s,
|
||||
@@ -1060,8 +964,8 @@ const kits = {
|
||||
for (const e of inside) {
|
||||
if (!e.alive || e.s.flags.untargetable) continue;
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 0.85), type: 'arts', isSkill: true, tags: ['skill', 'tornado'] });
|
||||
const d = Math.hypot(T.x - e.x, T.y - e.y);
|
||||
if (e.alive && d > 0.05) dragDmg(battle, unit, e, shove(battle, e, unit, force, { toward: T, maxDist: d }));
|
||||
// "小力地拖拽至中心": a pull to the marked point (PRTS 推与拉: its 6 tornado pulls stop 0.05 from it)
|
||||
if (e.alive) dragDmg(battle, unit, e, battle.pull(e, force, { to: T, stop: 0.05 }));
|
||||
}
|
||||
battle.fx('tornadoPulse', { x: T.x, y: T.y, id: unit.id, r: R });
|
||||
},
|
||||
@@ -1141,6 +1045,7 @@ const kits = {
|
||||
battle.on('beforeAttack', (c) => {
|
||||
if (c.attacker !== unit || !skillActive(unit)) return;
|
||||
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile);
|
||||
for (const e of battle.blockedTargets(unit, c.profile)) if (!cands.includes(e)) cands.push(e); // DESIGN §20.3
|
||||
if (cands.length <= 1) return;
|
||||
sortEnemyTargets(battle, unit, cands, c.profile?.priority ?? null);
|
||||
const n = Math.max(1, Math.floor((c.profile?.maxTargets || 1) + unit.s.maxTargets));
|
||||
@@ -1506,8 +1411,10 @@ const kits = {
|
||||
const g = grid(def.skill?.rangeGrid);
|
||||
return {
|
||||
skills: alt(def, {
|
||||
// (自动触发: an AUTO skill takes no 技能策略 — an AUTO DP skill fires as soon as it is ready, like 伺夜 S1)
|
||||
skchr_flamtl_1: () => ({
|
||||
kind: 'instant',
|
||||
trigger: 'SP_FULL',
|
||||
onStart({ battle, unit }) {
|
||||
battle.addDp(unit.ownerId, num(bb.cost, 6));
|
||||
battle.addBuff(unit, { key: 'flamtl:evade', visible: true });
|
||||
@@ -1526,7 +1433,7 @@ const kits = {
|
||||
if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun, 0.5), source: unit });
|
||||
}
|
||||
for (const a of battle.unitsInGrid(unit, area, { side: 'ally' })) {
|
||||
if (a.kind === 'device') continue;
|
||||
if (a.kind === 'device' || !battle.allySelectable(a, unit)) continue;
|
||||
battle.addBuff(a, { key: 'flamtl:redPine', duration: num(bb['flamtl_s_2.duration'], 10), mods: { dodgePhys: num(bb['flamtl_s_2.prob'], 0.4) }, visible: true, source: unit });
|
||||
}
|
||||
battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id });
|
||||
@@ -1651,7 +1558,9 @@ const kits = {
|
||||
const own = new Set(absoluteRangeKeys(unit.rangeGrid, unit.tileR, unit.tileC, unit.dir, unit.s.rangeExtend));
|
||||
const ok = (e) => bodyInKeys(e, own) || !!(e.blockedBy && e.blockedBy.side === 'ally' && e.blockedBy.alive);
|
||||
if (c.targets.every(ok)) return;
|
||||
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile).filter(ok);
|
||||
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile);
|
||||
for (const e of battle.blockedTargets(unit, c.profile)) if (!cands.includes(e)) cands.push(e); // DESIGN §20.3
|
||||
for (let i = cands.length - 1; i >= 0; i--) if (!ok(cands[i])) cands.splice(i, 1);
|
||||
sortEnemyTargets(battle, unit, cands, c.profile?.priority ?? null);
|
||||
c.targets = cands.slice(0, Math.max(1, c.targets.length));
|
||||
}, { owner: unit, priority: 5 });
|
||||
@@ -1935,6 +1844,7 @@ const kits = {
|
||||
skills: alt(def, {
|
||||
skchr_cetsyr_1: () => ({
|
||||
kind: 'toggle',
|
||||
trigger: 'SP_FULL', // 自动触发 (no 技能策略: the 吟游者 row is for MANUAL skills): on as soon as it is ready
|
||||
onStart({ battle, unit }) { setTrait(unit, bb['attack@atk_to_hp_recovery_ratio']); battle.fx('mote', { x: unit.x, y: unit.y, id: unit.id }); },
|
||||
onEnd({ unit }) { resetTrait(unit); },
|
||||
}),
|
||||
|
||||
@@ -19,10 +19,13 @@
|
||||
// - Non-stacking auras refresh a short buff with a fixed key every 0.25 s while the source is on the field.
|
||||
// SP auras ("同类效果取最高") share the buff key `aura:spRecovery` (mods.spRecoveryFlat): the highest value wins.
|
||||
// - Skills whose auto-cast needs a condition the engine rules can't express use the NEVER trigger (CUSTOM_RANGE with an
|
||||
// empty grid) plus their own activation — 塞雷娅 (`autoCast`: an injured ally in the heal area of the skill; her
|
||||
// initial range is her own tile), 华法琳 (heal target below half HP, checked right before the heal), 号角 S1 / 塞雷娅 S1
|
||||
// (the data TAKE_DAMAGE rule, `hurtCast`: a hit arms one charge only while none is waiting for its attack). Every other kit
|
||||
// keeps the data rule (DEFAULT = about to attack + an enemy in the INITIAL range, research 03 Addendum C1).
|
||||
// empty grid) plus their own activation — 塞雷娅 S2 (`autoCast`: an injured ally in the heal area of the skill; her
|
||||
// initial range is her own tile), 华法琳 (heal target below half HP, checked right before the heal). 塞雷娅 S1 (自动触发)
|
||||
// uses the engine's DEFAULT rule with `allies` / `hpAtMost` (about to attack + an ally of the skill area at ≤ half HP:
|
||||
// the heal replaces that attack). Every other
|
||||
// kit keeps the data rule (tools/build-data.mjs resolveTrigger, the official 技能策略: DEFAULT = about to attack + an
|
||||
// enemy in the INITIAL range; SKILL_RANGE for a MANUAL skill's own 技能范围; the class rows — 重装 TAKE_DAMAGE … — for
|
||||
// every MANUAL skill; AUTO skills never take a class row).
|
||||
// - fx kinds emitted (battle.fx(kind, {x, y, id, …})): 'aoe' {r, skill}, 'healAoe' {r}, 'summon' {token}, 'anchor'
|
||||
// {fromX, fromY, r}, 'teleport', 'zone' {r, duration}, 'iceSpike' {r}, 'extraAttack', 'downed', 'revive' {r},
|
||||
// 'overload', 'ember', 'bloodBattle', 'reborn', 'candle', 'wake' {scale}, 'mote', 'hpShare', 'knockout', 'crit',
|
||||
@@ -35,7 +38,7 @@ import { frontOf, rotateOffset, toLocal } from '../../dir.js';
|
||||
import { mitigate, hasHp } from '../../damage.js';
|
||||
|
||||
// ---- text-only constants (the official blackboards carry no key for these) --------------------------------------
|
||||
/** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 山 module "生命值高于50%时". */
|
||||
/** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 塞雷娅 S1 "血量小于等于一半"; 山 module "生命值高于50%时". */
|
||||
const HALF_HP = 0.5;
|
||||
/** 夕 S1 "下一次攻击溅射范围扩大" — expanded splash radius (tiles; the splash-caster default is 1.1). PRTS: "溅射半径扩大至1.7". */
|
||||
const DUSK_SPLASH_RADIUS = 1.7;
|
||||
@@ -118,8 +121,6 @@ const skillRange = (chess, def, extra = null) => {
|
||||
const g = skillGrid(chess, def);
|
||||
return g || extra ? { ...(g ? { rangeGrid: g } : {}), ...(extra || {}) } : undefined;
|
||||
};
|
||||
/** Push distance of an AK force level (same mapping as content/generic.js: 中等力度 = 1 → 1 tile). */
|
||||
const pushTiles = (force) => 0.5 + 0.5 * force;
|
||||
/** "攻击范围内存在N名及以上敌人时攻击速度+X" (modules REA-Y). */
|
||||
function crowdAspd(battle, unit, key, aspd, cnt) {
|
||||
if (!aspd || !(cnt > 0)) return;
|
||||
@@ -179,21 +180,6 @@ function autoCast(battle, unit, cond) {
|
||||
}, { owner: unit });
|
||||
}
|
||||
|
||||
/**
|
||||
* TAKE_DAMAGE auto-cast of a "下次攻击" skill with charges (号角 S1, 塞雷娅 S1 — spec trigger NEVER): a hit taken arms
|
||||
* the skill (the engine rule's conditions: not an element-gauge hit, not `noSp`, able to act, not silenced) only while no
|
||||
* armed charge is waiting for its attack. The engine rule re-activates a pending instant skill on every hit, so two hits
|
||||
* before her next attack would spend both charges of the elite on that one attack.
|
||||
*/
|
||||
function hurtCast(battle, unit) {
|
||||
battle.on('damaged', (c) => {
|
||||
if (c.target !== unit || !c.dmg || c.dmg.noSp || c.dmg.type === 'element' || !on(unit)) return;
|
||||
const sk = unit.skill;
|
||||
if (!sk || sk.noSkill || sk.pending || !sk.ready || (sk.active && sk.isTimed) || !unit.canAct || unit.s.flags.silence) return;
|
||||
sk.activate('TAKE_DAMAGE');
|
||||
}, { owner: unit });
|
||||
}
|
||||
|
||||
/** Periodic check while the unit is on the field. */
|
||||
function whileOn(battle, unit, sec, fn) {
|
||||
battle.every(sec, () => { if (on(unit)) fn(); }, { owner: unit });
|
||||
@@ -203,7 +189,7 @@ function whileOn(battle, unit, sec, fn) {
|
||||
function spAura(battle, unit, value, filter) {
|
||||
if (!(value > 0)) return;
|
||||
whileOn(battle, unit, AURA_IV, () => {
|
||||
for (const a of battle.allies()) {
|
||||
for (const a of battle.alliesFor(unit)) {
|
||||
if (!filter(a)) continue;
|
||||
const cur = a.findBuff('aura:spRecovery');
|
||||
if (cur && num(cur.mods?.spRecoveryFlat) > value + 1e-9) continue;
|
||||
@@ -788,12 +774,12 @@ const KITS = {
|
||||
const near = battle.enemiesInRadius(main.x, main.y, RING1).filter((e) => !e.isFlying && !e.s.flags.untargetable && !e.s.flags.sleep)
|
||||
.sort((a, b) => (a === main ? -1 : b === main ? 1 : 0) || dist(a, main) - dist(b, main) || a.spawnSeq - b.spawnSeq)
|
||||
.slice(0, Math.max(1, num(bb.max_target, 2)));
|
||||
const fr = unit.tileR + unit.fwd[0], fc = unit.tileC + unit.fwd[1];
|
||||
const force = num(bb.force, 1);
|
||||
battle.fx('anchor', { x: main.x, y: main.y, id: unit.id, fromX: unit.x, fromY: unit.y, r: RING1 });
|
||||
for (const e of near) {
|
||||
const dx = fc - e.x, dy = fr - e.y, d = Math.hypot(dx, dy);
|
||||
if (d > 0.1) battle.displace(e, { x: dx, y: dy }, d, { force: force + 1 });
|
||||
// "中等力度地拖拽至面前": the 捕网's pull uses the 拖拽 rules (PRTS 推与拉 §捕网 "力的大小:同拖拽") —
|
||||
// Battle.pullToFront, the official 力度 − 重量 pull
|
||||
battle.pullToFront(e, unit, force);
|
||||
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'anchorPull'] });
|
||||
}
|
||||
},
|
||||
@@ -893,7 +879,7 @@ const KITS = {
|
||||
skchr_etlchi_2: () => ({
|
||||
kind: 'duration', attack: { noAttack: true },
|
||||
onStart({ battle, unit }) {
|
||||
const other = battle.allies().filter((a) => a !== unit && a.ground && a.hp > 0)
|
||||
const other = battle.alliesFor(unit).filter((a) => a !== unit && a.ground && a.hp > 0)
|
||||
.sort((a, b) => aroundN(battle, b) - aroundN(battle, a) || dist(a, unit) - dist(b, unit) || a.id - b.id)[0] ?? null;
|
||||
unit.mem.sickles = other ? [unit, other] : [unit];
|
||||
unit.mem.sickleAcc = 0;
|
||||
@@ -1136,7 +1122,7 @@ const KITS = {
|
||||
// max HP/s at the end. T1 军事要塞: all Defenders ATK +20 % while she is on the field.
|
||||
// T2 血战: once per deployment, lethal damage → full heal, max HP −50 %, ASPD +18, DEF +18 %.
|
||||
// Module (elite): ×1.1 vs blocked enemies.
|
||||
// S1 照明榴弹 (instant / 2 charges elite, TAKE_DAMAGE): next attack atk_scale × ATK; a ranged (unblocked) one also
|
||||
// S1 照明榴弹 (instant / 2 charges elite, 自动触发 ⇒ DEFAULT): next attack atk_scale × ATK; a ranged (unblocked) one also
|
||||
// splashes projectile_range and lights the impact for projectile_delay_time s (enemies within projectile_range lose
|
||||
// 隐匿). S2 暴风号令 (ammo 10): every attack attack@s2.atk_scale × ATK phys splash; 过载 for the second half of the
|
||||
// ammo [ASSUMED like S3]: + attack@s2.magic_atk_scale × ATK arts to every enemy hit (manual close never happens in the
|
||||
@@ -1155,7 +1141,7 @@ const KITS = {
|
||||
return {
|
||||
skills: lazySkills({
|
||||
skchr_horn_1: () => ({
|
||||
kind: instantKind(chess, def), trigger: NEVER, // TAKE_DAMAGE (data) via hurtCast: one armed charge at a time
|
||||
kind: instantKind(chess, def), // 自动触发 "下次攻击": the data rule DEFAULT (an AUTO skill takes no 技能策略)
|
||||
attack: {
|
||||
atkScale: num(bb.atk_scale, 1),
|
||||
onHit({ battle, unit, x, y }) {
|
||||
@@ -1221,7 +1207,6 @@ const KITS = {
|
||||
const aspd = num(tm.attack_speed); // FOR-Y: 不阻挡敌人时…攻击速度+10
|
||||
if (aspd) whileOn(battle, unit, 0.2, () => { if (!unit.blocking.length) battle.addBuff(unit, { key: 'horn:moduleY', duration: 0.3, mods: { aspd } }); });
|
||||
if (sid === 'skchr_horn_1') {
|
||||
hurtCast(battle, unit);
|
||||
// the pending shot: a ranged (unblocked) attack widens its splash and lights the impact
|
||||
battle.on('beforeAttack', (c) => {
|
||||
if (c.attacker !== unit || !unit.skill?.pending || !c.profile) return;
|
||||
@@ -1436,8 +1421,9 @@ const KITS = {
|
||||
// 塞雷娅 — S2 药物配置 (instant, time SP): heals every ally in the skill range for heal_scale × ATK (cast when one is
|
||||
// injured). T1 莱茵充能护服: every 20 s on the field ATK +5 % / DEF +4 % (×5). T2 精神回复: +1 SP to every ally she heals.
|
||||
// Module (elite): heals on allies below 50 % ×1.15.
|
||||
// S1 急救 (instant / 2 charges elite, TAKE_DAMAGE): her next attack heals the ally below half HP with the lowest HP
|
||||
// ratio in the skill area (周围) for heal_scale × ATK. S3 钙质化 (duration): allies in the skill area heal
|
||||
// S1 急救 (instant / 2 charges elite, 自动触发: cast at her attack when an ally of the skill area 周围 is at ≤ half HP):
|
||||
// that attack is instead a heal of the lowest such ally for heal_scale × ATK.
|
||||
// S3 钙质化 (duration; the 重装 strategy TAKE_DAMAGE): allies in the skill area heal
|
||||
// attack@heal_scale × ATK per second; enemies there take arts ×demkni_s_3.damage_scale and move −60 %.
|
||||
// Module GUA-Y (elite): damage taken −15 %.
|
||||
chess_char_5_11_a: (bb, chess, def) => {
|
||||
@@ -1454,16 +1440,15 @@ const KITS = {
|
||||
return {
|
||||
skills: lazySkills({
|
||||
skchr_demkni_1: () => ({
|
||||
kind: instantKind(chess, def), trigger: NEVER, // TAKE_DAMAGE (data) via hurtCast: one armed charge at a time
|
||||
attack: {
|
||||
onHit({ battle, unit }) {
|
||||
const t = battle.unitsInGrid(unit, grid, { side: 'ally' }).filter((a) => healable(a, unit) && a.hpRatio < HALF_HP)
|
||||
.sort((a, b) => a.hpRatio - b.hpRatio || a.id - b.id)[0];
|
||||
if (!t) return;
|
||||
battle.heal(unit, t, unit.s.atk * num(bb.heal_scale, 1));
|
||||
battle.fx('healAoe', { x: t.x, y: t.y, id: t.id, r: 0.5 });
|
||||
},
|
||||
},
|
||||
// 自动触发: its own rule, no 技能策略 — PRTS 备注 "此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次
|
||||
// 攻击" and the corrected text "血量小于等于一半": checked when she is about to attack (an enemy target, the basic
|
||||
// rule), an ally of the skill area at ≤ half HP casts it and that attack becomes the heal (unlike 古米's 备用军粮,
|
||||
// no heal mode is left waiting: the engine withdraws a cast whose ally was healed before the attack)
|
||||
kind: instantKind(chess, def),
|
||||
trigger: { rule: 'DEFAULT', grid, allies: true, hpAtMost: HALF_HP },
|
||||
targeting: { rangeGrid: grid },
|
||||
attack: { dmgType: 'heal', heal: { mode: 'single', hpAtMost: HALF_HP }, healScale: num(bb.heal_scale, 1), projectile: 'none' },
|
||||
onHit({ battle, target }) { if (target) battle.fx('healAoe', { x: target.x, y: target.y, id: target.id, r: 0.5 }); },
|
||||
}),
|
||||
skchr_demkni_3: () => ({
|
||||
kind: 'duration',
|
||||
@@ -1512,7 +1497,6 @@ const KITS = {
|
||||
install(battle, unit) {
|
||||
// S2 only: the NEVER-trigger skill casts itself when an ally in its area is injured
|
||||
if (!sid || sid === 'skchr_demkni_2') autoCast(battle, unit, () => zone(battle, unit).some((a) => a.hp < a.s.maxHp - 1e-6));
|
||||
if (sid === 'skchr_demkni_1') hurtCast(battle, unit);
|
||||
lowHpHealUp(battle, unit, tb);
|
||||
if (num(tb.damage_resistance) > 0) permBuff(battle, unit, 'saria:moduleY', { dmgTakenMul: 1 - num(tb.damage_resistance) });
|
||||
},
|
||||
@@ -1761,8 +1745,10 @@ const KITS = {
|
||||
};
|
||||
return {
|
||||
skills: lazySkills({
|
||||
// (自动触发: an AUTO skill takes no 技能策略 — an AUTO DP skill fires as soon as it is ready, like 伺夜 S1)
|
||||
skchr_svash2_1: () => ({
|
||||
kind: instantKind(chess, def),
|
||||
trigger: 'SP_FULL',
|
||||
onStart({ battle, unit }) {
|
||||
battle.addDp(unit.ownerId, num(bb.cost));
|
||||
const { pick } = waitingArea(battle, unit);
|
||||
@@ -1916,7 +1902,7 @@ const KITS = {
|
||||
const tickSpecial = (battle, unit, z, seaHits) => {
|
||||
if (z.type === 'guard') {
|
||||
const inZone = (a) => Math.abs(a.tileR - z.r) <= 1 && Math.abs(a.tileC - z.c) <= 1;
|
||||
const allies = battle.allies().filter(inZone);
|
||||
const allies = battle.alliesFor(unit).filter(inZone);
|
||||
for (const a of allies) battle.addBuff(a, { key: 'thorn2:bastion', duration: AURA_DUR, mods: mods({ defFlat: num(bb.def) }) });
|
||||
if (z.acc >= 1 - 1e-9) {
|
||||
z.acc -= 1;
|
||||
@@ -1997,7 +1983,7 @@ const KITS = {
|
||||
const cnt = num(t1.cnt, 6);
|
||||
const ally = num(t1.attack_speed_ally), allyX = num(t1.attack_speed_ally_extra), foe = num(t1.attack_speed_enemy), foeX = num(t1.attack_speed_enemy_extra);
|
||||
whileOn(battle, unit, AURA_IV, () => {
|
||||
for (const a of battle.allies()) {
|
||||
for (const a of battle.alliesFor(unit)) {
|
||||
const v = ally + (straightRun(battle, a.tileR, a.tileC) >= cnt ? allyX : 0);
|
||||
if (v) battle.addBuff(a, { key: 'thorn2:vision', duration: AURA_DUR, mods: { aspd: v } });
|
||||
}
|
||||
@@ -2097,10 +2083,9 @@ const KITS = {
|
||||
hits: 2,
|
||||
onEachHit({ battle, unit, target, kind }) {
|
||||
if (kind !== 'main' || !target || !target.alive || target.side !== 'enemy') return;
|
||||
const force = num(bb['attack@force'], 1);
|
||||
const dx = target.x - unit.x, dy = target.y - unit.y;
|
||||
const dir = Math.hypot(dx, dy) > 0.05 ? { x: dx, y: dy } : { x: unit.fwd[1], y: unit.fwd[0] };
|
||||
battle.displace(target, dir, pushTiles(force), { force: force + 1 });
|
||||
// "中等力度地推动" (PRTS 备注: on the 2nd hit of each attack — onEachHit runs after both): a radial push by
|
||||
// the official 力度 − 重量 distance (Battle.push)
|
||||
battle.push(target, num(bb['attack@force'], 1), { from: unit });
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -2155,9 +2140,7 @@ const KITS = {
|
||||
attack: {
|
||||
onHit({ battle, unit, target }) {
|
||||
if (!target || !target.alive || target.side !== 'enemy' || target.blockedBy) return;
|
||||
const fx0 = unit.tileC + unit.fwd[1], fy0 = unit.tileR + unit.fwd[0];
|
||||
const dx = fx0 - target.x, dy = fy0 - target.y, d = Math.hypot(dx, dy);
|
||||
if (d > 0.1) battle.displace(target, { x: dx, y: dy }, d, { force: force + 1 });
|
||||
battle.pullToFront(target, unit, force); // the official 力度 − 重量 pull (Battle.pullToFront)
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2311,12 +2294,14 @@ const KITS = {
|
||||
talents: [
|
||||
{ install(battle, unit) { // 加速力场
|
||||
const aspd = num(t0.attack_speed);
|
||||
if (aspd) whileOn(battle, unit, AURA_IV, () => { for (const a of battle.allies()) battle.addBuff(a, { key: 'aglina:field', duration: AURA_DUR, mods: { aspd } }); });
|
||||
if (aspd) whileOn(battle, unit, AURA_IV, () => { for (const a of battle.alliesFor(unit)) battle.addBuff(a, { key: 'aglina:field', duration: AURA_DUR, mods: { aspd } }); });
|
||||
} },
|
||||
{ install(battle, unit) { // 兼职工作
|
||||
const hp = num(t1.hp_recovery_per_sec);
|
||||
// PRTS 备注: an HP-regeneration attribute ("生命回复速度"), not given to allies under 禁疗 (the noHeal status)
|
||||
if (hp > 0) whileOn(battle, unit, AURA_IV, () => { if (!unit.skill?.active) for (const a of battle.allies()) if (!a.s.flags.noHeal) battle.addBuff(a, { key: 'aglina:parttime', duration: AURA_DUR, mods: { hpRegen: hp } }); });
|
||||
// an HP-regeneration attribute, so 禁疗 does not stop it — PRTS 备注 "生命回复的提供方式为增加目标的'生命回复速度'属性,
|
||||
// 不受治疗加成和禁疗影响" (异常效果 禁疗: "增减生命回复速度…的效果不会被识别为治疗类能力"); a 孤立 unit (炎佑) is not
|
||||
// selected (Battle.alliesFor)
|
||||
if (hp > 0) whileOn(battle, unit, AURA_IV, () => { if (!unit.skill?.active) for (const a of battle.alliesFor(unit)) battle.addBuff(a, { key: 'aglina:parttime', duration: AURA_DUR, mods: { hpRegen: hp } }); });
|
||||
} },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
// can be copied. "被击败后25秒后自动刷新": one pending respawn at a time, only while she is on the field
|
||||
// and no 流形 of hers stands (her knock-out cancels it; her redeploy re-summons it as her 援军).
|
||||
// 6_12 迷迭香 "溅射范围扩大" ×1.3 (not in data); 感知稳定 picks among the owner's deployed casters (none ⇒ no buff).
|
||||
// 6_13 新约能天使 steal target = ally op in range with the highest ASPD; bombardment radius 1 tile (not in data).
|
||||
// 6_13 新约能天使 bombardment radius 1 tile (not in data).
|
||||
// 6_14 流明 S3 heals an abnormal ally even at full HP (forced heal); 抵抗 = the engine `resist` status.
|
||||
// 6_15 仇白 入隙 reads the target's sluggish/bind statuses; module adds 10 % ATK arts per hit.
|
||||
// 6_16 溯光星源 link transfers the pre-mitigation arts amount × share to the other locked target(s); 能源解析 = the
|
||||
@@ -52,7 +52,8 @@
|
||||
// 6_18 荒芜拉普兰德 S3 drones are virtual (fx events), move at projectile_move_speed tiles/s, she stops attacking
|
||||
// while they roam; 头狼 stage 2 "特殊能力失效" = silence; stage 3 = +1 drone (normal attacks hit once
|
||||
// more, S3 releases one more drone). Base drone count 1.
|
||||
// 6_19 锏 10 slashes every d_hit_interval, pulls every p_hit_interval, final blow (skill range) at the end.
|
||||
// 6_19 锏 10 slashes every d_hit_interval, pulls every p_hit_interval, final blow (skill range) at the end;
|
||||
// S3 slashes and pulls air units too (PRTS 备注 "可对空").
|
||||
// 6_20 纯烬艾雅法拉 5 shots are padded by cycling targets when fewer injured allies exist.
|
||||
//
|
||||
// Operator loadouts (DESIGN §16): every visible chess also authors its selectable NON-default skills in `skills`
|
||||
@@ -98,9 +99,9 @@
|
||||
// `skill.addTriggerRange`; element-type damage skips the `hit` hook, so element (损伤) amplification uses the
|
||||
// `elementHit` hook (`onElementHit`: × dmg.mul before the gauge fill).
|
||||
|
||||
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../../targeting.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy, aggroCmp } from '../../targeting.js';
|
||||
import { aggregateMods } from '../../buffs.js';
|
||||
import { COLS, ROWS } from '../../constants.js';
|
||||
import { COLS, ROWS, PULL_STOP_RADIUS } from '../../constants.js';
|
||||
import { rotateOffset } from '../../dir.js';
|
||||
import { bodyDist, bodyInKeys, bodyInRadius, bodyKeys } from '../../body.js';
|
||||
import { hasHp } from '../../damage.js';
|
||||
@@ -311,13 +312,15 @@ function ensureDeployTracker(battle) {
|
||||
return S.lastOp;
|
||||
}
|
||||
|
||||
/** Pull `e` toward `unit` (0.5 + 0.5·force tiles, never past `minDist`). */
|
||||
function pullToward(battle, unit, e, force, minDist = 0.3) {
|
||||
if (!e || !e.alive) return;
|
||||
const dx = unit.x - e.x, dy = unit.y - e.y, len = Math.hypot(dx, dy);
|
||||
if (len <= minDist) return;
|
||||
const dist = Math.min(len - minDist, 0.5 + 0.5 * Math.max(0, force));
|
||||
if (dist > 0) battle.displace(e, { x: dx, y: dy }, dist, { force: force + 1 });
|
||||
/**
|
||||
* Pull `e` with 力度 `force` towards the point / unit `unit` ("向自己中心…拖拽", "拉向目标所在位置"), stopping `stop` tiles
|
||||
* from it — Battle.pull: the official 力度 − 重量 pull (PRTS 推与拉; user playtest #6 item 14). The default stop is the
|
||||
* 急停 radius 0.6708 around a pulling unit. A pulling ally is also the pull's `center`, so an enemy it blocks itself
|
||||
* stays where it is held (as in Battle.pullToFront) — e.g. the 流形 S3 pulse then still stuns it.
|
||||
*/
|
||||
function pullToward(battle, unit, e, force, stop = PULL_STOP_RADIUS) {
|
||||
if (!e || !e.alive || !unit) return 0;
|
||||
return battle.pull(e, num(force), { to: { x: unit.x, y: unit.y }, center: unit.side === 'ally' ? unit : null, stop });
|
||||
}
|
||||
|
||||
/** Stat-aura pulse: short buffs refreshed every `period` s on `targets()`. */
|
||||
@@ -712,14 +715,14 @@ function sbell2(bb, chess, def) {
|
||||
}
|
||||
const skills = {
|
||||
// S1 铃音吹雪 (SEARCH trigger, 2 charges): atk_scale × ATK arts + `cold` s of 寒冷 on every enemy in range, pushed
|
||||
// (force 1 = 中等) along her direction; then one snow layer spreads forward over the ground (≤ trig_cnt tiles)
|
||||
// (force 1 = 中力) along her direction — PRTS 备注 "固定方向推动(不会因角度过大或距离过近而变化方向与力度),且仅对地面
|
||||
// 单位产生推力" (Battle.push fixed, official 力度 − 重量 distance); then one snow layer spreads forward over the ground
|
||||
// (≤ trig_cnt tiles)
|
||||
skchr_sbell2_1: {
|
||||
kind: instantKind(def),
|
||||
// data trigger SEARCH (the 阵法术师 profession row for S1): the engine reads it as "an enemy anywhere on the field",
|
||||
// which dumped both charges on an enemy spawning at the gate, far outside her range (the burst hits "范围内所有
|
||||
// 敌人"). SEARCH exists for units that never attack (DEFAULT fires on an attack; CUSTOM_RANGE_SEARCH_ENEMY is its
|
||||
// custom-range twin) [ASSUMED]: an enemy inside her range, checked every tick — the engine's DEFAULT for a
|
||||
// phalanx (noAttackUnlessSkill)
|
||||
// data trigger SEARCH (the 阵法术师 row, every MANUAL skill of the class: "在初始攻击范围内存在敌人时释放技能") =
|
||||
// an enemy inside her initial range, checked every tick — what the engine's DEFAULT does for a phalanx
|
||||
// (noAttackUnlessSkill); an enemy anywhere on the field would dump both charges on an enemy spawning at the gate
|
||||
trigger: 'DEFAULT',
|
||||
onStart({ battle, unit }) {
|
||||
const [fr, fc] = unit.fwd;
|
||||
@@ -728,7 +731,7 @@ function sbell2(bb, chess, def) {
|
||||
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] });
|
||||
if (!e.alive) continue;
|
||||
if (num(bb.cold) > 0) battle.applyStatus(e, 'cold', { duration: num(bb.cold), source: unit });
|
||||
battle.displace(e, { x: fc, y: fr }, 0.5 + 0.5 * force, { force: force + 1 });
|
||||
if (!e.isFlying) battle.push(e, force, { from: unit, dir: { x: fc, y: fr }, fixed: true });
|
||||
}
|
||||
const n = Math.max(0, Math.floor(num(bb.trig_cnt, 5)));
|
||||
let laid = 0;
|
||||
@@ -1210,9 +1213,10 @@ function pepe(bb, chess, def) {
|
||||
}, { owner: unit });
|
||||
}
|
||||
if (sid === 'skchr_pepe_2') {
|
||||
battle.on('beforeAttack', (ctx) => { // 随机攻击范围内的目标
|
||||
battle.on('beforeAttack', (ctx) => { // 随机攻击范围内的目标 (and the enemies she blocks — Battle.blockedTargets)
|
||||
if (ctx.attacker !== unit || !unit.skill?.active) return;
|
||||
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
|
||||
for (const e of battle.blockedTargets(unit, ctx.profile)) if (!c.includes(e)) c.push(e);
|
||||
if (c.length) ctx.targets = [battle.rng.pick(c)];
|
||||
}, { owner: unit });
|
||||
}
|
||||
@@ -1311,7 +1315,7 @@ function siege2(bb, chess, def) {
|
||||
if (sid === 'skchr_siege2_2') {
|
||||
const need = Math.max(1, Math.floor(num(bb.buff_stack_cnt, 2))), spr = num(bb.sp_recovery_per_sec);
|
||||
if (spr > 0) aura(battle, unit, 0.25, () => {
|
||||
const n = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a !== unit && a.kind !== 'device').length;
|
||||
const n = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a !== unit && a.kind !== 'device' && battle.allySelectable(a, unit)).length;
|
||||
if (n >= need) battle.addBuff(unit, { key: 'siege2:homeland', mods: { spRecoveryFlat: spr }, duration: 0.4, refresh: 'replace' });
|
||||
});
|
||||
}
|
||||
@@ -1367,7 +1371,7 @@ function siege2(bb, chess, def) {
|
||||
{ install(battle, unit) { // 诸王的叹息
|
||||
const dr = num(t0.damage_resistance), atk = num(t0.atk);
|
||||
aura(battle, unit, 0.25, () => {
|
||||
const allies = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a.kind !== 'device');
|
||||
const allies = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a.kind !== 'device' && battle.allySelectable(a, unit));
|
||||
if (!allies.includes(unit)) allies.push(unit);
|
||||
if (dr > 0) for (const a of allies) battle.addBuff(a, { key: 'siege2:sigh', mods: { physTakenMul: 1 - dr }, duration: 0.4, refresh: 'replace' });
|
||||
const n = allies.filter((a) => a !== unit).length;
|
||||
@@ -1875,7 +1879,7 @@ function mlyss(bb, chess, def) {
|
||||
if (!unit.skill?.active) return;
|
||||
for (const t of battle.allyUnits) {
|
||||
if (!isTok(t, tokId, unit) || !live(t) || !t.mem.mlyss || t.mem.mlyss.ranged) continue;
|
||||
for (const e of battle.unitsInGrid(t, AROUND8, { side: 'enemy' })) pullToward(battle, t, e, num(t.def?.skill?.bb?.force), 0.2);
|
||||
for (const e of battle.unitsInGrid(t, AROUND8, { side: 'enemy' })) pullToward(battle, t, e, num(t.def?.skill?.bb?.force));
|
||||
for (const e of t.blocking) if (e.alive) battle.applyStatus(e, 'stun', { duration: pulseIv + 0.1, source: unit });
|
||||
battle.fx('pulse', { x: t.x, y: t.y, id: t.id });
|
||||
}
|
||||
@@ -1915,7 +1919,7 @@ function mlyss(bb, chess, def) {
|
||||
if (unit.skill) unit.skill.addTriggerRange(() => battle.allyUnits.filter((t) => mine(t) && live(t) && t.mem.mlyss));
|
||||
battle.on('tick', () => { // without a dedicated token kit the copy fires once ready and someone can be copied
|
||||
for (const t of battle.allyUnits) {
|
||||
if (!mine(t) || !live(t) || t.mem.mlyssClone || !t.kit?.generic || !t.skill || t.skill.active || !t.skill.ready) continue;
|
||||
if (!mine(t) || !live(t) || t.mem.mlyssClone || !t.kit?.generic || !t.skill || t.skill.active || !t.skill.ready || t.skill.opCooling) continue;
|
||||
if (pickCopyTarget(battle, unit, t)) t.skill.activate('MLYSS_WTRMAN');
|
||||
}
|
||||
}, { owner: unit });
|
||||
@@ -2051,7 +2055,10 @@ function rosmon(bb, chess, def) {
|
||||
if (sid !== 'skchr_rosmon_3') return;
|
||||
battle.on('beforeAttack', (ctx) => { // "仅选择被阻挡的敌人作为目标"
|
||||
if (ctx.attacker !== unit || !unit.skill?.active) return;
|
||||
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile).filter((e) => !!e.blockedBy);
|
||||
// her range, plus the enemies she blocks herself — always her targets (Battle.blockedTargets, DESIGN §20.3)
|
||||
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
|
||||
for (const e of battle.blockedTargets(unit, ctx.profile)) if (!c.includes(e)) c.push(e);
|
||||
for (let i = c.length - 1; i >= 0; i--) if (!c[i].blockedBy) c.splice(i, 1);
|
||||
sortEnemyTargets(battle, unit, c, ctx.profile?.priority ?? null);
|
||||
ctx.targets = c.slice(0, Math.max(1, Math.floor(num(ctx.profile?.maxTargets, 2))));
|
||||
}, { owner: unit });
|
||||
@@ -2104,6 +2111,12 @@ function rosmon(bb, chess, def) {
|
||||
|
||||
// ------------------------------------------------------------------------------------------------------------------
|
||||
// 新约能天使 chess_char_6_13 (怪杰) — S2 开火成瘾症; 火力电台; 铳弹协约; module 新朋友圣城生活套组
|
||||
// S2 (PRTS 备注): the ally is the friendly operator of her attack range with the highest 仇恨值 ("选择的友方干员为攻击范围内
|
||||
// 仇恨值最高的我方干员": highest taunt level, then the latest deployed — targeting.js aggroCmp, the order enemies attack
|
||||
// in; it used to be the highest ASPD, so the shield often went to a back-row shooter instead of the operator in front);
|
||||
// both barriers are shield_max_hp_ratio × the holder's OWN max HP ("获得的屏障均以自身生命上限为标准计算"), losing
|
||||
// initial/shield_max_duration per second ("屏障每秒衰减量为:初始屏障量/30"); a new one replaces the old one ("重复获得此
|
||||
// 屏障时,重置屏障量与衰减速度").
|
||||
|
||||
const AIRSTRIKE_RADIUS = 1; // [ASSUMED] bombardment splash radius (not in data)
|
||||
|
||||
@@ -2201,7 +2214,7 @@ function angel2(bb, chess, def) {
|
||||
attack: { atkScale: num(bb['attack@atk_scale'], 1) },
|
||||
onStart({ battle, unit, skill }) {
|
||||
const cands = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op' && live(a));
|
||||
cands.sort((a, b) => b.s.aspd - a.s.aspd || a.deploySeq - b.deploySeq);
|
||||
cands.sort(aggroCmp);
|
||||
const victim = steal > 0 ? cands[0] ?? null : null;
|
||||
unit.mem.angelVictim = victim;
|
||||
decayingShield(battle, unit, 'angel2:barrier', unit.s.maxHp * shieldRatio, shieldDur);
|
||||
@@ -2278,7 +2291,7 @@ function lumen(bb, chess, def) {
|
||||
if (!(sc > 0) || !(life > 0)) return;
|
||||
battle.fx('healField', { x: target.x, y: target.y, id: unit.id });
|
||||
for (const a of battle.alliesInRadius(target.x, target.y, 1.5, target.ownerId)) {
|
||||
if (a.kind === 'device') continue;
|
||||
if (a.kind === 'device' || a.s.flags.noHeal || a.profile?.noHeal) continue; // a heal: never on 禁疗 / 孤立
|
||||
battle.addBuff(a, {
|
||||
key: `lumen:rain:${unit.id}`, duration: life, interval: iv, refresh: 'replace', visible: true, source: unit,
|
||||
onTick: ({ unit: x }) => { if (x.hp < x.s.maxHp) battle.heal(unit, x, unit.s.atk * sc, { hot: true }); },
|
||||
@@ -2798,7 +2811,9 @@ function whitw2(bb, chess, def) {
|
||||
battle.on('beforeAttack', (ctx) => {
|
||||
const m = unit.mem.hunt;
|
||||
if (ctx.attacker !== unit || !unit.skill?.active || !m) return;
|
||||
// the skill range, plus the enemies she blocks (always her targets, Battle.blockedTargets — DESIGN §20.3)
|
||||
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
|
||||
for (const e of battle.blockedTargets(unit, ctx.profile)) if (!c.includes(e)) c.push(e);
|
||||
if (!c.length) { ctx.targets = []; return; }
|
||||
const n = 1 + Math.floor(num(bb['attack@cnt'])) + ((unit.mem.wolfStage || 0) >= 3 ? 1 : 0);
|
||||
m.locks = m.locks.filter((e) => e.alive && c.includes(e)).slice(0, n);
|
||||
@@ -2923,8 +2938,10 @@ function blkkgt(bb, chess, def) {
|
||||
// the skill range (x-1): her range while S3 runs — also for the finisher, since onEnd runs before the engine
|
||||
// removes the skill's range
|
||||
const skillKeys = (unit) => unit.rangeKeys || [];
|
||||
// S3 hits and pulls air units too — PRTS 锏 S3 备注 "※可对空。不会拖拽自身中心半径0.6708范围内的敌人" (her attacks and S1 /
|
||||
// S2 stay ground-only: "地面敌人")
|
||||
const victims = (battle, unit) => {
|
||||
const c = battle.enemiesInKeys(skillKeys(unit), unit, unit.profile);
|
||||
const c = battle.enemiesInKeys(skillKeys(unit), unit, { ...unit.profile, canHitFly: true, groundOnly: false });
|
||||
sortEnemyTargets(battle, unit, c, null);
|
||||
return c.slice(0, maxT);
|
||||
};
|
||||
@@ -2933,7 +2950,13 @@ function blkkgt(bb, chess, def) {
|
||||
if (v.length) battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length });
|
||||
for (const e of v) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'slash'] });
|
||||
};
|
||||
const pull = (battle, unit, force) => { for (const e of enemiesIn(battle, unit, skillKeys(unit))) if (!e.isFlying) pullToward(battle, unit, e, force); };
|
||||
// "持续将敌人中等力度地拖拽至自身中心,之后…较大力地拖拽至自身": the official 力度 − 重量 pulls (PRTS 推与拉: the last one
|
||||
// aims at her own tile centre, the others at the usual 拉力起点 half a tile ahead; 急停 0.6708 around her)
|
||||
const pull = (battle, unit, force, last = false) => {
|
||||
for (const e of enemiesIn(battle, unit, skillKeys(unit))) {
|
||||
if (last) pullToward(battle, unit, e, force); else battle.pullToFront(e, unit, force);
|
||||
}
|
||||
};
|
||||
const skills = {
|
||||
// S1 纯粹的武力 (attack SP): the next attack hits up to max_target ground enemies of the 3×3 around her, each twice
|
||||
// (her trait) at atk_scale_s1 × ATK
|
||||
@@ -2966,6 +2989,10 @@ function blkkgt(bb, chess, def) {
|
||||
skills,
|
||||
install(battle, unit) {
|
||||
if (penFlat > 0 || legendAtk) battle.addBuff(unit, { key: 'blkkgt:contract', mods: { defIgnoreFlat: penFlat, atkPct: legendAtk }, persist: true, allowDead: true });
|
||||
// S3 slashes: 晕眩免疫, 冻结免疫 (PRTS 备注)
|
||||
battle.on('beforeStatus', (c) => {
|
||||
if (c.target === unit && unit.mem.dgb && (c.status === 'stun' || c.status === 'freeze')) c.cancel = true;
|
||||
}, { owner: unit });
|
||||
if (legendTremble > 0) {
|
||||
const seen = new WeakSet();
|
||||
battle.on('tick', () => {
|
||||
@@ -2983,8 +3010,11 @@ function blkkgt(bb, chess, def) {
|
||||
duration: slashes * iv,
|
||||
...(skillGrid ? { targeting: { rangeGrid: skillGrid } } : {}),
|
||||
attack: { noAttack: true },
|
||||
// PRTS 备注 "※多段斩击期间,自身获得无敌,晕眩免疫,冻结免疫": invulnerable while the slashes run (removed in onEnd,
|
||||
// before the finisher); stun / freeze refused by her `beforeStatus` hook (install) while `mem.dgb` is set
|
||||
onStart({ battle, unit }) {
|
||||
unit.mem.dgb = { n: 1, acc: 0, pacc: 0 };
|
||||
battle.addBuff(unit, { key: 'blkkgt:slashes', duration: slashes * iv + 1, flags: { invulnerable: true }, visible: true, source: unit });
|
||||
slash(battle, unit, num(bb.d_atk_scale, 1));
|
||||
},
|
||||
onTick({ battle, unit, dt }) {
|
||||
@@ -2997,11 +3027,12 @@ function blkkgt(bb, chess, def) {
|
||||
},
|
||||
onEnd({ battle, unit, reason }) {
|
||||
unit.mem.dgb = null;
|
||||
battle.removeBuff(unit, 'blkkgt:slashes');
|
||||
if (reason === 'death' || !unit.alive) return;
|
||||
battle.fx('finale', { x: unit.x, y: unit.y, id: unit.id });
|
||||
unit.mem.dgbFinale = true; // the finisher is part of the skill (talent at 100 %, module +10 %)
|
||||
try { slash(battle, unit, num(bb.e_atk_scale_end, 1)); } finally { unit.mem.dgbFinale = false; }
|
||||
pull(battle, unit, num(bb.e_force));
|
||||
pull(battle, unit, num(bb.e_force), true);
|
||||
},
|
||||
},
|
||||
talents: [
|
||||
|
||||
+153
-49
@@ -6,7 +6,8 @@
|
||||
// Every tokenId of data/tokens.json has a kit, so `battle.spawnToken(owner, tokenId, r, c)` works with data defaults
|
||||
// even when the summoner's kit passes no options:
|
||||
// 医疗探机 heal profile (data), untargetable, self-destructs after its withdraw skill time (10 s)
|
||||
// 诅咒娃娃 no attack, aura: enemies in its range ATK/DEF + bb.atk/bb.def (−25 %/−30 %), 15 s (skill duration)
|
||||
// 诅咒娃娃 no attack, aura: enemies in its range ATK/DEF + bb.atk/bb.def (−25 %/−30 %), 15 s (skill duration);
|
||||
// leaves when 巫恋 leaves (PRTS 备注) — the drone stays when 赫默 leaves (PRTS 医疗探机 备注)
|
||||
// 沙之碑 on appear: owner ATK × atk_scale arts + stun (skill range 3×3), blocks 3 (no attack), talent duration 20 s
|
||||
// 战术装备 on appear: stun around (bb.stun), blocked enemies DEF + talent def (−160), talent duration 25 s
|
||||
// “小自在” arts melee blocker, talent duration 25 s; kills emit `summonKill` (夕's 化境 is the 夕 kit's job)
|
||||
@@ -31,9 +32,11 @@
|
||||
// its blast fx carries `consumed: true` so clients play the explosion, not a death sound)
|
||||
// 从不混淆的方向 untargetable marker; when the owner's skill ends it vanishes and the owner returns to its tile
|
||||
// 黄金盟誓 attacks deal true damage (trait); lasts while the owner's skill runs
|
||||
// 防护单元 untargetable device: shield = 凯瑟琳 max HP × max_shield_ratio on one operator in its range (effects
|
||||
// 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)
|
||||
// 防护单元 untargetable, invulnerable device placed by the player (a hand piece, user playtest #6): shield =
|
||||
// 凯瑟琳 max HP × max_shield_ratio on the operator in its range (range 1-1: the tile it faces; effects do
|
||||
// not stack — `cathy:shield`, read by 凯瑟琳 S1 岁月锻打), in full whenever it takes a new operator,
|
||||
// refilled by shield_ratio_each_trigger /s when the target was not hit for `interval` s (always while
|
||||
// 凯瑟琳's timed skill runs, owner bb overwrite_ratio)
|
||||
// 投递坐标 / 风雪之眼 / 保护目标 inert pieces (no attack; 风雪之眼 untargetable) — their effects belong to the owner kit
|
||||
// 炎佑 (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)
|
||||
@@ -48,15 +51,28 @@
|
||||
// sees its 援军 standing; with the engine's default reinforcement the talent token replaces the generic 援军. Without
|
||||
// a board piece the token takes a tactical point: a free walkable tile of the owner's range on an enemy ground path
|
||||
// first (`tacticalPoint`), then the one nearest to the owner.
|
||||
// Placed summons (PRTS 卫戍协议/帮助 §作战阶段; user playtest #6): every board summon piece marks the tile its summon
|
||||
// deploys on. The talent ones the owner holds from the start (狼群, 海嗣, 流形, 凯瑟琳's devices) deploy with the
|
||||
// board, after the operators. A skill's summon (赫默's 医疗探机, 巫恋's 诅咒娃娃: "获得一个…") also deploys once with the
|
||||
// board, for free and ignoring the holding ("在赫默的技能未开启前未持有该召唤物,但作战开始时无视持有状态自动部署1个";
|
||||
// settled by the user after playtest #6 — shared/constants.js SKILL_SUMMON_START_DEPLOY, re-exported here; false =
|
||||
// the playtest #4 reading, `deferDeploy`: off the field until the skill). Then it is docked on its tile
|
||||
// (`dockSkillSummons`; the tile stays reserved) and takes the field there each time the owner's skill gives one
|
||||
// (`releaseSkillSummon`; PRTS "若战场区初始部署有召唤物,若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个"): one
|
||||
// in stock at most ("最多可库存1个"), after the token's redeploy time once it left, free [ASSUMED: no DP], never while
|
||||
// its owner is off the field — a stocked one deploys as soon as the owner is back. A skill's summon or a device
|
||||
// (凯瑟琳) that was not placed never appears (the hidden 待部署区 deploys nothing by itself), nor does 海嗣; only the
|
||||
// tacticians' 狼群 / 流形 still come as 援军 on a tactical point (above).
|
||||
// Fallbacks (only while the summoner still uses the generic kit — a hand-authored kit takes over): skill summons
|
||||
// (赫默/巫恋/蜜蜡/风丸/维娜/耀骑士临光/迷迭香 S3) spawn at skill start — also under a hand-authored kit when only the
|
||||
// SELECTED skill runs the generic spec (a non-default skill the kit has no `skills` entry for) — 夕 spawns 小自在 on
|
||||
// its first attack, 凯瑟琳 deploys her devices at battle start.
|
||||
// (赫默/巫恋 through their placed pieces as above; 蜜蜡/风丸/维娜/耀骑士临光/迷迭香 S3 on a tile of their own) spawn at
|
||||
// skill start — also under a hand-authored kit when only the SELECTED skill runs the generic spec (a non-default skill
|
||||
// the kit has no `skills` entry for) — and 夕 spawns 小自在 on its first attack.
|
||||
// Operator loadouts (DESIGN §16): variants, `sources` and counts are those of the owner's selected skill / module
|
||||
// (variantOf / tokenSources: getToken(id, owner.defId, owner.def.loadout)); a skill summon runs only when that skill
|
||||
// makes the token (Battle.spawnToken also refuses summons the owner's loadout does not produce).
|
||||
//
|
||||
// Exports for other content: spawnYanyou, spawnMapChar, findSummonTile, summonToken, tacticalPoint, wolfShadows, TOKEN_IDS.
|
||||
// Exports for other content: spawnYanyou, spawnMapChar, findSummonTile, summonToken, tacticalPoint, wolfShadows,
|
||||
// releaseSkillSummon, SKILL_SUMMON_START_DEPLOY, CAT_SHIELD_KEY, TOKEN_IDS.
|
||||
|
||||
import { COLS, ROWS, MOVE_SCALE } from '../constants.js';
|
||||
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../targeting.js';
|
||||
@@ -64,10 +80,13 @@ import { bodyInKeys, bodyOnTile } from '../body.js';
|
||||
import { hasHp } from '../damage.js';
|
||||
import { genericKit } from './generic.js';
|
||||
import { normDir, localOrder } from '../dir.js';
|
||||
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
|
||||
|
||||
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
|
||||
const GRID_3X3 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
|
||||
const GRID_PLUS = Object.freeze([[1, 0], [0, -1], [0, 0], [0, 1], [-1, 0]]);
|
||||
/** Buff key of the 爬行号·防护单元 shield on an operator (凯瑟琳 S1 岁月锻打 buffs the operators holding one). */
|
||||
export const CAT_SHIELD_KEY = 'cathy:shield';
|
||||
|
||||
export const TOKEN_IDS = Object.freeze({
|
||||
healDrone: 'token_10000_silent_healrb',
|
||||
@@ -236,6 +255,93 @@ function bindToOwnerSkill(battle, unit) {
|
||||
}, { owner: unit });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// placed skill summons (user playtest #6; see the header "Placed summons")
|
||||
|
||||
// SKILL_SUMMON_START_DEPLOY (shared/constants.js — also read by the summon card's hint): true = the PRTS reading the
|
||||
// user settled after playtest #6, the placed piece of a skill's summon also deploys once, for free, at the battle
|
||||
// start; false = the playtest #4 reading (it comes with the skill). Re-exported for content and tests.
|
||||
export { SKILL_SUMMON_START_DEPLOY };
|
||||
/** Seconds between two tries of a docked piece whose tile is busy / whose redeploy time is still running. */
|
||||
const DOCK_RETRY = 0.5;
|
||||
|
||||
/**
|
||||
* How the owner's loadout makes a board summon piece of a manually deployable summon (owner-loadout `sources`):
|
||||
* 'skill' (only its skill makes it), 'none' (the owner's skill / module makes none — 赫默 on S1 with a drone piece; the
|
||||
* match never hands such a card out), else null (a talent summon, or not a placed hand piece).
|
||||
*/
|
||||
function pieceSource(battle, u) {
|
||||
if (!u || u.kind !== 'token' || u.uid == null || !u.ownerUnit || u.ownerUnit.kind !== 'op') return null;
|
||||
if (u.def?.raw?.placeable !== true) return null;
|
||||
const src = tokenSources(battle, u.defId, u.ownerUnit);
|
||||
if (src.includes('talent')) return null;
|
||||
if (src.includes('skill')) return 'skill';
|
||||
return src.length ? 'none' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dock the board pieces of skill summons (before the start): off the field with the board (`deferDeploy`, unless
|
||||
* SKILL_SUMMON_START_DEPLOY), and whenever one leaves (10 s drone, 15 s doll) it stays its tile's piece — not removed,
|
||||
* its hooks kept, Battle.isReservedTile — ready again after the token's redeploy time (data respawnTime, 5 s). A piece
|
||||
* its owner's loadout does not make never deploys.
|
||||
*/
|
||||
function dockSkillSummons(battle) {
|
||||
for (const u of battle.allyUnits) {
|
||||
const how = pieceSource(battle, u);
|
||||
if (how === 'none') { u.deferDeploy = true; continue; }
|
||||
if (how !== 'skill') continue;
|
||||
u.mem.docked = true;
|
||||
u.mem.readyAt = -Infinity;
|
||||
if (!SKILL_SUMMON_START_DEPLOY) u.deferDeploy = true;
|
||||
battle.on('death', (ctx) => {
|
||||
if (ctx.unit !== u || battle.finished) return;
|
||||
u.removed = false;
|
||||
u.mem.readyAt = battle.time + Math.max(0, num(u.base.respawnTime, 0));
|
||||
deployDocked(battle, u);
|
||||
}, { owner: u, priority: -10 });
|
||||
// the owner back on the field with one in stock: the condition of the PRTS redeploy is met at once
|
||||
battle.on('deploy', (ctx) => {
|
||||
if (ctx.unit === u.ownerUnit && !battle.finished) deployDocked(battle, u);
|
||||
}, { owner: u });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy a docked piece on its tile when its owner holds one and it is ready; retried while it waits. Not while the
|
||||
* owner is off the field [ASSUMED: as in the base game, a summon is deployed only while its owner stands] — the stock
|
||||
* stays, and the piece deploys as soon as the owner is back (its `deploy` hook, dockSkillSummons).
|
||||
*/
|
||||
function deployDocked(battle, u) {
|
||||
const stock = u.ownerUnit?.mem.summonStock;
|
||||
if (!stock || !(stock[u.defId] > 0) || u.alive || u.removed || battle.finished || !u.ownerUnit.alive) return false;
|
||||
const wait = u.mem.readyAt - battle.time;
|
||||
if (wait > 1e-9 || !battle.redeploy(u, { free: true })) {
|
||||
if (!u.mem.dockRetry) {
|
||||
u.mem.dockRetry = true;
|
||||
battle.after(Math.max(wait, DOCK_RETRY), () => { u.mem.dockRetry = false; deployDocked(battle, u); }, { owner: u });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
stock[u.defId]--;
|
||||
battle.fx('summon', { x: u.x, y: u.y, id: u.id, token: u.defId });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner's skill gives it one `tokenId` ("获得一个医疗无人机"; at most `cap` in stock — "最多可库存1个"): its placed
|
||||
* piece takes the field on its own tile now, or as soon as it is ready (the previous one still up / just gone).
|
||||
* Returns the piece deployed now, else null — also when the player placed none (the summon then never appears).
|
||||
*/
|
||||
export function releaseSkillSummon(battle, owner, tokenId, { cap = 1 } = {}) {
|
||||
if (!owner) return null;
|
||||
const pieces = battle.allyUnits.filter((t) => t.kind === 'token' && t.defId === tokenId && t.ownerUnit === owner && t.mem.docked);
|
||||
if (!pieces.length) return null;
|
||||
const stock = owner.mem.summonStock || (owner.mem.summonStock = {});
|
||||
stock[tokenId] = Math.min(Math.max(1, Math.floor(num(cap, 1))), (stock[tokenId] || 0) + 1);
|
||||
for (const p of pieces) if (deployDocked(battle, p)) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
function inRectTile(battle, r, c) { return Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c); }
|
||||
|
||||
/**
|
||||
@@ -264,8 +370,10 @@ function countNear(list, r, c, rad = 1) {
|
||||
|
||||
/**
|
||||
* Pick a summon tile for `owner` (null when none). placement:
|
||||
* 'ally' — any standable tile in the owner's range covering the most injured allies (医疗探机)
|
||||
* 'enemy' — any standable tile in range covering the most enemies (诅咒娃娃)
|
||||
* 'ally' — any standable tile in the owner's range covering the most injured allies (a healing skill summon that
|
||||
* is no hand piece; 医疗探机 itself is placed by the player — releaseSkillSummon)
|
||||
* 'enemy' — any standable tile in range covering the most enemies (a debuff skill summon that is no hand piece;
|
||||
* 诅咒娃娃 itself is placed by the player)
|
||||
* 'melee' — melee-deployable tile in range, on the enemy path and near the enemies first (沙之碑)
|
||||
* 'adjacent' — melee-deployable tile among the 8 surrounding tiles (纸偶, 黄金盟誓)
|
||||
* 'plus' — melee-deployable tile among the 4 neighbours (“耀阳”)
|
||||
@@ -282,7 +390,10 @@ export function findSummonTile(battle, owner, placement = 'melee', opts = {}) {
|
||||
const melee = opts.melee ?? (placement === 'melee' || placement === 'adjacent' || placement === 'plus');
|
||||
const enemies = battle.enemies.filter((e) => e.alive && !e.hidden);
|
||||
const focus = opts.at ?? mostAdvancedEnemy(battle) ?? owner;
|
||||
const injured = placement === 'ally' ? battle.allies(owner.ownerId).filter((a) => a.hp < a.s.maxHp - 1e-6) : null;
|
||||
// injured allies a heal could reach: never 孤立 (Battle.alliesFor) nor 禁疗 units (炎佑, the 禁疗 summons)
|
||||
const injured = placement === 'ally'
|
||||
? battle.alliesFor(owner, owner.ownerId).filter((a) => a.hp < a.s.maxHp - 1e-6 && !(a.s.flags.noHeal || a.profile?.noHeal))
|
||||
: null;
|
||||
let best = null, bestS = null;
|
||||
for (const k of keys) {
|
||||
const r = (k / COLS) | 0, c = k % COLS;
|
||||
@@ -359,7 +470,11 @@ function curseDoll(bb, raw, def) {
|
||||
},
|
||||
},
|
||||
trait: { noAttack: true },
|
||||
install(battle, unit) { onDeploy(battle, unit, () => scheduleLifetime(battle, unit, life)); },
|
||||
install(battle, unit) {
|
||||
onDeploy(battle, unit, () => scheduleLifetime(battle, unit, life));
|
||||
// PRTS 诅咒娃娃 备注: "巫恋退场时强制撤退场上的诅咒娃娃" (knocked out or withdrawn)
|
||||
battle.on('death', (ctx) => { if (ctx.unit === unit.ownerUnit && unit.alive) battle.retreat(unit, { reason: 'expired', permanent: true }); }, { owner: unit });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -738,7 +853,7 @@ function manifold(bb, raw, def) {
|
||||
if (!unit.mem.noCopy) {
|
||||
battle.on('tick', () => {
|
||||
const sk = unit.skill;
|
||||
if (!sk || !unit.alive || !unit.deployed || sk.active || !sk.ready || !unit.canAct || unit.s.flags.silence) return;
|
||||
if (!sk || !unit.alive || !unit.deployed || sk.active || !sk.ready || sk.opCooling || !unit.canAct || unit.s.flags.silence) return;
|
||||
if (pickCopyTarget(battle, unit)) sk.activate('MLYSS_WTRMAN');
|
||||
}, { owner: unit });
|
||||
}
|
||||
@@ -818,19 +933,25 @@ function goldenOath(bb, raw, def) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 爬行号·防护单元 (凯瑟琳 talent 定向支援信号). */
|
||||
/**
|
||||
* 爬行号·防护单元 (凯瑟琳 talent 定向支援信号; a hand piece the player places and turns, user playtest #6): "使一名干员
|
||||
* 获得相当于凯瑟琳生命上限20%的屏障(若目标最近5秒内未受攻击,则每秒补充…不超过初始上限),装置效果不叠加" — the operator
|
||||
* on its range (1-1: its tile + the tile it faces) gets the full shield when the device takes it (deploy, or a new /
|
||||
* redeployed operator there), then the refill; untargetable ("不会受到攻击") and invulnerable (enemy AoE sweeping every
|
||||
* ally in a radius would otherwise destroy the 100-HP device). The buff key is 凯瑟琳 S1 岁月锻打's test.
|
||||
*/
|
||||
function catShield(bb, raw, def) {
|
||||
const t = talentBb(def, 'max_shield_ratio');
|
||||
const idle = num(t.interval, 0);
|
||||
const every = num(t['catsld_t_1[timer][interval].interval'], 1);
|
||||
const maxRatio = num(t.max_shield_ratio, 0);
|
||||
const refill = num(t.shield_ratio_each_trigger, 0);
|
||||
const KEY = 'catsld:shield';
|
||||
return {
|
||||
skill: null,
|
||||
trait: { noAttack: true },
|
||||
install(battle, unit) {
|
||||
const st = stateOf(battle);
|
||||
battle.addBuff(unit, { key: 'token:catDevice', flags: { invulnerable: true }, persist: true, allowDead: true });
|
||||
const ownerHp = () => { const o = ownerOf(unit); return o ? o.s.maxHp : unit.s.maxHp; };
|
||||
const valid = (a) => a && a.alive && a.deployed && a.kind === 'op';
|
||||
const pick = () => {
|
||||
@@ -851,18 +972,19 @@ function catShield(bb, raw, def) {
|
||||
const give = (a, amount) => {
|
||||
const cap = ownerHp() * maxRatio;
|
||||
if (!(cap > 0) || !(amount > 0)) return;
|
||||
const b = a.findBuff(KEY);
|
||||
if (b) { b.shield = Math.min(cap, (b.shield || 0) + amount); a.markDirty(); } else battle.addBuff(a, { key: KEY, shield: Math.min(cap, amount), duration: Infinity, source: unit });
|
||||
const b = a.findBuff(CAT_SHIELD_KEY);
|
||||
if (b) { b.shield = Math.min(cap, (b.shield || 0) + amount); a.markDirty(); } else battle.addBuff(a, { key: CAT_SHIELD_KEY, shield: Math.min(cap, amount), duration: Infinity, visible: true, source: unit });
|
||||
};
|
||||
onDeploy(battle, unit, () => {
|
||||
const connect = () => {
|
||||
const a = pick();
|
||||
if (a) { give(a, ownerHp() * maxRatio); battle.fx('catShield', { x: a.x, y: a.y, id: a.id, from: unit.id }); }
|
||||
});
|
||||
return a;
|
||||
};
|
||||
onDeploy(battle, unit, connect);
|
||||
battle.every(every, () => {
|
||||
if (!unit.alive || !unit.deployed) return;
|
||||
let a = unit.mem.target;
|
||||
if (!valid(a)) a = pick();
|
||||
if (!a) return;
|
||||
const a = unit.mem.target;
|
||||
if (!valid(a)) { connect(); return; }
|
||||
const o = ownerOf(unit);
|
||||
const skillOn = !!(o && o.alive && o.skill && o.skill.active && o.skill.isTimed);
|
||||
const rate = skillOn ? num(o.skill.bb?.overwrite_ratio, refill) : refill;
|
||||
@@ -1170,7 +1292,7 @@ function touchKit(bb, raw, def) {
|
||||
unit.mem.touchExtraAt = n;
|
||||
let best = target.hp < target.s.maxHp - 1e-6 ? target : null;
|
||||
for (const a of battle.alliesInRadius(target.x, target.y, 1, unit.ownerId)) {
|
||||
if (a === target || a.hp >= a.s.maxHp - 1e-6) continue;
|
||||
if (a === target || a.hp >= a.s.maxHp - 1e-6 || a.s.flags.noHeal || a.profile?.noHeal) continue; // (禁疗 / 孤立)
|
||||
if (Math.abs(a.tileR - target.tileR) + Math.abs(a.tileC - target.tileC) !== 1) continue;
|
||||
if (!best || a.hpRatio < best.hpRatio) best = a;
|
||||
}
|
||||
@@ -1260,7 +1382,9 @@ export default kits;
|
||||
* hand-authored kit without a spec for a non-default selected skill (content/index.js selectSkillSpec, `skillSource:
|
||||
* 'generic'`) — and the data says that skill makes the token (owner-loadout `sources` has 'skill'). Placement per
|
||||
* token; 迷迭香 S3 “如你所愿”: "立即在攻击范围内的近战位部署两个战术装备" (the token's deployLimit — 2, 20 with
|
||||
* module uniequip_003_rosmon — only caps how many stand; SKILL_SUMMON_PER_CAST).
|
||||
* module uniequip_003_rosmon — only caps how many stand; SKILL_SUMMON_PER_CAST). 医疗探机 / 诅咒娃娃 are hand pieces
|
||||
* (data `placeable`): their entries only mark them as skill summons — releaseSkillSummon deploys the player's piece on
|
||||
* its own tile, and their 'ally' / 'enemy' placement would apply only to data without such a piece.
|
||||
*/
|
||||
const SKILL_SUMMONS = Object.freeze({
|
||||
[TOKEN_IDS.healDrone]: 'ally',
|
||||
@@ -1326,6 +1450,9 @@ function ensureReinforcement(battle, owner, tokenId) {
|
||||
export function install(battle) {
|
||||
const st = stateOf(battle);
|
||||
|
||||
// the placed pieces of skill summons wait on their tiles for the skill (user playtest #6, see the header)
|
||||
dockSkillSummons(battle);
|
||||
|
||||
// last deployed operator per player (“耀阳”: 上一名部署干员势力为【卡西米尔】) — tokens are not operators
|
||||
battle.on('deploy', (ctx) => {
|
||||
const u = ctx.unit;
|
||||
@@ -1383,6 +1510,8 @@ export function install(battle) {
|
||||
battle.on('skillStart', (ctx) => {
|
||||
if (ctx.unit !== owner) return;
|
||||
for (const t of skillSummons) {
|
||||
// a hand piece (医疗探机 / 诅咒娃娃) deploys where the player placed it — nowhere when not placed
|
||||
if (battle.data.rawToken?.(t)?.placeable === true) { releaseSkillSummon(battle, owner, t); continue; }
|
||||
for (let i = 0; i < (SKILL_SUMMON_PER_CAST[t] ?? 1); i++) if (!summonToken(battle, owner, t, SKILL_SUMMONS[t])) break;
|
||||
}
|
||||
}, { owner });
|
||||
@@ -1403,31 +1532,6 @@ export function install(battle) {
|
||||
if (d) battle.fx('duskDragon', { x: d.x, y: d.y, id: d.id });
|
||||
}, { owner });
|
||||
}
|
||||
if (toks.includes(TOKEN_IDS.catShield)) {
|
||||
const deployDevices = () => {
|
||||
if (!owner.alive || !owner.deployed) return;
|
||||
const probe = battle.data.getToken?.(TOKEN_IDS.catShield, owner.defId, owner.def?.loadout ?? null);
|
||||
const v = ownVariant(battle.data.rawToken?.(TOKEN_IDS.catShield), owner);
|
||||
const limit = Math.max(1, Math.floor(num(v?.stats?.deployLimit, 1)));
|
||||
const stock = Math.max(1, Math.floor(num(v?.count ?? v?.stats?.deckStack, limit)));
|
||||
const grid = probe?.rangeGrid ?? [[0, 0], [0, 1]];
|
||||
let placed = battle.allyUnits.filter((t) => t.alive && t.defId === TOKEN_IDS.catShield && t.ownerUnit === owner).length;
|
||||
const used = new Set();
|
||||
for (const k of owner.rangeKeys || []) {
|
||||
if (placed >= Math.min(limit, stock)) break;
|
||||
const r = (k / COLS) | 0, c = k % COLS;
|
||||
if (!tileFree(battle, r, c) || !battle.grid.canStand(r, c, { ranged: true })) continue;
|
||||
// the device helps the operator in its own range (the tile in front)
|
||||
const keys = absoluteRangeKeys(grid, r, c, owner.dir, 0);
|
||||
const ally = keys.map((kk) => battle.unitAt((kk / COLS) | 0, kk % COLS)).find((a) => a && a.kind === 'op' && a.ownerId === owner.ownerId && !used.has(a.id));
|
||||
if (!ally) continue;
|
||||
if (battle.spawnToken(owner, TOKEN_IDS.catShield, r, c)) { placed++; used.add(ally.id); }
|
||||
}
|
||||
};
|
||||
// initial deployment: wait until every board unit stands (the devices need the operator in front of them)
|
||||
battle.on('battleStart', deployDevices, { owner });
|
||||
battle.on('deploy', (ctx) => { if (ctx.unit === owner && battle.started && !ctx.initial) deployDevices(); }, { owner, priority: -20 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -268,10 +268,13 @@ function burstLockOf(u) {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* cooldown runs. Official display (PRTS 元素): one icon, the "当前损伤元素" — the fullest gauge, i.e. the least 元素值
|
||||
* left (ties: the lower official element id, constants.js ELEMENT_ORDER) — with a white bar of its remaining 元素值;
|
||||
* during a 爆发冷却 the bursting element, refilling over the cooldown. Returns `[el, fill, cooldownEnd (game s),
|
||||
* cooldown (s)]`; the two cooldown numbers are 0 outside a cooldown. `fill` has 2 decimals and is rounded DOWN
|
||||
* (0.01–0.99 while no burst runs, 1 during one), so the remainder shown (1 − fill) is never less than what is left —
|
||||
* except that a first chip of less than 1 % (under 10 元素值 of 1000) already shows as 1 % taken — and the bar only
|
||||
* runs out when the gauge bursts (user playtest #6). `now` = the battle time.
|
||||
*/
|
||||
export function elementView(u, now) {
|
||||
const e = u.elem;
|
||||
@@ -286,7 +289,8 @@ export function elementView(u, now) {
|
||||
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));
|
||||
// rounded down (1e-9: 290 / 1000 × 100 is 28.999…), kept inside 1–99 %: a sliver shows, an empty bar = a burst
|
||||
const fill = Math.min(0.99, Math.max(0.01, Math.floor((bv / u.gaugeMax) * 100 + 1e-9) / 100));
|
||||
return [best, fill, 0, 0];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// server/sim/fear.js — 恐惧 movement of enemies (DESIGN §5.5; user playtest #6 item 13).
|
||||
//
|
||||
// Official rule (gamedata_const termDescriptionDict ba.fear "无法被阻挡并四散逃跑"; PRTS 术语释义 恐惧 "受影响的目标大体上会
|
||||
// 尝试尽可能远离效果来源"; PRTS 诱发移动 §恐惧 — the fear controller):
|
||||
// * 恐惧可达地块: when a fear is applied the controller records the tiles whose CENTRE lies within 10 tiles of the hit
|
||||
// position (the feared unit's position then), inside the 90° fan pointing away from the effect source (±45°), that
|
||||
// can reach the unit's goal, are passable terrain (obstacles or not) and are not the protection objective (no
|
||||
// 地穴 in this mode). Without a source, with the unit itself as the source (自惧 / "对自身施加…恐惧", e.g. the
|
||||
// “萨科塔” enemies' SelfFear) or with the source exactly on the hit position there is NO reachable tile.
|
||||
// * 恐惧移动: the unit picks a random reachable tile as a temporary checkpoint, offset at random inside a square of
|
||||
// side 0.5 around the tile centre, and moves there; on arrival it picks the next one, until the fear ends. A picked
|
||||
// tile farther than 5 path steps is dropped from the list for good and the unit's own tile is used instead; with
|
||||
// no reachable tile the checkpoint is always its own tile (same offset) — so a self-feared unit flutters around
|
||||
// inside its tile ("小范围乱飞"). A new fear refreshes the list from the current position (Battle.applyStatus
|
||||
// stamps `buff.data.fear` on every application). Waiting checkpoints keep counting down meanwhile; when the fear
|
||||
// ends the unit re-plans its route from where it stands ("恐惧效果结束时立刻清除【恐惧移动】" — so does a running
|
||||
// 诱导, ai.js moveAttracted). 恐惧 outranks 诱导 (PRTS 诱移优先级).
|
||||
// Path steps (the 5-step limit): 4-neighbour steps over the unit's passable, obstacle-free tiles, the checkpoint tile
|
||||
// included (ground: walkable, blocks and crates excluded — a crate's move cost 1000 is beyond any 5-step budget and a
|
||||
// block has no path; flyers: fly-passable), like the official SPFA's unit edges.
|
||||
// The temporary path: ground units walk the grid's smoothed flow-field waypoints to the checkpoint's tile (stepping
|
||||
// back to their tile centre first when the straight line from an off-centre position would clip a tile they cannot
|
||||
// walk, as ai.js planLeg), flyers fly straight. Like every enemy path it re-plans from where the unit stands when
|
||||
// something else moved it (a push / pull — Battle.displace) or an obstacle changed (grid.version): to the same
|
||||
// checkpoint while it stays reachable, else to a new pick. Speed = the unit's own (move-speed buffs such as the
|
||||
// SelfFear ×1.5 included). Deterministic: every draw is battle.rng().
|
||||
|
||||
import { MOVE_SCALE, COLS } from './constants.js';
|
||||
import { straightClear } from './grid.js';
|
||||
|
||||
/** 半径10格 — the fan's radius (tiles). */
|
||||
export const FEAR_RADIUS = 10;
|
||||
/** 左右各45° — cos of the fan's half angle. */
|
||||
export const FEAR_HALF_COS = Math.SQRT1_2;
|
||||
/** 寻路距离5格内 — the farthest checkpoint in path steps. */
|
||||
export const FEAR_REACH = 5;
|
||||
/** 0.5边长正方形的随机偏移 — side of the random offset square around a checkpoint tile's centre. */
|
||||
export const FEAR_JITTER = 0.5;
|
||||
|
||||
/**
|
||||
* Stamp a fear application on its buff (Battle.applyStatus): the hit position, the source's position and whether
|
||||
* the unit feared itself. Every application gets a new per-battle `seq`, which makes the mover rebuild its tiles.
|
||||
*/
|
||||
export function stampFear(b, target, buff, source) {
|
||||
const self = !source || source === target;
|
||||
b._fearSeq = (b._fearSeq || 0) + 1;
|
||||
buff.data = {
|
||||
...buff.data,
|
||||
fear: {
|
||||
seq: b._fearSeq, hx: target.x, hy: target.y, self,
|
||||
sx: self ? target.x : Number(source.x), sy: self ? target.y : Number(source.y),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The latest fear stamp among the unit's fear buffs (null when none carries one — e.g. a raw addBuff). */
|
||||
function stampOf(e) {
|
||||
let best = null;
|
||||
for (const b of e.buffs) {
|
||||
const f = (b.status === 'fear' || b.key === 'fear') && b.data ? b.data.fear : null;
|
||||
if (f && (!best || f.seq > best.seq)) best = f;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The goal tile of the enemy's route (its final leg), or null. */
|
||||
function goalOf(e) {
|
||||
const legs = e.route && e.route.legs;
|
||||
if (!legs) return null;
|
||||
for (let i = legs.length - 1; i >= 0; i--) if (legs[i].final && legs[i].r != null) return legs[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Passable terrain for this unit, obstacles or not ("不论该格是否有障碍物"). */
|
||||
const passable = (g, fly, r, c) => (fly ? g.flyPassable(r, c) : g.walkable(r, c, true));
|
||||
|
||||
/**
|
||||
* 恐惧可达地块 of a stamp: tile keys in row-major order (deterministic).
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function reachableTiles(b, e, st) {
|
||||
if (!st || st.self) return [];
|
||||
const dx = st.hx - st.sx, dy = st.hy - st.sy, d = Math.hypot(dx, dy);
|
||||
if (!(d > 1e-9)) return [];
|
||||
const ux = dx / d, uy = dy / d;
|
||||
const g = b.grid, R = g.rect, fly = e.motion === 'FLY';
|
||||
const goal = goalOf(e);
|
||||
let field = null, field2 = null;
|
||||
if (!fly && goal) { field = g.flowField(goal.r, goal.c); field2 = g.flowField(goal.r, goal.c, { ignoreObstacles: true }); }
|
||||
const out = [];
|
||||
for (let r = R.r0; r <= R.r1; r++) {
|
||||
for (let c = R.c0; c <= R.c1; c++) {
|
||||
const vx = c - st.hx, vy = r - st.hy, L = Math.hypot(vx, vy);
|
||||
if (L > FEAR_RADIUS + 1e-9) continue;
|
||||
if (L > 1e-9 && vx * ux + vy * uy < FEAR_HALF_COS * L - 1e-9) continue;
|
||||
if (!passable(g, fly, r, c) || g.tile(r, c).special === 'end') continue;
|
||||
const k = r * COLS + c;
|
||||
if (field && field.dist[k] < 0 && field2.dist[k] < 0) continue; // 该格可抵达其终点
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether the unit can stand on / step through tile key `k` (its passable terrain; ground: no block, no crate). */
|
||||
function openTile(g, fly, k) {
|
||||
const r = (k / COLS) | 0, c = k - r * COLS;
|
||||
return passable(g, fly, r, c) && (fly || !g.isObstacle(r, c));
|
||||
}
|
||||
|
||||
/**
|
||||
* Path steps (4-neighbour, the unit's open tiles, `to` included) from tile key `from` to `to`, or Infinity beyond
|
||||
* `limit` — an obstacle tile has no path ("寻路距离"), so a pick on one is dropped like a far one.
|
||||
*/
|
||||
function stepsBetween(b, e, from, to, limit) {
|
||||
if (from === to) return 0;
|
||||
const g = b.grid, fly = e.motion === 'FLY';
|
||||
const open = (k) => openTile(g, fly, k);
|
||||
if (!open(to)) return Infinity;
|
||||
let frontier = [from];
|
||||
const seen = new Set(frontier);
|
||||
for (let d = 1; d <= limit && frontier.length; d++) {
|
||||
const next = [];
|
||||
for (const k of frontier) {
|
||||
const r = (k / COLS) | 0, c = k - r * COLS;
|
||||
for (const [dr, dc] of [[1, 0], [0, 1], [-1, 0], [0, -1]]) {
|
||||
const rr = r + dr, cc = c + dc;
|
||||
if (!g.inRect(rr, cc)) continue;
|
||||
const kk = rr * COLS + cc;
|
||||
if (seen.has(kk)) continue;
|
||||
if (kk === to) return d;
|
||||
seen.add(kk);
|
||||
if (open(kk)) next.push(kk);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
/** The unit's tile (row, col, key), clamped to the rect. */
|
||||
function hereOf(g, e) {
|
||||
const r = Math.max(g.rect.r0, Math.min(g.rect.r1, Math.round(e.y)));
|
||||
const c = Math.max(g.rect.c0, Math.min(g.rect.c1, Math.round(e.x)));
|
||||
return { r, c, k: r * COLS + c };
|
||||
}
|
||||
|
||||
/**
|
||||
* The temporary path from where the unit stands to its checkpoint (`m.k` tile, `m.goal` point): ground units follow
|
||||
* the smoothed flow-field waypoints (null when an obstacle now cuts the tile off), flyers fly straight. Records the
|
||||
* grid version; moveFeared records where each step left the unit and re-plans when either changes behind its back.
|
||||
* @returns {boolean} false when a ground unit can no longer reach the checkpoint tile
|
||||
*/
|
||||
function planPath(b, e, m) {
|
||||
const g = b.grid, h = hereOf(g, e);
|
||||
const pts = [];
|
||||
if (e.motion !== 'FLY' && m.k !== h.k) {
|
||||
if (!openTile(g, false, m.k)) return false;
|
||||
const r = (m.k / COLS) | 0, c = m.k - r * COLS;
|
||||
const wp = g.waypoints(h.r, h.c, r, c);
|
||||
if (!wp) return false;
|
||||
for (let j = 1; j < wp.length - 1; j++) pts.push({ x: wp[j][1], y: wp[j][0] });
|
||||
// the smoothed chain is line-of-sight clear from the tile CENTRE only (as ai.js planLeg)
|
||||
const first = pts.length ? pts[0] : { x: c, y: r };
|
||||
if ((e.x !== h.c || e.y !== h.r) && !straightClear(g, e.x, e.y, first)) pts.unshift({ x: h.c, y: h.r });
|
||||
}
|
||||
pts.push(m.goal);
|
||||
m.pts = pts;
|
||||
m.i = 0;
|
||||
m.ver = g.version;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Choose the next temporary checkpoint (a random reachable tile within 5 path steps, else the own tile) and plan to it. */
|
||||
function pickCheckpoint(b, e, m) {
|
||||
const here = hereOf(b.grid, e).k;
|
||||
let k = here;
|
||||
if (m.tiles.length) {
|
||||
const i = Math.floor(b.rng() * m.tiles.length);
|
||||
const cand = m.tiles[i];
|
||||
if (stepsBetween(b, e, here, cand, FEAR_REACH) <= FEAR_REACH) k = cand;
|
||||
else m.tiles.splice(i, 1); // "…格子会永久从当次恐惧的恐惧可达地块中剔除"
|
||||
}
|
||||
const ox = (b.rng() - 0.5) * FEAR_JITTER, oy = (b.rng() - 0.5) * FEAR_JITTER;
|
||||
const aim = (key) => {
|
||||
const r = (key / COLS) | 0, c = key - r * COLS;
|
||||
m.k = key;
|
||||
m.goal = { x: c + ox, y: r + oy };
|
||||
return planPath(b, e, m);
|
||||
};
|
||||
// (a pick within 5 open steps has a path unless the unit itself stands on a block; the own tile needs none)
|
||||
if (!aim(k)) aim(here);
|
||||
}
|
||||
|
||||
/**
|
||||
* One tick of fear movement for enemy `e` (ai.js updateEnemy, while `e.s.flags.fear`). Sets `e.moving`.
|
||||
*/
|
||||
export function moveFeared(b, e, dt) {
|
||||
const st = stampOf(e);
|
||||
let m = e.mem.fearMove;
|
||||
if (!m || (st && m.seq !== st.seq)) {
|
||||
m = e.mem.fearMove = { seq: st ? st.seq : -1, tiles: reachableTiles(b, e, st), pts: null, i: 0 };
|
||||
}
|
||||
// pushed / pulled (Battle.displace) or an obstacle changed since the last tick: re-plan the walk from here — to the
|
||||
// same checkpoint while it stays reachable, else a new pick (a stale path would walk through walls)
|
||||
if (m.pts && (m.ver !== b.grid.version || e.x !== m.px || e.y !== m.py) && !planPath(b, e, m)) m.pts = null;
|
||||
// a waiting checkpoint of the route keeps counting down ("停驻时间会相应扣除恐惧生效时间")
|
||||
const R = e.route, leg = R && R.legs ? R.legs[R.legIdx] : null;
|
||||
if (leg && leg.t === 'wait') {
|
||||
if (R.waitLeft == null) R.waitLeft = leg.time;
|
||||
R.waitLeft -= dt;
|
||||
if (R.waitLeft <= 1e-9) { R.waitLeft = null; R.legIdx++; }
|
||||
}
|
||||
if (R) R.pts = null; // the route re-plans from wherever the fear leaves the unit
|
||||
const speed = e.s.moveSpeed * MOVE_SCALE;
|
||||
let moved = false;
|
||||
if (speed > 0) {
|
||||
let dist = speed * dt;
|
||||
for (let guard = 8; dist > 1e-9 && guard > 0; guard--) {
|
||||
if (!m.pts || m.i >= m.pts.length) pickCheckpoint(b, e, m);
|
||||
const p = m.pts[m.i];
|
||||
const dx = p.x - e.x, dy = p.y - e.y, d = Math.hypot(dx, dy);
|
||||
if (d <= dist) { e.x = p.x; e.y = p.y; dist -= d; m.i++; } else { e.x += (dx / d) * dist; e.y += (dy / d) * dist; dist = 0; }
|
||||
moved = moved || d > 1e-9;
|
||||
}
|
||||
}
|
||||
m.px = e.x; m.py = e.y;
|
||||
e.moving = moved;
|
||||
}
|
||||
|
||||
/** The fear ended: forget the controller; the route re-plans from here (route.pts was cleared while feared). */
|
||||
export function endFear(e) {
|
||||
e.mem.fearMove = null;
|
||||
if (e.route) e.route.pts = null;
|
||||
}
|
||||
+26
-1
@@ -34,7 +34,8 @@
|
||||
// platforms, research 08 §8 #1]) and CRATE (阻隔工事 devices — `setObstacle(r, c, on, 'crate')`: walkable at cost 1000,
|
||||
// blocks line of sight; an enemy that must cross one is blocked by the device and breaks it). Any change bumps
|
||||
// `version`, which invalidates the cached fields (enemies re-read their next step). `groundPassable` (placement,
|
||||
// displacement, content) still treats both kinds as occupied unless `ignoreObstacles`.
|
||||
// displacement, content) still treats both kinds as occupied unless `ignoreObstacles`. `straightClear` tells whether an
|
||||
// off-centre unit may walk straight to a tile centre (ai.js planLeg, fear.js: else it steps back to its tile centre).
|
||||
|
||||
import { ROWS, COLS } from './constants.js';
|
||||
|
||||
@@ -427,3 +428,27 @@ export function bresenhamTiles(a, b) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a straight move from (x, y) to the centre of tile p = {x: col, y: row} only crosses ground-walkable,
|
||||
* crate-free tiles (the start and end tiles excepted). Exact grid traversal (tile (r,c) spans c ± 0.5, r ± 0.5); a
|
||||
* line through a tile corner needs both side tiles clear (as the grid's Bresenham smoothing).
|
||||
*/
|
||||
export function straightClear(g, x, y, p) {
|
||||
let c = Math.round(x), r = Math.round(y);
|
||||
const dx = p.x - x, dy = p.y - y;
|
||||
const sc = dx > 0 ? 1 : -1, sr = dy > 0 ? 1 : -1;
|
||||
const ddx = dx !== 0 ? Math.abs(1 / dx) : Infinity, ddy = dy !== 0 ? Math.abs(1 / dy) : Infinity;
|
||||
let tx = dx !== 0 ? (c + 0.5 * sc - x) / dx : Infinity;
|
||||
let ty = dy !== 0 ? (r + 0.5 * sr - y) / dy : Infinity;
|
||||
const clear = (rr, cc) => (rr === p.y && cc === p.x) || (g.walkable(rr, cc) && !(g.obstacle[rr * COLS + cc] & OB_CRATE));
|
||||
for (let guard = 4 * COLS; guard > 0 && (r !== p.y || c !== p.x); guard--) {
|
||||
if (tx >= 1 && ty >= 1) break;
|
||||
if (Math.abs(tx - ty) < 1e-9) {
|
||||
if (!clear(r, c + sc) || !clear(r + sr, c)) return false;
|
||||
c += sc; r += sr; tx += ddx; ty += ddy;
|
||||
} else if (tx < ty) { c += sc; tx += ddx; } else { r += sr; ty += ddy; }
|
||||
if (!clear(r, c)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -104,12 +104,13 @@ export function normalizeSkill(rec) {
|
||||
let rule = normTriggerRule(typeof trig === 'string' ? trig : trig.rule);
|
||||
const matchedBy = typeof trig === 'object' ? String(trig.matchedBy ?? '') : '';
|
||||
const skillIndex = s.skillIndex ?? s.index ?? null;
|
||||
// Research correction (03 addendum C1): profession/subprofession rows apply to skill 1 only.
|
||||
if (/^(profession|subProfession)/.test(matchedBy) && skillIndex != null && skillIndex !== 0) rule = 'DEFAULT';
|
||||
const skillType = String(s.skillType ?? s.type ?? 'MANUAL').toUpperCase();
|
||||
// Class rows (profession / subprofession, research-shaped records carry `matchedBy`) apply to every MANUAL skill of the
|
||||
// class and never to an AUTO one (PRTS 卫戍协议/帮助 技能策略; tools/build-data.mjs resolveTrigger does the same for data).
|
||||
if (/^(profession|subProfession)/.test(matchedBy) && skillType !== 'MANUAL') rule = 'DEFAULT';
|
||||
const trigGrid = toArrayOfPairs((typeof trig === 'object' && (trig.customRangeGrid ?? trig.rangeGrid ?? trig.grid)) || rec.skillTriggerCustomRangeGrid);
|
||||
if (/^skcom_withdraw/.test(String(s.skillId ?? s.id ?? ''))) return null;
|
||||
const durRaw = s.duration ?? bb.duration ?? 0;
|
||||
const skillType = String(s.skillType ?? s.type ?? 'MANUAL').toUpperCase();
|
||||
return {
|
||||
id: s.skillId ?? s.id ?? 'skill',
|
||||
name: s.name ?? '',
|
||||
@@ -300,6 +301,8 @@ export function normalizeToken(id, t0, ownerChessId = null, variantOverride = nu
|
||||
canHitFly: t.canHitFly ?? null,
|
||||
targetPriority: t.targetPriority ?? null,
|
||||
untargetable: t.untargetable ?? /不会受到攻击|不会成为/.test(desc),
|
||||
// abnormal effects held from the start (tokens.json `abnormal`, PRTS summon pages): 'healFree' 禁疗, 'isolated' 孤立
|
||||
abnormal: Array.isArray(t0.abnormal) ? t0.abnormal.filter((x) => typeof x === 'string') : [],
|
||||
immune: immuneSet(t.immunities),
|
||||
trait: desc,
|
||||
traitBb: t.trait && typeof t.trait === 'object' ? { ...(t.trait.bb || {}) } : {},
|
||||
|
||||
+94
-16
@@ -4,14 +4,30 @@
|
||||
// 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: 海嗣, 流形),
|
||||
// TAKE_DAMAGE (ready + just took a hit), SP_FULL/ALWAYS (as soon as ready), CUSTOM_RANGE (enemy inside the
|
||||
// custom trigger grid), SEARCH (an enemy inside the INITIAL range, checked every tick without waiting for an attack:
|
||||
// BWIKI "解放者/阵法术师子职业干员 技能1,初始攻击范围内出现敌人后自动释放", research 03 Addendum C1 — not any enemy on
|
||||
// the field, which burnt the skill on enemies that had just spawned), GDGLOW_SKILL_2 = DEFAULT, NEVER/MANUAL (never
|
||||
// Trigger rules (the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; data: tools/build-data.mjs resolveTrigger):
|
||||
// DEFAULT — the basic strategy: ready + about to attack/heal + enemy / injured ally in the INITIAL range (or blocked by
|
||||
// a melee unit) — or, checked every tick, an enemy inside one of the content trigger ranges added with
|
||||
// addTriggerRange: 海嗣, 流形;
|
||||
// SKILL_RANGE — a MANUAL skill with a 技能范围 of its own: "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其
|
||||
// 不可选中)时释放技能": any living enemy on the trigger grid (the skill range; stealthed / untargetable / flying ones
|
||||
// too), checked every tick, no attack needed. Kit option `trigger.allies` (+ `hpAtMost`, default 1): a healable,
|
||||
// injured ally of the grid whose HP ratio is at most that instead (an AUTO heal skill's own rule — 古米 S1 waits in
|
||||
// its heal mode until it has healed);
|
||||
// TAKE_DAMAGE (ready + just took a hit: 重装 "不受技能范围影响,受到伤害时释放技能"), SP_FULL/ALWAYS (as soon as ready),
|
||||
// CUSTOM_RANGE (enemy inside the custom trigger grid), SEARCH (an enemy inside the INITIAL range, checked every tick
|
||||
// without waiting for an attack: "不受基础策略影响,在初始攻击范围内存在敌人时释放技能" — not any enemy on the field,
|
||||
// which burnt the skill on enemies that had just spawned), GDGLOW_SKILL_2 ("全场存在可选目标时释放技能": a targetable
|
||||
// enemy anywhere on the field — for a heal skill an ally that needs healing — every tick), NEVER/MANUAL (never
|
||||
// auto-cast: the kit calls skill.activate() itself); any unknown rule falls back to DEFAULT. Units that never attack
|
||||
// (noAttack profiles) evaluate DEFAULT every tick instead.
|
||||
// (noAttack profiles) evaluate DEFAULT every tick instead. DEFAULT with `trigger.allies` (+ `hpAtMost`): the basic
|
||||
// rule AND such an ally on the trigger grid — the cast replaces the attack about to be made (塞雷娅 S1 "触发时会替换当
|
||||
// 次攻击"); should that ally condition fail before the attack, the cast is withdrawn and its charge returned.
|
||||
// Automatic operations cool down (constants.js AUTO_OP_COOLDOWN, "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位
|
||||
// 将进入冷却"): the engine auto-casts a MANUAL skill (def.skillType) no sooner than 3 s after its previous cast (so a
|
||||
// charged skill spends its charges 3 s apart) or after the unit's deployment at the battle start (Battle._deploy
|
||||
// `initial`). AUTO skills are exempt; a kit with its own automatic cast checks `opCooling`; a cast made by activate()
|
||||
// directly is not held back (it still starts the cooldown). While a cast "next attack" waits for its attack, no
|
||||
// further charge is cast.
|
||||
// Kinds: duration (mods for `duration` s), ammo (mods until `ammo` attacks were made; optional duration cap),
|
||||
// instant (onStart + optional one-shot attack override for the next attack), charges (= instant with charges),
|
||||
// passive (always on from deployment, no SP), toggle (stays on until death once activated).
|
||||
@@ -23,11 +39,14 @@
|
||||
// also carries `noAmmo` (set it to true: this attack spends no ammo). onEnd runs while the skill's mods / range are
|
||||
// still applied (`active` is already false); they are removed right after it (unless onEnd re-activated the skill).
|
||||
|
||||
import { absoluteRangeKeys } from './targeting.js';
|
||||
import { absoluteRangeKeys, canTargetEnemy } from './targeting.js';
|
||||
import { AUTO_OP_COOLDOWN, COLS, ROWS } from './constants.js';
|
||||
|
||||
const TICK_RULES = new Set(['SP_FULL', 'SEARCH', 'CUSTOM_RANGE']);
|
||||
const TICK_RULES = new Set(['SP_FULL', 'SEARCH', 'CUSTOM_RANGE', 'SKILL_RANGE', 'GDGLOW_SKILL_2']);
|
||||
/** Enemies that satisfy a content trigger range (any targetable enemy, flyers included). */
|
||||
const TRIGGER_PROFILE = Object.freeze({ canHitFly: true });
|
||||
/** Every tile of the stage (GDGLOW_SKILL_2: the whole field). */
|
||||
const ALL_TILES = new Set(Array.from({ length: ROWS * COLS }, (_, i) => i));
|
||||
|
||||
export class SkillRuntime {
|
||||
/**
|
||||
@@ -60,7 +79,14 @@ export class SkillRuntime {
|
||||
if (this.rule === 'MANUAL') this.rule = 'NEVER';
|
||||
if (this.rule.startsWith('CUSTOM_RANGE')) this.rule = 'CUSTOM_RANGE';
|
||||
this.triggerGrid = trig.grid ?? trig.rangeGrid ?? d.trigger?.grid ?? null;
|
||||
// kit options: an injured, healable ally of the trigger grid with an HP ratio of at most `hpAtMost` (SKILL_RANGE:
|
||||
// instead of an enemy; DEFAULT: in addition to the basic rule)
|
||||
this.triggerAllies = !!trig.allies;
|
||||
this.triggerHpAtMost = Number.isFinite(+trig.hpAtMost) && +trig.hpAtMost > 0 ? +trig.hpAtMost : 1;
|
||||
this.healSkill = s.heal ?? (unit.profile && unit.profile.dmgType === 'heal' && !!unit.profile.heal);
|
||||
// the official skill strategies automate the manual 开启: only MANUAL skills wait for the operation cooldown
|
||||
this.manual = String(d.skillType ?? 'MANUAL').toUpperCase() === 'MANUAL';
|
||||
this.opReadyAt = -Infinity; // no automatic cast before this battle time (AUTO_OP_COOLDOWN)
|
||||
if (this.kind === 'passive') this.baseSpCost = 0;
|
||||
this._spCostMul = 1; // content may set spCostMul (e.g. 绝技 ×0.7) — see the accessor below
|
||||
this.sp = 0;
|
||||
@@ -73,6 +99,7 @@ export class SkillRuntime {
|
||||
this.lastStart = -Infinity;
|
||||
this._buffKey = `skill:${unit.id}`;
|
||||
this._trigKeys = null;
|
||||
this._trigSet = null;
|
||||
this.triggerRanges = []; // content trigger ranges (addTriggerRange)
|
||||
this.noSkill = !spec; // unit without any skill spec
|
||||
}
|
||||
@@ -140,6 +167,8 @@ export class SkillRuntime {
|
||||
this.charges = 0;
|
||||
this.sp = 0;
|
||||
this._trigKeys = null;
|
||||
this._trigSet = null;
|
||||
this.opReadyAt = -Infinity; // (Battle._deploy starts the operation cooldown of the battle-start deployment)
|
||||
if (this.noSkill) return;
|
||||
if (this.kind === 'passive') {
|
||||
this._startPassive();
|
||||
@@ -235,8 +264,16 @@ export class SkillRuntime {
|
||||
const rate = u.s.spRecovery;
|
||||
if (rate > 0) this.gainSp(rate * dt, 'time');
|
||||
}
|
||||
// a DEFAULT cast bound to an ally condition (塞雷娅 S1) replaces the attack about to be made: should the condition
|
||||
// have failed before that attack (the ally healed meanwhile), the cast is withdrawn — no heal mode stays behind
|
||||
if (this.pending && this.triggerAllies && this.rule !== 'SKILL_RANGE' && !this._allyTriggerSatisfied()) {
|
||||
this.end('withdrawn');
|
||||
this.addCharge(1);
|
||||
}
|
||||
if (!this.ready || u.s.flags.silence) return;
|
||||
if (this.active && this.isTimed) return;
|
||||
// a cast "next attack" still waits for its attack: another charge now would be spent on the same attack
|
||||
if (this.pending || this._opCooling()) return;
|
||||
if (TICK_RULES.has(this.rule)) {
|
||||
if (this._tickRuleSatisfied()) this.activate(this.rule);
|
||||
} else if (this.rule !== 'TAKE_DAMAGE' && this.rule !== 'NEVER') {
|
||||
@@ -249,17 +286,55 @@ export class SkillRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/** The automatic operations of a MANUAL skill are cooling down (AUTO_OP_COOLDOWN). */
|
||||
_opCooling() { return this.manual && this.battle.time < this.opReadyAt - 1e-9; }
|
||||
|
||||
/** Public form of the operation cooldown, for kits with their own automatic cast of a MANUAL skill. */
|
||||
get opCooling() { return this._opCooling(); }
|
||||
|
||||
/** Absolute tile keys of the trigger grid at the unit's current tile and direction (cached, with their Set). */
|
||||
_triggerKeys() {
|
||||
const u = this.unit;
|
||||
const tile = u.tileR * COLS + u.tileC;
|
||||
if (!this._trigKeys || this._trigTile !== tile || this._trigDir !== u.dir) {
|
||||
this._trigKeys = absoluteRangeKeys(this.triggerGrid, u.tileR, u.tileC, u.dir, 0);
|
||||
this._trigSet = new Set(this._trigKeys);
|
||||
this._trigTile = tile;
|
||||
this._trigDir = u.dir;
|
||||
}
|
||||
return this._trigKeys;
|
||||
}
|
||||
|
||||
/** `trigger.allies`: an injured, healable ally on the trigger grid (the unit's own range without one) ≤ hpAtMost. */
|
||||
_allyTriggerSatisfied() {
|
||||
const b = this.battle;
|
||||
const u = this.unit;
|
||||
let keys;
|
||||
if (this.triggerGrid) { this._triggerKeys(); keys = this._trigSet; } else keys = u.baseRangeKeys || u.rangeKeys;
|
||||
const lim = this.triggerHpAtMost + 1e-9;
|
||||
return b.injuredAlliesInKeys(keys, u).some((a) => a.hpRatio <= lim);
|
||||
}
|
||||
|
||||
_tickRuleSatisfied() {
|
||||
const b = this.battle;
|
||||
const u = this.unit;
|
||||
if (this.rule === 'SP_FULL') return true;
|
||||
// SEARCH: an enemy inside the initial attack range (research 03 Addendum C1), every tick (librators / phalanxes
|
||||
// and 安洁莉娜 do not attack while the skill is off, so DEFAULT's "about to attack" never comes)
|
||||
// SEARCH: an enemy inside the initial attack range, every tick (librators / phalanxes and 安洁莉娜 do not attack
|
||||
// while the skill is off, so DEFAULT's "about to attack" never comes)
|
||||
if (this.rule === 'SEARCH') return this._defaultCondition();
|
||||
if (this.rule === 'CUSTOM_RANGE') {
|
||||
if (!this.triggerGrid) return this._defaultCondition();
|
||||
if (!this._trigKeys) this._trigKeys = absoluteRangeKeys(this.triggerGrid, u.tileR, u.tileC, u.dir, 0);
|
||||
return b.enemiesInKeys(this._trigKeys, u, { canHitFly: true }).length > 0;
|
||||
return b.enemiesInKeys(this._triggerKeys(), u, { canHitFly: true }).length > 0;
|
||||
}
|
||||
if (this.rule === 'SKILL_RANGE') {
|
||||
if (this.triggerAllies) return this._allyTriggerSatisfied();
|
||||
if (!this.triggerGrid) return this._defaultCondition();
|
||||
return b.anyEnemyInKeys(this._triggerKeys());
|
||||
}
|
||||
// GDGLOW_SKILL_2 "全场存在可选目标时释放技能": a targetable enemy anywhere (heal skill: an ally that needs healing)
|
||||
if (this.rule === 'GDGLOW_SKILL_2') {
|
||||
if (this.healSkill) return b.injuredAlliesInKeys(ALL_TILES, u, !!u.profile?.heal?.elementHealRatio).length > 0;
|
||||
return b.enemies.some((e) => canTargetEnemy(u, e, TRIGGER_PROFILE));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -277,8 +352,8 @@ export class SkillRuntime {
|
||||
if (this.healSkill) return b.injuredAlliesInKeys(keys, u).length > 0;
|
||||
if (b.enemiesInKeys(keys, u, u.profile).length > 0) return true;
|
||||
}
|
||||
// the enemies a melee unit blocks are always its targets (Battle.blockedTargets), in range or not — PRTS 卫戍协议/帮助
|
||||
// "敌人被近战干员自身阻挡" satisfies the target condition of the basic strategy
|
||||
// the enemies a unit blocks are always its targets (Battle.blockedTargets), in range or not — PRTS 卫戍协议/帮助
|
||||
// "敌人被近战干员自身阻挡" satisfies the target condition of the basic strategy (a ranged blocker too: user playtest #6)
|
||||
if (!this.healSkill && u.blocking.length && b.blockedTargets(u, u.profile).length > 0) return true;
|
||||
return !this.healSkill && this.triggerRanges.length > 0 && this._extraTriggerSatisfied();
|
||||
}
|
||||
@@ -288,7 +363,9 @@ export class SkillRuntime {
|
||||
if (!this.ready || this.unit.s.flags.silence) return false;
|
||||
if (this.active && this.isTimed) return false;
|
||||
if (this.rule === 'TAKE_DAMAGE' || this.rule === 'NEVER' || TICK_RULES.has(this.rule)) return false;
|
||||
if (this.pending || this._opCooling()) return false;
|
||||
if (!this._defaultCondition()) return false;
|
||||
if (this.triggerAllies && !this._allyTriggerSatisfied()) return false;
|
||||
return this.activate('DEFAULT');
|
||||
}
|
||||
|
||||
@@ -296,7 +373,7 @@ export class SkillRuntime {
|
||||
onDamaged() {
|
||||
if (this.noSkill) return;
|
||||
if (this.spType === 'hurt' && !(this.active && this.isTimed)) this.gainSp(1, 'hurt');
|
||||
if (this.rule === 'TAKE_DAMAGE' && this.ready && !(this.active && this.isTimed) && this.unit.canAct && !this.unit.s.flags.silence) {
|
||||
if (this.rule === 'TAKE_DAMAGE' && this.ready && !this.pending && !(this.active && this.isTimed) && this.unit.canAct && !this.unit.s.flags.silence && !this._opCooling()) {
|
||||
this.activate('TAKE_DAMAGE');
|
||||
}
|
||||
}
|
||||
@@ -316,6 +393,7 @@ export class SkillRuntime {
|
||||
this.activations++;
|
||||
this.lastStart = this.battle.time;
|
||||
const b = this.battle;
|
||||
if (this.manual && reason !== 'carry') this.opReadyAt = b.time + AUTO_OP_COOLDOWN;
|
||||
if (this.isTimed) {
|
||||
this.active = true;
|
||||
this.timeLeft = this.kind === 'duration' ? Math.max(0.01, this.duration) : (this.kind === 'ammo' && this.duration > 0 ? this.duration : Infinity);
|
||||
|
||||
@@ -48,7 +48,8 @@ export function flagsOf(u) {
|
||||
if (u.side === 'enemy' ? !!u.blockedBy : u.blocking.length > 0) bits |= UF.BLOCKED;
|
||||
if (f.stun && !f.freeze && !f.sleep) bits |= UF.STUNNED;
|
||||
if (f.freeze) bits |= UF.FROZEN;
|
||||
if (f.stealth) bits |= UF.STEALTH;
|
||||
// 隐匿 or 迷彩 (buffs.js camou): both shown the see-through way, blocking or not (targeting.js canTargetAlly)
|
||||
if (f.stealth || f.camou) bits |= UF.STEALTH;
|
||||
if (u.skill && u.skill.active && u.skill.kind !== 'passive') bits |= UF.SKILL;
|
||||
if (u.s.shield > 0 || u.buffs.some((b) => b.shieldHits > 0)) bits |= UF.SHIELD;
|
||||
if (f.invulnerable) bits |= UF.INVULN;
|
||||
|
||||
+46
-5
@@ -23,6 +23,7 @@
|
||||
|
||||
import { Battle } from './Battle.js';
|
||||
import { toDataSource, withUnitLoadouts } from './simdata.js';
|
||||
import { BOSS_POOL_MIN_HP } from './constants.js';
|
||||
|
||||
export const SPEC_VERSION = 1;
|
||||
|
||||
@@ -133,10 +134,14 @@ export function createBattleFromSpec(spec, dataSource, opts = {}) {
|
||||
/**
|
||||
* Client-side view of the shared boss HP pool (DESIGN §14 b.pool): the server owns the pool; this field adds its own
|
||||
* damage locally and shows `server hp − local damage the server has not acknowledged yet`.
|
||||
* damage(playerId, amount) called by the sim; returns the damage dealt (≤ the remaining displayed hp)
|
||||
* damage(playerId, amount) called by the sim; returns the damage dealt (≤ the remaining displayed hp; the hit that
|
||||
* would leave less than BOSS_POOL_MIN_HP (1) takes the rest)
|
||||
* sync(serverHp, ackedCum) a b.pool broadcast: the server's hp and how much of THIS field's cumulative damage
|
||||
* (`cum`) it has already counted
|
||||
* cum / byPlayer cumulative damage of this field (reported as b.progress.bossDmg / .by)
|
||||
* `hp` below 1 reads 0 (the leader is down; user playtest #6 item 5): the difference of the server's float hp and the
|
||||
* local counters can leave dust (3.6e-12) smaller than half an ulp of `cum`, which no hit could remove — `cum += dust`
|
||||
* changes nothing — so the leader stood at "0 HP" and the field never ended.
|
||||
*/
|
||||
export class LocalBossPool {
|
||||
constructor(maxHp, hp = maxHp) {
|
||||
@@ -149,7 +154,10 @@ export class LocalBossPool {
|
||||
this.byPlayer = {};
|
||||
}
|
||||
|
||||
get hp() { return Math.max(0, this.serverHp - Math.max(0, this.cum - this.acked)); }
|
||||
get hp() {
|
||||
const h = this.serverHp - Math.max(0, this.cum - this.acked);
|
||||
return h < BOSS_POOL_MIN_HP ? 0 : h;
|
||||
}
|
||||
|
||||
/** Only used by the sim's fallback path when damage() throws. */
|
||||
set hp(v) {
|
||||
@@ -161,7 +169,7 @@ export class LocalBossPool {
|
||||
const a = Number(amount);
|
||||
const left = this.hp;
|
||||
if (!Number.isFinite(a) || a <= 0 || left <= 0) return 0;
|
||||
const dealt = Math.min(left, a);
|
||||
const dealt = left - a < BOSS_POOL_MIN_HP ? left : a;
|
||||
this.cum += dealt;
|
||||
if (playerId != null) this.byPlayer[playerId] = (this.byPlayer[playerId] || 0) + dealt;
|
||||
return dealt;
|
||||
@@ -198,9 +206,37 @@ export function attachLpMeter(battle) {
|
||||
return meter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 联防 (user playtest #6 item 7): each source player's enemies still standing on a unite field — not spawned yet,
|
||||
* alive, or already through the objective again — i.e. what settlement would charge them if the battle ended now
|
||||
* (server/match/unite.js uniteSurvivors: the unite result's counted leaks + unspawned entries by `sourcePlayerId`;
|
||||
* alive enemies become leaks on a timeout, keyed like Battle._recordLeak). It falls as the helpers strike them down and
|
||||
* rises when one splits or summons (content-spawned children inherit the parent's sourcePlayerId).
|
||||
* `{ [playerId]: n }` (a player with none left is absent), or null for a battle without the sim's state (a stand-in).
|
||||
* @returns {Record<string, number> | null}
|
||||
*/
|
||||
export function uniteLeft(battle) {
|
||||
if (!battle) return null;
|
||||
const out = {};
|
||||
const add = (pid) => { if (typeof pid === 'string' && pid) out[pid] = (out[pid] || 0) + 1; };
|
||||
if (battle.finished && typeof battle.result === 'function') {
|
||||
const r = battle.result();
|
||||
if (!r || !r.perPlayer) return null;
|
||||
for (const pp of Object.values(r.perPlayer)) for (const l of (pp && pp.leaked) || []) if (l && l.counted !== false) add(l.sourcePlayerId);
|
||||
for (const u of r.unspawned || []) if (u) add(u.sourcePlayerId);
|
||||
return out;
|
||||
}
|
||||
if (!battle._perPlayer || !Array.isArray(battle._pending) || !Array.isArray(battle.enemies)) return null;
|
||||
for (const p of battle._pending) if (p) add(p.sourcePlayerId);
|
||||
for (const e of battle.enemies) if (e && e.alive && e.counted) add(e.sourcePlayerId ?? e.ownerId);
|
||||
for (const pp of Object.values(battle._perPlayer)) for (const l of (pp && pp.leaked) || []) if (l && l.counted !== false) add(l.sourcePlayerId);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress numbers of a battle for b.progress / the teammates' waiting UI: game time, kills, total, counted leaks
|
||||
* (normal / unite) and the boss pool damage of this field.
|
||||
* (normal / unite), the boss pool damage of this field and — unite fields — `left` (uniteLeft: each leaker's enemies
|
||||
* still standing).
|
||||
*/
|
||||
export function battleProgress(battle) {
|
||||
const r = battle && typeof battle.result === 'function' && battle.finished ? battle.result() : null;
|
||||
@@ -209,7 +245,7 @@ export function battleProgress(battle) {
|
||||
for (const k of Object.keys(pp)) for (const l of pp[k].leaked || []) if (l && l.counted !== false) leaks++;
|
||||
const pool = battle && battle.sharedBoss;
|
||||
const gt = Number(battle && battle.time) || 0;
|
||||
return {
|
||||
const out = {
|
||||
gt: Math.round(gt * 1000) / 1000,
|
||||
killed: Math.max(0, Math.trunc(Number(battle && battle.killed) || 0)),
|
||||
total: Math.max(0, Math.trunc(Number(battle && battle.total) || 0)),
|
||||
@@ -217,6 +253,11 @@ export function battleProgress(battle) {
|
||||
bossDmg: pool && Number.isFinite(pool.cum) ? pool.cum : 0,
|
||||
done: !!(battle && battle.finished),
|
||||
};
|
||||
if (battle && battle.kind === 'unite') {
|
||||
const left = uniteLeft(battle);
|
||||
if (left) out.left = left;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const r4 = (v) => Math.round((Number(v) || 0) * 1e4) / 1e4;
|
||||
|
||||
+8
-18
@@ -5,7 +5,8 @@
|
||||
// it (round(y), round(x)) is one of the absolute range tiles — a huge enemy (巨型单位) when any tile its hit rectangle
|
||||
// occupies is (body.js; user playtest #5 item 10). `rangeExtend` (ability_range_forward_extend) adds N tiles
|
||||
// past the furthest cell of every row, along +dCol BEFORE rotating (DESIGN §3).
|
||||
// Operator priority: (1) enemies it blocks (melee units only: meleeUnit), (2) profile priority (fly/lowDef/…),
|
||||
// Operator priority: (1) enemies it blocks (every blocker, ranged ones on melee tiles included — user playtest #6
|
||||
// follow-up "阻挡了就一定要能打到"; Battle.blockedTargets), (2) profile priority (fly/lowDef/…),
|
||||
// (3) higher enemy taunt, (4) least remaining path distance to the goal, (5) earliest spawned. Air units
|
||||
// (Unit.isFlying: FLY, 近地悬浮, 浮空) need a profile that can hit them (`canHitFly`, never `groundOnly`). Enemy
|
||||
// priority: sortAllyTargets.
|
||||
@@ -49,18 +50,6 @@ export function tileKeyOf(u) {
|
||||
return r * COLS + c;
|
||||
}
|
||||
|
||||
/**
|
||||
* A melee unit (近战位: data `position` MELEE — melee-tile operators and summons, 重装要塞 / 领主 / 哨戒铁卫 included):
|
||||
* it may always select, and selects first, the enemies it blocks — PRTS 选择器 "可以选择且优先选择阻挡单位" (the
|
||||
* BlockedOrAdvancedSelector of units whose range is smaller than their block radius), 索敌的概念 "我方索敌优先级:
|
||||
* 阻挡(近战限定)". A ranged operator standing on a melee tile (卫戍协议 "所有行动内远程干员可部署在近战位") blocks, but
|
||||
* targets by its range alone: "远程位干员通常而言不会优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在该干员的身后,该干员
|
||||
* 可能因为敌人未处于自身攻击范围而无法攻击到这个敌人" (索敌的概念). Used by Battle.blockedTargets and sortEnemyTargets.
|
||||
*/
|
||||
export function meleeUnit(u) {
|
||||
return String(u?.def?.position ?? 'MELEE').toUpperCase() === 'MELEE';
|
||||
}
|
||||
|
||||
/** Can `attacker` (ally) target enemy `e` at all (ignoring range)? */
|
||||
export function canTargetEnemy(attacker, e, profile) {
|
||||
if (!e.alive || e.hidden || !e.deployed) return false;
|
||||
@@ -73,17 +62,19 @@ export function canTargetEnemy(attacker, e, profile) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Can enemy `e` target ally `a`? `ranged` = the attack is a ranged (non-blocked) one. A stealthed ally (隐匿 / 迷彩, the
|
||||
* Can enemy `e` target ally `a`? `ranged` = the attack is a ranged (non-blocked) one. A stealthed ally (隐匿, the
|
||||
* 排气格栅 tile) is a target only for the enemy it blocks — PRTS 作战机制 §隐匿 "我方干员并不会因为阻挡而解除隐匿" and 索敌的
|
||||
* 概念 "敌人会在自身被干员阻挡情况下强行无视对方可选性发动攻击" (the term text "不阻挡时…" is the short form; 异常效果:
|
||||
* "隐匿与'阻挡时解除'没有直接关系"). Devices are never targets (阻隔工事: obstacles nobody can select; “双眼皮”: 迷彩 and
|
||||
* off the enemy paths — PRTS).
|
||||
* off the enemy paths — PRTS). 迷彩 (flag `camou`, term ba.camou "不阻挡时不成为敌方普通攻击的目标") works the same way:
|
||||
* PRTS 异常效果 gives both anomalies the note "与'阻挡时解除'没有直接关系" [ASSUMED: enemy skills and splash selectors
|
||||
* treat 迷彩 like target selection — officially splash and selectors without a projectile ignore it].
|
||||
*/
|
||||
export function canTargetAlly(e, a, ranged) {
|
||||
if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') return false;
|
||||
const f = a.s.flags;
|
||||
if (f.untargetable || f.sleep) return false;
|
||||
if (ranged && f.stealth && e.blockedBy !== a) return false;
|
||||
if (ranged && (f.stealth || f.camou) && e.blockedBy !== a) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -111,10 +102,9 @@ export function sortEnemyTargets(battle, attacker, cands, priority) {
|
||||
if (cands.length <= 1) return cands;
|
||||
const pf = priority ? (PRIORITY_FNS[priority] || (priority === 'nearest' || priority === 'farthest' ? null : null)) : null;
|
||||
const ax = attacker.x, ay = attacker.y;
|
||||
const melee = meleeUnit(attacker);
|
||||
const keyed = cands.map((e) => ({
|
||||
e,
|
||||
b: melee && e.blockedBy === attacker ? 0 : 1,
|
||||
b: e.blockedBy === attacker ? 0 : 1,
|
||||
p: pf ? pf(e) : priority === 'nearest' ? bodyDist(e, ax, ay) : priority === 'farthest' ? -bodyDist(e, ax, ay) : 0,
|
||||
t: -(e.s.taunt || 0),
|
||||
d: battle.remainingDistance(e),
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
// Aggregation (recomputed lazily whenever buffs change — `unit.markDirty()`):
|
||||
// ATK/DEF/maxHp = (base + Σflat) × (1 + Σpct) × Πmul
|
||||
// res = clamp((base + Σflat) × Πmul, 0, 100)
|
||||
// aspd = clamp(base + Σaspd, 10, 600) (base is 100 for almost everyone)
|
||||
// aspd = clamp(base + Σaspd, 20, 600) (base is 100 for almost everyone; floor 20 = PRTS 数值范围)
|
||||
// interval = bat × (1 + ΣbatPct) × 100 / aspd (ΣbatPct floored at −0.9)
|
||||
// moveSpeed = (base + ΣmoveFlat) × ΠmoveMul (tiles/s = moveSpeed × MOVE_SCALE)
|
||||
// massLevel = max(0, base + ΣmassFlat) (重量: displacement, 浮空 halving; 失重 = massFlat −1)
|
||||
|
||||
Reference in new issue
Block a user