From bb24f18285c850624a7abf95c66be4492c701711 Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Sun, 18 May 2025 21:43:12 +0200 Subject: [PATCH] Add new FX layer and a nuke animation (#807) ## Description: Changes: - Added an AnimatedSprite class to handle spritesheets - New FX layer, displaying cosmectics effects - New Nuke FX: an animated sprite explosion, and a shockwave effect - New "Special effects" setting, toggle to deactivate the FX layer for lower-end hardware / personal taste #### Note that the animation is a placeholder. It should be replaced when a better looking one is available. - Nuke: https://github.com/user-attachments/assets/6eff1d0d-5081-47ad-932f-2bfcda72cb3c - Mirv: https://github.com/user-attachments/assets/3bc891b4-449c-4acb-8e24-e237b423c2a9 - SAM are also using the same Nuke animation. To be improved with a custom FX: https://github.com/user-attachments/assets/d65addce-5890-42c2-81e0-3eaa79ed87f3 ## Performances: Excellent since it's not manipulating the underlying imagedata directly. Profiling during a MIRV with 100's of animations: ![image](https://github.com/user-attachments/assets/3477c963-d10f-493b-bcb1-93b7990d3edb) ### New settings: - main menu: ![image](https://github.com/user-attachments/assets/5b1127bb-3b89-4c06-b519-fb173301d9fd) - In game: ![image](https://github.com/user-attachments/assets/ba899253-a7cf-4d1c-8801-da41d2b1536b) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: IngloriousTom --- resources/lang/en.json | 2 + resources/lang/fr.json | 2 + resources/sprites/nukeExplosion.png | Bin 0 -> 4213 bytes src/client/UserSettingModal.ts | 18 ++++ src/client/graphics/AnimatedSprite.ts | 71 +++++++++++++ src/client/graphics/AnimatedSpriteLoader.ts | 76 ++++++++++++++ src/client/graphics/GameRenderer.ts | 2 + src/client/graphics/fx/Fx.ts | 7 ++ src/client/graphics/fx/NukeFx.ts | 62 +++++++++++ src/client/graphics/layers/FxLayer.ts | 109 ++++++++++++++++++++ src/client/graphics/layers/OptionsMenu.ts | 10 ++ src/core/game/UserSettings.ts | 8 ++ 12 files changed, 367 insertions(+) create mode 100644 resources/sprites/nukeExplosion.png create mode 100644 src/client/graphics/AnimatedSprite.ts create mode 100644 src/client/graphics/AnimatedSpriteLoader.ts create mode 100644 src/client/graphics/fx/Fx.ts create mode 100644 src/client/graphics/fx/NukeFx.ts create mode 100644 src/client/graphics/layers/FxLayer.ts 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 0000000000000000000000000000000000000000..f96f6e51e49be22aeff586930ca77a26d20f4ea0 GIT binary patch literal 4213 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 LNkvXXu0mjfZ@l+W literal 0 HcmV?d00001 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()) {