delete dead canvas2D FX system

graphics/fx/ (6 files) and the AnimatedSprite/AnimatedSpriteLoader pair
were the canvas2D-era visual-effects pipeline. WebGL has its own FX
stack now (render/gl/passes/fx-pass/), so nothing outside the dead
cluster imported any of these. The only "reference" left was a stale
comment in fx-sprite-pass.ts.
This commit is contained in:
evanpelle
2026-05-17 13:02:00 -07:00
parent 8a4b12c4d6
commit be182bb7f7
8 changed files with 0 additions and 656 deletions
-86
View File
@@ -1,86 +0,0 @@
export class AnimatedSprite {
private frameHeight: number;
private frameWidth: number;
private currentFrame: number = 0;
private elapsedTime: number = 0;
private active: boolean = true;
constructor(
private image: CanvasImageSource,
private frameCount: number,
private frameDuration: number, // in milliseconds
private looping: boolean = false,
private originX: number,
private originY: number,
) {
if (frameCount <= 0) {
throw new Error("Animated sprite should at least have one frame");
}
if ("height" in image && "width" in image) {
this.frameHeight = (image as HTMLImageElement | HTMLCanvasElement).height;
this.frameWidth = Math.floor(
(image as HTMLImageElement | HTMLCanvasElement).width / frameCount,
);
} else {
throw new Error(
"Image source must have 'width' and 'height' properties.",
);
}
}
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;
}
lifeTime(): number | undefined {
if (this.looping) {
return undefined;
}
return this.frameDuration * this.frameCount;
}
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;
}
}
-236
View File
@@ -1,236 +0,0 @@
import { Theme } from "src/core/configuration/Theme";
import miniBigSmoke from "../../../resources/sprites/bigsmoke.png";
import buildingExplosion from "../../../resources/sprites/buildingExplosion.png";
import conquestSword from "../../../resources/sprites/conquestSword.png";
import dust from "../../../resources/sprites/dust.png";
import miniExplosion from "../../../resources/sprites/miniExplosion.png";
import miniFire from "../../../resources/sprites/minifire.png";
import nuke from "../../../resources/sprites/nukeExplosion.png";
import SAMExplosion from "../../../resources/sprites/samExplosion.png";
import sinkingShip from "../../../resources/sprites/sinkingShip.png";
import miniSmoke from "../../../resources/sprites/smoke.png";
import miniSmokeAndFire from "../../../resources/sprites/smokeAndFire.png";
import unitExplosion from "../../../resources/sprites/unitExplosion.png";
import { PlayerView } from "../../core/game/GameView";
import { AnimatedSprite } from "./AnimatedSprite";
import { FxType } from "./fx/Fx";
import { colorizeCanvas } from "./SpriteLoader";
type AnimatedSpriteConfig = {
url: string;
frameCount: number;
frameDuration: number; // ms per frame
looping?: boolean;
originX: number;
originY: number;
};
const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
[FxType.MiniFire]: {
url: miniFire,
frameCount: 6,
frameDuration: 100,
looping: true,
originX: 3,
originY: 11,
},
[FxType.MiniSmoke]: {
url: miniSmoke,
frameCount: 4,
frameDuration: 120,
looping: true,
originX: 2,
originY: 10,
},
[FxType.MiniBigSmoke]: {
url: miniBigSmoke,
frameCount: 5,
frameDuration: 120,
looping: true,
originX: 9,
originY: 14,
},
[FxType.MiniSmokeAndFire]: {
url: miniSmokeAndFire,
frameCount: 6,
frameDuration: 120,
looping: true,
originX: 9,
originY: 14,
},
[FxType.MiniExplosion]: {
url: miniExplosion,
frameCount: 4,
frameDuration: 70,
looping: false,
originX: 6,
originY: 6,
},
[FxType.Dust]: {
url: dust,
frameCount: 3,
frameDuration: 100,
looping: false,
originX: 4,
originY: 5,
},
[FxType.UnitExplosion]: {
url: unitExplosion,
frameCount: 4,
frameDuration: 70,
looping: false,
originX: 9,
originY: 9,
},
[FxType.BuildingExplosion]: {
url: buildingExplosion,
frameCount: 10,
frameDuration: 70,
looping: false,
originX: 8,
originY: 8,
},
[FxType.SinkingShip]: {
url: sinkingShip,
frameCount: 14,
frameDuration: 90,
looping: false,
originX: 7,
originY: 7,
},
[FxType.Nuke]: {
url: nuke,
frameCount: 9,
frameDuration: 70,
looping: false,
originX: 30,
originY: 30,
},
[FxType.SAMExplosion]: {
url: SAMExplosion,
frameCount: 9,
frameDuration: 70,
looping: false,
originX: 23,
originY: 19,
},
[FxType.Conquest]: {
url: conquestSword,
frameCount: 10,
frameDuration: 90,
looping: false,
originX: 10,
originY: 16,
},
};
export class AnimatedSpriteLoader {
private animatedSpriteImageMap: Map<FxType, HTMLCanvasElement> = new Map();
// Do not color the same sprite twice
private coloredAnimatedSpriteCache: Map<string, HTMLCanvasElement> =
new Map();
public async loadAllAnimatedSpriteImages(): 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);
this.animatedSpriteImageMap.set(typedFxType, canvas);
} catch (err) {
console.error(`Failed to load sprite for ${typedFxType}:`, err);
}
}),
);
}
private createRegularAnimatedSprite(fxType: FxType): AnimatedSprite | null {
const config = ANIMATED_SPRITE_CONFIG[fxType];
const image = this.animatedSpriteImageMap.get(fxType);
if (!config || !image) return null;
return new AnimatedSprite(
image,
config.frameCount,
config.frameDuration,
config.looping ?? true,
config.originX,
config.originY,
);
}
private getColoredAnimatedSprite(
owner: PlayerView,
fxType: FxType,
theme: Theme,
): HTMLCanvasElement | null {
const baseImage = this.animatedSpriteImageMap.get(fxType);
const config = ANIMATED_SPRITE_CONFIG[fxType];
if (!baseImage || !config) return null;
const territoryColor = owner.territoryColor();
const borderColor = owner.borderColor();
const spawnHighlightColor = theme.spawnHighlightColor();
const key = `${fxType}-${owner.id()}`;
let coloredCanvas: HTMLCanvasElement;
if (this.coloredAnimatedSpriteCache.has(key)) {
coloredCanvas = this.coloredAnimatedSpriteCache.get(key)!;
} else {
coloredCanvas = colorizeCanvas(
baseImage,
territoryColor,
borderColor,
spawnHighlightColor,
);
this.coloredAnimatedSpriteCache.set(key, coloredCanvas);
}
return coloredCanvas;
}
private createColoredAnimatedSpriteForUnit(
fxType: FxType,
owner: PlayerView,
theme: Theme,
): AnimatedSprite | null {
const config = ANIMATED_SPRITE_CONFIG[fxType];
const image = this.getColoredAnimatedSprite(owner, fxType, theme);
if (!config || !image) return null;
return new AnimatedSprite(
image,
config.frameCount,
config.frameDuration,
config.looping ?? true,
config.originX,
config.originY,
);
}
public createAnimatedSprite(
fxType: FxType,
owner?: PlayerView,
theme?: Theme,
): AnimatedSprite | null {
if (owner && theme) {
return this.createColoredAnimatedSpriteForUnit(fxType, owner, theme);
}
return this.createRegularAnimatedSprite(fxType);
}
}
-28
View File
@@ -1,28 +0,0 @@
import { ConquestUpdate } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "./Fx";
import { FadeFx, SpriteFx } from "./SpriteFx";
/**
* Conquest FX:
* - conquest sprite
*/
export function conquestFxFactory(
animatedSpriteLoader: AnimatedSpriteLoader,
conquest: ConquestUpdate,
game: GameView,
): Fx {
const conquered = game.player(conquest.conqueredId);
const x = conquered.nameLocation().x;
const y = conquered.nameLocation().y;
const swordAnimation = new SpriteFx(
animatedSpriteLoader,
x,
y,
FxType.Conquest,
2500,
);
return new FadeFx(swordAnimation, 0.1, 0.6);
}
-19
View File
@@ -1,19 +0,0 @@
export interface Fx {
renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean;
}
export enum FxType {
MiniFire = "MiniFire",
MiniSmoke = "MiniSmoke",
MiniBigSmoke = "MiniBigSmoke",
MiniSmokeAndFire = "MiniSmokeAndFire",
MiniExplosion = "MiniExplosion",
UnitExplosion = "UnitExplosion",
BuildingExplosion = "BuildingExplosion",
SinkingShip = "SinkingShip",
Nuke = "Nuke",
SAMExplosion = "SAMExplosion",
UnderConstruction = "UnderConstruction",
Dust = "Dust",
Conquest = "Conquest",
}
-110
View File
@@ -1,110 +0,0 @@
import { GameView } from "../../../core/game/GameView";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "./Fx";
import { FadeFx, SpriteFx } from "./SpriteFx";
/**
* 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;
}
}
/**
* Spawn @p number of @p type animation within a perimeter
*/
function addSpriteInCircle(
animatedSpriteLoader: AnimatedSpriteLoader,
x: number,
y: number,
radius: number,
num: number,
type: FxType,
result: Fx[],
game: GameView,
) {
const count = Math.max(0, Math.floor(num));
for (let i = 0; i < count; i++) {
const angle = Math.random() * 2 * Math.PI;
const distance = Math.random() * (radius / 2);
const spawnX = Math.floor(x + Math.cos(angle) * distance);
const spawnY = Math.floor(y + Math.sin(angle) * distance);
if (
game.isValidCoord(spawnX, spawnY) &&
game.isLand(game.ref(spawnX, spawnY))
) {
const sprite = new FadeFx(
new SpriteFx(animatedSpriteLoader, spawnX, spawnY, type, 6000),
0.1,
0.8,
);
result.push(sprite as Fx);
}
}
}
/**
* Explosion effect:
* - explosion animation
* - shockwave
* - ruins and desolation fx
*/
export function nukeFxFactory(
animatedSpriteLoader: AnimatedSpriteLoader,
x: number,
y: number,
radius: number,
game: GameView,
): Fx[] {
const nukeFx: Fx[] = [];
// Explosion animation
nukeFx.push(new SpriteFx(animatedSpriteLoader, x, y, FxType.Nuke));
// Shockwave animation
nukeFx.push(new ShockwaveFx(x, y, 1500, radius * 1.5));
// Ruins and desolation sprites
const debrisPlan: Array<{
type: FxType;
radiusFactor: number;
density: number;
}> = [
{ type: FxType.MiniFire, radiusFactor: 1.0, density: 1 / 25 },
{ type: FxType.MiniSmoke, radiusFactor: 1.0, density: 1 / 28 },
{ type: FxType.MiniBigSmoke, radiusFactor: 0.9, density: 1 / 70 },
{ type: FxType.MiniSmokeAndFire, radiusFactor: 0.9, density: 1 / 70 },
];
for (const { type, radiusFactor, density } of debrisPlan) {
addSpriteInCircle(
animatedSpriteLoader,
x,
y,
radius * radiusFactor,
radius * density,
type,
nukeFx,
game,
);
}
return nukeFx;
}
-97
View File
@@ -1,97 +0,0 @@
import { Theme } from "src/core/configuration/Theme";
import { PlayerView } from "../../../core/game/GameView";
import { AnimatedSprite } from "../AnimatedSprite";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "./Fx";
function fadeInOut(
t: number,
fadeIn: number = 0.3,
fadeOut: number = 0.7,
): number {
if (t < fadeIn) {
const f = t / fadeIn; // Map to [0, 1]
return f * f;
} else if (t < fadeOut) {
return 1;
} else {
const f = (t - fadeOut) / (1 - fadeOut); // Map to [0, 1]
return 1 - f * f;
}
}
/**
* Fade in/out another FX
*/
export class FadeFx implements Fx {
constructor(
private fxToFade: SpriteFx,
private fadeIn: number,
private fadeOut: number,
) {}
renderTick(duration: number, ctx: CanvasRenderingContext2D): 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;
}
}
/**
* Animated sprite. Can be colored if provided an owner/theme
*/
export class SpriteFx implements Fx {
protected animatedSprite: AnimatedSprite | null;
protected elapsedTime = 0;
protected duration: number;
protected waitToTheEnd = false;
constructor(
animatedSpriteLoader: AnimatedSpriteLoader,
protected x: number,
protected y: number,
fxType: FxType,
duration?: number,
owner?: PlayerView,
theme?: Theme,
) {
this.animatedSprite = animatedSpriteLoader.createAnimatedSprite(
fxType,
owner,
theme,
);
if (!this.animatedSprite) {
console.error("Could not load animated sprite", fxType);
} else {
this.waitToTheEnd = duration ? true : false;
this.duration = duration ?? this.animatedSprite.lifeTime() ?? 1000;
}
}
public setPosition(x: number, y: number): void {
this.x = x;
this.y = y;
}
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
if (!this.animatedSprite) return false;
this.elapsedTime += frameTime;
if (this.elapsedTime >= this.duration) return false;
if (!this.animatedSprite.isActive() && !this.waitToTheEnd) return false;
this.animatedSprite.update(frameTime);
this.animatedSprite.draw(ctx, this.x, this.y);
return true;
}
getElapsedTime(): number {
return this.elapsedTime;
}
getDuration(): number {
return this.duration;
}
}
-33
View File
@@ -1,33 +0,0 @@
type TimedTask = {
delay: number;
action: () => void;
triggered: boolean;
};
/**
* Basic timeline to chain actions
*/
export class Timeline {
private tasks: TimedTask[] = [];
private timeElapsed = 0;
add(delay: number, action: () => void): Timeline {
this.tasks.push({ delay, action, triggered: false });
return this;
}
update(dt: number) {
this.timeElapsed += dt;
for (const task of this.tasks) {
if (!task.triggered && this.timeElapsed >= task.delay) {
task.action();
task.triggered = true;
}
}
}
isComplete() {
return this.tasks.every((t) => t.triggered);
}
}
-47
View File
@@ -1,47 +0,0 @@
import { GameView } from "../../../core/game/GameView";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "./Fx";
import { SpriteFx } from "./SpriteFx";
import { Timeline } from "./Timeline";
/**
* Explosion Effect: a few timed explosions
*/
export class UnitExplosionFx implements Fx {
private timeline = new Timeline();
private explosions: Fx[] = [];
constructor(
animatedSpriteLoader: AnimatedSpriteLoader,
private x: number,
private y: number,
game: GameView,
) {
const config = [
{ dx: 0, dy: 0, delay: 0, type: FxType.UnitExplosion },
{ dx: 4, dy: -6, delay: 80, type: FxType.UnitExplosion },
{ dx: -6, dy: 4, delay: 160, type: FxType.UnitExplosion },
];
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),
);
}
});
}
}
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
this.timeline.update(frameTime);
let allDone = true;
for (const fx of this.explosions) {
if (fx.renderTick(frameTime, ctx)) {
allDone = false;
}
}
return !allDone || !this.timeline.isComplete();
}
}