feedback5 R5: split the battle effect system into modules

public/js/render/fx.js becomes a re-export barrel. Speeds, camera heights, fonts, the kind table and the shared tunables move verbatim into public/js/render/fx/*.js. FxSystem keeps its constructor, clear, update, counts and destroy; the other methods move verbatim into container classes installed on FxSystem.prototype in source order. simSpeeds and fontsReady each stay one instance. The dynamic import of /sim/constants.js stays next to setSimProjectileSpeeds.
This commit is contained in:
sganggs committed 2026-10-05 17:13:07 +08:00
1 parent eb04f5dfa4
commit c1b8292f92
16 files changed
+2385 -2224

No files matched your search

+368
View File
@@ -0,0 +1,368 @@
// public/js/render/fx/numbers.js — FxSystem damage numbers and screen-space labels.
// Installed on FxSystem.prototype by ./system.js (a method container: never instantiated; `this` is the effect system).
import { DMG_STYLE, HIT_TINT, dmgStyleKey } from '../style.js';
import { camKey } from './camera.js';
import { clamp, easeOut } from './limits.js';
const MAX_NUMBERS = 90;
const NUM_RISE = 0.45; // tiles a damage number rises over NUM_RISE_T (easeOut), then it stays
const NUM_RISE_T = 0.9; // seconds of the rise
const NUM_LIFE = 0.9; // seconds a number lives (a merged running total lives longer, ≤ NUM_MAX_LIFE)
const NUM_MAX_LIFE = 1.8;
const NUM_FADE_T = 0.27; // fade-out at the end of its life
const NUM_LINE_EM = 0.95; // vertical room of one line of digits, in em of the 24 px bitmap font
const NUM_MAX_LINES = 5; // a number starts at most this many lines above the head
const NUM_DIGIT_EM = 0.66; // advance of one digit of the damage font, in em (Bender 700: 0.56–0.66, the widest kept)
const NUM_POP = 1.35; // birth / merge pop scale (the layout reserves the popped size)
const NUM_GAP_PX = 7; // horizontal gap between two numbers side by side (never read as one number)
const NUM_MERGE_GAP = 0.3; // s: same-style hits on one unit closer than this join its running total
const NUM_PER_TARGET = 4; // live numbers per unit (then hits join / the oldest fades)
const NUM_CROWD = 6; // more live numbers than this around a spot → shorter lives there
const NUM_LANES = Object.freeze([0, -1, 1, -2, 2]); // lane order (lane widths from the unit's head)
/** Screen scale of a number's text (24 px font) at `s` px per tile. */
const numScale = (s, big, style) => clamp(s / 115, 0.42, 1.05) * (big ? 1.35 : 1) * (style === 'heal' ? 0.9 : 1);
/** Characters of a damage number as drawn (heals get a '+'). */
const numChars = (v, style) => String(Math.round(v)).length + (style === 'heal' ? 1 : 0);
/** Sub-professions whose attacks splash around the struck target (the 阵法术师 / 轰击术师 strike every enemy in range). */
const SPLASH_SUBS = new Set(['aoesniper', 'splashcaster', 'bombarder', 'fortress', 'hammer']);
export class FxNumbers {
// ---- hits / numbers -----------------------------------------------------------------------------------------
/** b.ev 'dmg' visual: glow + sparks in the hit colour; a melee blow (atk 'none' just before) adds its slash. */
damage(view, amount, type, srcView) {
if (!view) return;
const style = dmgStyleKey(type);
const p = this._chest(view);
const px = p.x, py = p.y, s = p.s;
const tint = HIT_TINT[style] || 0xffffff;
const big = view.maxHp > 0 && amount >= view.maxHp * 0.18;
const melee = !!srcView && this._slashAt === srcView.id;
this.particle('glow', px, py, { tint, life: 0.18, s0: (s / 128) * (big ? 1.0 : 0.6), s1: (s / 128) * (big ? 1.5 : 0.9), a0: 0.9, a1: 0 });
if (melee) { this._slashAt = null; this._slash(view, srcView, px, py, s, style, big); }
this.burst(px, py, s, big ? 8 : melee ? 6 : 4, tint, { speed: melee ? 2.8 : 2.4, size: big ? 0.6 : 0.46 });
if (srcView && this.ctx.subProfOf && SPLASH_SUBS.has(this.ctx.subProfOf(srcView.info?.defId))) {
if (!this._lastRing || this.time - this._lastRing > 0.08) {
this._lastRing = this.time;
this.ring(view.x, view.y, view.z || 0, 0.1, 1.1, tint, 0.3);
}
}
view.onHit?.();
if (this.ctx.settings?.damageNumbers !== false) this.number(view, amount, style, big);
}
/**
* Melee blow on `view` by `src`: a crescent swept across the victim, bulging along the blow (attacker → victim on
* screen, a little random tilt / mirroring), in the hit colour with a white-hot inner stroke.
*/
_slash(view, src, px, py, s, style, big) {
const q = this._chest(src, this._q);
const dx = px - q.x, dy = py - q.y;
const ang = Math.abs(dx) + Math.abs(dy) > 1 ? Math.atan2(dy, dx) : (src.x > view.x ? Math.PI : 0);
const rot = ang + Math.PI / 2 + (Math.random() - 0.5) * 0.9;
const flip = Math.random() < 0.5 ? -1 : 1;
const k = big ? 1.25 : 1;
const a = this.particle('slash', px, py, { tint: style === 'phys' ? 0xffe2b0 : HIT_TINT[style] || 0xffffff, life: 0.2, s0: (s / 128) * 1.05 * k, s1: (s / 128) * 1.3 * k, a0: 1, a1: 0, rot });
a.sx = flip;
const b = this.particle('slash', px, py, { tint: 0xffffff, life: 0.12, s0: (s / 128) * 0.8 * k, s1: (s / 128) * 1.0 * k, a0: 0.85, a1: 0, rot });
b.sx = flip;
}
heal(view, amount) {
if (!view) return;
const p = this._chest(view);
const s = p.s;
this.particle('glow', p.x, p.y, { tint: 0x62f08a, life: 0.3, s0: (s / 128) * 0.5, s1: (s / 128) * 0.9, a0: 0.55, a1: 0 });
for (let i = 0; i < (this.quality === 'low' ? 1 : 3); i++) {
this.particle('plus', p.x + (Math.random() - 0.5) * s * 0.5, p.y + (Math.random() - 0.2) * s * 0.3, {
tint: 0x7dffa8, vy: -s * 0.9, life: 0.7, s0: s / 64 * 0.32, s1: s / 64 * 0.2, a0: 0.95, a1: 0, fadeIn: 0.08,
});
}
if (this.ctx.settings?.damageNumbers !== false) this.number(view, amount, 'heal', false);
}
/**
* A damage / heal number over `view` (see the header). Layout in screen space, against EVERY live number (the
* '41509' / '201625' overlaps were numbers of neighbouring units standing side by side):
* * merge: a same-style hit on the same unit within NUM_MERGE_GAP of that number's last hit (and while it is still
* young) adds to it — a running total that re-pops and lives a little longer;
* * cap: a unit shows at most NUM_PER_TARGET numbers; past it the hit joins the unit's youngest same-style number,
* else the unit's oldest number fades out at once;
* * lanes: the new number takes the lowest free slot of its unit's lanes (centre, then beside the head, 0 / ±1 / ±2
* lane widths), stacking upwards at most NUM_MAX_LINES lines; a slot is free when no live number of any unit
* overlaps it now or later (a younger number rises faster: below an older one it must keep that one's rise so
* far as a margin) with a gap wide enough that two numbers never read as one;
* * crowded (> NUM_CROWD numbers near it): it lives shorter; a pair that still ends up overlapping (units walking
* into each other) resolves by fading the older one quickly (_updateNums).
*/
number(view, amount, style, big) {
const n = Math.round(amount);
if (!(n > 0) || !view) return;
const now = this.time;
const cam = this.ctx.cam();
const base = (view.z || 0) + (view.hover || 0) + (view._headTiles || 1.2) * 0.8;
const a = cam.project(view.x, view.y, base, this._p);
const ax = a.x, ay = a.y, s = a.s > 0 ? a.s : 100;
const pxPerZ = ay - cam.project(view.x, view.y, base + 1, this._q).y;
const risePx = NUM_RISE * (pxPerZ > 1e-3 ? pxPerZ : s * 0.5);
// the live boxes only change between frames: laid out once per frame / camera, not once per hit (a heavy AoE
// lands dozens of hits in one frame — re-projecting every live number for each was O(hits × numbers))
this._layoutNumsOnce(cam);
// 1. merge into this unit's running total of the same style
let mine = 0, youngest = null, oldest = null;
for (const t of this.nums) {
if (t.unit !== view || t.fading) continue;
mine++;
if (!oldest || t.born < oldest.born) oldest = t;
if (t.style === style && (!youngest || t.born > youngest.born)) youngest = t;
}
const merge = youngest && now - youngest.lastHit < NUM_MERGE_GAP && youngest.life < NUM_RISE_T * 0.55 ? youngest
: (mine >= NUM_PER_TARGET && youngest ? youngest : null);
if (merge && this._growFits(merge, n, big)) {
merge.value += n;
merge.text.text = (style === 'heal' ? '+' : '') + merge.value;
merge.pop = 1;
merge.big = merge.big || big;
merge.lastHit = now;
merge.end = Math.min(merge.life + NUM_LIFE * 0.75, Math.max(merge.end, merge.life + NUM_LIFE * 0.6), NUM_MAX_LIFE);
this._sizeNum(merge);
return;
}
if (mine >= NUM_PER_TARGET && oldest) this._fadeNum(oldest, 0.1);
// 2. a free slot among the unit's lanes
const sc = numScale(s, big, style);
const w = numChars(n, style) * NUM_DIGIT_EM * 24 * sc * NUM_POP + NUM_GAP_PX;
const h = 24 * NUM_LINE_EM * sc * NUM_POP;
const lane = Math.max(w, 24 * NUM_DIGIT_EM * sc * 3.2) * 0.62;
let best = null;
for (const k of NUM_LANES) {
const cx = ax + k * lane;
const cy = this._numSlotPx(cx, ay, w, h, risePx);
const lift = ay - cy;
if (!best || lift < best.lift - 0.5) best = { cx, cy, lift, k };
if (lift <= h * 1.2) break; // low enough: keep the nearest lane
}
const cap = h * NUM_MAX_LINES;
let { cx, cy } = best;
if (ay - cy > cap) {
// over capacity (a knot of units under fire): join this unit's latest same-style total when it fits, else take
// the capped spot and drop whatever is there (crowded numbers give way at once)
let same = null;
for (const t of this.nums) if (t.unit === view && t.style === style && !t.fading && (!same || t.born > same.born)) same = t;
if (same && this._growFits(same, n, big)) {
same.value += n;
same.text.text = (style === 'heal' ? '+' : '') + same.value;
same.pop = 1;
same.big = same.big || big;
same.lastHit = now;
this._sizeNum(same);
return;
}
// (released at once, not merely ended: they would vanish before the next render anyway, and as obstacles they
// made every further hit of the same frame search a crowd that is no longer there — a knot under AoE)
cx = ax; cy = ay - cap;
let keep = 0;
for (const t of this.nums) {
if (Math.abs(t._x - cx) < (t._w + w) / 2 && t._y > cy - h && t._y - t._h < cy) { this._releaseNum(t); continue; }
this.nums[keep++] = t;
}
this.nums.length = keep;
}
// 3. crowding: numbers around this spot → a shorter life for everyone new here
let near = 0;
for (const t of this.nums) if (!t.fading && Math.abs(t._x - cx) < 150 && Math.abs(t._y - cy) < 110) near++;
const life = near >= NUM_CROWD ? NUM_LIFE * 0.62 : NUM_LIFE;
const maxNums = this.load >= 2 ? MAX_NUMBERS >> 1 : MAX_NUMBERS;
while (this.nums.length >= maxNums) this._releaseNum(this.nums.shift());
const t = this._takeNum(style);
t.text.visible = true;
t.text.alpha = 1;
t.text.text = (style === 'heal' ? '+' : '') + n;
Object.assign(t, {
unit: view, style, value: n, life: 0, end: life, born: now, lastHit: now, pop: 1, big: !!big, fading: false,
ox: cx - ax, oy: cy - ay, x0: view.x, y0: view.y, z0: base, risePx, _x: cx, _y: cy, _w: w, _h: h,
});
this.nums.push(t);
}
/** Whether a merged number's grown text still fits among its neighbours (else a new number is made). */
_growFits(t, add, big) {
const sc = numScale(t._s || 100, t.big || big, t.style);
const w = numChars(t.value + add, t.style) * NUM_DIGIT_EM * 24 * sc * NUM_POP + NUM_GAP_PX;
for (const o of this.nums) {
if (o === t || o.fading) continue;
if (Math.abs(o._x - t._x) < (o._w + w) / 2 && Math.abs(o._y - t._y) < (o._h + t._h) / 2) return false;
}
return true;
}
/**
* Lowest (largest screen y) bottom for a number of size w×h centred at x, starting at y0, clear of every live number
* now and later: above one it must sit on its top; below one it must leave that one's rise so far as a margin
* (the new number rises faster and would catch up).
*/
_numSlotPx(x, y0, w, h, risePx) {
let y = y0;
for (let pass = 0; pass <= this.nums.length; pass++) {
let moved = false;
for (const t of this.nums) {
if (t.fading && t.text.alpha < 0.3) continue; // almost gone
if (Math.abs(t._x - x) >= (t._w + w) / 2) continue;
const top = t._y - t._h, bottom = t._y;
const risen = t.risePx * easeOut(Math.min(1, t.life / NUM_RISE_T));
if (y <= top) continue; // entirely above it (bottom at or over its top)
if (y - h >= bottom + risen) continue; // below it, for good
y = top;
moved = true;
}
if (!moved) break;
}
return y;
}
/** Screen boxes of the live numbers (their current anchor, rise and size) → t._x, t._y (bottom), t._w, t._h. */
_layoutNums(cam) {
const p = this._p;
for (const t of this.nums) {
const u = t.unit;
if (u && !u.destroyed) { t.x0 = u.x; t.y0 = u.y; }
cam.project(t.x0, t.y0, t.z0, p);
t._s = p.s;
const k = Math.min(1, t.life / NUM_RISE_T);
t._x = p.x + t.ox;
t._y = p.y + t.oy - t.risePx * easeOut(k);
this._sizeNum(t);
}
this._laidAt = this.time;
this._laidCam = cam;
this._laidKey = camKey(cam);
}
/** `_layoutNums` unless the boxes are already current: same frame (fx time) and the same camera framing. */
_layoutNumsOnce(cam) {
if (this._laidAt === this.time && this._laidCam === cam && this._laidKey === camKey(cam)) return;
this._layoutNums(cam);
}
/** A number's box size from its value, style, pop and the scale of its last layout (t._s px per tile). */
_sizeNum(t) {
const sc = numScale(t._s || 100, t.big, t.style);
const pop = 1 + t.pop * (NUM_POP - 1);
t._w = numChars(t.value, t.style) * NUM_DIGIT_EM * 24 * sc * pop + NUM_GAP_PX;
t._h = 24 * NUM_LINE_EM * sc * pop;
t._sc = sc;
}
_fadeNum(t, within) {
if (t.fading) return;
t.fading = true;
t.fadeFrom = t.text.alpha;
t.end = Math.min(t.end, t.life + within);
t.fadeT0 = t.life;
}
_takeNum(style) {
const P = this.P;
const font = DMG_STYLE[style]?.font || DMG_STYLE.phys.font;
const pool = this._numPools || (this._numPools = new Map());
const list = pool.get(font);
let t = list && list.length ? list.pop() : null;
if (!t) {
const text = new P.BitmapText('0', { fontName: font, fontSize: 24, align: 'center' });
text.anchor.set(0.5, 1);
this.ctx.layers.text.addChild(text);
t = { text, font };
}
return t;
}
_releaseNum(t) {
t.text.visible = false;
t.unit = null;
const pool = this._numPools || (this._numPools = new Map());
let list = pool.get(t.font);
if (!list) pool.set(t.font, list = []);
if (list.length < 30) list.push(t); else t.text.destroy();
}
_updateNums(dt) {
const cam = this.ctx.cam();
let w = 0;
for (const t of this.nums) {
t.life += dt;
if (t.life >= t.end) { this._releaseNum(t); continue; }
t.pop = Math.max(0, t.pop - dt * 6);
this.nums[w++] = t;
}
this.nums.length = w;
this._layoutNums(cam);
// a pair still overlapping (their units walked into each other): the older one gives way
const L = this.nums;
for (let i = 0; i < L.length; i++) {
const a = L[i];
if (a.fading) continue;
for (let j = i + 1; j < L.length; j++) {
const b = L[j];
if (b.fading) continue;
if (Math.abs(a._x - b._x) < (a._w + b._w) / 2 - NUM_GAP_PX * 0.5 && Math.abs((a._y - a._h / 2) - (b._y - b._h / 2)) < (a._h + b._h) / 2 * 0.9) {
this._fadeNum(a.born <= b.born ? a : b, 0.06);
if (a.fading) break;
}
}
}
for (const t of L) {
const tx = t.text;
tx.scale.set(t._sc * (1 + t.pop * (NUM_POP - 1)));
tx.position.set(t._x, t._y);
let alpha = 1;
const left = t.end - t.life;
if (t.fading) alpha = (t.fadeFrom ?? 1) * clamp(left / Math.max(0.01, t.end - t.fadeT0), 0, 1);
else if (left < NUM_FADE_T) alpha = left / NUM_FADE_T;
tx.alpha = alpha;
}
}
/** Floating label ('!', '+10') at a screen point, using the damage-number pool. */
numberAt(x, y, label, tint, life = 0.8) {
const P = this.P;
if (this.labels.length >= 24) { const o = this.labels.shift(); o.t.destroy(); }
const t = new P.BitmapText(String(label), { fontName: DMG_STYLE.true.font, fontSize: 26, align: 'center' });
t.anchor.set(0.5, 1);
t.tint = tint;
t.position.set(x, y);
this.ctx.layers.text.addChild(t);
this.labels.push({ t, y0: y, life: 0, max: life });
}
_updateLabels(dt) {
let w = 0;
for (const l of this.labels) {
l.life += dt;
if (l.life >= l.max) { l.t.destroy(); continue; }
const k = l.life / l.max;
l.t.position.y = l.y0 - 22 * easeOut(k);
l.t.alpha = k > 0.6 ? 1 - (k - 0.6) / 0.4 : 1;
l.t.scale.set(k < 0.12 ? 0.6 + (k / 0.12) * 0.5 : 1.1 - Math.min(0.1, k - 0.12));
this.labels[w++] = l;
}
this.labels.length = w;
}
}