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
This commit is contained in:
DevelopingTom
2025-05-18 21:43:12 +02:00
committed by GitHub
parent 1ebf6d4fab
commit bb24f18285
12 changed files with 367 additions and 0 deletions
+2
View File
@@ -227,6 +227,8 @@
"dark_mode_desc": "Toggle the sites 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",
+2
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+18
View File
@@ -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}
></setting-toggle>
<!-- 💥 Special effects -->
<setting-toggle
label="${translateText("user_setting.special_effects_label")}"
description="${translateText("user_setting.special_effects_desc")}"
id="special-effect-toggle"
.checked=${this.userSettings.fxLayer()}
@change=${this.toggleFxLayer}
></setting-toggle>
<!-- 🖱️ Left Click Menu -->
<setting-toggle
label="${translateText("user_setting.left_click_label")}"
+71
View File
@@ -0,0 +1,71 @@
export class AnimatedSprite {
private frameHeight: number;
private currentFrame: number = 0;
private elapsedTime: number = 0;
private active: boolean = true;
constructor(
private image: CanvasImageSource,
private frameWidth: number,
private frameCount: number,
private frameDuration: number, // in milliseconds
private looping: boolean = true,
private originX: number,
private originY: number,
) {
if ("height" in image) {
this.frameHeight = (image as HTMLImageElement | HTMLCanvasElement).height;
} else {
throw new Error("Image source must have a 'height' property.");
}
}
update(deltaTime: number) {
if (!this.active) return;
this.elapsedTime += deltaTime;
if (this.elapsedTime >= 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;
}
}
@@ -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<Record<FxType, AnimatedSpriteConfig>> = {
[FxType.Nuke]: {
url: nuke,
frameWidth: 60,
frameCount: 9,
frameDuration: 70,
looping: false,
originX: 30,
originY: 30,
},
};
const animatedSpriteImageMap: Map<FxType, CanvasImageSource> = new Map();
export const loadAllAnimatedSpriteImages = async (): Promise<void> => {
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<void>((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,
);
};
+2
View File
@@ -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,
+7
View File
@@ -0,0 +1,7 @@
export interface Fx {
renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean;
}
export enum FxType {
Nuke = "Nuke",
}
+62
View File
@@ -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;
}
}
+109
View File
@@ -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);
}
}
}
}
+10
View File
@@ -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",
+8
View File
@@ -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()) {