fertig-classic-games/src/games/totalannihilation/TAFx.js

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 };