diff --git a/resources/lang/en.json b/resources/lang/en.json
index f421e1011..b2a282a55 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -227,6 +227,8 @@
"dark_mode_desc": "Toggle the site’s appearance between light and dark themes",
"emojis_label": "😊 Emojis",
"emojis_desc": "Toggle whether emojis are shown in game",
+ "special_effects_label": "💥 Special effects",
+ "special_effects_desc": "Toggle special effects. Deactivate to improve performances",
"anonymous_names_label": "🥷 Hidden Names",
"anonymous_names_desc": "Hide real player names with random ones on your screen.",
"left_click_label": "🖱️ Left Click to Open Menu",
diff --git a/resources/lang/fr.json b/resources/lang/fr.json
index d84ecf305..ce2168395 100644
--- a/resources/lang/fr.json
+++ b/resources/lang/fr.json
@@ -213,6 +213,8 @@
"dark_mode_desc": "Basculer l'apparence du site entre les thèmes clairs et sombres",
"emojis_label": "😊 Émojis",
"emojis_desc": "Afficher/Masquer les émoticônes dans le jeu",
+ "special_effects_label": "💥 Effets spéciaux",
+ "special_effects_desc": "Affiche les effets spéciaux - Désactivez pour améliorer les performances",
"anonymous_names_label": "🥷 Noms masqués",
"anonymous_names_desc": "Cacher le vrai nom des joueurs avec des noms aléatoires sur votre écran.",
"left_click_label": "🖱️ Clic gauche pour ouvrir le menu",
diff --git a/resources/sprites/nukeExplosion.png b/resources/sprites/nukeExplosion.png
new file mode 100644
index 000000000..f96f6e51e
Binary files /dev/null and b/resources/sprites/nukeExplosion.png differ
diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts
index f94f0591a..cfd9d21c1 100644
--- a/src/client/UserSettingModal.ts
+++ b/src/client/UserSettingModal.ts
@@ -102,6 +102,15 @@ export class UserSettingModal extends LitElement {
console.log("🤡 Emojis:", enabled ? "ON" : "OFF");
}
+ private toggleFxLayer(e: CustomEvent<{ checked: boolean }>) {
+ const enabled = e.detail?.checked;
+ if (typeof enabled !== "boolean") return;
+
+ this.userSettings.set("settings.specialEffects", enabled);
+
+ console.log("💥 Special effects:", enabled ? "ON" : "OFF");
+ }
+
private toggleAnonymousNames(e: CustomEvent<{ checked: boolean }>) {
const enabled = e.detail?.checked;
if (typeof enabled !== "boolean") return;
@@ -226,6 +235,15 @@ export class UserSettingModal extends LitElement {
@change=${this.toggleEmojis}
>
+
+
+
= this.frameDuration) {
+ this.elapsedTime -= this.frameDuration;
+ this.currentFrame++;
+
+ if (this.currentFrame >= this.frameCount) {
+ if (this.looping) {
+ this.currentFrame = 0;
+ } else {
+ this.currentFrame = this.frameCount - 1;
+ this.active = false;
+ }
+ }
+ }
+ }
+
+ isActive(): boolean {
+ return this.active;
+ }
+
+ draw(ctx: CanvasRenderingContext2D, x: number, y: number) {
+ const drawX = x - this.originX;
+ const drawY = y - this.originY;
+
+ ctx.drawImage(
+ this.image,
+ this.currentFrame * this.frameWidth,
+ 0,
+ this.frameWidth,
+ this.frameHeight,
+ drawX,
+ drawY,
+ this.frameWidth,
+ this.frameHeight,
+ );
+ }
+
+ reset() {
+ this.currentFrame = 0;
+ this.elapsedTime = 0;
+ }
+
+ setOrigin(xRatio: number, yRatio: number) {
+ this.originX = xRatio;
+ this.originY = yRatio;
+ }
+}
diff --git a/src/client/graphics/AnimatedSpriteLoader.ts b/src/client/graphics/AnimatedSpriteLoader.ts
new file mode 100644
index 000000000..63cb67ff6
--- /dev/null
+++ b/src/client/graphics/AnimatedSpriteLoader.ts
@@ -0,0 +1,76 @@
+import nuke from "../../../resources/sprites/nukeExplosion.png";
+import { AnimatedSprite } from "./AnimatedSprite";
+import { FxType } from "./fx/Fx";
+
+type AnimatedSpriteConfig = {
+ url: string;
+ frameWidth: number;
+ frameCount: number;
+ frameDuration: number; // ms per frame
+ looping?: boolean;
+ originX: number;
+ originY: number;
+};
+
+const ANIMATED_SPRITE_CONFIG: Partial> = {
+ [FxType.Nuke]: {
+ url: nuke,
+ frameWidth: 60,
+ frameCount: 9,
+ frameDuration: 70,
+ looping: false,
+ originX: 30,
+ originY: 30,
+ },
+};
+
+const animatedSpriteImageMap: Map = new Map();
+
+export const loadAllAnimatedSpriteImages = async (): 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;
+
+ 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);
+ });
+
+ 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);
+ }
+ }),
+ );
+};
+
+export const createAnimatedSpriteForUnit = (
+ fxType: FxType,
+): AnimatedSprite | null => {
+ const config = ANIMATED_SPRITE_CONFIG[fxType];
+ const image = 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,
+ );
+};
diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts
index 3f83a1c41..9cc5b8282 100644
--- a/src/client/graphics/GameRenderer.ts
+++ b/src/client/graphics/GameRenderer.ts
@@ -12,6 +12,7 @@ import { ChatModal } from "./layers/ChatModal";
import { ControlPanel } from "./layers/ControlPanel";
import { EmojiTable } from "./layers/EmojiTable";
import { EventsDisplay } from "./layers/EventsDisplay";
+import { FxLayer } from "./layers/FxLayer";
import { Layer } from "./layers/Layer";
import { Leaderboard } from "./layers/Leaderboard";
import { MultiTabModal } from "./layers/MultiTabModal";
@@ -165,6 +166,7 @@ export function createRenderer(
new TerritoryLayer(game, eventBus),
new StructureLayer(game, eventBus),
new UnitLayer(game, eventBus, clientID, transformHandler),
+ new FxLayer(game),
new UILayer(game, eventBus, clientID, transformHandler),
new NameLayer(game, transformHandler, clientID),
eventsDisplay,
diff --git a/src/client/graphics/fx/Fx.ts b/src/client/graphics/fx/Fx.ts
new file mode 100644
index 000000000..8956c4ca4
--- /dev/null
+++ b/src/client/graphics/fx/Fx.ts
@@ -0,0 +1,7 @@
+export interface Fx {
+ renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean;
+}
+
+export enum FxType {
+ Nuke = "Nuke",
+}
diff --git a/src/client/graphics/fx/NukeFx.ts b/src/client/graphics/fx/NukeFx.ts
new file mode 100644
index 000000000..680d4d64d
--- /dev/null
+++ b/src/client/graphics/fx/NukeFx.ts
@@ -0,0 +1,62 @@
+import { AnimatedSprite } from "../AnimatedSprite";
+import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader";
+import { Fx, FxType } from "./Fx";
+
+/**
+ * Shockwave effect: draw a growing 1px white circle
+ */
+export class ShockwaveFx implements Fx {
+ private lifeTime: number = 0;
+ constructor(
+ private x: number,
+ private y: number,
+ private duration: number,
+ private maxRadius: number,
+ ) {}
+
+ renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
+ this.lifeTime += frameTime;
+ if (this.lifeTime >= this.duration) {
+ return false;
+ }
+ const t = this.lifeTime / this.duration;
+ const radius = t * this.maxRadius;
+ ctx.beginPath();
+ ctx.arc(this.x, this.y, radius, 0, Math.PI * 2);
+ ctx.strokeStyle = "rgba(255, 255, 255, " + (1 - t) + ")";
+ ctx.lineWidth = 0.5;
+ ctx.stroke();
+ return true;
+ }
+}
+
+/**
+ * Explosion effect: sprite animation of an explosion
+ */
+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;
+ }
+ return false;
+ }
+}
diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts
new file mode 100644
index 000000000..f19147b65
--- /dev/null
+++ b/src/client/graphics/layers/FxLayer.ts
@@ -0,0 +1,109 @@
+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 { NukeExplosionFx, ShockwaveFx } from "../fx/NukeFx";
+import { Layer } from "./Layer";
+
+export class FxLayer implements Layer {
+ private canvas: HTMLCanvasElement;
+ private context: CanvasRenderingContext2D;
+
+ private lastRefresh: number = 0;
+ private refreshRate: number = 10;
+
+ private allFx: Fx[] = [];
+
+ constructor(private game: GameView) {}
+
+ shouldTransform(): boolean {
+ return true;
+ }
+
+ tick() {
+ this.game
+ .updatesSinceLastTick()
+ ?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
+ ?.forEach((unitView) => {
+ if (unitView === undefined) return;
+ this.onUnitEvent(unitView);
+ });
+ }
+
+ onUnitEvent(unit: UnitView) {
+ switch (unit.type()) {
+ case UnitType.AtomBomb:
+ case UnitType.MIRVWarhead:
+ this.handleNukeExplosion(unit, 70);
+ break;
+ case UnitType.HydrogenBomb:
+ this.handleNukeExplosion(unit, 250);
+ break;
+ }
+ }
+
+ handleNukeExplosion(unit: UnitView, shockwaveRadius: number) {
+ if (!unit.isActive()) {
+ 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);
+ }
+ }
+
+ async init() {
+ this.redraw();
+ try {
+ await loadAllAnimatedSpriteImages();
+ console.log("FX sprites loaded successfully");
+ } catch (err) {
+ console.error("Failed to load FX sprites:", err);
+ }
+ }
+
+ redraw(): void {
+ this.canvas = document.createElement("canvas");
+ const context = this.canvas.getContext("2d");
+ if (context === null) throw new Error("2d context not supported");
+ this.context = context;
+ this.context.imageSmoothingEnabled = false;
+ this.canvas.width = this.game.width();
+ this.canvas.height = this.game.height();
+ }
+
+ renderLayer(context: CanvasRenderingContext2D) {
+ const now = Date.now();
+ if (this.game.config().userSettings()?.fxLayer()) {
+ if (now > this.lastRefresh + this.refreshRate) {
+ const delta = now - this.lastRefresh;
+ this.renderAllFx(context, delta);
+ this.lastRefresh = now;
+ }
+ context.drawImage(
+ this.canvas,
+ -this.game.width() / 2,
+ -this.game.height() / 2,
+ this.game.width(),
+ this.game.height(),
+ );
+ }
+ }
+
+ renderAllFx(context: CanvasRenderingContext2D, delta: number) {
+ if (this.allFx.length > 0) {
+ this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
+ this.renderContextFx(delta);
+ }
+ }
+
+ renderContextFx(duration: number) {
+ for (let i = this.allFx.length - 1; i >= 0; i--) {
+ if (!this.allFx[i].renderTick(duration, this.context)) {
+ this.allFx.splice(i, 1);
+ }
+ }
+ }
+}
diff --git a/src/client/graphics/layers/OptionsMenu.ts b/src/client/graphics/layers/OptionsMenu.ts
index bd6248825..7e85e38f4 100644
--- a/src/client/graphics/layers/OptionsMenu.ts
+++ b/src/client/graphics/layers/OptionsMenu.ts
@@ -100,6 +100,11 @@ export class OptionsMenu extends LitElement implements Layer {
this.requestUpdate();
}
+ private onToggleSpecialEffectsButtonClick() {
+ this.userSettings.toggleFxLayer();
+ this.requestUpdate();
+ }
+
private onToggleDarkModeButtonClick() {
this.userSettings.toggleDarkMode();
this.requestUpdate();
@@ -197,6 +202,11 @@ export class OptionsMenu extends LitElement implements Layer {
title: "Toggle Emojis",
children: "🙂: " + (this.userSettings.emojis() ? "On" : "Off"),
})}
+ ${button({
+ onClick: this.onToggleSpecialEffectsButtonClick,
+ title: "Toggle Special effects",
+ children: "💥: " + (this.userSettings.fxLayer() ? "On" : "Off"),
+ })}
${button({
onClick: this.onToggleDarkModeButtonClick,
title: "Dark Mode",
diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts
index f94dcdf26..c7005573c 100644
--- a/src/core/game/UserSettings.ts
+++ b/src/core/game/UserSettings.ts
@@ -21,6 +21,10 @@ export class UserSettings {
return this.get("settings.anonymousNames", false);
}
+ fxLayer() {
+ return this.get("settings.specialEffects", true);
+ }
+
darkMode() {
return this.get("settings.darkMode", false);
}
@@ -51,6 +55,10 @@ export class UserSettings {
this.set("settings.anonymousNames", !this.anonymousNames());
}
+ toggleFxLayer() {
+ this.set("settings.specialEffects", !this.fxLayer());
+ }
+
toggleDarkMode() {
this.set("settings.darkMode", !this.darkMode());
if (this.darkMode()) {