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()) {