Files
ark-stronghold-protocol/public/js/render/fx/projectiles.js
T
sganggs c1b8292f92 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.
2026-10-05 17:13:07 +08:00

465 lines
22 KiB
JavaScript

// public/js/render/fx/projectiles.js — FxSystem shots, boomerangs, mortar shells and impacts.
// Installed on FxSystem.prototype by ./system.js (a method container: never instantiated; `this` is the effect system).
import { PROJ } from '../style.js';
import { SHOT_HEIGHT, bodyZ, feetZ } from './camera.js';
import { clamp } from './limits.js';
import { projSpeed } from './speeds.js';
const MAX_PROJ = 260;
/** Trail particles (and boomerang afterimages) per real second of one flying projectile (cosmetic). */
const TRAIL_HZ = 36;
/** Real seconds a shot lingers after arriving: the tracer shrinks into the target, the head fades (short shots read). */
const SHOT_FADE = 0.08;
/** Boomerang: spin (rad per real s), sideways bow of each leg (tiles, at ≥ 2 tiles range), safety lifetime (real s). */
const BOOM_SPIN = 22, BOOM_BOW = 0.35, BOOM_MAX_T = 6;
/** Bombard shell: share of its flight spent rising from the shooter, and the height it climbs / falls from (tiles). */
const SHELL_RISE = 0.34, SHELL_UP = 5.5;
/** 蕾缪安 S3 shell (fx 'bombardShell'); `look` 'mortar' is its own flight (_stepMortar). */
const BOMBARD_SHELL = Object.freeze({ look: 'mortar', tint: 0xfff2d8, glow: 0xff9c33, trail: 0xffb35c, smoke: 0x3a3430, len: 1.3, width: 0.3, head: 0.56 });
export class FxProjectiles {
// ---- helpers ------------------------------------------------------------------------------------------
_proj(x, y, z, out = this._p) { return this.ctx.cam().project(x, y, z, out); }
/** Ground decals: on a raised top they are drawn with that block row (tiles.surfaceLayer), else in groundFx. */
_onGround(sp, y, z) {
let layer = this.ctx.layers.groundFx;
if (z > 0.12 && this.ctx.surfaceLayer) layer = this.ctx.surfaceLayer(Math.round(y)) || layer;
if (sp.parent !== layer) layer.addChild(sp);
}
_chest(view, out = this._p) {
const z = (view.z || 0) + (view.hover || 0) + (view._headTiles ? view._headTiles * 0.45 : 0.5);
return this._proj(view.x, view.y, z, out);
}
/** Screen point `frac` of a unit's drawn model height above its feet (SHOT_HEIGHT: beams, a mortar's muzzle). */
_bodyPt(view, frac, out = this._p) {
const p = this._proj(view.x, view.y, (view.z || 0) + (view.hover || 0), out);
p.y -= (view._headTiles || 1.2) * frac * p.s;
return p;
}
// ---- projectiles -----------------------------------------------------------------------------------------
/** b.ev 'atk' visual. src/tgt are views (tgt may be null). */
attack(src, tgt, kind) {
if (!src) return;
// chain: the source is the previous target of the bounce (sim ai.js), so the arc hops unit to unit
if (kind === 'chain' || kind === 'chainHeal') { if (tgt && tgt !== src) this._beam(src, tgt, kind === 'chainHeal' ? 0x7dffa8 : 0xc9a2ff, 0.22, 1, true); return; }
if (kind === 'beam') { if (tgt && tgt !== src) this._beam(src, tgt, src.isEnemy ? 0xff7a5a : 0xffe6a8, 0.18, 0.15); return; }
const spec = PROJ[kind];
if (!spec || !tgt) {
if (kind === 'none' || !kind) this._slashAt = src.id;
return;
}
const pr = this._takeProj();
const cam = this.ctx.cam();
const dx = tgt.x - src.x, dy = tgt.y - src.y;
const dist = Math.hypot(dx, dy);
const ux = dist > 1e-6 ? dx / dist : (src.facing || 1) >= 0 ? 1 : -1, uy = dist > 1e-6 ? dy / dist : 0;
const hand = Math.min(0.28, dist * 0.3); // the weapon is in front of the body
const look = spec.look;
pr.kind = kind; pr.spec = spec; pr.src = src; pr.tgt = tgt; pr.rise = 0;
pr.x0 = src.x + ux * hand; pr.y0 = src.y + uy * hand; pr.z0 = bodyZ(cam, src, SHOT_HEIGHT.launch);
pr.tx = tgt.x; pr.ty = tgt.y; pr.tz = look === 'shell' ? feetZ(tgt) : bodyZ(cam, tgt, SHOT_HEIGHT.aim);
pr.t = 0; pr.fade = 0; pr.hit = false; pr.emit = Math.random(); pr.ang = Math.atan2(-uy, ux); // ≈ on screen (rows run up)
pr.dur = clamp(dist / projSpeed(kind) / this._ts(), 0.04, 1.5);
pr.arc = spec.arc ? spec.arc * clamp(0.45 + dist * 0.18, 0.6, 1.8) : 0;
pr.glow = spec.glow;
pr.trailTint = spec.trail ?? spec.glow;
// boomerang legs: kinematic, from (bx, by, bz) at constant speed towards the target, then back to the thrower
pr.phase = 0; pr.bx = pr.x0; pr.by = pr.y0; pr.bz = pr.z0; pr.trav = 0; pr.d0 = Math.max(0.1, dist); pr.ux = ux; pr.uy = uy;
pr.spin = Math.random() * 6;
this._dressProj(pr);
this.projs.push(pr);
if (this.rich) this._muzzle(pr);
}
/** A pooled projectile record: trail + halo + core sprites (additive, above units) and a ground shadow. */
_takeProj() {
if (this.projs.length >= MAX_PROJ) this._releaseProj(this.projs.shift());
let pr = this.projFree.pop();
if (!pr) {
const P = this.P;
const add = (tex, ax) => {
const sp = new P.Sprite(this.tex[tex]);
sp.anchor.set(ax, 0.5);
sp.blendMode = P.BLEND_MODES.ADD;
sp.visible = false;
return sp;
};
const trail = add('tracer', 1), halo = add('glow', 0.5), core = add('orb', 0.5);
this.projLayer.addChild(trail, halo, core);
const shadow = new P.Sprite(this.tex.soft);
shadow.anchor.set(0.5);
shadow.tint = 0x000000;
shadow.visible = false;
this.shadowLayer.addChild(shadow);
pr = { trail, halo, core, shadow };
}
return pr;
}
/** Textures / tints of a projectile's sprites for its look (shown from its first update). */
_dressProj(pr) {
const spec = pr.spec, look = spec.look;
const thin = look === 'tracer' || look === 'dart';
const { trail, halo, core, shadow } = pr;
trail.texture = this.tex[thin ? 'tracer' : 'streak'];
trail.tint = thin ? spec.tint : pr.trailTint;
trail.visible = look !== 'boomerang';
halo.texture = this.tex.glow;
halo.tint = pr.glow;
halo.visible = true;
core.texture = this.tex[look === 'boomerang' ? 'boomerang' : thin ? 'dot' : 'orb'];
core.tint = spec.tint;
core.rotation = 0;
core.visible = true;
shadow.visible = look === 'shell' || look === 'boomerang';
trail.alpha = halo.alpha = core.alpha = shadow.alpha = 0;
}
_releaseProj(pr) {
pr.trail.visible = pr.halo.visible = pr.core.visible = pr.shadow.visible = false;
pr.src = pr.tgt = null;
this.projFree.push(pr);
}
_updateProjs(dt) {
if (!this.projs.length) return;
const cam = this.ctx.cam();
const rich = this.rich;
let w = 0;
for (let i = 0; i < this.projs.length; i++) {
const pr = this.projs[i];
const look = pr.spec.look;
const live = look === 'boomerang' ? this._stepBoomerang(pr, dt, cam, rich)
: look === 'mortar' ? this._stepMortar(pr, dt, cam, rich) : this._stepShot(pr, dt, cam, rich);
if (!live) { this._releaseProj(pr); continue; }
this.projs[w++] = pr;
}
this.projs.length = w;
}
/** Screen point of a straight / lobbed shot at flight fraction k (a parabola of height `arc` over the line). */
_shotPoint(pr, k, cam, out) {
const x = pr.x0 + (pr.tx - pr.x0) * k, y = pr.y0 + (pr.ty - pr.y0) * k;
const z = pr.z0 + (pr.tz - pr.z0) * k + (pr.arc ? pr.arc * 4 * k * (1 - k) : 0);
return cam.project(x, y, z, out);
}
/**
* One frame of a tracer / orb / shell / dart: placed by flight time (homing on the target), trail behind the head
* along the path (never longer than the part flown), an arrival burst, then a SHOT_FADE linger in the target.
*/
_stepShot(pr, dt, cam, rich) {
const spec = pr.spec, look = spec.look;
pr.t += dt;
const tg = pr.tgt;
if (tg && !tg.destroyed && tg.alive !== false) { pr.tx = tg.x; pr.ty = tg.y; pr.tz = look === 'shell' ? feetZ(tg) : bodyZ(cam, tg, SHOT_HEIGHT.aim); }
const k = Math.min(1, pr.t / pr.dur);
if (k >= 1 && !pr.hit) { pr.hit = true; pr.fade = 0; this._impact(pr, cam); }
let fk = 0;
if (pr.hit) { pr.fade += dt; fk = pr.fade / SHOT_FADE; if (fk >= 1) return false; }
const p = this._shotPoint(pr, k, cam, this._p);
const kb = Math.max(0, k - (pr.arc ? 0.12 : 0.25));
const q = this._shotPoint(pr, kb, cam, this._q);
const s = p.s, px = p.x, py = p.y;
const seg = Math.hypot(px - q.x, py - q.y);
if (seg > 0.5) pr.ang = Math.atan2(py - q.y, px - q.x);
const thin = look === 'tracer' || look === 'dart';
const flown = k > kb ? seg * (k / (k - kb)) : 0;
const L = Math.min(spec.len * s, flown) * (1 - fk);
const tr = pr.trail;
tr.position.set(px, py);
tr.rotation = pr.ang;
tr.scale.set(Math.max(0.001, L / 128), (spec.width * s) / (thin ? 16 : 20));
tr.alpha = (thin ? 1 : 0.85) * (1 - fk);
const hs = spec.head * s;
const halo = pr.halo;
halo.position.set(px, py);
halo.scale.set((hs / 128) * (1 + 0.12 * Math.sin(pr.t * 40)));
halo.alpha = 0.85 * (1 - fk);
const core = pr.core;
core.position.set(px, py);
if (thin) core.scale.set((hs * 0.5) / 32);
else if (look === 'shell') { core.rotation = pr.ang; core.scale.set((hs * 0.85) / 64, (hs * 0.55) / 64); } // a shell along its flight
else core.scale.set((hs * 0.62) / 64);
core.alpha = 1 - fk;
if (look === 'shell') {
// its shadow on the ground under it: bigger and darker the lower it flies
const gx = pr.x0 + (pr.tx - pr.x0) * k, gy = pr.y0 + (pr.ty - pr.y0) * k;
const g = cam.project(gx, gy, this._groundZ(gx, gy) + 0.01, this._g);
const near = 1 - clamp((pr.arc * 4 * k * (1 - k)) / Math.max(0.3, pr.arc), 0, 1);
const r = g.s * (0.17 + 0.13 * near);
const sh = pr.shadow;
sh.position.set(g.x, g.y);
sh.scale.set((r * 2) / 128, (r * 0.9) / 128);
sh.alpha = (0.32 + 0.3 * near) * (1 - fk);
}
if (rich && !pr.hit && !thin) {
pr.emit += dt * TRAIL_HZ;
for (let n = 0; pr.emit >= 1 && n < 2; n++) {
pr.emit -= 1;
if (!this._room()) { pr.emit = 0; break; }
if (look === 'shell') this._puff(px, py, s, pr.trailTint, spec.smoke, n);
else this._mote(px, py, s, pr.trailTint, spec.width);
}
}
return true;
}
/**
* 回环射手 (sim ai.js throwBoomerang): out to the (moving) target at the boomerang speed, a hit flash, then back to the
* thrower's current position at the return speed (PRTS 跃跃: 15 out, 3.75 back); gone when caught — or at once when
* the thrower's view is gone (the sim drops a boomerang whose thrower left). Each leg bows sideways (left of its own
* direction, so out and back form a loop); it spins, leaves afterimages and a shadow.
*/
_stepBoomerang(pr, dt, cam, rich) {
const spec = pr.spec;
pr.t += dt;
if (pr.t > BOOM_MAX_T) return false;
const step = projSpeed(pr.phase ? 'boomerangReturn' : pr.kind) * this._ts() * dt;
let gx, gy, gz;
if (pr.phase === 0) {
const tg = pr.tgt;
if (tg && !tg.destroyed && tg.alive !== false) { pr.tx = tg.x; pr.ty = tg.y; pr.tz = bodyZ(cam, tg, SHOT_HEIGHT.aim); }
gx = pr.tx; gy = pr.ty; gz = pr.tz;
} else {
const sv = pr.src;
if (!sv || sv.destroyed || sv.alive === false) return false;
gx = sv.x; gy = sv.y; gz = bodyZ(cam, sv, SHOT_HEIGHT.launch);
}
const dx = gx - pr.bx, dy = gy - pr.by, dz = gz - pr.bz;
const d = Math.hypot(dx, dy, dz);
if (d <= step) {
pr.bx = gx; pr.by = gy; pr.bz = gz;
if (pr.phase === 1) { this._catch(pr, cam); return false; }
this._impact(pr, cam);
// turn back: the next leg runs from here to the thrower (a thrower already gone gets nothing back)
const sv = pr.src;
if (!sv || sv.destroyed || sv.alive === false) return false;
const bx = sv.x - gx, by = sv.y - gy;
const bd = Math.hypot(bx, by);
pr.phase = 1; pr.trav = 0; pr.d0 = Math.max(0.1, bd);
if (bd > 1e-6) { pr.ux = bx / bd; pr.uy = by / bd; } else { pr.ux = -pr.ux; pr.uy = -pr.uy; }
} else {
pr.bx += (dx / d) * step; pr.by += (dy / d) * step; pr.bz += (dz / d) * step;
pr.trav += step;
}
const arcK = Math.sin(clamp(pr.trav / pr.d0, 0, 1) * Math.PI);
const bow = arcK * BOOM_BOW * Math.min(1, pr.d0 / 2);
const x = pr.bx - pr.uy * bow, y = pr.by + pr.ux * bow, z = pr.bz + arcK * 0.12;
const p = cam.project(x, y, z, this._p);
const px = p.x, py = p.y, hs = spec.head * p.s;
pr.spin += BOOM_SPIN * dt;
const core = pr.core;
core.position.set(px, py);
core.rotation = pr.spin;
core.scale.set(hs / 64);
core.alpha = 1;
const halo = pr.halo;
halo.position.set(px, py);
halo.scale.set((hs * 1.5) / 128);
halo.alpha = 0.4;
const g = cam.project(x, y, this._groundZ(x, y) + 0.01, this._g);
const sh = pr.shadow;
sh.position.set(g.x, g.y);
sh.scale.set((g.s * 0.4) / 128, (g.s * 0.18) / 128);
sh.alpha = 0.22;
if (rich) {
pr.emit += dt * TRAIL_HZ;
if (pr.emit >= 1) {
pr.emit = Math.min(1, pr.emit - 1);
if (this._room()) {
const o = this._o();
o.tint = spec.glow; o.life = 0.14; o.s0 = hs / 64; o.s1 = (hs / 64) * 0.9; o.a0 = 0.4; o.rot = pr.spin;
this.particle('boomerang', px, py, o);
}
}
}
return true;
}
/**
* One frame of a 蕾缪安 bombard shell: a long flight first streaks up out of the shooter (`rise` of it, following
* her); then it falls onto its spot, faster and faster, its shadow growing there. It ends at the impact — the
* explosion (and the end of its lock) is the sim's own 'bombard' fx.
*/
_stepMortar(pr, dt, cam, rich) {
const spec = pr.spec;
pr.t += dt;
const k = pr.t / pr.dur;
if (k >= 1) return false;
let x, y, z, zq;
if (k < pr.rise) {
const u = k / pr.rise, ub = Math.max(0, u - 0.25);
const sv = pr.src;
if (sv && !sv.destroyed) { pr.x0 = sv.x; pr.y0 = sv.y; }
x = pr.x0; y = pr.y0;
z = pr.z0 + SHELL_UP * (1 - (1 - u) * (1 - u)); // out of the barrel fast, slowing as it climbs
zq = pr.z0 + SHELL_UP * (1 - (1 - ub) * (1 - ub));
} else {
const u = (k - pr.rise) / (1 - pr.rise), ub = Math.max(0, u - 0.25);
x = pr.tx; y = pr.ty;
z = pr.tz + SHELL_UP * (1 - u * u); // falling faster and faster
zq = pr.tz + SHELL_UP * (1 - ub * ub) + 0.3;
const g = cam.project(x, y, pr.tz + 0.01, this._g);
const r = g.s * (0.12 + 0.3 * u);
const sh = pr.shadow;
sh.visible = true;
sh.position.set(g.x, g.y);
sh.scale.set((r * 2) / 128, (r * 0.9) / 128);
sh.alpha = 0.12 + 0.36 * u;
}
const p = cam.project(x, y, z, this._p);
const q = cam.project(x, y, zq, this._q);
const s = p.s, px = p.x, py = p.y;
const seg = Math.hypot(px - q.x, py - q.y);
if (seg > 0.5) pr.ang = Math.atan2(py - q.y, px - q.x);
const tr = pr.trail;
tr.position.set(px, py);
tr.rotation = pr.ang;
tr.scale.set(Math.max(0.001, Math.min(spec.len * s, seg * 1.6) / 128), (spec.width * s) / 20);
tr.alpha = 0.95;
const hs = spec.head * s;
pr.halo.position.set(px, py);
pr.halo.scale.set((hs / 128) * (1 + 0.15 * Math.sin(pr.t * 50)));
pr.halo.alpha = 0.95;
pr.core.position.set(px, py);
pr.core.rotation = pr.ang;
pr.core.scale.set((hs * 0.85) / 64, (hs * 0.5) / 64);
pr.core.alpha = 1;
if (rich) {
pr.emit += dt * TRAIL_HZ;
for (let n = 0; pr.emit >= 1 && n < 2; n++) {
pr.emit -= 1;
if (!this._room()) { pr.emit = 0; break; }
this._puff(px, py, s, spec.trail, spec.smoke, n);
}
}
return true;
}
/** Flash at the shooter as a shot leaves (tracers: an oriented muzzle cone). Cosmetic. */
_muzzle(pr) {
const spec = pr.spec, look = spec.look;
if (!spec.muzzle) return;
const cam = this.ctx.cam();
const p = cam.project(pr.x0, pr.y0, pr.z0, this._g);
const px = p.x, py = p.y, s = p.s;
if (look === 'tracer') {
const q = cam.project(pr.tx, pr.ty, pr.tz, this._q);
this.particle('muzzle', px, py, { tint: spec.muzzle, life: 0.09, s0: (s / 64) * 0.55, s1: (s / 64) * 0.72, a0: 1, a1: 0, rot: Math.atan2(q.y - py, q.x - px), anchorX: 0.19 });
this.particle('glow', px, py, { tint: spec.muzzle, life: 0.1, s0: (s / 128) * 0.45, s1: (s / 128) * 0.7, a0: 0.9, a1: 0 });
} else {
this.particle('glow', px, py, { tint: pr.glow === spec.glow ? spec.muzzle : pr.glow, life: 0.16, s0: (s / 128) * 0.45, s1: (s / 128) * 0.8, a0: 0.9, a1: 0 });
if (look === 'shell') this.smoke(px, py, s * 0.28, spec.smoke ?? 0x2a2522, 0.3);
}
}
/** Arrival burst of a shot (PROJ `hit`): at the target, or the ground under it for shells. */
_impact(pr, cam) {
const spec = pr.spec;
const p = cam.project(pr.tx, pr.ty, pr.tz, this._g);
const x = p.x, y = p.y, s = p.s;
const rich = this.rich;
switch (spec.hit) {
case 'arts':
this.particle('glow', x, y, { tint: pr.glow, life: 0.24, s0: (s / 128) * 0.7, s1: (s / 128) * 1.2, a0: 0.95, a1: 0 });
this.particle('shock', x, y, { tint: pr.glow, life: 0.26, s0: (s / 128) * 0.25, s1: (s / 128) * 1.05, a0: 0.9, a1: 0 });
if (rich) this.burst(x, y, s, 4, pr.trailTint, { speed: 2.2, life: 0.3, tex: 'dot', size: 0.35 });
break;
case 'heal': {
this.particle('glow', x, y, { tint: spec.glow, life: 0.3, s0: (s / 128) * 0.6, s1: (s / 128) * 1.1, a0: 0.9, a1: 0 });
this.particle('flare', x, y, { tint: 0xc8ffd8, life: 0.22, s0: (s / 128) * 0.9, s1: (s / 128) * 0.3, a0: 1, a1: 0, rot: Math.random() });
const n = rich ? 3 : 1;
for (let i = 0; i < n; i++) {
this.particle('plus', x + (Math.random() - 0.5) * s * 0.45, y - Math.random() * s * 0.2, { tint: 0x7dffa8, vy: -s * 0.8, life: 0.6, s0: (s / 64) * 0.26, s1: (s / 64) * 0.14, a0: 0.95, a1: 0, fadeIn: 0.05 });
}
break;
}
case 'boom': this.explosion(pr.tx, pr.ty, this._groundZ(pr.tx, pr.ty), 1, pr.glow, { smoke: spec.smoke, small: true }); break;
case 'splash': this.explosion(pr.tx, pr.ty, this._groundZ(pr.tx, pr.ty), 0.7, pr.glow, { smoke: spec.smoke, small: true }); break;
case 'zap':
this.particle('flare', x, y, { tint: spec.glow, life: 0.16, s0: (s / 128) * 0.7, s1: (s / 128) * 0.25, a0: 1, a1: 0, rot: Math.random() });
if (rich) this.burst(x, y, s, 3, spec.glow, { speed: 2.4, life: 0.22, size: 0.35 });
break;
case 'enemy':
this.particle('glow', x, y, { tint: spec.glow, life: 0.2, s0: (s / 128) * 0.6, s1: (s / 128) * 1.0, a0: 0.9, a1: 0 });
this.particle('flare', x, y, { tint: 0xffb0a0, life: 0.16, s0: (s / 128) * 0.8, s1: (s / 128) * 0.3, a0: 1, a1: 0, rot: Math.random() });
break;
default: // 'spark': bullets, boomerang hits (the hit's own sparks come with its damage number)
this.particle('flare', x, y, { tint: spec.glow, life: 0.11, s0: (s / 128) * 0.55, s1: (s / 128) * 0.2, a0: 1, a1: 0, rot: Math.random() });
}
}
/** The thrower caught its boomerang: a small flash in its hands. */
_catch(pr, cam) {
const p = cam.project(pr.bx, pr.by, pr.bz, this._g);
this.particle('flare', p.x, p.y, { tint: pr.spec.glow, life: 0.12, s0: (p.s / 128) * 0.6, s1: (p.s / 128) * 0.2, a0: 0.9, a1: 0, rot: Math.random() });
}
/**
* 蕾缪安 S3 shell (fx 'bombardShell'): fired now by `src` (its view, or null) at the spot (x, y), where it lands after
* `flight` real seconds (_stepMortar; the sim's shells fly 0.3 game s — they drop out of the sky, a launch streak
* leaving the shooter; a flight ≥ 0.45 real s climbs out of her first). The spot gets a thin ring closing in and
* brightening plus the blast radius throbbing until the impact. The shell takes the shooter's lock on that spot: the
* lock ends with this shell's 'bombard' (_landed).
*/
mortar(src, x, y, r, flight) {
const pr = this._takeProj();
const gz = this._groundZ(x, y);
pr.kind = 'bombardShell'; pr.spec = BOMBARD_SHELL; pr.src = src; pr.tgt = null;
pr.x0 = src ? src.x : x; pr.y0 = src ? src.y : y; pr.z0 = src ? bodyZ(this.ctx.cam(), src, SHOT_HEIGHT.launch) : gz + 0.5;
pr.tx = x; pr.ty = y; pr.tz = gz;
pr.t = 0; pr.dur = clamp(flight, 0.1, 4); pr.fade = 0; pr.hit = false; pr.emit = 0; pr.arc = 0; pr.ang = Math.PI / 2;
pr.rise = pr.dur >= 0.45 ? SHELL_RISE : 0;
pr.glow = BOMBARD_SHELL.glow; pr.trailTint = BOMBARD_SHELL.trail;
const L = this._nearestLock(src ? src.id : null, x, y, 3, true);
if (L) { L.shell = true; L.sx = x; L.sy = y; }
this._dressProj(pr);
pr.shadow.visible = false;
this.projs.push(pr);
const rr = Math.max(0.5, r);
this.ring(x, y, gz, rr, rr * 0.22, 0xff5a3a, pr.dur, 'shock', 'in');
this.ring(x, y, gz, rr * 0.96, rr, 0xff7a4a, pr.dur, 'ring', 'pulse');
if (src && this.rich) {
// the shot leaves her upwards: a muzzle flash and a streak climbing out of sight
const p = this._bodyPt(src, SHOT_HEIGHT.launch, this._g);
const s = p.s;
this.particle('muzzle', p.x, p.y, { tint: 0xffc27a, life: 0.1, s0: (s / 64) * 0.6, s1: (s / 64) * 0.8, a0: 1, a1: 0, rot: -Math.PI / 2, anchorX: 0.19 });
this.particle('glow', p.x, p.y, { tint: 0xffb35c, life: 0.14, s0: (s / 128) * 0.5, s1: (s / 128) * 0.9, a0: 0.9, a1: 0 });
this.particle('tracer', p.x, p.y, { tint: 0xffe0b0, life: 0.16, vy: -s * 16, s0: (s * 0.3) / 16, s1: (s * 0.24) / 16, sx: (1.3 * 16) / (0.3 * 128), a0: 1, a1: 0.2, rot: -Math.PI / 2, anchorX: 1 });
}
return pr;
}
/**
* fx 'bombard' (the impact) at (x, y) by shooter `src`: the shell falling there has landed (ended now if still in
* the air) and its lock goes — the lock that shell took, found by the landing point (the sim uses the same spot for
* both events); a sim without 'bombardShell' releases the shooter's lock nearest to the spot.
*/
_landed(src, x, y, r) {
for (const pr of this.projs) {
if (pr.spec !== BOMBARD_SHELL || (src != null && pr.src && pr.src.id !== src)) continue;
if (Math.hypot(pr.tx - x, pr.ty - y) < 0.05) pr.t = Math.max(pr.t, pr.dur);
}
let best = null, bd = Infinity;
for (const L of this.locks) {
if (L.out >= 0 || (src != null && L.src != null && L.src !== src)) continue;
const d = L.shell ? Math.hypot(L.sx - x, L.sy - y) : Math.hypot(L.x - x, L.y - y) + 0.5;
if (d < bd) { bd = d; best = L; }
}
if (best && bd <= r + 1.5) this._releaseLock(best);
}
}