smoke trails

This commit is contained in:
Restart2008
2026-03-10 17:27:30 -07:00
parent 8329dd479e
commit e66b6274f5
3 changed files with 145 additions and 2 deletions
+109
View File
@@ -0,0 +1,109 @@
import { GameView, UnitView } from "../../../core/game/GameView";
import { Fx } from "./Fx";
class SmokeParticle {
public life: number = 0;
public maxLife: number;
public x: number;
public y: number;
public vx: number;
public vy: number;
public size: number;
public maxSize: number;
public opacity: number;
public color: string;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
// Longer life for "puffy" trails
this.maxLife = 1500 + Math.random() * 1000;
// Slow drift
this.vx = (Math.random() - 0.5) * 0.02;
this.vy = (Math.random() - 0.5) * 0.02;
// Start small, grow BIG
this.size = 1 + Math.random() * 1;
this.maxSize = 6 + Math.random() * 6;
// Bolder opacity
this.opacity = 0.6 + Math.random() * 0.3;
// Varying shades of gray/white
const gray = Math.floor(180 + Math.random() * 75);
this.color = `rgba(${gray}, ${gray}, ${gray},`;
}
update(delta: number): boolean {
this.life += delta;
if (this.life >= this.maxLife) return false;
const t = this.life / this.maxLife;
this.x += this.vx * delta;
this.y += this.vy * delta;
// Grow significantly over time
this.size = 1 + t * (this.maxSize - 1);
return true;
}
draw(ctx: CanvasRenderingContext2D) {
const t = this.life / this.maxLife;
// Fade out towards the end
const currentOpacity = this.opacity * (1 - t * t);
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fillStyle = `${this.color} ${currentOpacity})`;
ctx.fill();
}
}
export class SmokeTrailFx implements Fx {
private particles: SmokeParticle[] = [];
private lastEmitTime: number = 0;
private emitInterval: number = 15; // Faster emission for denser trail
constructor(
private game: GameView,
private unitId: number,
) {}
renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean {
const unit = this.game.unit(this.unitId);
const isActive = unit?.isActive() ?? false;
if (isActive) {
this.lastEmitTime += duration;
while (this.lastEmitTime >= this.emitInterval) {
this.emit(unit!);
this.lastEmitTime -= this.emitInterval;
}
}
// Update and render particles
for (let i = this.particles.length - 1; i >= 0; i--) {
if (!this.particles[i].update(duration)) {
this.particles.splice(i, 1);
} else {
this.particles[i].draw(ctx);
}
}
return isActive || this.particles.length > 0;
}
private emit(unit: UnitView) {
// Add some randomness to spawn position so it's not a perfect line
const offsetX = (Math.random() - 0.5) * 2;
const offsetY = (Math.random() - 0.5) * 2;
this.particles.push(
new SmokeParticle(
this.game.x(unit.tile()) + offsetX,
this.game.y(unit.tile()) + offsetY,
),
);
}
}
+35 -1
View File
@@ -9,11 +9,13 @@ import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { conquestFxFactory } from "../fx/ConquestFx";
import { Fx, FxType } from "../fx/Fx";
import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
import { SmokeTrailFx } from "../fx/SmokeTrailFx";
import { SpriteFx } from "../fx/SpriteFx";
import { UnitExplosionFx } from "../fx/UnitExplosionFx";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { RailTileChangedEvent } from "./RailroadLayer";
export class FxLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
@@ -25,6 +27,7 @@ export class FxLayer implements Layer {
new AnimatedSpriteLoader();
private allFx: Fx[] = [];
private missileTrails = new Map<number, SmokeTrailFx>();
private hasBufferedFrame = false;
constructor(
@@ -59,6 +62,22 @@ export class FxLayer implements Layer {
}
onUnitEvent(unit: UnitView) {
if (unit.isActive() && !this.missileTrails.has(unit.id())) {
const type = unit.type();
if (
type === UnitType.AtomBomb ||
type === UnitType.HydrogenBomb ||
type === UnitType.MIRV ||
type === UnitType.MIRVWarhead ||
type === UnitType.SAMMissile
) {
this.missileTrails.set(
unit.id(),
new SmokeTrailFx(this.game, unit.id()),
);
}
}
switch (unit.type()) {
case UnitType.AtomBomb: {
this.onNukeEvent(unit, 70);
@@ -71,6 +90,14 @@ export class FxLayer implements Layer {
this.onNukeEvent(unit, 160);
break;
}
case UnitType.MIRV: {
this.onNukeEvent(unit, 70);
break;
}
case UnitType.SAMMissile: {
this.onNukeEvent(unit, 70);
break;
}
case UnitType.Warship:
this.onWarshipEvent(unit);
break;
@@ -256,12 +283,13 @@ export class FxLayer implements Layer {
renderLayer(context: CanvasRenderingContext2D) {
const nowMs = performance.now();
const hasFx = this.allFx.length > 0;
const hasFx = this.allFx.length > 0 || this.missileTrails.size > 0;
if (!this.game.config().userSettings()?.fxLayer() || !hasFx) {
if (this.hasBufferedFrame) {
// Clear stale pixels once when fx ends/disabled so re-enabling doesn't
// flash old frames.
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.missileTrails.clear();
this.hasBufferedFrame = false;
}
this.lastRefreshMs = nowMs;
@@ -320,5 +348,11 @@ export class FxLayer implements Layer {
this.allFx.splice(i, 1);
}
}
for (const [unitId, trail] of this.missileTrails) {
if (!trail.renderTick(duration, this.context)) {
this.missileTrails.delete(unitId);
}
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { EventBus } from "../../../core/EventBus";
import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, UnitView } from "../../../core/game/GameView";
import { UnitView } from "../../../core/game/GameView";
import { BezenhamLine } from "../../../core/utilities/Line";
import {
AlternateViewEvent,