Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/client/graphics/AnimatedSpriteLoader.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -228,4 +229,39 @@
}
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;

Check warning on line 245 in src/client/graphics/AnimatedSpriteLoader.ts

View workflow job for this annotation

GitHub Actions / 🔍 ESLint

Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator. (@typescript-eslint/prefer-nullish-coalescing)
}

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];
}
}
31 changes: 17 additions & 14 deletions src/client/graphics/GameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();

Expand Down
5 changes: 4 additions & 1 deletion src/client/graphics/fx/Fx.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
29 changes: 21 additions & 8 deletions src/client/graphics/fx/NukeFx.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
}
}

/**
Expand Down
98 changes: 73 additions & 25 deletions src/client/graphics/fx/SpriteFx.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -29,23 +29,28 @@
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();
}
}

/**
* 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,
Expand All @@ -56,30 +61,69 @@
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;

Check warning on line 111 in src/client/graphics/fx/SpriteFx.ts

View workflow job for this annotation

GitHub Actions / 🔍 ESLint

Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator. (@typescript-eslint/prefer-nullish-coalescing)
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;
}

Expand All @@ -90,4 +134,8 @@
getDuration(): number {
return this.duration;
}

getDisplayObject(): PIXI.Container {
return this.container;
}
}
26 changes: 19 additions & 7 deletions src/client/graphics/fx/UnitExplosionFx.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,13 +11,15 @@ import { Timeline } from "./Timeline";
export class UnitExplosionFx implements Fx {
private timeline = new Timeline();
private explosions: Fx[] = [];
private container: PIXI.Container;

constructor(
animatedSpriteLoader: AnimatedSpriteLoader,
private x: number,
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 },
Expand All @@ -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;
}
}
Loading
Loading