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.
299 lines
17 KiB
JavaScript
299 lines
17 KiB
JavaScript
// public/js/render/fx/simfx.js — FxSystem placement of a sim fx event.
|
|
// Installed on FxSystem.prototype by ./system.js (a method container: never instantiated; `this` is the effect system).
|
|
|
|
import { SHOT_HEIGHT, bodyZ } from './camera.js';
|
|
import { fxSpec, tilesAround, wallTiles } from './kinds.js';
|
|
import { clamp } from './limits.js';
|
|
|
|
/**
|
|
* An fx anchored on a unit (extra.id) is drawn at that unit's rendered position while the event's own (x, y) is within
|
|
* this many tiles of it (it happens on the unit); farther away the fx happens at (x, y) — the sim puts the caster in
|
|
* `id` of many area / target effects (an 'aoe' ahead of the caster, a 'crit' on the victim, 蕾缪安's 'bombard').
|
|
*/
|
|
const ANCHOR_SNAP = 0.75;
|
|
|
|
const num = (v, d) => { const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN; return Number.isFinite(n) ? n : d; };
|
|
|
|
export class FxSim {
|
|
// ---- sim fx (b.ev ['fx', kind, x, y, extra]) -------------------------------------------------------------
|
|
|
|
/** Unit view of an fx's `id` / `src` / … (null when unknown or gone). */
|
|
_viewOf(id) {
|
|
if (id == null || !this.ctx.view) return null;
|
|
const v = this.ctx.view(id);
|
|
return v && !v.destroyed ? v : null;
|
|
}
|
|
|
|
/**
|
|
* Where an fx happens: on its anchor unit `ex.id` (at the unit's rendered position, so it sticks to a moving unit)
|
|
* when the event's (x, y) is that unit's spot (within ANCHOR_SNAP tiles) or has none; otherwise at (x, y) on the ground
|
|
* — the sim often names the caster in `id` of an effect elsewhere ('aoe' at a target or ahead of the caster, 'crit'
|
|
* on the victim, 'zone' where a skill lands), which used to be drawn on the caster.
|
|
*/
|
|
_where(x, y, ex) {
|
|
const v = this._viewOf(ex.id);
|
|
if (v && Number.isFinite(v.x) && Number.isFinite(v.y)) {
|
|
const here = !Number.isFinite(x) || !Number.isFinite(y) || Math.hypot(v.x - x, v.y - y) <= ANCHOR_SNAP;
|
|
if (here) return { x: v.x, y: v.y, z: (v.z || 0) + (v.hover || 0), v };
|
|
}
|
|
return this._point(x, y);
|
|
}
|
|
|
|
/** An fx at the sim position (x, y), on the ground there. */
|
|
_point(x, y) {
|
|
return { x, y, z: Number.isFinite(x) && Number.isFinite(y) ? this._groundZ(x, y) : 0, v: null };
|
|
}
|
|
|
|
/**
|
|
* b.ev 'fx': every kind the sim / content emits has a visual (FX_KINDS archetypes: blast, shell, zone, telegraph,
|
|
* heal, sp, shield, shatter, summon, vanish, blink, move, wave, mark, reticle, buff, lift, sleep, crit, dodge,
|
|
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame), except kinds whose
|
|
* archetype is 'none' (hitCap: a leader hit cancelled by 限伤 draws nothing); unknown kinds get a generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration,
|
|
* t (shell flight, game s), src / from / to / target / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions),
|
|
* element, n, scale, kind, tiles.
|
|
*/
|
|
simFx(kind, x, y, extra) {
|
|
const ex = extra && typeof extra === 'object' ? extra : {};
|
|
const spec = fxSpec(kind, ex);
|
|
if (spec.a === 'none') return; // an event the screen does not show (hitCap)
|
|
const at = spec.pt ? this._point(Number(x), Number(y)) : this._where(Number(x), Number(y), ex);
|
|
if (!Number.isFinite(at.x) || !Number.isFinite(at.y)) return;
|
|
// 蕾缪安 S2: the aimed snipe ('crit' on the locked enemy, from her) ends that aim lock
|
|
if (kind === 'crit' && ex.src != null && ex.id != null) {
|
|
for (const L of this.locks) if (L.src === ex.src && L.id === ex.id) this._releaseLock(L);
|
|
}
|
|
const col = spec.c;
|
|
const r = clamp(num(ex.r ?? ex.radius, spec.r ?? 1), 0.3, 30);
|
|
const ts = this.ctx.timeScale ? Math.max(0.25, this.ctx.timeScale()) : 2;
|
|
const dur = num(ex.dur ?? ex.duration, spec.dur ?? 0) / ts;
|
|
const cam = this.ctx.cam();
|
|
const chest = (v, out = this._p) => (v ? this._chest(v, out) : cam.project(at.x, at.y, at.z + 0.5, out));
|
|
const p = chest(at.v);
|
|
const s = p.s;
|
|
switch (spec.a) {
|
|
case 'blast': {
|
|
if (r >= 12) { this.flashScreen(col, 0.5); break; }
|
|
if (kind === 'bombard') { this._touchLocks(ex.id ?? ex.src ?? null); this._landed(ex.id ?? ex.src ?? null, at.x, at.y, r); }
|
|
this.explosion(at.x, at.y, at.z, r, col, { smoke: spec.smoke, heavy: !!spec.heavy, tiles: ex.tiles ? tilesAround(at.x, at.y, r, ex.tiles) : null });
|
|
break;
|
|
}
|
|
case 'shell': {
|
|
// 蕾缪安 S3: a shell fired now by `id` that lands at (x, y) after `t` game seconds (its 'bombard' explodes there)
|
|
const flight = num(ex.t ?? ex.flight ?? ex.dur ?? ex.duration, 1) / ts;
|
|
this._touchLocks(ex.id ?? ex.src ?? null);
|
|
this.mortar(this._viewOf(ex.id ?? ex.src), at.x, at.y, r, flight);
|
|
break;
|
|
}
|
|
case 'zone': this.zone(at.x, at.y, at.z, r, col, Math.max(0.6, dur || 1.5), spec.tex); break;
|
|
case 'wall': {
|
|
// a line of burning tiles through the anchor tile along `axis` ('col' | 'row', from the sim event)
|
|
const rect = this.ctx.fieldRect ? this.ctx.fieldRect() : null;
|
|
this.tileFlash(wallTiles(Number(x), Number(y), ex.axis === 'row' ? 'row' : 'col', rect), col, Math.max(0.6, dur || 1.5));
|
|
this.zone(at.x, at.y, at.z, 0.6, col, Math.max(0.6, dur || 1.5), spec.tex);
|
|
break;
|
|
}
|
|
case 'telegraph': {
|
|
const d = Math.max(0.4, dur || 1);
|
|
if (r >= 12) { this.flashScreen(col, 0.45, d); break; }
|
|
if (ex.tiles) this.tileFlash(tilesAround(at.x, at.y, r, ex.tiles), col, d, true);
|
|
else this.zone(at.x, at.y, at.z, r, col, d, 'ring', true);
|
|
break;
|
|
}
|
|
case 'chill': this.flashScreen(col, 0.35, 0.8); this.snowfall(col); break;
|
|
case 'heal': this.heal(at.v || { x: at.x, y: at.y, z: at.z }, 0); break;
|
|
case 'healAoe': {
|
|
this.ring(at.x, at.y, at.z, 0.2, r, col, 0.6);
|
|
const n = this.quality === 'low' ? 4 : 9;
|
|
for (let i = 0; i < n; i++) {
|
|
const a = Math.random() * Math.PI * 2, d = Math.random() * r * 0.85;
|
|
const q = cam.project(at.x + Math.cos(a) * d, at.y + Math.sin(a) * d, at.z + 0.2);
|
|
this.particle('plus', q.x, q.y, { tint: col, vy: -q.s * 0.9, life: 0.8, s0: q.s / 64 * 0.3, s1: q.s / 64 * 0.18, a0: 0.9, a1: 0, fadeIn: 0.1 });
|
|
}
|
|
break;
|
|
}
|
|
case 'sp': {
|
|
for (let i = 0; i < (this.quality === 'low' ? 3 : 7); i++) {
|
|
this.particle('dot', p.x + (Math.random() - 0.5) * s * 0.6, p.y + (Math.random() - 0.2) * s * 0.4, { tint: col, vy: -s * (0.6 + Math.random() * 0.6), life: 0.7, s0: s / 32 * 0.2, s1: 0, a0: 1, a1: 0, fadeIn: 0.05 });
|
|
}
|
|
this.particle('glow', p.x, p.y, { tint: col, life: 0.3, s0: s / 128 * 0.6, s1: s / 128 * 1.1, a0: 0.7, a1: 0 });
|
|
break;
|
|
}
|
|
case 'shield': {
|
|
this.particle('hex', p.x, p.y, { tint: col, life: 0.55, s0: s / 128 * 0.9, s1: s / 128 * 1.25, a0: 0.85, a1: 0, sx: 0.85 });
|
|
this.particle('glow', p.x, p.y, { tint: col, life: 0.4, s0: s / 128 * 0.9, s1: s / 128 * 1.3, a0: 0.5, a1: 0 });
|
|
break;
|
|
}
|
|
case 'shatter': {
|
|
this.particle('hex', p.x, p.y, { tint: col, life: 0.25, s0: s / 128 * 1.1, s1: s / 128 * 1.5, a0: 0.9, a1: 0 });
|
|
for (let i = 0; i < (this.quality === 'low' ? 5 : 12); i++) {
|
|
const a = Math.random() * Math.PI * 2;
|
|
this.particle('shard', p.x + Math.cos(a) * s * 0.3, p.y + Math.sin(a) * s * 0.3, { tint: col, vx: Math.cos(a) * s * 1.6, vy: Math.sin(a) * s * 1.2 - s * 0.3, g: s * 3, life: 0.55, s0: s / 32 * 0.16, s1: s / 32 * 0.05, a0: 1, a1: 0, spin: (Math.random() - 0.5) * 12 });
|
|
}
|
|
break;
|
|
}
|
|
case 'summon': {
|
|
const g = cam.project(at.x, at.y, at.z);
|
|
this.particle('pillar', g.x, g.y, { tint: col, life: 0.55, s0: g.s / 64 * 0.8, s1: g.s / 64 * 0.3, a0: 0.9, a1: 0, sx: 1, ay: 1 });
|
|
this.ring(at.x, at.y, at.z, 0.1, 0.9, col, 0.5, 'hex');
|
|
this.burst(g.x, g.y - g.s * 0.3, g.s, 8, col, { speed: 1.4, up: 0.8, life: 0.5, tex: 'dot' });
|
|
break;
|
|
}
|
|
case 'vanish': {
|
|
this.particle('glow', p.x, p.y, { tint: col, life: 0.4, s0: s / 128 * 0.7, s1: s / 128 * 1.5, a0: 0.8, a1: 0 });
|
|
this.smoke(p.x, p.y, s * 0.7, 0x2c2436, 0.5);
|
|
this.burst(p.x, p.y, s, 7, col, { speed: 1.5, tex: 'dot', life: 0.45 });
|
|
break;
|
|
}
|
|
case 'blink': {
|
|
const fx0 = num(ex.fx ?? ex.fromX, NaN), fy0 = num(ex.fy ?? ex.fromY, NaN);
|
|
if (Number.isFinite(fx0) && Number.isFinite(fy0)) {
|
|
const q = cam.project(fx0, fy0, at.z + 0.5, this._q);
|
|
this.smoke(q.x, q.y, q.s * 0.6, 0x2c2436, 0.45);
|
|
this.particle('glow', q.x, q.y, { tint: col, life: 0.3, s0: q.s / 128 * 0.8, s1: q.s / 128 * 0.2, a0: 0.8, a1: 0 });
|
|
this.streak(fx0, fy0, at.x, at.y, at.z + 0.5, col, 0.3);
|
|
}
|
|
this.particle('glow', p.x, p.y, { tint: col, life: 0.35, s0: s / 128 * 0.3, s1: s / 128 * 1.2, a0: 0.9, a1: 0 });
|
|
this.burst(p.x, p.y, s, 6, col, { speed: 1.4, tex: 'dot', life: 0.4 });
|
|
break;
|
|
}
|
|
case 'move': {
|
|
const fx0 = num(ex.fromX ?? ex.fx ?? ex.x0, NaN), fy0 = num(ex.fromY ?? ex.fy ?? ex.y0, NaN);
|
|
const tx = num(ex.tx, NaN), ty = num(ex.ty, NaN);
|
|
if (Number.isFinite(fx0) && Number.isFinite(fy0)) this.streak(fx0, fy0, at.x, at.y, at.z + 0.35, col, 0.35);
|
|
else if (Number.isFinite(tx) && Number.isFinite(ty)) this.streak(at.x, at.y, tx, ty, at.z + 0.35, col, 0.35);
|
|
const g = cam.project(at.x, at.y, at.z + 0.05);
|
|
for (let i = 0; i < (this.quality === 'low' ? 2 : 5); i++) {
|
|
this.particle('smoke', g.x + (Math.random() - 0.5) * g.s * 0.5, g.y, { add: false, tint: 0x6b6358, vx: (Math.random() - 0.5) * g.s * 0.6, vy: -g.s * 0.2, drag: 2, life: 0.5, s0: g.s / 128 * 0.25, s1: g.s / 128 * 0.55, a0: 0.45, a1: 0 });
|
|
}
|
|
break;
|
|
}
|
|
case 'wave': {
|
|
const k = clamp(num(ex.scale, 1), 0.5, 4);
|
|
const rr = Math.max(0.8, r * (ex.scale ? Math.min(2, k / 2) : 1));
|
|
for (let i = 0; i < 3; i++) this.ring(at.x, at.y, at.z, 0.15 + i * 0.15, rr * (0.7 + i * 0.25), col, 0.45 + i * 0.12);
|
|
this.particle('glow', p.x, p.y, { tint: col, life: 0.3, s0: s / 128 * 0.5, s1: s / 128 * 1.2, a0: 0.7, a1: 0 });
|
|
break;
|
|
}
|
|
case 'mark': case 'reticle': {
|
|
if (kind === 'lock') {
|
|
// `id` is always the locked enemy: the reticle sticks to its view even a little off the event's spot
|
|
const lv = at.v || this._viewOf(ex.id);
|
|
this._touchLocks(ex.src ?? null);
|
|
this._lock(lv, ex.src ?? null, lv ? lv.x : at.x, lv ? lv.y : at.y, lv ? bodyZ(cam, lv, SHOT_HEIGHT.aim) : at.z + 0.55);
|
|
break;
|
|
}
|
|
const v = at.v;
|
|
const hz = v ? (v.z || 0) + (v.hover || 0) + (v._headTiles || 1.2) + 0.25 : at.z + 1.4;
|
|
const q = cam.project(at.x, at.y, hz, this._q);
|
|
if (spec.a === 'mark') {
|
|
this.particle('glow', q.x, q.y, { tint: col, life: 0.6, s0: q.s / 128 * 0.5, s1: q.s / 128 * 0.7, a0: 0.8, a1: 0 });
|
|
this.numberAt(q.x, q.y, '!', col, 0.8);
|
|
} else {
|
|
this.particle('ring', p.x, p.y, { tint: col, life: 0.6, s0: s / 128 * 1.3, s1: s / 128 * 0.7, a0: 0.95, a1: 0, spin: 3 });
|
|
this.particle('hex', p.x, p.y, { tint: col, life: 0.6, s0: s / 128 * 0.5, s1: s / 128 * 0.9, a0: 0.8, a1: 0, spin: -2 });
|
|
}
|
|
break;
|
|
}
|
|
case 'buff': {
|
|
const g = cam.project(at.x, at.y, at.z + 0.05);
|
|
const n = this.quality === 'low' ? 3 : 6;
|
|
for (let i = 0; i < n; i++) {
|
|
const ox = (Math.random() - 0.5) * g.s * 0.7;
|
|
this.particle('chevron', g.x + ox, g.y - g.s * (0.1 + Math.random() * 0.5), { tint: col, vy: -g.s * (1 + Math.random() * 0.5), life: 0.65, s0: g.s / 64 * 0.22, s1: g.s / 64 * 0.12, a0: 0.95, a1: 0, rot: -Math.PI / 2, fadeIn: 0.06 });
|
|
}
|
|
this.ring(at.x, at.y, at.z, 0.2, 0.75, col, 0.4);
|
|
this.particle('glow', p.x, p.y, { tint: col, life: 0.35, s0: s / 128 * 0.6, s1: s / 128 * 1.1, a0: 0.6, a1: 0 });
|
|
break;
|
|
}
|
|
case 'lift': {
|
|
const g = cam.project(at.x, at.y, at.z);
|
|
this.ring(at.x, at.y, at.z, 0.2, 0.8, col, 0.5);
|
|
for (let i = 0; i < (this.quality === 'low' ? 3 : 7); i++) {
|
|
this.particle('streak', g.x + (Math.random() - 0.5) * g.s * 0.6, g.y - g.s * Math.random() * 0.4, { tint: col, vy: -g.s * 1.8, life: 0.4, s0: g.s / 128 * 0.4, s1: g.s / 128 * 0.2, a0: 0.8, a1: 0, rot: -Math.PI / 2, sx: 1 });
|
|
}
|
|
break;
|
|
}
|
|
case 'sleep': {
|
|
const v = at.v;
|
|
const hz = v ? (v.z || 0) + (v._headTiles || 1.2) : at.z + 1.2;
|
|
const q = cam.project(at.x + 0.2, at.y, hz, this._q);
|
|
for (let i = 0; i < 3; i++) this.particle('st_sleep', q.x + i * q.s * 0.12, q.y - i * q.s * 0.12, { add: false, vy: -q.s * 0.5, vx: q.s * 0.15, life: 0.9 + i * 0.2, s0: q.s / 32 * 0.22, s1: q.s / 32 * 0.32, a0: 1, a1: 0, fadeIn: 0.1 * i });
|
|
break;
|
|
}
|
|
case 'crit': {
|
|
this.particle('spark', p.x, p.y, { tint: col, life: 0.28, s0: s / 64 * 1.3, s1: s / 64 * 0.2, a0: 1, a1: 0, rot: Math.random() * Math.PI });
|
|
this.particle('glow', p.x, p.y, { tint: 0xffffff, life: 0.18, s0: s / 128 * 0.9, s1: s / 128 * 1.4, a0: 0.9, a1: 0 });
|
|
this.burst(p.x, p.y, s, 8, col, { speed: 3.2, life: 0.3, size: 0.5 });
|
|
break;
|
|
}
|
|
case 'dodge': {
|
|
this.particle('soft', p.x, p.y, { tint: 0xffffff, life: 0.3, s0: s / 128 * 0.5, s1: s / 128 * 0.9, a0: 0.5, a1: 0 });
|
|
this.particle('slash', p.x, p.y, { tint: col, life: 0.22, s0: s / 128 * 0.8, s1: s / 128 * 1.1, a0: 0.7, a1: 0, rot: -0.5 });
|
|
break;
|
|
}
|
|
case 'counter': {
|
|
this.particle('spark', p.x, p.y, { tint: col, life: 0.22, s0: s / 64 * 0.9, s1: 0, a0: 1, a1: 0 });
|
|
this.particle('slash', p.x, p.y, { tint: col, life: 0.22, s0: s / 128 * 0.9, s1: s / 128 * 1.2, a0: 0.9, a1: 0, rot: 0.6 });
|
|
this.burst(p.x, p.y, s, 4, col, { speed: 2.4, life: 0.25 });
|
|
break;
|
|
}
|
|
case 'dp': {
|
|
const n = Math.round(num(ex.n, 0));
|
|
this.particle('shard', p.x, p.y, { tint: col, vy: -s * 0.6, life: 0.9, s0: s / 32 * 0.28, s1: s / 32 * 0.22, a0: 1, a1: 0 });
|
|
if (n > 0) this.numberAt(p.x + s * 0.2, p.y, `+${n}`, col, 0.7);
|
|
break;
|
|
}
|
|
case 'coin': {
|
|
for (let i = 0; i < 4; i++) this.particle('coin', p.x + (Math.random() - 0.5) * s * 0.4, p.y, { add: false, vx: (Math.random() - 0.5) * s, vy: -s * (1.2 + Math.random() * 0.6), g: s * 4, life: 0.7, s0: s / 48 * 0.2, s1: s / 48 * 0.16, a0: 1, a1: 0.2 });
|
|
break;
|
|
}
|
|
case 'crate': this.crateBreak(at.x, at.y, at.z); break;
|
|
case 'down': {
|
|
this.smoke(p.x, p.y, s * 0.6, spec.smoke ?? 0x1a1a1a, 0.5);
|
|
this.burst(p.x, p.y, s, 5, col, { speed: 1.2, tex: 'dot', life: 0.5, g: 2 });
|
|
break;
|
|
}
|
|
case 'beam': case 'bolt': {
|
|
const a = this._viewOf(ex.from ?? ex.src) || (spec.a === 'bolt' ? null : at.v);
|
|
const b = this._viewOf(ex.to ?? ex.target) || (a === at.v ? null : at.v);
|
|
if (a && b && a !== b) this._beam(a, b, col, spec.a === 'bolt' ? 0.28 : 0.4, spec.a === 'bolt' ? 1 : 0.4);
|
|
else this.strike(at.x, at.y, at.z, col);
|
|
break;
|
|
}
|
|
case 'flame': {
|
|
// at the event's spot (the locked target), not snapped onto the dragon hovering 0.25 tile from it
|
|
const fx0 = Number(x), fy0 = Number(y);
|
|
this._flame(ex.id ?? ex.src ?? null, ex.target ?? ex.to ?? null, fx0, fy0, r, col, Math.max(0.2, dur || 0.5));
|
|
break;
|
|
}
|
|
case 'strike': case 'pillar': this.strike(at.x, at.y, at.z, col, spec.a === 'pillar'); break;
|
|
case 'volley': {
|
|
const list = Array.isArray(ex.targets) ? ex.targets.slice(0, 12) : [];
|
|
const src = this._viewOf(ex.src ?? ex.id);
|
|
for (const id of list) {
|
|
const t = this._viewOf(id);
|
|
if (!t) continue;
|
|
if (src && src !== t) this.attack(src, t, 'arrow');
|
|
else { const q = this._chest(t, this._q); this.burst(q.x, q.y, q.s, 4, col, { speed: 2, life: 0.3 }); }
|
|
}
|
|
if (!list.length) this.burst(p.x, p.y, s, 6, col, { speed: 2.4, life: 0.35 });
|
|
break;
|
|
}
|
|
case 'lp': this.leak(); break;
|
|
case 'element': {
|
|
const el = ex.element;
|
|
const c2 = el === 'burn' ? 0xff7a33 : el === 'neural' ? 0xff5ad0 : el === 'necrosis' || el === 'apoptosis' ? 0x9dff6a : el === 'erosion' ? 0x6fe0ff : col;
|
|
this.particle('glow', p.x, p.y, { tint: c2, life: 0.45, s0: s / 128 * 1.2, s1: s / 128 * 2.6, a0: 1, a1: 0 });
|
|
this.ring(at.x, at.y, at.z, 0.2, 1.5, c2, 0.55);
|
|
this.burst(p.x, p.y, s, 12, c2, { speed: 3, life: 0.5 });
|
|
break;
|
|
}
|
|
default: {
|
|
this.particle('spark', p.x, p.y, { tint: col, life: 0.35, s0: s / 64 * 0.6, s1: 0, a0: 0.9, a1: 0, rot: Math.random() });
|
|
this.particle('glow', p.x, p.y, { tint: col, life: 0.3, s0: s / 128 * 0.4, s1: s / 128 * 0.9, a0: 0.6, a1: 0 });
|
|
this.burst(p.x, p.y, s, 4, col, { speed: 1.6, tex: 'dot', life: 0.35 });
|
|
}
|
|
}
|
|
}
|
|
}
|