Add ruins and desolation FX on nuke explosions (#847)

## Description:

Add a few animations after a nuke exploded:
- small fire
- big fire
- small smoke
- big smokes


https://github.com/user-attachments/assets/6ef7c1e3-ae3e-4420-aab2-3a3a3630ad98

## 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:
DevelopingTom
2025-05-23 01:27:07 +02:00
committed by GitHub
parent 85c03d659c
commit 7520bc8352
10 changed files with 197 additions and 40 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

+8 -1
View File
@@ -9,7 +9,7 @@ export class AnimatedSprite {
private frameWidth: number,
private frameCount: number,
private frameDuration: number, // in milliseconds
private looping: boolean = true,
private looping: boolean = false,
private originX: number,
private originY: number,
) {
@@ -42,6 +42,13 @@ export class AnimatedSprite {
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;
@@ -1,5 +1,9 @@
import miniBigSmoke from "../../../resources/sprites/bigsmoke.png";
import miniFire from "../../../resources/sprites/minifire.png";
import nuke from "../../../resources/sprites/nukeExplosion.png";
import SAMExplosion from "../../../resources/sprites/samExplosion.png";
import miniSmoke from "../../../resources/sprites/smoke.png";
import miniSmokeAndFire from "../../../resources/sprites/smokeAndFire.png";
import { AnimatedSprite } from "./AnimatedSprite";
import { FxType } from "./fx/Fx";
@@ -14,6 +18,42 @@ type AnimatedSpriteConfig = {
};
const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
[FxType.MiniFire]: {
url: miniFire,
frameWidth: 7,
frameCount: 6,
frameDuration: 100,
looping: true,
originX: 3,
originY: 11,
},
[FxType.MiniSmoke]: {
url: miniSmoke,
frameWidth: 11,
frameCount: 4,
frameDuration: 120,
looping: true,
originX: 2,
originY: 10,
},
[FxType.MiniBigSmoke]: {
url: miniBigSmoke,
frameWidth: 24,
frameCount: 5,
frameDuration: 120,
looping: true,
originX: 9,
originY: 14,
},
[FxType.MiniSmokeAndFire]: {
url: miniSmokeAndFire,
frameWidth: 24,
frameCount: 5,
frameDuration: 120,
looping: true,
originX: 9,
originY: 14,
},
[FxType.Nuke]: {
url: nuke,
frameWidth: 60,
+4
View File
@@ -3,6 +3,10 @@ export interface Fx {
}
export enum FxType {
MiniFire = "MiniFire",
MiniSmoke = "MiniSmoke",
MiniBigSmoke = "MiniBigSmoke",
MiniSmokeAndFire = "MiniSmokeAndFire",
Nuke = "Nuke",
SAMExplosion = "SAMExplosion",
}
+67 -27
View File
@@ -1,6 +1,6 @@
import { AnimatedSprite } from "../AnimatedSprite";
import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader";
import { GameView } from "../../../core/game/GameView";
import { Fx, FxType } from "./Fx";
import { SpriteFX } from "./SpriteFx";
/**
* Shockwave effect: draw a growing 1px white circle
@@ -31,32 +31,72 @@ export class ShockwaveFx implements Fx {
}
/**
* Explosion effect: sprite animation of an explosion
* Spawn @p number of @p type animation within a perimeter
*/
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;
function addSpriteInCircle(
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 SpriteFX(spawnX, spawnY, type, 6000, 0.1, 0.8);
result.push(sprite as Fx);
}
return false;
}
}
/**
* Explosion effect:
* - explosion animation
* - shockwave
* - ruins and desolation fx
*/
export function nukeFxFactory(
x: number,
y: number,
radius: number,
game: GameView,
): Fx[] {
const nukeFx: Fx[] = [];
// Explosion animation
nukeFx.push(new SpriteFX(x, y, FxType.Nuke) as Fx);
// 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(
x,
y,
radius * radiusFactor,
radius * density,
type,
nukeFx,
game,
);
}
return nukeFx;
}
+68
View File
@@ -0,0 +1,68 @@
import { consolex } from "../../../core/Consolex";
import { AnimatedSprite } from "../AnimatedSprite";
import { createAnimatedSpriteForUnit } 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;
}
}
/**
* A simple FX displaying an animated sprite
*/
export class SpriteFX implements Fx {
private lifeTime: number = 0;
private animatedSprite: AnimatedSprite | null;
private totalLifeTime: number = 0;
constructor(
private x: number,
private y: number,
fxType: FxType,
duration?: number,
private fadeIn?: number,
private fadeOut?: number,
) {
this.animatedSprite = createAnimatedSpriteForUnit(fxType);
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
} else {
this.totalLifeTime = duration;
}
}
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;
}
}
+10 -12
View File
@@ -3,7 +3,7 @@ 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 { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
import { SAMExplosionFx } from "../fx/SAMExplosionFx";
import { Layer } from "./Layer";
@@ -36,41 +36,39 @@ export class FxLayer implements Layer {
switch (unit.type()) {
case UnitType.AtomBomb:
case UnitType.MIRVWarhead:
this.handleNukes(unit, 70);
this.onNukeEvent(unit, 70);
break;
case UnitType.HydrogenBomb:
this.handleNukes(unit, 250);
this.onNukeEvent(unit, 160);
break;
}
}
handleNukes(unit: UnitView, shockwaveRadius: number) {
onNukeEvent(unit: UnitView, radius: number) {
if (!unit.isActive()) {
if (unit.wasInterceptedBySAM()) {
this.handleSAMInterception(unit);
} else {
// Kaboom
this.handleNukeExplosion(unit, shockwaveRadius);
this.handleNukeExplosion(unit, radius);
}
}
}
handleNukeExplosion(unit: UnitView, shockwaveRadius: number) {
handleNukeExplosion(unit: UnitView, radius: number) {
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);
const nukeFx = nukeFxFactory(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 as Fx);
this.allFx.push(interception);
const shockwave = new ShockwaveFx(x, y, 800, 40);
this.allFx.push(shockwave as Fx);
this.allFx.push(shockwave);
}
async init() {