235 lines
8.5 KiB
JavaScript
235 lines
8.5 KiB
JavaScript
// Total Annihilation — procedural combat effects.
|
|
//
|
|
// Every weapon effect in this game is drawn, not sprited: gunfire tracers, tank shells,
|
|
// rocket trails, beams, explosions, nanolathe streams and craters are all Graphics strokes.
|
|
// Two layers are cleared and re-stroked each frame from a TTL list — the Star Control
|
|
// `fxList` idiom (StarControlGame.js renderWorld) using the repo's glow-stroke pair: one
|
|
// wide low-alpha pass, then a thin opaque one on top.
|
|
|
|
const MAX_FX = 420; // hard cap; oldest transients are dropped rather than dropping frames
|
|
|
|
export default class TAFx {
|
|
constructor(scene, worldRoot, depths) {
|
|
this.scene = scene;
|
|
// Under the actors: trails, nanolathe streams, scorch. Over them: tracers and blasts.
|
|
this.gUnder = scene.add.graphics().setDepth(depths.fxUnder);
|
|
this.gOver = scene.add.graphics().setDepth(depths.fxOver);
|
|
// Depth on a Container child does nothing until the container is sorted; TAWorldView
|
|
// re-sorts the world container every frame, which is what puts these layers in place.
|
|
worldRoot.add(this.gUnder);
|
|
worldRoot.add(this.gOver);
|
|
this.list = [];
|
|
this.shakeUntil = 0;
|
|
}
|
|
|
|
destroy() {
|
|
this.gUnder.destroy();
|
|
this.gOver.destroy();
|
|
this.list.length = 0;
|
|
}
|
|
|
|
clear() { this.list.length = 0; }
|
|
|
|
push(fx) {
|
|
if (this.list.length >= MAX_FX) this.list.shift();
|
|
fx.age = 0;
|
|
this.list.push(fx);
|
|
}
|
|
|
|
/** Translate a simulation event into a transient effect. */
|
|
onEvent(ev, rules) {
|
|
switch (ev.t) {
|
|
case 'shot':
|
|
this.push({
|
|
kind: ev.style === 'beam' || ev.style === 'dgun' ? 'beam' : 'tracer',
|
|
x1: ev.x1, y1: ev.y1, x2: ev.x2, y2: ev.y2,
|
|
color: colorInt(ev.color), width: ev.width ?? 2, ttl: ev.lifeMs ?? 80,
|
|
});
|
|
break;
|
|
case 'weaponFired':
|
|
this.push({
|
|
kind: 'muzzle', x: ev.x, y: ev.y, heading: ev.heading,
|
|
color: 0xffe6a0, ttl: 70,
|
|
});
|
|
break;
|
|
case 'impact':
|
|
this.push({
|
|
kind: 'blast', x: ev.x, y: ev.y, r: Math.max(10, ev.radius),
|
|
color: 0xffb45a, ttl: ev.radius > 30 ? 380 : 200,
|
|
});
|
|
break;
|
|
case 'bigExplosion':
|
|
this.push({ kind: 'shockwave', x: ev.x, y: ev.y, r: ev.radius, color: 0xfff0c0, ttl: 700 });
|
|
this.push({ kind: 'blast', x: ev.x, y: ev.y, r: ev.radius * 0.55, color: 0xff8a3c, ttl: 520 });
|
|
this.shakeUntil = this.scene.time.now + 320;
|
|
this.scene.cameras.main.shake(300, 0.010);
|
|
break;
|
|
case 'unitDestroyed':
|
|
this.push({
|
|
kind: 'blast', x: ev.x, y: ev.y,
|
|
r: Math.max(16, ev.radius * (ev.isBuilding ? 2.0 : 1.4)),
|
|
color: ev.isBuilding ? 0xffa040 : 0xff9060,
|
|
ttl: ev.isBuilding ? 460 : 300,
|
|
});
|
|
this.push({ kind: 'scorch', x: ev.x, y: ev.y, r: ev.radius * 1.2, ttl: 4200 });
|
|
break;
|
|
case 'nanolathe':
|
|
// The stream itself is drawn live from builder->site in draw(); this is the sparkle
|
|
// at the receiving end so a site under construction always reads as active.
|
|
this.push({ kind: 'spark', x: ev.x, y: ev.y, color: 0x8ad4ff, ttl: 220 });
|
|
break;
|
|
default: break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {number} delta ms since last frame
|
|
* @param {Array} nanoLinks live [{x1,y1,x2,y2,color}] builder->target streams
|
|
* @param {Array} projectiles interpolated [{x,y,vx,vy,color,width,style,trail}]
|
|
*/
|
|
draw(delta, nanoLinks, projectiles, timeMs) {
|
|
const gu = this.gUnder, go = this.gOver;
|
|
gu.clear(); go.clear();
|
|
|
|
// ---- persistent-ish world marks (under actors) ----
|
|
for (const fx of this.list) {
|
|
if (fx.kind !== 'scorch') continue;
|
|
const t = fx.age / fx.ttl;
|
|
gu.fillStyle(0x1a1410, 0.5 * (1 - t));
|
|
gu.fillCircle(fx.x, fx.y, fx.r);
|
|
}
|
|
|
|
// ---- nanolathe streams (under actors) ----
|
|
for (const link of nanoLinks) {
|
|
const phase = (timeMs * 0.006) % 1;
|
|
gu.lineStyle(5, link.color, 0.14);
|
|
gu.lineBetween(link.x1, link.y1, link.x2, link.y2);
|
|
gu.lineStyle(2, link.color, 0.9);
|
|
// dashed, marching toward the target so it reads as material flowing
|
|
const dx = link.x2 - link.x1, dy = link.y2 - link.y1;
|
|
const len = Math.hypot(dx, dy) || 1;
|
|
const ux = dx / len, uy = dy / len;
|
|
const dash = 14, gap = 10;
|
|
for (let d = phase * (dash + gap); d < len; d += dash + gap) {
|
|
const a = Math.min(len, d), b = Math.min(len, d + dash);
|
|
gu.lineBetween(link.x1 + ux * a, link.y1 + uy * a, link.x1 + ux * b, link.y1 + uy * b);
|
|
}
|
|
}
|
|
|
|
// ---- rocket trails (under actors) ----
|
|
for (const p of projectiles) {
|
|
if (!p.trail || p.trail.length < 4) continue;
|
|
const n = p.trail.length / 2;
|
|
for (let i = 1; i < n; i++) {
|
|
const a = i / n;
|
|
go.lineStyle(p.width * 1.6 * a, p.color, 0.30 * a);
|
|
gu.lineStyle(p.width * 1.6 * a, p.color, 0.30 * a);
|
|
gu.lineBetween(p.trail[(i - 1) * 2], p.trail[(i - 1) * 2 + 1], p.trail[i * 2], p.trail[i * 2 + 1]);
|
|
}
|
|
}
|
|
|
|
// ---- live projectiles (over actors) ----
|
|
for (const p of projectiles) {
|
|
// Sprite-style shots (the Rocket Artillery's shell) are drawn as a real Image+shadow
|
|
// pair by TAWorldView's projectile pool instead — this loop only owns the vector body.
|
|
if (p.style === 'sprite') continue;
|
|
const ang = Math.atan2(p.vy, p.vx);
|
|
if (p.style === 'rocket') {
|
|
const bx = p.x - Math.cos(ang) * 9, by = p.y - Math.sin(ang) * 9;
|
|
go.lineStyle(p.width * 2.4, p.color, 0.22);
|
|
go.lineBetween(bx, by, p.x, p.y);
|
|
go.fillStyle(0xfff0c0, 1);
|
|
go.fillCircle(p.x, p.y, p.width * 0.9);
|
|
go.fillStyle(p.color, 0.85);
|
|
go.fillCircle(bx, by, p.width * 1.3);
|
|
} else {
|
|
const bx = p.x - Math.cos(ang) * 12, by = p.y - Math.sin(ang) * 12;
|
|
go.lineStyle(p.width * 1.9, p.color, 0.20);
|
|
go.lineBetween(bx, by, p.x, p.y);
|
|
go.lineStyle(p.width * 0.8, p.color, 1);
|
|
go.lineBetween(bx, by, p.x, p.y);
|
|
go.fillStyle(0xfff6d8, 1);
|
|
go.fillCircle(p.x, p.y, p.width * 0.75);
|
|
}
|
|
}
|
|
|
|
// ---- transients (over actors) ----
|
|
const keep = [];
|
|
for (const fx of this.list) {
|
|
fx.age += delta;
|
|
if (fx.age >= fx.ttl) continue;
|
|
keep.push(fx);
|
|
const t = fx.age / fx.ttl;
|
|
|
|
switch (fx.kind) {
|
|
case 'tracer': {
|
|
const a = 1 - t;
|
|
go.lineStyle(fx.width * 3.2, fx.color, 0.16 * a);
|
|
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
|
|
go.lineStyle(fx.width, fx.color, a);
|
|
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
|
|
break;
|
|
}
|
|
case 'beam': {
|
|
const a = 1 - t * 0.75;
|
|
go.lineStyle(fx.width * 2.6 * (1 - t) + 1, fx.color, 0.18 * a);
|
|
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
|
|
go.lineStyle(fx.width * (1 - t) + 1, fx.color, a);
|
|
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
|
|
break;
|
|
}
|
|
case 'muzzle': {
|
|
const a = 1 - t;
|
|
const len = 13 * (1 - t) + 4;
|
|
go.fillStyle(fx.color, 0.85 * a);
|
|
for (const spread of [-0.35, 0, 0.35]) {
|
|
const ang = fx.heading + spread;
|
|
go.beginPath();
|
|
go.moveTo(fx.x, fx.y);
|
|
go.lineTo(fx.x + Math.cos(ang - 0.13) * len, fx.y + Math.sin(ang - 0.13) * len);
|
|
go.lineTo(fx.x + Math.cos(ang + 0.13) * len, fx.y + Math.sin(ang + 0.13) * len);
|
|
go.closePath();
|
|
go.fillPath();
|
|
}
|
|
break;
|
|
}
|
|
case 'blast': {
|
|
const a = 1 - t;
|
|
const r = fx.r * (0.35 + t * 0.9);
|
|
go.fillStyle(fx.color, 0.55 * a);
|
|
go.fillCircle(fx.x, fx.y, r);
|
|
go.fillStyle(0xfff6d8, 0.7 * a * a);
|
|
go.fillCircle(fx.x, fx.y, r * 0.45);
|
|
go.lineStyle(3 * a + 1, 0xffffff, 0.7 * a);
|
|
go.strokeCircle(fx.x, fx.y, r);
|
|
break;
|
|
}
|
|
case 'shockwave': {
|
|
const a = 1 - t;
|
|
go.lineStyle(9 * a + 1, fx.color, a);
|
|
go.strokeCircle(fx.x, fx.y, fx.r * t);
|
|
go.lineStyle(4 * a + 1, 0xff8a3c, a * 0.7);
|
|
go.strokeCircle(fx.x, fx.y, fx.r * t * 0.72);
|
|
break;
|
|
}
|
|
case 'spark': {
|
|
const a = 1 - t;
|
|
go.fillStyle(fx.color, a);
|
|
go.fillCircle(fx.x, fx.y, 3 * a + 1);
|
|
break;
|
|
}
|
|
default: break;
|
|
}
|
|
}
|
|
this.list = keep;
|
|
}
|
|
}
|
|
|
|
function colorInt(v) {
|
|
if (typeof v === 'number') return v;
|
|
if (typeof v === 'string' && v[0] === '#') return parseInt(v.slice(1), 16);
|
|
return 0xffffff;
|
|
}
|
|
|
|
export { colorInt };
|