Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners

- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight
  (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s
- #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks,
  hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped
  at one per frame
- #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates'
  pending LP in the team panel
- #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue);
  temp-row warning and ready-button reason
- #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx
- #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught)
- #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost
- #8 特质 right under the detail card header, with its official type icon
- #9 no spinning busy cursors / spinners next to texts; game data warmed before the match
- docs, recordings and tests updated (2414 unit tests, all browser suites green)
This commit is contained in:
sganggs committed 2026-09-29 23:42:55 +08:00
1 parent 01a44cd288
commit 0aba602ecf
72 files changed
+5132 -571

No files matched your search

+29 -1
View File
@@ -72,6 +72,9 @@
// duplicate b.result (a browser re-sending one it believes lost) is answered ok and changes nothing.
// Solo pause (g.pause { on }, setPause): freezes the field clocks, result deadlines, server release timers, the boss
// 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.
// opts.clientCombat default true (env SP_COMBAT=server → false: the legacy server-run + snapshot streaming mode)
// opts.verify 'off' | 'sample' | 'all' (env SP_VERIFY, default 'off'): re-simulate accepted client results
// ('sample': ~1 in 8, in a later callback, mismatches logged; 'all': before accepting — the
@@ -125,7 +128,7 @@ 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,
} from './fields.js';
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult as compactForVerify } from '../sim/spec.js';
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult as compactForVerify, battleProgress } from '../sim/spec.js';
import { CreditPool } from './finalAssault.js';
import { buildResult } from './results.js';
import { botPrepBeginSteps, botPrepEndSteps, botPickBand, botPickCard } from './bot.js';
@@ -753,6 +756,8 @@ export class Match {
fieldId: this.fieldOf(ps),
status: this.statusOf(ps),
autoplay: ps.autoplay,
// the LP this round's own battle will cost at settlement so far (COMBAT / 联防 only, omitted when 0)
...this._pendingLpView(ps),
})),
fields: this.fields.map((f) => {
const v = { fieldId: f.fieldId, kind: f.kind, players: f.players.slice(), live: !!f.live };
@@ -1778,6 +1783,29 @@ export class Match {
return { killed: f.progress.killed, total: f.progress.total, done: false };
}
/**
* 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 }}
*/
_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);
let n = 0;
if (this.lastResults.has(ps.playerId)) n = counted(this.lastResults.get(ps.playerId));
else if (this.phase === PHASE.COMBAT) {
const f = this.fields.find((x) => x && x.kind === 'normal' && Array.isArray(x.players) && x.players.includes(ps.playerId));
if (f && f.cc) n = f.done && f.result ? counted(f.result.perPlayer && f.result.perPlayer[ps.playerId]) : Number(f.progress && f.progress.leaks) || 0;
else if (f && f.battle) { try { n = battleProgress(f.battle).leaks; } catch { n = 0; } }
}
const loss = Math.min(this.gd.lpCapPerRound, Math.max(0, Math.trunc(Number(n) || 0)));
return loss > 0 ? { pendingLp: loss } : {};
}
/** The connected human who simulates a field: lowest seat among its players (normal: the owner). */
_authorityFor(f, exclude = null) {
let best = null;
+68 -17
View File
@@ -3,8 +3,14 @@
// Handlers validate → mutate → recompute bonds → mark the private view dirty. They never throw on bad input; they
// return `{ ok: true }` or `{ error: ERR.*, detail? }`. Rules (research 00-INDEX §3–§4, 01 A1, 04 §2):
// * 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; temp is wiped at the next round start (and auto-resolved at the prep deadline).
// 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
// 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
// usable (move to a free hand slot, place, equip, destroy, sell) through the NEXT prep — nothing is destroyed before
// the player saw it in a prep ("处于临时整备区的调度资源,在进入下一回合后会自动销毁").
// * Board: own region rows 9–12 × cols 2–10, legality from the stage legend (board.js); deploy cap 8 (+effects);
// tokens (placeable summons) don't use deploy slots. Board↔hand swaps are always allowed. A terrain change
// (terrain 机变 cards, content overrides) withdraws the pieces left on tiles they may no longer occupy (hand,
@@ -14,8 +20,10 @@
// 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
// after the prep — SETTLE / Final Assault effects — is kept for the next prep). Outside PREP a full hand sends the
// elite to a freed board tile of a consumed copy rather than to temp (wiped at the next round start).
// 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
// freed board tile of a consumed copy.
// * Items: equip max 2 (a 3rd replaces the equipped item the player picks — g.equip replaceUid, the oldest when
// 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
@@ -81,6 +89,10 @@ export class PlayerState {
this.hand = new Array(HAND_SIZE).fill(null);
/** @type {Array<any>} */
this.temp = new Array(TEMP_SIZE).fill(null);
/** preps of this player that ended so far (endPrep) = index of the current (or next) prep */
this.prepsEnded = 0;
/** @type {Map<number, number>} temp piece uid → index of the prep whose deadline resolves it (see tempDue) */
this._tempDue = new Map();
/** @type {Map<string, any>} 'r,c' → piece */
this.board = new Map();
/** persistent bond layers */
@@ -128,6 +140,30 @@ export class PlayerState {
get deployCount() { let n = 0; for (const p of this.board.values()) if (p.kind === 'chess') n++; return n; }
get tempEmpty() { return this.temp.every((x) => x == null); }
/**
* Index of the prep whose deadline resolves a temp piece (compare with `prepsEnded`): recorded when the piece entered
* temp (_putTemp); a piece put there by other means counts as due at the current (or next) prep.
*/
tempDue(piece) {
const due = piece ? this._tempDue.get(piece.uid) : undefined;
return Number.isInteger(due) ? due : this.prepsEnded;
}
/**
* Due prep of a piece entering temp now: the current prep while the player can still act on it (PREP, not ready);
* after Ready or at the prep end (onPrepEnd grants) the next one; outside PREP (COMBAT, SETTLE, ROUND_START, 机变)
* the next prep to end — `prepsEnded` then already names it.
*/
_tempDueNow() {
return this.prepsEnded + (this.m.phase === PHASE.PREP && this.ready ? 1 : 0);
}
/** Every write of a piece into a temp slot goes through here (records its due prep). */
_putTemp(i, piece) {
this.temp[i] = piece;
this._tempDue.set(piece.uid, this._tempDueNow());
}
/**
* Replace the operator loadout (DESIGN §16) after re-checking it against this match's data. Accepts the checked
* form `{ id: { skill, module|null } }` or raw `room.loadout` entries. Returns false (loadout unchanged) when it does
@@ -259,7 +295,7 @@ export class PlayerState {
_detach(loc) {
if (!loc) return;
if (loc.area === 'hand') this.hand[loc.idx] = null;
else if (loc.area === 'temp') this.temp[loc.idx] = null;
else if (loc.area === 'temp') { this.temp[loc.idx] = null; this._tempDue.delete(loc.piece.uid); }
else if (loc.area === 'board') this.board.delete(loc.key);
else if (loc.area === 'equipped') {
const i = loc.holder.items.indexOf(loc.piece);
@@ -268,7 +304,8 @@ export class PlayerState {
}
/**
* Put a piece into the hand (right→left) or, when `allowTemp`, the temp slots. Returns 'hand' | 'temp' | null.
* Put a piece into the hand (right→left) or, when `allowTemp`, the temp slots (due at the deadline of the first prep
* in which the player can act on it, _tempDueNow). Returns 'hand' | 'temp' | null.
*/
stow(piece, { allowTemp = true, toTemp = false, preferIdx = null } = {}) {
if (!toTemp) {
@@ -281,7 +318,7 @@ export class PlayerState {
if (!allowTemp) return null;
}
const j = freeSlot(this.temp);
if (j >= 0) { this.temp[j] = piece; return 'temp'; }
if (j >= 0) { this._putTemp(j, piece); return 'temp'; }
return null;
}
@@ -411,10 +448,9 @@ export class PlayerState {
this.grantTokensFor(elite);
return 'board';
};
// outside PREP (a merge completed by SETTLE / battle-result effects) the player cannot clear the temp slots before
// they are wiped at the next round start: with a full hand the elite takes a freed board tile instead
const canAct = this.m.phase === PHASE.PREP;
let where = this.stow(elite, { allowTemp: canAct || !boardLoc });
// the elite goes to the hand, overflow temp — outside PREP too (a merge completed by SETTLE / battle-result effects):
// a temp piece that arrived after the prep waits there through the next prep (tempDue)
let where = this.stow(elite, { allowTemp: true });
if (!where && boardLoc) {
// hand and temp both full ⇒ every consumed copy stood on the board: the elite takes the first freed tile
// instead of being lost
@@ -896,7 +932,7 @@ export class PlayerState {
/** Put a piece into the container slot described by `loc` (hand/temp idx), or anywhere free. */
_putBack(loc, piece) {
if (loc.area === 'hand' && this.hand[loc.idx] == null) { this.hand[loc.idx] = piece; return true; }
if (loc.area === 'temp' && this.temp[loc.idx] == null) { this.temp[loc.idx] = piece; return true; }
if (loc.area === 'temp' && this.temp[loc.idx] == null) { this._putTemp(loc.idx, piece); return true; }
return !!this.stow(piece, { allowTemp: true });
}
@@ -1006,7 +1042,7 @@ export class PlayerState {
this.hand[idx] = piece;
if (occ) {
if (loc.area === 'hand') this.hand[loc.idx] = occ;
else this.temp[loc.idx] = occ;
else this._putTemp(loc.idx, occ);
}
this.recompute();
return OK;
@@ -1161,18 +1197,25 @@ export class PlayerState {
if (on && !this.tempEmpty) return fail(ERR.TEMP_NOT_EMPTY);
if (this.ready === !!on) return OK;
this.ready = !!on;
// un-ready: the player can act again, so what overflowed while it was ready is due at this prep's deadline
if (!on) for (const p of this.temp) if (p && this.tempDue(p) > this.prepsEnded) this._tempDue.set(p.uid, this.prepsEnded);
this.dirty();
this.m.onReadyChanged(this);
return OK;
}
/** Prep deadline / round start: temp contents are sold (copies back to the pool) or destroyed. */
/**
* 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.
*/
resolveTemp() {
let changed = false;
for (let i = 0; i < this.temp.length; i++) {
const p = this.temp[i];
if (!p) continue;
if (!p || this.tempDue(p) > this.prepsEnded) continue;
this.temp[i] = null;
this._tempDue.delete(p.uid);
changed = true;
if (p.kind === 'chess') {
this.removeTokensOf(p.uid);
@@ -1194,8 +1237,9 @@ export class PlayerState {
this.m.dispatch(this, 'onIncome', ev);
const nonNeg = (v) => (Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0);
this.addFunds(nonNeg(ev.income) + nonNeg(ev.pending), { reason: 'income' });
this.resolveTemp();
// reward offers of the last prep already expired at its end (endPrep); what is still queued was earned after it —
// temp is NOT wiped here: the last prep's deadline resolved what the player could act on (endPrep); what overflowed
// after it (battle-result grants, SETTLE merges, returned equipment) is shown and usable in this prep (tempDue).
// 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;
this.rollShop({ keepFrozen: true });
@@ -1204,8 +1248,14 @@ export class PlayerState {
this.recompute();
}
/**
* Prep deadline (Match.endPrep, after the <休整期结束时> onPrepEnd effects): the temp pieces due at this prep are
* resolved; what overflowed after Ready or during onPrepEnd stays for the next prep, which `prepsEnded` now names.
*/
endPrep() {
this.resolveTemp();
this.prepsEnded++;
for (const uid of [...this._tempDue.keys()]) if (!this.temp.some((p) => p && p.uid === uid)) this._tempDue.delete(uid);
this.offers = [];
this.clearUnfrozenShop();
if (!this.gd.leftoverKeptBands.includes(this.bandId)) this.funds = 0;
@@ -1225,6 +1275,7 @@ export class PlayerState {
this.board.clear();
this.hand.fill(null);
this.temp.fill(null);
this._tempDue.clear();
this.offers = [];
this.bounties = [];
this.shop.slots = [];
+48 -15
View File
@@ -2,15 +2,22 @@
//
// attachAudit(m) wraps a live Match's phase transitions and a few prep handlers (instance-level wrappers; the engine
// is untouched) and records every rule violation it observes, next to the structural invariants of invariants.js:
// prep end leftover funds lost (carry bands excepted), temp resolved, reward offers expired
// temp (临时整备区) every piece entering a temp slot (PlayerState._putTemp) is due at the deadline of the first
// prep in which its player could act on it: the current prep when it arrived in PREP before Ready,
// the next one when it arrived after Ready / at the prep end, else (COMBAT, SETTLE, ROUND_START, 机变)
// the next prep to end; a piece that got into temp by other means counts as due at the prep in
// progress when it is first seen at a prep end. No temp piece may outlive its due prep.
// prep end leftover funds lost (carry bands excepted), temp resolved (only pieces due at a later prep stay),
// reward offers expired
// round start income = config income(r) (= min(3 + r, 12)) + pending funds, upgrade price −1 (floor 0) from R2,
// temp wiped, offers earned after the last prep kept, frozen slots kept in place (same id; chess slots
// keep their index, the item slot(s) follow the chess slots, so a level-up moves them right),
// everything else rerolled with tier ≤ shop level from the unbanned pool, freeze toggle released
// temp not wiped (nothing overdue), offers earned after the last prep kept, frozen slots kept in
// place (same id; chess slots keep their index, the item slot(s) follow the chess slots, so a
// level-up moves them right), everything else rerolled with tier ≤ shop level from the unbanned pool,
// freeze toggle released
// prep handlers buy / sell / refresh / levelUp pay exactly price / +sell price / refresh price (free first) / level
// price, the level rises by one and its price resets to the next base
// combat start temp empty, everyone ready, funds lost (carry bands excepted), unfrozen shop cleared, one field per
// alive player
// price, the level rises by one and its price resets to the next base; Ready only with an empty temp
// combat start nothing overdue in temp, everyone ready, funds lost (carry bands excepted), unfrozen shop cleared,
// one field per alive player
// drafts every seat holds an allowed band with LP = totalHp; 机变: one card per alive player, card ↔ picker
// maps consistent, 6 (co-op) / 3 (solo) cards
// 联防 decided after the COMBAT_END pause from the players still in: runs iff co-op with ≥ 1 leaker and
@@ -74,7 +81,34 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
if (hook === 'onIncome' && ev && ps) incomeEv.set(ps, { initial: { income: ev.income, pending: ev.pending }, ev });
return orig(ps, hook, ev, opts);
});
// temp arrivals: ps → Map(uid → index of the prep whose deadline resolves it); ps → preps ended (own count)
const tempDue = new Map();
const prepsEnded = new Map();
const endedOf = (ps) => prepsEnded.get(ps) || 0;
const dueOf = (ps) => { let d = tempDue.get(ps); if (!d) tempDue.set(ps, (d = new Map())); return d; };
const overdue = (ps, label) => {
const d = dueOf(ps);
for (const p of ps.temp) {
if (!p) continue;
const due = d.get(p.uid);
if (due != null && due < endedOf(ps)) fail(`${ps.playerId}: temp piece ${p.id} (due at prep ${due}) survived ${label} (${endedOf(ps)} preps ended)`);
}
};
for (const ps of m.players.values()) {
wrap(ps, '_putTemp', function (orig, i, piece) {
const res = orig(i, piece);
// after Ready / at the prep end the player cannot act on it any more: due at the next prep
if (piece) dueOf(ps).set(piece.uid, endedOf(ps) + (m.phase === PHASE.PREP && ps.ready ? 1 : 0));
return res;
});
wrap(ps, 'setReady', function (orig, on) {
const was = ps.ready;
const res = orig(on);
// un-ready: the player can act on what arrived while it was ready — due at this prep again
if (res && res.ok && was && !on) for (const p of ps.temp) if (p && (dueOf(ps).get(p.uid) ?? 0) > endedOf(ps)) dueOf(ps).set(p.uid, endedOf(ps));
if (res && res.ok && on) check('ready', () => { if (!ps.tempEmpty) fail(`${ps.playerId}: ready with a non-empty temp`); });
return res;
});
wrap(ps, '_rollChessSlot', function (orig) {
const s = orig();
if (s) {
@@ -114,7 +148,8 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
const upWant = r > 1 ? Math.max(0, up0 - 1) : up0;
if (ps.shop.upgradePrice !== upWant) fail(`${id}: upgrade price ${up0} → ${ps.shop.upgradePrice}, expected ${upWant}`);
if (ps.pendingFunds !== 0) fail(`${id}: pending funds not cleared`);
if (!ps.tempEmpty) fail(`${id}: temp not wiped at round start`);
// temp is not wiped here: what overflowed after the last prep's deadline is shown in this prep
overdue(ps, 'the round start');
// offers of the last prep expired at its end; the ones queued after it (SETTLE merges) wait for this prep
if (offers0.some((o) => !ps.offers.includes(o))) fail(`${id}: a reward offer earned after the prep was dropped at the round start`);
if (ps.shop.frozen) fail(`${id}: freeze toggle still on after the round start`);
@@ -183,20 +218,18 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
return res;
});
wrap(ps, 'endPrep', function (orig) {
// a temp piece that got there by other means (not _putTemp) was in temp during this prep: due now
for (const p of ps.temp) if (p && !dueOf(ps).has(p.uid)) dueOf(ps).set(p.uid, endedOf(ps));
const res = orig();
prepsEnded.set(ps, endedOf(ps) + 1);
check('prep end', () => {
// leftover funds are lost at prep end (carry bands excepted); gains after this (SETTLE effects) are kept
if (ps.funds !== 0 && !carries(ps)) fail(`${ps.playerId}: kept ${ps.funds} funds past the prep end without a carry band`);
if (!ps.tempEmpty) fail(`${ps.playerId}: temp not resolved at prep end`);
overdue(ps, 'its prep end');
if (ps.offers.length) fail(`${ps.playerId}: reward offer survived the prep end`);
});
return res;
});
wrap(ps, 'setReady', function (orig, on) {
const res = orig(on);
if (res && res.ok && on) check('ready', () => { if (!ps.tempEmpty) fail(`${ps.playerId}: ready with a non-empty temp`); });
return res;
});
}
// ---- phases ------------------------------------------------------------------------------------------------
@@ -283,7 +316,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
check('combat start', () => {
for (const ps of m.alivePlayers()) {
const id = ps.playerId;
if (!ps.tempEmpty) fail(`${id}: temp not empty at combat start`);
overdue(ps, 'into combat');
if (!ps.ready) fail(`${id}: not ready at combat start`);
if (ps.funds !== 0 && !carries(ps)) fail(`${id}: kept ${ps.funds} funds into combat`);
if (ps.offers.length) fail(`${id}: reward offer survived the prep`);
+27 -2
View File
@@ -13,7 +13,7 @@
// ATTACK_PAUSE seconds after each attack. 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.
import { ATTACK_PAUSE, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, COLS } from './constants.js';
import { ATTACK_PAUSE, 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';
@@ -140,7 +140,9 @@ export function performAttack(b, u, prof, targets, opts = null) {
b._ev(['atk', u.id, t.id, vis]);
if (isHeal) { doHeal(b, u, prof, t); continue; }
const info = { isSkill, index: i, attackId };
if (ranged && t.side === 'enemy') {
if (ranged && t.side === 'enemy' && prof.projectile === 'boomerang') {
throwBoomerang(b, u, prof, t, info);
} else if (ranged && t.side === 'enemy') {
const speed = PROJECTILE_SPEEDS[prof.projectile] ?? PROJECTILE_SPEED;
// projectiles land even if the shooter died meanwhile (damage is credited to it)
b.addProjectile({ from: u, target: t, speed, visual: prof.projectile, source: u, hitDead: prof.splashRadius > 0,
@@ -154,6 +156,29 @@ export function performAttack(b, u, prof, targets, opts = null) {
if (prof.afterAttack) b._safe(() => prof.afterAttack(b, u, targets), 'profile.afterAttack', u);
}
/**
* 回环射手 boomerang (projectile 'boomerang', professions.js loopshooter): it flies out to the target at
* PROJECTILE_SPEEDS.boomerang and the attack hits on arrival; a target that died / vanished meanwhile is not hit (the
* boomerang still flies to its last position — a splash profile would burst there), then it flies back to the thrower's
* current position at BOOMERANG_RETURN_SPEED, dealing nothing on the way back, and is caught (u.trait.boomerangsOut −1:
* the thrower attacks again once every boomerang is back). A thrower knocked out / withdrawn meanwhile loses it — nothing
* returns to a unit off the field or to a later deployment of it (the deploy hook hands it a fresh one).
*/
function throwBoomerang(b, u, prof, t, info) {
const seq = u.deploySeq;
u.trait.boomerangsOut = (u.trait.boomerangsOut || 0) + 1;
const home = () => u.alive && u.deployed && u.deploySeq === seq;
b.addProjectile({ from: u, target: t, speed: PROJECTILE_SPEEDS.boomerang, visual: 'boomerang', source: u, hitDead: true,
onHit: (c) => {
// (guarded on its own: a content error in the hit must not cost the thrower its boomerang for the battle)
if (c.target || prof.splashRadius > 0) b._safe(() => resolveHit(b, u, prof, c.target, info, c.x, c.y), 'boomerang.hit', u);
if (!home()) return;
// hitDead: flies on to the thrower's last position even while it is hidden, caught there when it is still home
b.addProjectile({ from: { x: c.x, y: c.y }, target: u, speed: BOOMERANG_RETURN_SPEED, visual: 'boomerangReturn', source: u, hitDead: true,
onHit: () => { if (home() && u.trait.boomerangsOut > 0) u.trait.boomerangsOut--; } });
} });
}
/** Apply one attack hit (called on impact for projectiles). `target` may be null (splash on a dead target's spot). */
export function resolveHit(b, u, prof, target, info, x, y) {
const atk = u.s.atk;
+8 -2
View File
@@ -17,8 +17,14 @@ export const MOVE_SCALE = 0.5;
export const ATTACK_PAUSE = 0.35;
/** Default projectile speed in tiles/s for ranged operators/enemies. */
export const PROJECTILE_SPEED = 12;
/** Projectile speeds per visual kind (tiles/s). `none`/`beam` are instant. */
export const PROJECTILE_SPEEDS = Object.freeze({ arrow: 14, bolt: 11, bomb: 8, lob: 8, orb: 10, drone: 16, enemy: 10 });
/**
* Projectile speeds per visual kind (tiles/s). `none`/`beam` are instant. `boomerang` (回环射手 跃跃) is the OUTBOUND
* flight to the target — PRTS 跃跃 特性 note "投射物飞行速度15,返回时飞行速度3.75"; the way back is
* BOOMERANG_RETURN_SPEED (ai.js throwBoomerang).
*/
export const PROJECTILE_SPEEDS = Object.freeze({ arrow: 14, bolt: 11, bomb: 8, lob: 8, orb: 10, drone: 16, enemy: 10, boomerang: 15 });
/** 回环射手: speed (tiles/s) of a boomerang flying back from its hit point to its thrower (PRTS "返回时飞行速度3.75"). */
export const BOOMERANG_RETURN_SPEED = 3.75;
/** Minimum damage ratio after mitigation (5 % of the pre-mitigation amount). */
export const MIN_DAMAGE_RATIO = 0.05;
+4 -1
View File
@@ -593,7 +593,10 @@ export default {
}),
// ---------------------------------------------------------------------------------------------------------------
// 1_09 跃跃 乐趣加倍: ATK +atk, each attack throws `cnt` boomerangs (cnt hits). 戏耍随心: prob per hit for
// 1_09 跃跃 (回环射手: every attack is a boomerang out to the target and back — she attacks again only once it is
// caught, professions.js loopshooter / ai.js throwBoomerang). 乐趣加倍: ATK +atk, each attack throws `cnt`
// boomerangs at the target (cnt hits); they fly the same path, so the flight carries cnt hits and all of them are
// back together ("必须回收全部回旋投掷物才可以进行下一次攻击"). 戏耍随心: prob per hit (each boomerang on its own) for
// ATK ×atk_scale. Elite module (LPS-X, trait atk_scale): vs enemies on the 8 surrounding tiles ATK ×atk_scale.
// Alternate S1 强力击·β型 (AUTO, attack SP): the next attack at atk_scale × ATK.
chess_char_1_09_a: (bb, chess) => {
+50 -11
View File
@@ -12,6 +12,9 @@
// Simplifications (one line per id; see also the report of the content phase):
// 1_15 盟约·辅助干员 element pick = neural → burn → apoptosis, skipping the elements already bursting on the target.
// 6_01 蕾缪安 locks every 0.5 s while an enemy is in range (ends early — and bombs — when the range empties);
// the shells then follow one every 0.3 s (PRTS), each landing 0.3 s after it is fired [ASSUMED flight];
// the 1.5 radius (PRTS "碰撞箱判定") is read as the centre distance like every engine radius; knocked
// out / withdrawn after the end, she fires no further shell (those already in the air still land);
// "wanted" needs a continuous 8 s stay in some 拉特兰 range; wanted targets are added to her range tiles.
// 6_02 圣聆初雪 "诱导" (ba.attract 无法被阻挡并向目标位置移动) = the engine `attract` status for attract_time: the enemy
// is unblockable and walks (own speed, grid path) to the nearest ground tile around her, then waits there;
@@ -98,6 +101,7 @@
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../../targeting.js';
import { aggregateMods } from '../../buffs.js';
import { COLS, ROWS } from '../../constants.js';
import { rotateOffset } from '../../dir.js';
import { summonToken, TOKEN_IDS } from '../tokens.js';
// ------------------------------------------------------------------------------------------------------------------
@@ -345,6 +349,15 @@ function pithst(bb, chess, def) {
// ------------------------------------------------------------------------------------------------------------------
// 蕾缪安 chess_char_6_01 (神射手) — S3 礼炮·强制追思; 跨境追缉许可; 逃犯引渡手续
/** S3 bombardment: one shell every 0.3 s after the skill ends, for at most 10 s (PRTS S3 note, not in the data). */
const LEMUEN_SHELL_INTERVAL = 0.3;
const LEMUEN_SHELL_WINDOW = 10;
/**
* Shell flight time [ASSUMED]: the first bombardment lands one interval after the skill ends, so every shell of the
* PRTS count (≤ 33 in the 10 s window) lands inside it; the renderer draws the shell over this time (fx bombardShell).
*/
const LEMUEN_SHELL_FLIGHT = 0.3;
function ensureWanted(battle) {
const S = bstate(battle);
if (S.wanted) return S.wanted;
@@ -394,18 +407,43 @@ function lemuen(bb, chess, def) {
for (const e of cands) if (!best || cnt(e) < cnt(best)) best = e;
return best;
};
const bombard = (battle, unit, locks) => {
for (const L of locks) {
const x = L.e.alive && !L.e.hidden ? L.e.x : L.x;
const y = L.e.alive && !L.e.hidden ? L.e.y : L.y;
battle.fx('bombard', { x, y, id: unit.id, r: d2 });
for (const e of battle.enemiesInRadius(x, y, d2)) {
if (e.s.flags.untargetable) continue;
const d = Math.hypot(e.x - x, e.y - y);
battle.dealDamage(unit, e, { amount: unit.s.atk * (d <= d1 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] });
}
// S3 bombardment (PRTS 蕾缪安 S3 note — the timing is not in the blackboard): after the skill ends ONE shell every
// LEMUEN_SHELL_INTERVAL s, in lock order, on a random point of the square of side 2 × emit_offset (PRTS "边长0.4",
// emit_offset 0.2) around its lock mark — the locked enemy while it is on the field ("持续追踪锁定目标"), else the spot
// it left ("锁定标记会留在原地") — landing LEMUEN_SHELL_FLIGHT s later; each shell hits every enemy within dist_2 once
// (limited_hit_time 1): proj_atk_scale_1 × ATK within dist_1 of its point, proj_atk_scale_2 × ATK beyond, all with the
// ATK cached when the skill ended ("缓存攻击力"); at most LEMUEN_SHELL_WINDOW s of shells ("最多产生33次轰炸").
const spread = Math.max(0, num(bb['attack@emit_offset'], 0.2));
const markOf = (L) => {
const e = L.e;
if (!L.gone && (!e.alive || !e.hidden)) { L.x = e.x; L.y = e.y; if (!e.alive) L.gone = true; }
return L;
};
const blast = (battle, unit, atk, x, y) => {
battle.fx('bombard', { x, y, id: unit.id, r: d2 });
for (const e of battle.enemiesInRadius(x, y, d2)) {
if (e.s.flags.untargetable) continue;
const d = Math.hypot(e.x - x, e.y - y);
battle.dealDamage(unit, e, { amount: atk * (d <= d1 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] });
}
};
const bombard = (battle, unit, locks) => {
const atk = unit.s.atk;
const seq = unit.deploySeq;
const n = Math.min(locks.length, Math.floor(LEMUEN_SHELL_WINDOW / LEMUEN_SHELL_INTERVAL + 1e-9));
const fire = (i) => {
// knocked out / withdrawn after the skill ended: the shells not fired yet are dropped (those in the air land)
if (!(unit.alive && unit.deployed && unit.deploySeq === seq)) return;
const L = markOf(locks[i]);
// the random offset is drawn in her facing frame (the square turns onto itself): a turned board plays alike
const [oy, ox] = rotateOffset(battle.rng.range(-spread, spread), battle.rng.range(-spread, spread), unit.dir);
const x = L.x + ox, y = L.y + oy;
battle.fx('bombardShell', { x, y, id: unit.id, r: d2, t: LEMUEN_SHELL_FLIGHT, i });
battle.after(LEMUEN_SHELL_FLIGHT, () => blast(battle, unit, atk, x, y), { owner: unit });
if (i + 1 < n) battle.after(LEMUEN_SHELL_INTERVAL, () => fire(i + 1), { owner: unit });
};
if (n > 0) fire(0);
};
// S2 归乡邀约: aimed snipe at a wanted enemy — ATK scale ramps main → fin by ex every interval (at most trig_cnt
// steps, aim_duration s), fired early once ATK × scale > the target's HP + DEF; ignores dodge; one bullet per aim
const aimS = {
@@ -463,7 +501,7 @@ function lemuen(bb, chess, def) {
onTick({ battle, unit, skill, dt }) {
const m = unit.mem;
if (!m.lemLocks) return;
for (const L of m.lemLocks) if (L.e.alive && !L.e.hidden) { L.x = L.e.x; L.y = L.e.y; }
for (const L of m.lemLocks) markOf(L);
if (!unit.canAct) return;
m.lemAcc += dt;
if (m.lemAcc + 1e-9 < aim) return;
@@ -477,6 +515,7 @@ function lemuen(bb, chess, def) {
if (skill.active && skill.ammoLeft <= 0) skill.end('ammo');
},
onEnd({ battle, unit, reason }) {
// the shells follow one by one after the end (bombard); knocked out / withdrawn mid-lock ('death'): none
const locks = unit.mem.lemLocks || [];
unit.mem.lemLocks = null;
if (reason !== 'death' && unit.alive) bombard(battle, unit, locks);
+20 -8
View File
@@ -4,7 +4,9 @@
// PROFESSION_DEFAULTS[profession] → SUB[subProfessionId] → trait-text / trait-blackboard tunables (TUNE[sub]) →
// data fields (dmgType/attackKind/projectile/canHitFly/targetPriority from data/chess.json) → kit.trait overrides.
// Profile fields:
// attack 'melee'|'ranged' dmgType 'phys'|'arts'|'true'|'heal'|'none' projectile 'none'|'arrow'|'bolt'|'bomb'|'lob'|'orb'|'drone'
// attack 'melee'|'ranged' dmgType 'phys'|'arts'|'true'|'heal'|'none'
// projectile 'none'|'arrow'|'bolt'|'bomb'|'lob'|'orb'|'drone'|'boomerang' ('boomerang': out to the target and back to
// the thrower, ai.js throwBoomerang) boomerang bool (回环射手: keeps 'boomerang')
// canHitFly bool maxTargets n (≥1) hitAllBlocked bool (attack every blocked enemy)
// allInRange bool splashRadius tiles splashScale (× damage for splash victims)
// splashOthersOnly bool groundOnly bool hits n (damage instances per attack)
@@ -175,6 +177,18 @@ const installHunter = (battle, unit) => {
battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.trait.ammo = max; }, { owner: unit });
};
/**
* 回环射手 (loopshooter) trait "持有回旋投射物时才能够攻击(投射物需要时间回收)": its projectile is a boomerang that
* flies to the target, hits on arrival and flies back to the thrower (ai.js throwBoomerang, speeds in constants.js);
* the thrower attacks only while it holds its boomerang — every one it threw must be caught first (PRTS 跃跃 S2 note
* "必须回收全部回旋投掷物才可以进行下一次攻击") — and once its attack cooldown is ready, so the real interval is the longer
* of the two ("实际攻击间隔会受投掷物的实际飞行时间影响产生浮动"). A (re)deployed thrower holds a fresh one.
*/
const installLoopshooter = (battle, unit) => {
unit.trait.boomerangsOut = 0;
battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.trait.boomerangsOut = 0; }, { owner: unit });
};
const installTactician = (battle, unit) => {
const spawn = () => {
if (!unit.alive || !unit.deployed) return;
@@ -266,13 +280,8 @@ export const SUB = Object.freeze({
canAttack: (battle, unit) => (unit.trait.ammo ?? 8) > 0,
dmgMul: (b, u) => u.profile.ammoScale ?? 1.2,
afterAttack: (battle, unit) => { unit.trait.ammo = Math.max(0, (unit.trait.ammo ?? 8) - 1); } }),
loopshooter: P({ projectile: 'lob',
afterAttack: (battle, unit, targets) => {
const t = targets[0];
if (!t) return;
const d = Math.hypot(t.x - unit.x, t.y - unit.y);
unit.atkCd = Math.max(unit.atkCd, (2 * d) / 10);
} }),
loopshooter: P({ projectile: 'boomerang', boomerang: true, install: installLoopshooter,
canAttack: (battle, unit) => !(unit.trait.boomerangsOut > 0) }),
reaperrange: P({ allInRange: true,
dmgMul: (battle, unit, target) => {
const grid = unit.profile.frontGrid;
@@ -456,6 +465,9 @@ export function resolveProfile(def, kitTrait = null) {
}
if (def.targetPriority) p.priority = PRIORITY_ALIASES[def.targetPriority] ?? def.targetPriority;
if (def.splashRadius != null && def.splashRadius > 0) p.splashRadius = def.splashRadius;
// a boomerang thrower (回环射手) always throws its boomerang: the data's generic ranged projectile ('arrow', a
// build-data default) would turn the out-and-back flight into a plain shot
if (p.boomerang && p.attack === 'ranged') p.projectile = 'boomerang';
if (def.type === 'token') {
if (p.dmgType === 'heal') p.heal = p.heal || { mode: 'single' };
if (def.stats.atk <= 0) p.noAttack = true;