diff --git a/src/client/graphics/fx/SmokeTrailFx.ts b/src/client/graphics/fx/SmokeTrailFx.ts new file mode 100644 index 000000000..2f4b0698d --- /dev/null +++ b/src/client/graphics/fx/SmokeTrailFx.ts @@ -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, + ), + ); + } +} diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index d6281be8d..2172e9027 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -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(); 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); + } + } } } diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index e5d72f0e8..304d77c4e 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -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,