diff --git a/src/client/graphics/AnimatedSpriteLoader.ts b/src/client/graphics/AnimatedSpriteLoader.ts index c5c41a829..b7fc656b7 100644 --- a/src/client/graphics/AnimatedSpriteLoader.ts +++ b/src/client/graphics/AnimatedSpriteLoader.ts @@ -1,3 +1,4 @@ +import * as PIXI from "pixi.js"; import miniBigSmoke from "../../../resources/sprites/bigsmoke.png"; import miniExplosion from "../../../resources/sprites/miniExplosion.png"; import miniFire from "../../../resources/sprites/minifire.png"; @@ -228,4 +229,39 @@ export class AnimatedSpriteLoader { } return this.createRegularAnimatedSprite(fxType, scale); } + + public getPixiTextures( + fxType: FxType, + owner?: PlayerView, + theme?: Theme, + ): PIXI.Texture[] | null { + const config = ANIMATED_SPRITE_CONFIG[fxType]; + if (!config) return null; + + let image: CanvasImageSource | null = null; + if (owner && theme) { + image = this.getColoredAnimatedSprite(owner, fxType, theme); + } else { + image = this.animatedSpriteImageMap.get(fxType) || null; + } + + if (!image) return null; + + const base = PIXI.Texture.from(image as any); + const textures: PIXI.Texture[] = []; + for (let i = 0; i < config.frameCount; i++) { + const rect = new PIXI.Rectangle( + i * config.frameWidth, + 0, + config.frameWidth, + (image as any).height, + ); + textures.push(new PIXI.Texture({ source: base.source, frame: rect })); + } + return textures; + } + + public getConfig(fxType: FxType) { + return ANIMATED_SPRITE_CONFIG[fxType]; + } } diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 40df04715..370568ffa 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -252,7 +252,7 @@ export function createRenderer( new RangeOverlayLayer(game, eventBus, transformHandler, uiState), structureLayer, new UnitLayer(game, eventBus, transformHandler, uiState), - new FxLayer(game), + new FxLayer(game, transformHandler), // Draw name labels in world space along with other transformed layers new NameLayer(game, transformHandler, eventBus), // UI layer comes after world-space drawing to minimize save/restore @@ -362,23 +362,26 @@ export class GameRenderer { .toHex(); this.context.fillRect(0, 0, this.canvas.width, this.canvas.height); - // Minimize save/restore by rendering world-space (transformed) layers in one block, - // then UI/screen-space layers without a transform. - // First pass: world-space layers - this.context.save(); - this.transformHandler.handleTransform(this.context); + // Render layers in order, switching transform state as needed + let isTransformed = false; + for (const layer of this.layers) { - if (layer.shouldTransform?.() ?? false) { - layer.renderLayer?.(this.context); + const layerNeedsTransform = layer.shouldTransform?.() ?? false; + + if (layerNeedsTransform && !isTransformed) { + this.context.save(); + this.transformHandler.handleTransform(this.context); + isTransformed = true; + } else if (!layerNeedsTransform && isTransformed) { + this.context.restore(); + isTransformed = false; } + + layer.renderLayer?.(this.context); } - this.context.restore(); - // Second pass: UI layers (no transform) - for (const layer of this.layers) { - if (!(layer.shouldTransform?.() ?? false)) { - layer.renderLayer?.(this.context); - } + if (isTransformed) { + this.context.restore(); } this.transformHandler.resetChanged(); diff --git a/src/client/graphics/fx/Fx.ts b/src/client/graphics/fx/Fx.ts index d98064c11..c926a7f61 100644 --- a/src/client/graphics/fx/Fx.ts +++ b/src/client/graphics/fx/Fx.ts @@ -1,5 +1,8 @@ +import * as PIXI from "pixi.js"; + export interface Fx { - renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean; + update(delta: number): boolean; + getDisplayObject(): PIXI.Container; } export enum FxType { diff --git a/src/client/graphics/fx/NukeFx.ts b/src/client/graphics/fx/NukeFx.ts index ba5382e05..57c8efd35 100644 --- a/src/client/graphics/fx/NukeFx.ts +++ b/src/client/graphics/fx/NukeFx.ts @@ -1,3 +1,4 @@ +import * as PIXI from "pixi.js"; import { GameView } from "../../../core/game/GameView"; import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; import { Fx, FxType } from "./Fx"; @@ -8,27 +9,39 @@ import { FadeFx, SpriteFx } from "./SpriteFx"; */ export class ShockwaveFx implements Fx { private lifeTime: number = 0; + private graphics: PIXI.Graphics; + private container: PIXI.Container; + constructor( private x: number, private y: number, private duration: number, private maxRadius: number, - ) {} + ) { + this.container = new PIXI.Container(); + this.container.position.set(x, y); + this.graphics = new PIXI.Graphics(); + this.container.addChild(this.graphics); + } - renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean { - this.lifeTime += frameTime; + update(delta: number): boolean { + this.lifeTime += delta; 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(); + + this.graphics.clear(); + this.graphics.circle(0, 0, radius); + this.graphics.stroke({ width: 0.5, color: 0xffffff, alpha: 1 - t }); + return true; } + + getDisplayObject(): PIXI.Container { + return this.container; + } } /** diff --git a/src/client/graphics/fx/SpriteFx.ts b/src/client/graphics/fx/SpriteFx.ts index 68e7f8c54..5a4df0b35 100644 --- a/src/client/graphics/fx/SpriteFx.ts +++ b/src/client/graphics/fx/SpriteFx.ts @@ -1,6 +1,6 @@ +import * as PIXI from "pixi.js"; import { Theme } from "../../../core/configuration/Config"; import { PlayerView } from "../../../core/game/GameView"; -import { AnimatedSprite } from "../AnimatedSprite"; import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; import { Fx, FxType } from "./Fx"; @@ -29,13 +29,15 @@ export class FadeFx implements Fx { private fadeOut: number, ) {} - renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean { + update(delta: number): boolean { const t = this.fxToFade.getElapsedTime() / this.fxToFade.getDuration(); - ctx.save(); - ctx.globalAlpha = fadeInOut(t, this.fadeIn, this.fadeOut); - const result = this.fxToFade.renderTick(duration, ctx); - ctx.restore(); - return result; + const alpha = fadeInOut(t, this.fadeIn, this.fadeOut); + this.fxToFade.getDisplayObject().alpha = alpha; + return this.fxToFade.update(delta); + } + + getDisplayObject(): PIXI.Container { + return this.fxToFade.getDisplayObject(); } } @@ -43,9 +45,12 @@ export class FadeFx implements Fx { * Animated sprite. Can be colored if provided an owner/theme */ export class SpriteFx implements Fx { - protected animatedSprite: AnimatedSprite | null; + protected sprite: PIXI.AnimatedSprite | null = null; + protected container: PIXI.Container; protected elapsedTime = 0; protected duration = 1000; + protected animationTime = 0; + constructor( animatedSpriteLoader: AnimatedSpriteLoader, protected x: number, @@ -56,30 +61,69 @@ export class SpriteFx implements Fx { private theme?: Theme, scale: number = 1, ) { - this.animatedSprite = animatedSpriteLoader.createAnimatedSprite( - fxType, - owner, - theme, - scale, - ); - if (!this.animatedSprite) { - console.error("Could not load animated sprite", fxType); + this.container = new PIXI.Container(); + this.container.position.set(x, y); + + const textures = animatedSpriteLoader.getPixiTextures(fxType, owner, theme); + const config = animatedSpriteLoader.getConfig(fxType); + + if (textures && config) { + this.sprite = new PIXI.AnimatedSprite(textures); + this.sprite.autoUpdate = false; + this.sprite.loop = config.looping ?? true; + + // Anchor + const texture = textures[0]; + this.sprite.anchor.set( + config.originX / config.frameWidth, + config.originY / texture.height, + ); + + this.sprite.scale.set(scale); + // this.sprite.play(); // We manually update + this.container.addChild(this.sprite); + + // Calculate duration if not provided + if (duration) { + this.duration = duration; + } else { + this.duration = config.frameCount * config.frameDuration; + if (config.looping) { + this.duration = Infinity; + } + } + + // Store frame duration for update + (this.sprite as any).msPerFrame = config.frameDuration; } else { - this.duration = duration ?? this.animatedSprite.lifeTime() ?? 1000; + console.error("Could not load animated sprite", fxType); } } - renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean { - if (!this.animatedSprite) return false; + update(delta: number): boolean { + if (!this.sprite) return false; - this.elapsedTime += frameTime; - if (this.elapsedTime >= this.duration) return false; + this.elapsedTime += delta; + if (this.duration !== Infinity && this.elapsedTime >= this.duration) { + return false; + } - if (!this.animatedSprite.isActive()) return false; + const msPerFrame = (this.sprite as any).msPerFrame || 100; + this.animationTime += delta; + + const frameIndex = Math.floor(this.animationTime / msPerFrame); + + if (this.sprite.loop) { + this.sprite.gotoAndStop(frameIndex % this.sprite.totalFrames); + } else { + if (frameIndex < this.sprite.totalFrames) { + this.sprite.gotoAndStop(frameIndex); + } else { + // Animation finished, but we might wait for duration + this.sprite.gotoAndStop(this.sprite.totalFrames - 1); + } + } - const t = this.elapsedTime / this.duration; - this.animatedSprite.update(frameTime); - this.animatedSprite.draw(ctx, this.x, this.y); return true; } @@ -90,4 +134,8 @@ export class SpriteFx implements Fx { getDuration(): number { return this.duration; } + + getDisplayObject(): PIXI.Container { + return this.container; + } } diff --git a/src/client/graphics/fx/UnitExplosionFx.ts b/src/client/graphics/fx/UnitExplosionFx.ts index b77d5f4fa..094360b07 100644 --- a/src/client/graphics/fx/UnitExplosionFx.ts +++ b/src/client/graphics/fx/UnitExplosionFx.ts @@ -1,3 +1,4 @@ +import * as PIXI from "pixi.js"; import { GameView } from "../../../core/game/GameView"; import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; import { Fx, FxType } from "./Fx"; @@ -10,6 +11,7 @@ import { Timeline } from "./Timeline"; export class UnitExplosionFx implements Fx { private timeline = new Timeline(); private explosions: Fx[] = []; + private container: PIXI.Container; constructor( animatedSpriteLoader: AnimatedSpriteLoader, @@ -17,6 +19,7 @@ export class UnitExplosionFx implements Fx { private y: number, game: GameView, ) { + this.container = new PIXI.Container(); const config = [ { dx: 0, dy: 0, delay: 0, type: FxType.UnitExplosion }, { dx: 4, dy: -6, delay: 80, type: FxType.UnitExplosion }, @@ -25,23 +28,32 @@ export class UnitExplosionFx implements Fx { for (const { dx, dy, delay, type } of config) { this.timeline.add(delay, () => { if (game.isValidCoord(x + dx, y + dy)) { - this.explosions.push( - new SpriteFx(animatedSpriteLoader, x + dx, y + dy, type), - ); + const fx = new SpriteFx(animatedSpriteLoader, x + dx, y + dy, type); + this.explosions.push(fx); + this.container.addChild(fx.getDisplayObject()); } }); } } - renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean { - this.timeline.update(frameTime); + update(delta: number): boolean { + this.timeline.update(delta); let allDone = true; - for (const fx of this.explosions) { - if (fx.renderTick(frameTime, ctx)) { + + for (let i = this.explosions.length - 1; i >= 0; i--) { + const fx = this.explosions[i]; + if (!fx.update(delta)) { + this.container.removeChild(fx.getDisplayObject()); + this.explosions.splice(i, 1); + } else { allDone = false; } } return !allDone || !this.timeline.isComplete(); } + + getDisplayObject(): PIXI.Container { + return this.container; + } } diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index 6e9d637e8..fa27fdf3b 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -1,5 +1,6 @@ +import * as PIXI from "pixi.js"; import { Theme } from "../../../core/configuration/Config"; -import { UnitType } from "../../../core/game/Game"; +import { Cell, UnitType } from "../../../core/game/Game"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; @@ -7,11 +8,20 @@ import { Fx, FxType } from "../fx/Fx"; import { doomsdayFxFactory, nukeFxFactory, ShockwaveFx } from "../fx/NukeFx"; import { SpriteFx } from "../fx/SpriteFx"; import { UnitExplosionFx } from "../fx/UnitExplosionFx"; +import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; +// Store FX with world coordinates for repositioning on camera changes +interface FxInfo { + fx: Fx; + worldX: number; + worldY: number; +} + export class FxLayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; + private renderer: PIXI.Renderer; + private stage: PIXI.Container; + private pixicanvas: HTMLCanvasElement; private lastRefresh: number = 0; // Target ~60 FPS for FX layer to reduce CPU (was 10ms ~= 100 FPS) @@ -22,14 +32,50 @@ export class FxLayer implements Layer { private animatedSpriteLoader: AnimatedSpriteLoader = new AnimatedSpriteLoader(); - private allFx: Fx[] = []; + private allFx: FxInfo[] = []; - constructor(private game: GameView) { + constructor( + private game: GameView, + private transformHandler: TransformHandler, + ) { this.theme = this.game.config().theme(); } shouldTransform(): boolean { - return true; + return false; + } + + async init() { + this.renderer = new PIXI.WebGLRenderer(); + this.pixicanvas = document.createElement("canvas"); + this.pixicanvas.width = window.innerWidth; + this.pixicanvas.height = window.innerHeight; + this.stage = new PIXI.Container(); + + await this.renderer.init({ + canvas: this.pixicanvas, + width: this.pixicanvas.width, + height: this.pixicanvas.height, + backgroundAlpha: 0, + clearBeforeRender: true, + }); + + window.addEventListener("resize", () => this.resizeCanvas()); + + try { + await this.animatedSpriteLoader.loadAllAnimatedSpriteImages(); + console.log("FX sprites loaded successfully"); + } catch (err) { + console.error("Failed to load FX sprites:", err); + } + } + + resizeCanvas() { + if (this.renderer) { + this.pixicanvas.width = window.innerWidth; + this.pixicanvas.height = window.innerHeight; + this.renderer.resize(window.innerWidth, window.innerHeight); + } } tick() { @@ -44,37 +90,42 @@ export class FxLayer implements Layer { this.game .updatesSinceLastTick() ?.[GameUpdateType.BomberExplosion]?.forEach((update) => { - const { x, y, radius } = update; const bomberFx = nukeFxFactory( this.animatedSpriteLoader, - x, - y, - radius, + 0, + 0, + update.radius, this.game, 0.2, ); - for (const fx of bomberFx) { - this.allFx.push(fx); - } + this.addFx(bomberFx, update.x, update.y); }); this.game .updatesSinceLastTick() ?.[GameUpdateType.DoomsdayExplosion]?.forEach((update) => { - const { x, y, radius } = update; const doomFx = doomsdayFxFactory( this.animatedSpriteLoader, - x, - y, - radius, + 0, + 0, + update.radius, this.game, ); - for (const fx of doomFx) { - this.allFx.push(fx); - } + this.addFx(doomFx, update.x, update.y); }); } + private addFx(fx: Fx | Fx[], worldX: number, worldY: number) { + const list = Array.isArray(fx) ? fx : [fx]; + for (const f of list) { + const info: FxInfo = { fx: f, worldX, worldY }; + this.allFx.push(info); + this.stage.addChild(f.getDisplayObject()); + // Set initial screen position + this.updateFxPosition(info); + } + } + onUnitEvent(unit: UnitView) { switch (unit.type()) { case UnitType.AtomBomb: @@ -96,40 +147,40 @@ export class FxLayer implements Layer { onShellEvent(unit: UnitView) { if (!unit.isActive()) { if (unit.reachedTarget()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); + const worldX = this.game.x(unit.lastTile()); + const worldY = this.game.y(unit.lastTile()); const shipExplosion = new SpriteFx( this.animatedSpriteLoader, - x, - y, + 0, + 0, FxType.MiniExplosion, ); - this.allFx.push(shipExplosion); + this.addFx(shipExplosion, worldX, worldY); } } } onWarshipEvent(unit: UnitView) { if (!unit.isActive()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); + const worldX = this.game.x(unit.lastTile()); + const worldY = this.game.y(unit.lastTile()); const shipExplosion = new UnitExplosionFx( this.animatedSpriteLoader, - x, - y, + 0, + 0, this.game, ); - this.allFx.push(shipExplosion); + this.addFx(shipExplosion, worldX, worldY); const sinkingShip = new SpriteFx( this.animatedSpriteLoader, - x, - y, + 0, + 0, FxType.SinkingShip, undefined, unit.owner(), this.theme, ); - this.allFx.push(sinkingShip); + this.addFx(sinkingShip, worldX, worldY); } } @@ -145,109 +196,89 @@ export class FxLayer implements Layer { } handleNukeExplosion(unit: UnitView, radius: number) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); + const worldX = this.game.x(unit.lastTile()); + const worldY = this.game.y(unit.lastTile()); const nukeFx = nukeFxFactory( this.animatedSpriteLoader, - x, - y, + 0, + 0, radius, this.game, ); - for (const fx of nukeFx) { - this.allFx.push(fx); - } + this.addFx(nukeFx, worldX, worldY); } handleSAMInterception(unit: UnitView) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); + const worldX = this.game.x(unit.lastTile()); + const worldY = this.game.y(unit.lastTile()); const explosion = new SpriteFx( this.animatedSpriteLoader, - x, - y, + 0, + 0, FxType.SAMExplosion, ); - this.allFx.push(explosion); - const shockwave = new ShockwaveFx(x, y, 800, 40); - this.allFx.push(shockwave); + this.addFx(explosion, worldX, worldY); + const shockwave = new ShockwaveFx(0, 0, 800, 40); + this.addFx(shockwave, worldX, worldY); } - async init() { - this.redraw(); - try { - this.animatedSpriteLoader.loadAllAnimatedSpriteImages(); - console.log("FX sprites loaded successfully"); - } catch (err) { - console.error("Failed to load FX sprites:", err); - } + redraw(): void { + // No-op } - 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(); + private updateFxPosition(fxInfo: FxInfo) { + const screenPos = this.transformHandler.worldToScreenCoordinates( + new Cell(fxInfo.worldX, fxInfo.worldY), + ); + const displayObject = fxInfo.fx.getDisplayObject(); + displayObject.x = screenPos.x; + displayObject.y = screenPos.y; + // Scale FX based on zoom level + const scale = this.transformHandler.scale; + displayObject.scale.set(scale); } renderLayer(context: CanvasRenderingContext2D) { + if (!this.renderer) return; + 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.updateFx(delta); this.lastRefresh = now; } - // If the offscreen canvas size matches the game size, use 3-arg drawImage (no scaling) for minor perf gain. - // Otherwise, fall back to 5-arg drawImage to scale correctly. - if ( - this.canvas.width === this.game.width() && - this.canvas.height === this.game.height() - ) { - context.drawImage( - this.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - ); - } else { - context.drawImage( - this.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); + + // Update FX positions when camera changes (like StructureLayer) + if (this.transformHandler.hasChanged()) { + for (const fxInfo of this.allFx) { + this.updateFxPosition(fxInfo); + } } + + this.renderer.render(this.stage); + + context.drawImage(this.pixicanvas, 0, 0); } } - renderAllFx(context: CanvasRenderingContext2D, delta: number) { + updateFx(delta: number) { if (this.allFx.length > 0) { const t0 = performance.now(); - this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); - this.renderContextFx(delta); + + for (let i = this.allFx.length - 1; i >= 0; i--) { + const fxInfo = this.allFx[i]; + if (!fxInfo.fx.update(delta)) { + this.stage.removeChild(fxInfo.fx.getDisplayObject()); + this.allFx.splice(i, 1); + } + } + if (this.adaptiveRefresh) { const elapsed = performance.now() - t0; - // If FX rendering takes longer than ~12ms, drop FX FPS a bit this.refreshRate = elapsed > 12 ? Math.min(33, Math.ceil(elapsed * 2)) : 16; } } } - - renderContextFx(duration: number) { - for (let i = 0; i < this.allFx.length; ) { - const fx = this.allFx[i]; - if (!fx.renderTick(duration, this.context)) { - const last = this.allFx.length - 1; - if (i !== last) this.allFx[i] = this.allFx[last]; - this.allFx.pop(); - } else { - i++; - } - } - } }