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