mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 15:20:43 +00:00
Add naval combat animations (#858)
## Description: https://github.com/user-attachments/assets/b46f949a-eb50-4656-8492-216cf820ac46 Add a couple animations for naval combat: - shell hit - ship explosion - ship sinking Added a simple `Timeline` class to spread FX animations over time. Added a `ColoredAnimatedSprite` similar to the existing `ColoredSprite`. Refactored the latter to avoid code duplication. ## 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:
committed by
Scott Anderson
parent
182a42e0db
commit
cea22c8220
Binary file not shown.
|
After Width: | Height: | Size: 188 B |
Binary file not shown.
|
After Width: | Height: | Size: 461 B |
Binary file not shown.
|
After Width: | Height: | Size: 378 B |
@@ -1,11 +1,17 @@
|
||||
import miniBigSmoke from "../../../resources/sprites/bigsmoke.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 { Theme } from "../../core/configuration/Config";
|
||||
import { PlayerView } from "../../core/game/GameView";
|
||||
import { AnimatedSprite } from "./AnimatedSprite";
|
||||
import { FxType } from "./fx/Fx";
|
||||
import { colorizeCanvas } from "./SpriteLoader";
|
||||
|
||||
type AnimatedSpriteConfig = {
|
||||
url: string;
|
||||
@@ -54,6 +60,33 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
originX: 9,
|
||||
originY: 14,
|
||||
},
|
||||
[FxType.MiniExplosion]: {
|
||||
url: miniExplosion,
|
||||
frameWidth: 13,
|
||||
frameCount: 4,
|
||||
frameDuration: 70,
|
||||
looping: false,
|
||||
originX: 6,
|
||||
originY: 6,
|
||||
},
|
||||
[FxType.UnitExplosion]: {
|
||||
url: unitExplosion,
|
||||
frameWidth: 19,
|
||||
frameCount: 4,
|
||||
frameDuration: 70,
|
||||
looping: false,
|
||||
originX: 9,
|
||||
originY: 9,
|
||||
},
|
||||
[FxType.SinkingShip]: {
|
||||
url: sinkingShip,
|
||||
frameWidth: 16,
|
||||
frameCount: 14,
|
||||
frameDuration: 90,
|
||||
looping: false,
|
||||
originX: 7,
|
||||
originY: 7,
|
||||
},
|
||||
[FxType.Nuke]: {
|
||||
url: nuke,
|
||||
frameWidth: 60,
|
||||
@@ -74,53 +107,115 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
},
|
||||
};
|
||||
|
||||
const animatedSpriteImageMap: Map<FxType, CanvasImageSource> = new Map();
|
||||
export class AnimatedSpriteLoader {
|
||||
private animatedSpriteImageMap: Map<FxType, HTMLCanvasElement> = new Map();
|
||||
// Do not color the same sprite twice
|
||||
private coloredAnimatedSpriteCache: Map<string, HTMLCanvasElement> =
|
||||
new Map();
|
||||
|
||||
export const loadAllAnimatedSpriteImages = async (): Promise<void> => {
|
||||
const entries = Object.entries(ANIMATED_SPRITE_CONFIG);
|
||||
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;
|
||||
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;
|
||||
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);
|
||||
});
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
this.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;
|
||||
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.frameWidth,
|
||||
config.frameCount,
|
||||
config.frameDuration,
|
||||
config.looping ?? true,
|
||||
config.originX,
|
||||
config.originY,
|
||||
);
|
||||
};
|
||||
return new AnimatedSprite(
|
||||
image,
|
||||
config.frameWidth,
|
||||
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 = theme.territoryColor(owner);
|
||||
const borderColor = theme.borderColor(owner);
|
||||
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.frameWidth,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,53 @@ export const isSpriteReady = (unitType: UnitType): boolean => {
|
||||
|
||||
const coloredSpriteCache: Map<string, HTMLCanvasElement> = new Map();
|
||||
|
||||
// puts the sprite in an canvas colors it and caches the colored canvas
|
||||
/**
|
||||
* Load a canvas and replace grayscale with border colors
|
||||
*/
|
||||
export const colorizeCanvas = (
|
||||
source: CanvasImageSource & { width: number; height: number },
|
||||
colorA: Colord,
|
||||
colorB: Colord,
|
||||
colorC: Colord,
|
||||
): HTMLCanvasElement => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = source.width;
|
||||
canvas.height = source.height;
|
||||
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(source, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
const colorARgb = colorA.toRgb();
|
||||
const colorBRgb = colorB.toRgb();
|
||||
const colorCRgb = colorC.toRgb();
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i],
|
||||
g = data[i + 1],
|
||||
b = data[i + 2];
|
||||
|
||||
if (r === 180 && g === 180 && b === 180) {
|
||||
data[i] = colorARgb.r;
|
||||
data[i + 1] = colorARgb.g;
|
||||
data[i + 2] = colorARgb.b;
|
||||
} else if (r === 70 && g === 70 && b === 70) {
|
||||
data[i] = colorBRgb.r;
|
||||
data[i + 1] = colorBRgb.g;
|
||||
data[i + 2] = colorBRgb.b;
|
||||
} else if (r === 130 && g === 130 && b === 130) {
|
||||
data[i] = colorCRgb.r;
|
||||
data[i + 1] = colorCRgb.g;
|
||||
data[i + 2] = colorCRgb.b;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
return canvas;
|
||||
};
|
||||
|
||||
export const getColoredSprite = (
|
||||
unit: UnitView,
|
||||
theme: Theme,
|
||||
@@ -82,8 +128,7 @@ export const getColoredSprite = (
|
||||
const territoryColor = customTerritoryColor ?? theme.territoryColor(owner);
|
||||
const borderColor = customBorderColor ?? theme.borderColor(owner);
|
||||
const spawnHighlightColor = theme.spawnHighlightColor();
|
||||
const colorKey = territoryColor.toRgbString() + borderColor.toRgbString();
|
||||
const key = unit.type() + colorKey;
|
||||
const key = `${unit.type()}-${owner.id()}`;
|
||||
|
||||
if (coloredSpriteCache.has(key)) {
|
||||
return coloredSpriteCache.get(key)!;
|
||||
@@ -94,45 +139,13 @@ export const getColoredSprite = (
|
||||
throw new Error(`Failed to load sprite for ${unit.type()}`);
|
||||
}
|
||||
|
||||
const territoryRgb = territoryColor.toRgb();
|
||||
const borderRgb = borderColor.toRgb();
|
||||
const spawnHighlightRgb = spawnHighlightColor.toRgb();
|
||||
const coloredCanvas = colorizeCanvas(
|
||||
sprite,
|
||||
territoryColor,
|
||||
borderColor,
|
||||
spawnHighlightColor,
|
||||
);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
canvas.width = sprite.width;
|
||||
canvas.height = sprite.height;
|
||||
|
||||
ctx.drawImage(sprite, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
|
||||
if (r === 180 && g === 180 && b === 180) {
|
||||
data[i] = territoryRgb.r;
|
||||
data[i + 1] = territoryRgb.g;
|
||||
data[i + 2] = territoryRgb.b;
|
||||
}
|
||||
|
||||
if (r === 70 && g === 70 && b === 70) {
|
||||
data[i] = borderRgb.r;
|
||||
data[i + 1] = borderRgb.g;
|
||||
data[i + 2] = borderRgb.b;
|
||||
}
|
||||
|
||||
if (r === 130 && g === 130 && b === 130) {
|
||||
data[i] = spawnHighlightRgb.r;
|
||||
data[i + 1] = spawnHighlightRgb.g;
|
||||
data[i + 2] = spawnHighlightRgb.b;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0.5, 0.5);
|
||||
coloredSpriteCache.set(key, canvas);
|
||||
return canvas;
|
||||
coloredSpriteCache.set(key, coloredCanvas);
|
||||
return coloredCanvas;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@ export enum FxType {
|
||||
MiniSmoke = "MiniSmoke",
|
||||
MiniBigSmoke = "MiniBigSmoke",
|
||||
MiniSmokeAndFire = "MiniSmokeAndFire",
|
||||
MiniExplosion = "MiniExplosion",
|
||||
UnitExplosion = "UnitExplosion",
|
||||
SinkingShip = "SinkingShip",
|
||||
Nuke = "Nuke",
|
||||
SAMExplosion = "SAMExplosion",
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
|
||||
import { Fx, FxType } from "./Fx";
|
||||
import { SpriteFX } from "./SpriteFx";
|
||||
import { FadeFx, SpriteFx } from "./SpriteFx";
|
||||
|
||||
/**
|
||||
* Shockwave effect: draw a growing 1px white circle
|
||||
@@ -34,6 +35,7 @@ export class ShockwaveFx implements Fx {
|
||||
* Spawn @p number of @p type animation within a perimeter
|
||||
*/
|
||||
function addSpriteInCircle(
|
||||
animatedSpriteLoader: AnimatedSpriteLoader,
|
||||
x: number,
|
||||
y: number,
|
||||
radius: number,
|
||||
@@ -52,7 +54,11 @@ function addSpriteInCircle(
|
||||
game.isValidCoord(spawnX, spawnY) &&
|
||||
game.isLand(game.ref(spawnX, spawnY))
|
||||
) {
|
||||
const sprite = new SpriteFX(spawnX, spawnY, type, 6000, 0.1, 0.8);
|
||||
const sprite = new FadeFx(
|
||||
new SpriteFx(animatedSpriteLoader, spawnX, spawnY, type, 6000),
|
||||
0.1,
|
||||
0.8,
|
||||
);
|
||||
result.push(sprite as Fx);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +71,7 @@ function addSpriteInCircle(
|
||||
* - ruins and desolation fx
|
||||
*/
|
||||
export function nukeFxFactory(
|
||||
animatedSpriteLoader: AnimatedSpriteLoader,
|
||||
x: number,
|
||||
y: number,
|
||||
radius: number,
|
||||
@@ -72,7 +79,7 @@ export function nukeFxFactory(
|
||||
): Fx[] {
|
||||
const nukeFx: Fx[] = [];
|
||||
// Explosion animation
|
||||
nukeFx.push(new SpriteFX(x, y, FxType.Nuke) as Fx);
|
||||
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
|
||||
@@ -89,6 +96,7 @@ export function nukeFxFactory(
|
||||
|
||||
for (const { type, radiusFactor, density } of debrisPlan) {
|
||||
addSpriteInCircle(
|
||||
animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
radius * radiusFactor,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { AnimatedSprite } from "../AnimatedSprite";
|
||||
import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader";
|
||||
import { Fx, FxType } from "./Fx";
|
||||
|
||||
/**
|
||||
* Explosion effect: sprite animation of an explosion
|
||||
*/
|
||||
export class SAMExplosionFx implements Fx {
|
||||
private lifeTime: number = 0;
|
||||
private explosionSprite: AnimatedSprite | null;
|
||||
constructor(
|
||||
private x: number,
|
||||
private y: number,
|
||||
private duration: number,
|
||||
) {
|
||||
this.explosionSprite = createAnimatedSpriteForUnit(FxType.SAMExplosion);
|
||||
}
|
||||
|
||||
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
|
||||
if (this.explosionSprite) {
|
||||
this.lifeTime += frameTime;
|
||||
if (this.lifeTime >= this.duration) {
|
||||
return false;
|
||||
}
|
||||
if (this.explosionSprite.isActive()) {
|
||||
this.explosionSprite.update(frameTime);
|
||||
this.explosionSprite.draw(ctx, this.x, this.y);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { consolex } from "../../../core/Consolex";
|
||||
import { PlayerView } from "../../../core/game/GameView";
|
||||
import { AnimatedSprite } from "../AnimatedSprite";
|
||||
import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader";
|
||||
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
|
||||
import { Fx, FxType } from "./Fx";
|
||||
|
||||
function fadeInOut(
|
||||
@@ -18,51 +20,73 @@ function fadeInOut(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple FX displaying an animated sprite
|
||||
* Animated sprite. Can be colored if provided an owner/theme
|
||||
*/
|
||||
export class SpriteFX implements Fx {
|
||||
private lifeTime: number = 0;
|
||||
private animatedSprite: AnimatedSprite | null;
|
||||
private totalLifeTime: number = 0;
|
||||
export class SpriteFx implements Fx {
|
||||
protected animatedSprite: AnimatedSprite | null;
|
||||
protected elapsedTime = 0;
|
||||
protected duration = 1000;
|
||||
constructor(
|
||||
private x: number,
|
||||
private y: number,
|
||||
animatedSpriteLoader: AnimatedSpriteLoader,
|
||||
protected x: number,
|
||||
protected y: number,
|
||||
fxType: FxType,
|
||||
duration?: number,
|
||||
private fadeIn?: number,
|
||||
private fadeOut?: number,
|
||||
private owner?: PlayerView,
|
||||
private theme?: Theme,
|
||||
) {
|
||||
this.animatedSprite = createAnimatedSpriteForUnit(fxType);
|
||||
this.animatedSprite = animatedSpriteLoader.createAnimatedSprite(
|
||||
fxType,
|
||||
owner,
|
||||
theme,
|
||||
);
|
||||
if (!this.animatedSprite) {
|
||||
consolex.error("Could not load animated sprite ", fxType);
|
||||
this.totalLifeTime = 0;
|
||||
} else if (!duration) {
|
||||
// When no duration set, rely on the sprite lifetime
|
||||
this.totalLifeTime = this.animatedSprite.lifeTime() ?? 1000; // 1s by default
|
||||
consolex.error("Could not load animated sprite", fxType);
|
||||
} else {
|
||||
this.totalLifeTime = duration;
|
||||
this.duration = duration ?? this.animatedSprite.lifeTime() ?? 1000;
|
||||
}
|
||||
}
|
||||
|
||||
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
|
||||
if (this.animatedSprite) {
|
||||
this.lifeTime += frameTime;
|
||||
if (this.lifeTime >= this.totalLifeTime) {
|
||||
return false;
|
||||
}
|
||||
if (this.animatedSprite.isActive()) {
|
||||
ctx.save();
|
||||
const t = this.lifeTime / this.totalLifeTime;
|
||||
ctx.globalAlpha = fadeInOut(t, this.fadeIn ?? 0, this.fadeOut ?? 0.7);
|
||||
this.animatedSprite.update(frameTime);
|
||||
this.animatedSprite.draw(ctx, this.x, this.y);
|
||||
ctx.restore();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
if (!this.animatedSprite) return false;
|
||||
|
||||
this.elapsedTime += frameTime;
|
||||
if (this.elapsedTime >= this.duration) return false;
|
||||
|
||||
if (!this.animatedSprite.isActive()) return false;
|
||||
|
||||
const t = this.elapsedTime / this.duration;
|
||||
this.animatedSprite.update(frameTime);
|
||||
this.animatedSprite.draw(ctx, this.x, this.y);
|
||||
return true;
|
||||
}
|
||||
|
||||
getElapsedTime(): number {
|
||||
return this.elapsedTime;
|
||||
}
|
||||
|
||||
getDuration(): number {
|
||||
return this.duration;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
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 { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
|
||||
import { Fx, FxType } from "../fx/Fx";
|
||||
import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
|
||||
import { SAMExplosionFx } from "../fx/SAMExplosionFx";
|
||||
import { SpriteFx } from "../fx/SpriteFx";
|
||||
import { UnitExplosionFx } from "../fx/UnitExplosionFx";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
export class FxLayer implements Layer {
|
||||
@@ -13,10 +15,15 @@ export class FxLayer implements Layer {
|
||||
|
||||
private lastRefresh: number = 0;
|
||||
private refreshRate: number = 10;
|
||||
private theme: Theme;
|
||||
private animatedSpriteLoader: AnimatedSpriteLoader =
|
||||
new AnimatedSpriteLoader();
|
||||
|
||||
private allFx: Fx[] = [];
|
||||
|
||||
constructor(private game: GameView) {}
|
||||
constructor(private game: GameView) {
|
||||
this.theme = this.game.config().theme();
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return true;
|
||||
@@ -41,12 +48,58 @@ export class FxLayer implements Layer {
|
||||
case UnitType.HydrogenBomb:
|
||||
this.onNukeEvent(unit, 160);
|
||||
break;
|
||||
case UnitType.Warship:
|
||||
this.onWarshipEvent(unit);
|
||||
break;
|
||||
case UnitType.Shell:
|
||||
this.onShellEvent(unit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onShellEvent(unit: UnitView) {
|
||||
if (!unit.isActive()) {
|
||||
if (unit.reachedTarget()) {
|
||||
const x = this.game.x(unit.lastTile());
|
||||
const y = this.game.y(unit.lastTile());
|
||||
const shipExplosion = new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
FxType.MiniExplosion,
|
||||
);
|
||||
this.allFx.push(shipExplosion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onWarshipEvent(unit: UnitView) {
|
||||
if (!unit.isActive()) {
|
||||
const x = this.game.x(unit.lastTile());
|
||||
const y = this.game.y(unit.lastTile());
|
||||
const shipExplosion = new UnitExplosionFx(
|
||||
this.animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
this.game,
|
||||
);
|
||||
this.allFx.push(shipExplosion);
|
||||
const sinkingShip = new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
FxType.SinkingShip,
|
||||
undefined,
|
||||
unit.owner(),
|
||||
this.theme,
|
||||
);
|
||||
this.allFx.push(sinkingShip);
|
||||
}
|
||||
}
|
||||
|
||||
onNukeEvent(unit: UnitView, radius: number) {
|
||||
if (!unit.isActive()) {
|
||||
if (unit.wasInterceptedBySAM()) {
|
||||
if (!unit.reachedTarget()) {
|
||||
this.handleSAMInterception(unit);
|
||||
} else {
|
||||
// Kaboom
|
||||
@@ -58,15 +111,26 @@ 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 nukeFx = nukeFxFactory(x, y, radius, this.game);
|
||||
const nukeFx = nukeFxFactory(
|
||||
this.animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
radius,
|
||||
this.game,
|
||||
);
|
||||
this.allFx = this.allFx.concat(nukeFx);
|
||||
}
|
||||
|
||||
handleSAMInterception(unit: UnitView) {
|
||||
const x = this.game.x(unit.lastTile());
|
||||
const y = this.game.y(unit.lastTile());
|
||||
const interception = new SAMExplosionFx(x, y, 1000);
|
||||
this.allFx.push(interception);
|
||||
const explosion = new SpriteFx(
|
||||
this.animatedSpriteLoader,
|
||||
x,
|
||||
y,
|
||||
FxType.SAMExplosion,
|
||||
);
|
||||
this.allFx.push(explosion);
|
||||
const shockwave = new ShockwaveFx(x, y, 800, 40);
|
||||
this.allFx.push(shockwave);
|
||||
}
|
||||
@@ -74,7 +138,7 @@ export class FxLayer implements Layer {
|
||||
async init() {
|
||||
this.redraw();
|
||||
try {
|
||||
await loadAllAnimatedSpriteImages();
|
||||
this.animatedSpriteLoader.loadAllAnimatedSpriteImages();
|
||||
console.log("FX sprites loaded successfully");
|
||||
} catch (err) {
|
||||
console.error("Failed to load FX sprites:", err);
|
||||
|
||||
@@ -239,6 +239,7 @@ export class NukeExecution implements Execution {
|
||||
}
|
||||
}
|
||||
this.active = false;
|
||||
this.nuke.setReachedTarget();
|
||||
this.nuke.delete(false);
|
||||
|
||||
// Record stats
|
||||
|
||||
@@ -181,7 +181,6 @@ export class SAMLauncherExecution implements Execution {
|
||||
);
|
||||
// Delete warheads
|
||||
mirvWarheadTargets.forEach((u) => {
|
||||
u.setInterceptedBySam();
|
||||
u.delete();
|
||||
});
|
||||
} else if (target !== null) {
|
||||
|
||||
@@ -66,7 +66,6 @@ export class SAMMissileExecution implements Execution {
|
||||
this._owner.id(),
|
||||
);
|
||||
this.active = false;
|
||||
this.target.setInterceptedBySam();
|
||||
this.target.delete(true, this._owner);
|
||||
this.SAMMissile.delete(false);
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ export class ShellExecution implements Execution {
|
||||
if (result === true) {
|
||||
this.active = false;
|
||||
this.target.modifyHealth(-this.effectOnTarget(), this._owner);
|
||||
this.shell.setReachedTarget();
|
||||
this.shell.delete(false);
|
||||
return;
|
||||
} else {
|
||||
|
||||
@@ -369,8 +369,8 @@ export interface Unit {
|
||||
targetUnit(): Unit | undefined;
|
||||
setTargetedBySAM(targeted: boolean): void;
|
||||
targetedBySAM(): boolean;
|
||||
setInterceptedBySam(): void;
|
||||
interceptedBySam(): boolean;
|
||||
setReachedTarget(): void;
|
||||
reachedTarget(): boolean;
|
||||
|
||||
// Health
|
||||
hasHealth(): boolean;
|
||||
|
||||
@@ -73,7 +73,7 @@ export interface UnitUpdate {
|
||||
pos: TileRef;
|
||||
lastPos: TileRef;
|
||||
isActive: boolean;
|
||||
wasIntercepted: boolean;
|
||||
reachedTarget: boolean;
|
||||
retreating: boolean;
|
||||
targetUnitId?: number; // Only for trade ships
|
||||
targetTile?: TileRef; // Only for nukes
|
||||
|
||||
@@ -93,8 +93,8 @@ export class UnitView {
|
||||
isActive(): boolean {
|
||||
return this.data.isActive;
|
||||
}
|
||||
wasInterceptedBySAM(): boolean {
|
||||
return this.data.wasIntercepted;
|
||||
reachedTarget(): boolean {
|
||||
return this.data.reachedTarget;
|
||||
}
|
||||
hasHealth(): boolean {
|
||||
return this.data.health !== undefined;
|
||||
|
||||
@@ -21,7 +21,7 @@ export class UnitImpl implements Unit {
|
||||
private _lastTile: TileRef;
|
||||
private _retreating: boolean = false;
|
||||
private _targetedBySAM = false;
|
||||
private _interceptedBySAM = false;
|
||||
private _reachedTarget = false;
|
||||
private _lastSetSafeFromPirates: number; // Only for trade ships
|
||||
private _constructionType: UnitType | undefined;
|
||||
private _lastOwner: PlayerImpl | null = null;
|
||||
@@ -104,7 +104,7 @@ export class UnitImpl implements Unit {
|
||||
ownerID: this._owner.smallID(),
|
||||
lastOwnerID: this._lastOwner?.smallID(),
|
||||
isActive: this._active,
|
||||
wasIntercepted: this._interceptedBySAM,
|
||||
reachedTarget: this._reachedTarget,
|
||||
retreating: this._retreating,
|
||||
pos: this._tile,
|
||||
lastPos: this._lastTile,
|
||||
@@ -324,12 +324,12 @@ export class UnitImpl implements Unit {
|
||||
return this._targetedBySAM;
|
||||
}
|
||||
|
||||
setInterceptedBySam(): void {
|
||||
this._interceptedBySAM = true;
|
||||
setReachedTarget(): void {
|
||||
this._reachedTarget = true;
|
||||
}
|
||||
|
||||
interceptedBySam(): boolean {
|
||||
return this._interceptedBySAM;
|
||||
reachedTarget(): boolean {
|
||||
return this._reachedTarget;
|
||||
}
|
||||
|
||||
setSafeFromPirates(): void {
|
||||
|
||||
Reference in New Issue
Block a user