diff --git a/resources/sprites/miniExplosion.png b/resources/sprites/miniExplosion.png new file mode 100644 index 000000000..c73aafc40 Binary files /dev/null and b/resources/sprites/miniExplosion.png differ diff --git a/resources/sprites/sinkingShip.png b/resources/sprites/sinkingShip.png new file mode 100644 index 000000000..69cfe9ec4 Binary files /dev/null and b/resources/sprites/sinkingShip.png differ diff --git a/resources/sprites/unitExplosion.png b/resources/sprites/unitExplosion.png new file mode 100644 index 000000000..72695bf50 Binary files /dev/null and b/resources/sprites/unitExplosion.png differ diff --git a/src/client/graphics/AnimatedSpriteLoader.ts b/src/client/graphics/AnimatedSpriteLoader.ts index bf04943b5..709d4b358 100644 --- a/src/client/graphics/AnimatedSpriteLoader.ts +++ b/src/client/graphics/AnimatedSpriteLoader.ts @@ -1,11 +1,17 @@ import miniBigSmoke from "../../../resources/sprites/bigsmoke.png"; +import miniExplosion from "../../../resources/sprites/miniExplosion.png"; import miniFire from "../../../resources/sprites/minifire.png"; import nuke from "../../../resources/sprites/nukeExplosion.png"; import SAMExplosion from "../../../resources/sprites/samExplosion.png"; +import sinkingShip from "../../../resources/sprites/sinkingShip.png"; import miniSmoke from "../../../resources/sprites/smoke.png"; import miniSmokeAndFire from "../../../resources/sprites/smokeAndFire.png"; +import unitExplosion from "../../../resources/sprites/unitExplosion.png"; +import { Theme } from "../../core/configuration/Config"; +import { PlayerView } from "../../core/game/GameView"; import { AnimatedSprite } from "./AnimatedSprite"; import { FxType } from "./fx/Fx"; +import { colorizeCanvas } from "./SpriteLoader"; type AnimatedSpriteConfig = { url: string; @@ -54,6 +60,33 @@ const ANIMATED_SPRITE_CONFIG: Partial> = { originX: 9, originY: 14, }, + [FxType.MiniExplosion]: { + url: miniExplosion, + frameWidth: 13, + frameCount: 4, + frameDuration: 70, + looping: false, + originX: 6, + originY: 6, + }, + [FxType.UnitExplosion]: { + url: unitExplosion, + frameWidth: 19, + frameCount: 4, + frameDuration: 70, + looping: false, + originX: 9, + originY: 9, + }, + [FxType.SinkingShip]: { + url: sinkingShip, + frameWidth: 16, + frameCount: 14, + frameDuration: 90, + looping: false, + originX: 7, + originY: 7, + }, [FxType.Nuke]: { url: nuke, frameWidth: 60, @@ -74,53 +107,115 @@ const ANIMATED_SPRITE_CONFIG: Partial> = { }, }; -const animatedSpriteImageMap: Map = new Map(); +export class AnimatedSpriteLoader { + private animatedSpriteImageMap: Map = new Map(); + // Do not color the same sprite twice + private coloredAnimatedSpriteCache: Map = + new Map(); -export const loadAllAnimatedSpriteImages = async (): Promise => { - const entries = Object.entries(ANIMATED_SPRITE_CONFIG); + public async loadAllAnimatedSpriteImages(): Promise { + const entries = Object.entries(ANIMATED_SPRITE_CONFIG); - await Promise.all( - entries.map(async ([fxType, config]) => { - const typedFxType = fxType as FxType; - if (!config?.url) return; + await Promise.all( + entries.map(async ([fxType, config]) => { + const typedFxType = fxType as FxType; + if (!config?.url) return; - try { - const img = new Image(); - img.crossOrigin = "anonymous"; - img.src = config.url; + try { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = config.url; - await new Promise((resolve, reject) => { - img.onload = () => resolve(); - img.onerror = (e) => reject(e); - }); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = (e) => reject(e); + }); - const canvas = document.createElement("canvas"); - canvas.width = img.width; - canvas.height = img.height; - canvas.getContext("2d")!.drawImage(img, 0, 0); + const canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + canvas.getContext("2d")!.drawImage(img, 0, 0); - animatedSpriteImageMap.set(typedFxType, canvas); - } catch (err) { - console.error(`Failed to load sprite for ${typedFxType}:`, err); - } - }), - ); -}; + this.animatedSpriteImageMap.set(typedFxType, canvas); + } catch (err) { + console.error(`Failed to load sprite for ${typedFxType}:`, err); + } + }), + ); + } -export const createAnimatedSpriteForUnit = ( - fxType: FxType, -): AnimatedSprite | null => { - const config = ANIMATED_SPRITE_CONFIG[fxType]; - const image = animatedSpriteImageMap.get(fxType); - if (!config || !image) return null; + private createRegularAnimatedSprite(fxType: FxType): AnimatedSprite | null { + const config = ANIMATED_SPRITE_CONFIG[fxType]; + const image = this.animatedSpriteImageMap.get(fxType); + if (!config || !image) return null; - return new AnimatedSprite( - image, - config.frameWidth, - config.frameCount, - config.frameDuration, - config.looping ?? true, - config.originX, - config.originY, - ); -}; + return new AnimatedSprite( + image, + config.frameWidth, + config.frameCount, + config.frameDuration, + config.looping ?? true, + config.originX, + config.originY, + ); + } + + private getColoredAnimatedSprite( + owner: PlayerView, + fxType: FxType, + theme: Theme, + ): HTMLCanvasElement | null { + const baseImage = this.animatedSpriteImageMap.get(fxType); + const config = ANIMATED_SPRITE_CONFIG[fxType]; + if (!baseImage || !config) return null; + const territoryColor = theme.territoryColor(owner); + const borderColor = theme.borderColor(owner); + const spawnHighlightColor = theme.spawnHighlightColor(); + const key = `${fxType}-${owner.id()}`; + let coloredCanvas: HTMLCanvasElement; + if (this.coloredAnimatedSpriteCache.has(key)) { + coloredCanvas = this.coloredAnimatedSpriteCache.get(key)!; + } else { + coloredCanvas = colorizeCanvas( + baseImage, + territoryColor, + borderColor, + spawnHighlightColor, + ); + + this.coloredAnimatedSpriteCache.set(key, coloredCanvas); + } + return coloredCanvas; + } + + private createColoredAnimatedSpriteForUnit( + fxType: FxType, + owner: PlayerView, + theme: Theme, + ): AnimatedSprite | null { + const config = ANIMATED_SPRITE_CONFIG[fxType]; + const image = this.getColoredAnimatedSprite(owner, fxType, theme); + if (!config || !image) return null; + + return new AnimatedSprite( + image, + config.frameWidth, + config.frameCount, + config.frameDuration, + config.looping ?? true, + config.originX, + config.originY, + ); + } + + public createAnimatedSprite( + fxType: FxType, + owner?: PlayerView, + theme?: Theme, + ): AnimatedSprite | null { + if (owner && theme) { + return this.createColoredAnimatedSpriteForUnit(fxType, owner, theme); + } + return this.createRegularAnimatedSprite(fxType); + } +} diff --git a/src/client/graphics/SpriteLoader.ts b/src/client/graphics/SpriteLoader.ts index 49abd3c1e..7fb1ab852 100644 --- a/src/client/graphics/SpriteLoader.ts +++ b/src/client/graphics/SpriteLoader.ts @@ -71,7 +71,53 @@ export const isSpriteReady = (unitType: UnitType): boolean => { const coloredSpriteCache: Map = new Map(); -// puts the sprite in an canvas colors it and caches the colored canvas +/** + * Load a canvas and replace grayscale with border colors + */ +export const colorizeCanvas = ( + source: CanvasImageSource & { width: number; height: number }, + colorA: Colord, + colorB: Colord, + colorC: Colord, +): HTMLCanvasElement => { + const canvas = document.createElement("canvas"); + canvas.width = source.width; + canvas.height = source.height; + + const ctx = canvas.getContext("2d")!; + ctx.drawImage(source, 0, 0); + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + + const colorARgb = colorA.toRgb(); + const colorBRgb = colorB.toRgb(); + const colorCRgb = colorC.toRgb(); + + for (let i = 0; i < data.length; i += 4) { + const r = data[i], + g = data[i + 1], + b = data[i + 2]; + + if (r === 180 && g === 180 && b === 180) { + data[i] = colorARgb.r; + data[i + 1] = colorARgb.g; + data[i + 2] = colorARgb.b; + } else if (r === 70 && g === 70 && b === 70) { + data[i] = colorBRgb.r; + data[i + 1] = colorBRgb.g; + data[i + 2] = colorBRgb.b; + } else if (r === 130 && g === 130 && b === 130) { + data[i] = colorCRgb.r; + data[i + 1] = colorCRgb.g; + data[i + 2] = colorCRgb.b; + } + } + + ctx.putImageData(imageData, 0, 0); + return canvas; +}; + export const getColoredSprite = ( unit: UnitView, theme: Theme, @@ -82,8 +128,7 @@ export const getColoredSprite = ( const territoryColor = customTerritoryColor ?? theme.territoryColor(owner); const borderColor = customBorderColor ?? theme.borderColor(owner); const spawnHighlightColor = theme.spawnHighlightColor(); - const colorKey = territoryColor.toRgbString() + borderColor.toRgbString(); - const key = unit.type() + colorKey; + const key = `${unit.type()}-${owner.id()}`; if (coloredSpriteCache.has(key)) { return coloredSpriteCache.get(key)!; @@ -94,45 +139,13 @@ export const getColoredSprite = ( throw new Error(`Failed to load sprite for ${unit.type()}`); } - const territoryRgb = territoryColor.toRgb(); - const borderRgb = borderColor.toRgb(); - const spawnHighlightRgb = spawnHighlightColor.toRgb(); + const coloredCanvas = colorizeCanvas( + sprite, + territoryColor, + borderColor, + spawnHighlightColor, + ); - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d")!; - canvas.width = sprite.width; - canvas.height = sprite.height; - - ctx.drawImage(sprite, 0, 0); - - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - const data = imageData.data; - - for (let i = 0; i < data.length; i += 4) { - const r = data[i]; - const g = data[i + 1]; - const b = data[i + 2]; - - if (r === 180 && g === 180 && b === 180) { - data[i] = territoryRgb.r; - data[i + 1] = territoryRgb.g; - data[i + 2] = territoryRgb.b; - } - - if (r === 70 && g === 70 && b === 70) { - data[i] = borderRgb.r; - data[i + 1] = borderRgb.g; - data[i + 2] = borderRgb.b; - } - - if (r === 130 && g === 130 && b === 130) { - data[i] = spawnHighlightRgb.r; - data[i + 1] = spawnHighlightRgb.g; - data[i + 2] = spawnHighlightRgb.b; - } - } - - ctx.putImageData(imageData, 0.5, 0.5); - coloredSpriteCache.set(key, canvas); - return canvas; + coloredSpriteCache.set(key, coloredCanvas); + return coloredCanvas; }; diff --git a/src/client/graphics/fx/Fx.ts b/src/client/graphics/fx/Fx.ts index 09e4b7fd9..d98064c11 100644 --- a/src/client/graphics/fx/Fx.ts +++ b/src/client/graphics/fx/Fx.ts @@ -7,6 +7,9 @@ export enum FxType { MiniSmoke = "MiniSmoke", MiniBigSmoke = "MiniBigSmoke", MiniSmokeAndFire = "MiniSmokeAndFire", + MiniExplosion = "MiniExplosion", + UnitExplosion = "UnitExplosion", + SinkingShip = "SinkingShip", Nuke = "Nuke", SAMExplosion = "SAMExplosion", } diff --git a/src/client/graphics/fx/NukeFx.ts b/src/client/graphics/fx/NukeFx.ts index df6f714d0..479d68e18 100644 --- a/src/client/graphics/fx/NukeFx.ts +++ b/src/client/graphics/fx/NukeFx.ts @@ -1,6 +1,7 @@ import { GameView } from "../../../core/game/GameView"; +import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; import { Fx, FxType } from "./Fx"; -import { SpriteFX } from "./SpriteFx"; +import { FadeFx, SpriteFx } from "./SpriteFx"; /** * Shockwave effect: draw a growing 1px white circle @@ -34,6 +35,7 @@ export class ShockwaveFx implements Fx { * Spawn @p number of @p type animation within a perimeter */ function addSpriteInCircle( + animatedSpriteLoader: AnimatedSpriteLoader, x: number, y: number, radius: number, @@ -52,7 +54,11 @@ function addSpriteInCircle( game.isValidCoord(spawnX, spawnY) && game.isLand(game.ref(spawnX, spawnY)) ) { - const sprite = new SpriteFX(spawnX, spawnY, type, 6000, 0.1, 0.8); + const sprite = new FadeFx( + new SpriteFx(animatedSpriteLoader, spawnX, spawnY, type, 6000), + 0.1, + 0.8, + ); result.push(sprite as Fx); } } @@ -65,6 +71,7 @@ function addSpriteInCircle( * - ruins and desolation fx */ export function nukeFxFactory( + animatedSpriteLoader: AnimatedSpriteLoader, x: number, y: number, radius: number, @@ -72,7 +79,7 @@ export function nukeFxFactory( ): Fx[] { const nukeFx: Fx[] = []; // Explosion animation - nukeFx.push(new SpriteFX(x, y, FxType.Nuke) as Fx); + nukeFx.push(new SpriteFx(animatedSpriteLoader, x, y, FxType.Nuke)); // Shockwave animation nukeFx.push(new ShockwaveFx(x, y, 1500, radius * 1.5)); // Ruins and desolation sprites @@ -89,6 +96,7 @@ export function nukeFxFactory( for (const { type, radiusFactor, density } of debrisPlan) { addSpriteInCircle( + animatedSpriteLoader, x, y, radius * radiusFactor, diff --git a/src/client/graphics/fx/SAMExplosionFx.ts b/src/client/graphics/fx/SAMExplosionFx.ts deleted file mode 100644 index 3be5c3a79..000000000 --- a/src/client/graphics/fx/SAMExplosionFx.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { AnimatedSprite } from "../AnimatedSprite"; -import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader"; -import { Fx, FxType } from "./Fx"; - -/** - * Explosion effect: sprite animation of an explosion - */ -export class SAMExplosionFx implements Fx { - private lifeTime: number = 0; - private explosionSprite: AnimatedSprite | null; - constructor( - private x: number, - private y: number, - private duration: number, - ) { - this.explosionSprite = createAnimatedSpriteForUnit(FxType.SAMExplosion); - } - - renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean { - if (this.explosionSprite) { - this.lifeTime += frameTime; - if (this.lifeTime >= this.duration) { - return false; - } - if (this.explosionSprite.isActive()) { - this.explosionSprite.update(frameTime); - this.explosionSprite.draw(ctx, this.x, this.y); - return true; - } - return false; - } - return false; - } -} diff --git a/src/client/graphics/fx/SpriteFx.ts b/src/client/graphics/fx/SpriteFx.ts index a28c39411..54b4f18df 100644 --- a/src/client/graphics/fx/SpriteFx.ts +++ b/src/client/graphics/fx/SpriteFx.ts @@ -1,6 +1,8 @@ +import { Theme } from "../../../core/configuration/Config"; import { consolex } from "../../../core/Consolex"; +import { PlayerView } from "../../../core/game/GameView"; import { AnimatedSprite } from "../AnimatedSprite"; -import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader"; +import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; import { Fx, FxType } from "./Fx"; function fadeInOut( @@ -18,51 +20,73 @@ function fadeInOut( return 1 - f * f; } } +/** + * Fade in/out another FX + */ +export class FadeFx implements Fx { + constructor( + private fxToFade: SpriteFx, + private fadeIn: number, + private fadeOut: number, + ) {} + + renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean { + const t = this.fxToFade.getElapsedTime() / this.fxToFade.getDuration(); + ctx.save(); + ctx.globalAlpha = fadeInOut(t, this.fadeIn, this.fadeOut); + const result = this.fxToFade.renderTick(duration, ctx); + ctx.restore(); + return result; + } +} /** - * A simple FX displaying an animated sprite + * Animated sprite. Can be colored if provided an owner/theme */ -export class SpriteFX implements Fx { - private lifeTime: number = 0; - private animatedSprite: AnimatedSprite | null; - private totalLifeTime: number = 0; +export class SpriteFx implements Fx { + protected animatedSprite: AnimatedSprite | null; + protected elapsedTime = 0; + protected duration = 1000; constructor( - private x: number, - private y: number, + animatedSpriteLoader: AnimatedSpriteLoader, + protected x: number, + protected y: number, fxType: FxType, duration?: number, - private fadeIn?: number, - private fadeOut?: number, + private owner?: PlayerView, + private theme?: Theme, ) { - this.animatedSprite = createAnimatedSpriteForUnit(fxType); + this.animatedSprite = animatedSpriteLoader.createAnimatedSprite( + fxType, + owner, + theme, + ); 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 + consolex.error("Could not load animated sprite", fxType); } else { - this.totalLifeTime = duration; + this.duration = duration ?? this.animatedSprite.lifeTime() ?? 1000; } } 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; + if (!this.animatedSprite) return false; + + this.elapsedTime += frameTime; + if (this.elapsedTime >= this.duration) return false; + + if (!this.animatedSprite.isActive()) return false; + + const t = this.elapsedTime / this.duration; + this.animatedSprite.update(frameTime); + this.animatedSprite.draw(ctx, this.x, this.y); + return true; + } + + getElapsedTime(): number { + return this.elapsedTime; + } + + getDuration(): number { + return this.duration; } } diff --git a/src/client/graphics/fx/Timeline.ts b/src/client/graphics/fx/Timeline.ts new file mode 100644 index 000000000..32310244e --- /dev/null +++ b/src/client/graphics/fx/Timeline.ts @@ -0,0 +1,33 @@ +type TimedTask = { + delay: number; + action: () => void; + triggered: boolean; +}; + +/** + * Basic timeline to chain actions + */ +export class Timeline { + private tasks: TimedTask[] = []; + private timeElapsed = 0; + + add(delay: number, action: () => void): Timeline { + this.tasks.push({ delay, action, triggered: false }); + return this; + } + + update(dt: number) { + this.timeElapsed += dt; + + for (const task of this.tasks) { + if (!task.triggered && this.timeElapsed >= task.delay) { + task.action(); + task.triggered = true; + } + } + } + + isComplete() { + return this.tasks.every((t) => t.triggered); + } +} diff --git a/src/client/graphics/fx/UnitExplosionFx.ts b/src/client/graphics/fx/UnitExplosionFx.ts new file mode 100644 index 000000000..b77d5f4fa --- /dev/null +++ b/src/client/graphics/fx/UnitExplosionFx.ts @@ -0,0 +1,47 @@ +import { GameView } from "../../../core/game/GameView"; +import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; +import { Fx, FxType } from "./Fx"; +import { SpriteFx } from "./SpriteFx"; +import { Timeline } from "./Timeline"; + +/** + * Explosion Effect: a few timed explosions + */ +export class UnitExplosionFx implements Fx { + private timeline = new Timeline(); + private explosions: Fx[] = []; + + constructor( + animatedSpriteLoader: AnimatedSpriteLoader, + private x: number, + private y: number, + game: GameView, + ) { + const config = [ + { dx: 0, dy: 0, delay: 0, type: FxType.UnitExplosion }, + { dx: 4, dy: -6, delay: 80, type: FxType.UnitExplosion }, + { dx: -6, dy: 4, delay: 160, type: FxType.UnitExplosion }, + ]; + for (const { dx, dy, delay, type } of config) { + this.timeline.add(delay, () => { + if (game.isValidCoord(x + dx, y + dy)) { + this.explosions.push( + new SpriteFx(animatedSpriteLoader, x + dx, y + dy, type), + ); + } + }); + } + } + + renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean { + this.timeline.update(frameTime); + let allDone = true; + for (const fx of this.explosions) { + if (fx.renderTick(frameTime, ctx)) { + allDone = false; + } + } + + return !allDone || !this.timeline.isComplete(); + } +} diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index 58c74f84f..0d9367e4a 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -1,10 +1,12 @@ +import { Theme } from "../../../core/configuration/Config"; import { UnitType } from "../../../core/game/Game"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; -import { loadAllAnimatedSpriteImages } from "../AnimatedSpriteLoader"; -import { Fx } from "../fx/Fx"; +import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; +import { Fx, FxType } from "../fx/Fx"; import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx"; -import { SAMExplosionFx } from "../fx/SAMExplosionFx"; +import { SpriteFx } from "../fx/SpriteFx"; +import { UnitExplosionFx } from "../fx/UnitExplosionFx"; import { Layer } from "./Layer"; export class FxLayer implements Layer { @@ -13,10 +15,15 @@ export class FxLayer implements Layer { private lastRefresh: number = 0; private refreshRate: number = 10; + private theme: Theme; + private animatedSpriteLoader: AnimatedSpriteLoader = + new AnimatedSpriteLoader(); private allFx: Fx[] = []; - constructor(private game: GameView) {} + constructor(private game: GameView) { + this.theme = this.game.config().theme(); + } shouldTransform(): boolean { return true; @@ -41,12 +48,58 @@ export class FxLayer implements Layer { case UnitType.HydrogenBomb: this.onNukeEvent(unit, 160); break; + case UnitType.Warship: + this.onWarshipEvent(unit); + break; + case UnitType.Shell: + this.onShellEvent(unit); + break; + } + } + + onShellEvent(unit: UnitView) { + if (!unit.isActive()) { + if (unit.reachedTarget()) { + const x = this.game.x(unit.lastTile()); + const y = this.game.y(unit.lastTile()); + const shipExplosion = new SpriteFx( + this.animatedSpriteLoader, + x, + y, + FxType.MiniExplosion, + ); + this.allFx.push(shipExplosion); + } + } + } + + onWarshipEvent(unit: UnitView) { + if (!unit.isActive()) { + const x = this.game.x(unit.lastTile()); + const y = this.game.y(unit.lastTile()); + const shipExplosion = new UnitExplosionFx( + this.animatedSpriteLoader, + x, + y, + this.game, + ); + this.allFx.push(shipExplosion); + const sinkingShip = new SpriteFx( + this.animatedSpriteLoader, + x, + y, + FxType.SinkingShip, + undefined, + unit.owner(), + this.theme, + ); + this.allFx.push(sinkingShip); } } onNukeEvent(unit: UnitView, radius: number) { if (!unit.isActive()) { - if (unit.wasInterceptedBySAM()) { + if (!unit.reachedTarget()) { this.handleSAMInterception(unit); } else { // Kaboom @@ -58,15 +111,26 @@ export class FxLayer implements Layer { handleNukeExplosion(unit: UnitView, radius: number) { const x = this.game.x(unit.lastTile()); const y = this.game.y(unit.lastTile()); - const nukeFx = nukeFxFactory(x, y, radius, this.game); + const nukeFx = nukeFxFactory( + this.animatedSpriteLoader, + 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); + const explosion = new SpriteFx( + this.animatedSpriteLoader, + x, + y, + FxType.SAMExplosion, + ); + this.allFx.push(explosion); const shockwave = new ShockwaveFx(x, y, 800, 40); this.allFx.push(shockwave); } @@ -74,7 +138,7 @@ export class FxLayer implements Layer { async init() { this.redraw(); try { - await loadAllAnimatedSpriteImages(); + this.animatedSpriteLoader.loadAllAnimatedSpriteImages(); console.log("FX sprites loaded successfully"); } catch (err) { console.error("Failed to load FX sprites:", err); diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index daa50dca1..facdbb649 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -239,6 +239,7 @@ export class NukeExecution implements Execution { } } this.active = false; + this.nuke.setReachedTarget(); this.nuke.delete(false); // Record stats diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 9910a6aa3..36584e36d 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -181,7 +181,6 @@ export class SAMLauncherExecution implements Execution { ); // Delete warheads mirvWarheadTargets.forEach((u) => { - u.setInterceptedBySam(); u.delete(); }); } else if (target !== null) { diff --git a/src/core/execution/SAMMissileExecution.ts b/src/core/execution/SAMMissileExecution.ts index 9e1ad5b0d..fdfe635da 100644 --- a/src/core/execution/SAMMissileExecution.ts +++ b/src/core/execution/SAMMissileExecution.ts @@ -66,7 +66,6 @@ export class SAMMissileExecution implements Execution { this._owner.id(), ); this.active = false; - this.target.setInterceptedBySam(); this.target.delete(true, this._owner); this.SAMMissile.delete(false); diff --git a/src/core/execution/ShellExecution.ts b/src/core/execution/ShellExecution.ts index bd1f7ddb3..456585198 100644 --- a/src/core/execution/ShellExecution.ts +++ b/src/core/execution/ShellExecution.ts @@ -52,6 +52,7 @@ export class ShellExecution implements Execution { if (result === true) { this.active = false; this.target.modifyHealth(-this.effectOnTarget(), this._owner); + this.shell.setReachedTarget(); this.shell.delete(false); return; } else { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 112083238..ec1d5aec7 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -369,8 +369,8 @@ export interface Unit { targetUnit(): Unit | undefined; setTargetedBySAM(targeted: boolean): void; targetedBySAM(): boolean; - setInterceptedBySam(): void; - interceptedBySam(): boolean; + setReachedTarget(): void; + reachedTarget(): boolean; // Health hasHealth(): boolean; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 00b6f9b92..6fc973e30 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -73,7 +73,7 @@ export interface UnitUpdate { pos: TileRef; lastPos: TileRef; isActive: boolean; - wasIntercepted: boolean; + reachedTarget: boolean; retreating: boolean; targetUnitId?: number; // Only for trade ships targetTile?: TileRef; // Only for nukes diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index da95b44ed..ab570918e 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -93,8 +93,8 @@ export class UnitView { isActive(): boolean { return this.data.isActive; } - wasInterceptedBySAM(): boolean { - return this.data.wasIntercepted; + reachedTarget(): boolean { + return this.data.reachedTarget; } hasHealth(): boolean { return this.data.health !== undefined; diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 86ec9680b..de77f066e 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -21,7 +21,7 @@ export class UnitImpl implements Unit { private _lastTile: TileRef; private _retreating: boolean = false; private _targetedBySAM = false; - private _interceptedBySAM = false; + private _reachedTarget = false; private _lastSetSafeFromPirates: number; // Only for trade ships private _constructionType: UnitType | undefined; private _lastOwner: PlayerImpl | null = null; @@ -104,7 +104,7 @@ export class UnitImpl implements Unit { ownerID: this._owner.smallID(), lastOwnerID: this._lastOwner?.smallID(), isActive: this._active, - wasIntercepted: this._interceptedBySAM, + reachedTarget: this._reachedTarget, retreating: this._retreating, pos: this._tile, lastPos: this._lastTile, @@ -324,12 +324,12 @@ export class UnitImpl implements Unit { return this._targetedBySAM; } - setInterceptedBySam(): void { - this._interceptedBySAM = true; + setReachedTarget(): void { + this._reachedTarget = true; } - interceptedBySam(): boolean { - return this._interceptedBySAM; + reachedTarget(): boolean { + return this._reachedTarget; } setSafeFromPirates(): void {