diff --git a/resources/sprites/bigsmoke.png b/resources/sprites/bigsmoke.png new file mode 100644 index 000000000..01d601570 Binary files /dev/null and b/resources/sprites/bigsmoke.png differ diff --git a/resources/sprites/minifire.png b/resources/sprites/minifire.png new file mode 100644 index 000000000..a0948a8fe Binary files /dev/null and b/resources/sprites/minifire.png differ diff --git a/resources/sprites/smoke.png b/resources/sprites/smoke.png new file mode 100644 index 000000000..7eeab0aa5 Binary files /dev/null and b/resources/sprites/smoke.png differ diff --git a/resources/sprites/smokeAndFire.png b/resources/sprites/smokeAndFire.png new file mode 100644 index 000000000..34e5333a9 Binary files /dev/null and b/resources/sprites/smokeAndFire.png differ diff --git a/src/client/graphics/AnimatedSprite.ts b/src/client/graphics/AnimatedSprite.ts index f4dd6f039..5fea319d7 100644 --- a/src/client/graphics/AnimatedSprite.ts +++ b/src/client/graphics/AnimatedSprite.ts @@ -9,7 +9,7 @@ export class AnimatedSprite { private frameWidth: number, private frameCount: number, private frameDuration: number, // in milliseconds - private looping: boolean = true, + private looping: boolean = false, private originX: number, private originY: number, ) { @@ -42,6 +42,13 @@ export class AnimatedSprite { return this.active; } + lifeTime(): number | undefined { + if (this.looping) { + return undefined; + } + return this.frameDuration * this.frameCount; + } + draw(ctx: CanvasRenderingContext2D, x: number, y: number) { const drawX = x - this.originX; const drawY = y - this.originY; diff --git a/src/client/graphics/AnimatedSpriteLoader.ts b/src/client/graphics/AnimatedSpriteLoader.ts index 42f88f8de..bf04943b5 100644 --- a/src/client/graphics/AnimatedSpriteLoader.ts +++ b/src/client/graphics/AnimatedSpriteLoader.ts @@ -1,5 +1,9 @@ +import miniBigSmoke from "../../../resources/sprites/bigsmoke.png"; +import miniFire from "../../../resources/sprites/minifire.png"; import nuke from "../../../resources/sprites/nukeExplosion.png"; import SAMExplosion from "../../../resources/sprites/samExplosion.png"; +import miniSmoke from "../../../resources/sprites/smoke.png"; +import miniSmokeAndFire from "../../../resources/sprites/smokeAndFire.png"; import { AnimatedSprite } from "./AnimatedSprite"; import { FxType } from "./fx/Fx"; @@ -14,6 +18,42 @@ type AnimatedSpriteConfig = { }; const ANIMATED_SPRITE_CONFIG: Partial> = { + [FxType.MiniFire]: { + url: miniFire, + frameWidth: 7, + frameCount: 6, + frameDuration: 100, + looping: true, + originX: 3, + originY: 11, + }, + [FxType.MiniSmoke]: { + url: miniSmoke, + frameWidth: 11, + frameCount: 4, + frameDuration: 120, + looping: true, + originX: 2, + originY: 10, + }, + [FxType.MiniBigSmoke]: { + url: miniBigSmoke, + frameWidth: 24, + frameCount: 5, + frameDuration: 120, + looping: true, + originX: 9, + originY: 14, + }, + [FxType.MiniSmokeAndFire]: { + url: miniSmokeAndFire, + frameWidth: 24, + frameCount: 5, + frameDuration: 120, + looping: true, + originX: 9, + originY: 14, + }, [FxType.Nuke]: { url: nuke, frameWidth: 60, diff --git a/src/client/graphics/fx/Fx.ts b/src/client/graphics/fx/Fx.ts index a80bae2af..09e4b7fd9 100644 --- a/src/client/graphics/fx/Fx.ts +++ b/src/client/graphics/fx/Fx.ts @@ -3,6 +3,10 @@ export interface Fx { } export enum FxType { + MiniFire = "MiniFire", + MiniSmoke = "MiniSmoke", + MiniBigSmoke = "MiniBigSmoke", + MiniSmokeAndFire = "MiniSmokeAndFire", Nuke = "Nuke", SAMExplosion = "SAMExplosion", } diff --git a/src/client/graphics/fx/NukeFx.ts b/src/client/graphics/fx/NukeFx.ts index 680d4d64d..df6f714d0 100644 --- a/src/client/graphics/fx/NukeFx.ts +++ b/src/client/graphics/fx/NukeFx.ts @@ -1,6 +1,6 @@ -import { AnimatedSprite } from "../AnimatedSprite"; -import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader"; +import { GameView } from "../../../core/game/GameView"; import { Fx, FxType } from "./Fx"; +import { SpriteFX } from "./SpriteFx"; /** * Shockwave effect: draw a growing 1px white circle @@ -31,32 +31,72 @@ export class ShockwaveFx implements Fx { } /** - * Explosion effect: sprite animation of an explosion + * Spawn @p number of @p type animation within a perimeter */ -export class NukeExplosionFx implements Fx { - private lifeTime: number = 0; - private nukeExplosionSprite: AnimatedSprite | null; - constructor( - private x: number, - private y: number, - private duration: number, - ) { - this.nukeExplosionSprite = createAnimatedSpriteForUnit(FxType.Nuke); - } - - renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean { - if (this.nukeExplosionSprite) { - this.lifeTime += frameTime; - if (this.lifeTime >= this.duration) { - return false; - } - if (this.nukeExplosionSprite.isActive()) { - this.nukeExplosionSprite.update(frameTime); - this.nukeExplosionSprite.draw(ctx, this.x, this.y); - return true; - } - return false; +function addSpriteInCircle( + x: number, + y: number, + radius: number, + num: number, + type: FxType, + result: Fx[], + game: GameView, +) { + const count = Math.max(0, Math.floor(num)); + for (let i = 0; i < count; i++) { + const angle = Math.random() * 2 * Math.PI; + const distance = Math.random() * (radius / 2); + const spawnX = Math.floor(x + Math.cos(angle) * distance); + const spawnY = Math.floor(y + Math.sin(angle) * distance); + if ( + game.isValidCoord(spawnX, spawnY) && + game.isLand(game.ref(spawnX, spawnY)) + ) { + const sprite = new SpriteFX(spawnX, spawnY, type, 6000, 0.1, 0.8); + result.push(sprite as Fx); } - return false; } } + +/** + * Explosion effect: + * - explosion animation + * - shockwave + * - ruins and desolation fx + */ +export function nukeFxFactory( + x: number, + y: number, + radius: number, + game: GameView, +): Fx[] { + const nukeFx: Fx[] = []; + // Explosion animation + nukeFx.push(new SpriteFX(x, y, FxType.Nuke) as Fx); + // Shockwave animation + nukeFx.push(new ShockwaveFx(x, y, 1500, radius * 1.5)); + // Ruins and desolation sprites + const debrisPlan: Array<{ + type: FxType; + radiusFactor: number; + density: number; + }> = [ + { type: FxType.MiniFire, radiusFactor: 1.0, density: 1 / 25 }, + { type: FxType.MiniSmoke, radiusFactor: 1.0, density: 1 / 28 }, + { type: FxType.MiniBigSmoke, radiusFactor: 0.9, density: 1 / 70 }, + { type: FxType.MiniSmokeAndFire, radiusFactor: 0.9, density: 1 / 70 }, + ]; + + for (const { type, radiusFactor, density } of debrisPlan) { + addSpriteInCircle( + x, + y, + radius * radiusFactor, + radius * density, + type, + nukeFx, + game, + ); + } + return nukeFx; +} diff --git a/src/client/graphics/fx/SpriteFx.ts b/src/client/graphics/fx/SpriteFx.ts new file mode 100644 index 000000000..a28c39411 --- /dev/null +++ b/src/client/graphics/fx/SpriteFx.ts @@ -0,0 +1,68 @@ +import { consolex } from "../../../core/Consolex"; +import { AnimatedSprite } from "../AnimatedSprite"; +import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader"; +import { Fx, FxType } from "./Fx"; + +function fadeInOut( + t: number, + fadeIn: number = 0.3, + fadeOut: number = 0.7, +): number { + if (t < fadeIn) { + const f = t / fadeIn; // Map to [0, 1] + return f * f; + } else if (t < fadeOut) { + return 1; + } else { + const f = (t - fadeOut) / (1 - fadeOut); // Map to [0, 1] + return 1 - f * f; + } +} + +/** + * A simple FX displaying an animated sprite + */ +export class SpriteFX implements Fx { + private lifeTime: number = 0; + private animatedSprite: AnimatedSprite | null; + private totalLifeTime: number = 0; + constructor( + private x: number, + private y: number, + fxType: FxType, + duration?: number, + private fadeIn?: number, + private fadeOut?: number, + ) { + this.animatedSprite = createAnimatedSpriteForUnit(fxType); + if (!this.animatedSprite) { + consolex.error("Could not load animated sprite ", fxType); + this.totalLifeTime = 0; + } else if (!duration) { + // When no duration set, rely on the sprite lifetime + this.totalLifeTime = this.animatedSprite.lifeTime() ?? 1000; // 1s by default + } else { + this.totalLifeTime = duration; + } + } + + renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean { + if (this.animatedSprite) { + this.lifeTime += frameTime; + if (this.lifeTime >= this.totalLifeTime) { + return false; + } + if (this.animatedSprite.isActive()) { + ctx.save(); + const t = this.lifeTime / this.totalLifeTime; + ctx.globalAlpha = fadeInOut(t, this.fadeIn ?? 0, this.fadeOut ?? 0.7); + this.animatedSprite.update(frameTime); + this.animatedSprite.draw(ctx, this.x, this.y); + ctx.restore(); + return true; + } + return false; + } + return false; + } +} diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index 34b46b458..58c74f84f 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -3,7 +3,7 @@ import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; import { loadAllAnimatedSpriteImages } from "../AnimatedSpriteLoader"; import { Fx } from "../fx/Fx"; -import { NukeExplosionFx, ShockwaveFx } from "../fx/NukeFx"; +import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx"; import { SAMExplosionFx } from "../fx/SAMExplosionFx"; import { Layer } from "./Layer"; @@ -36,41 +36,39 @@ export class FxLayer implements Layer { switch (unit.type()) { case UnitType.AtomBomb: case UnitType.MIRVWarhead: - this.handleNukes(unit, 70); + this.onNukeEvent(unit, 70); break; case UnitType.HydrogenBomb: - this.handleNukes(unit, 250); + this.onNukeEvent(unit, 160); break; } } - handleNukes(unit: UnitView, shockwaveRadius: number) { + onNukeEvent(unit: UnitView, radius: number) { if (!unit.isActive()) { if (unit.wasInterceptedBySAM()) { this.handleSAMInterception(unit); } else { // Kaboom - this.handleNukeExplosion(unit, shockwaveRadius); + this.handleNukeExplosion(unit, radius); } } } - handleNukeExplosion(unit: UnitView, shockwaveRadius: number) { + handleNukeExplosion(unit: UnitView, radius: number) { const x = this.game.x(unit.lastTile()); const y = this.game.y(unit.lastTile()); - const nuke = new NukeExplosionFx(x, y, 1000); - this.allFx.push(nuke as Fx); - const shockwave = new ShockwaveFx(x, y, 1500, shockwaveRadius); - this.allFx.push(shockwave as Fx); + const nukeFx = nukeFxFactory(x, y, radius, this.game); + this.allFx = this.allFx.concat(nukeFx); } handleSAMInterception(unit: UnitView) { const x = this.game.x(unit.lastTile()); const y = this.game.y(unit.lastTile()); const interception = new SAMExplosionFx(x, y, 1000); - this.allFx.push(interception as Fx); + this.allFx.push(interception); const shockwave = new ShockwaveFx(x, y, 800, 40); - this.allFx.push(shockwave as Fx); + this.allFx.push(shockwave); } async init() {