369 lines
16 KiB
JavaScript
369 lines
16 KiB
JavaScript
// 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, srcView.info))) {
|
||
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;
|
||
}
|
||
}
|