mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:50:43 +00:00
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:  ### New settings: - main menu:  - In game:  ## 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:
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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")}"
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface Fx {
|
||||
renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean;
|
||||
}
|
||||
|
||||
export enum FxType {
|
||||
Nuke = "Nuke",
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user