From 7520bc8352805726e49693c3690c4bc7cf887e02 Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Fri, 23 May 2025 01:27:07 +0200 Subject: [PATCH] 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 --- resources/sprites/bigsmoke.png | Bin 0 -> 647 bytes resources/sprites/minifire.png | Bin 0 -> 256 bytes resources/sprites/smoke.png | Bin 0 -> 345 bytes resources/sprites/smokeAndFire.png | Bin 0 -> 748 bytes src/client/graphics/AnimatedSprite.ts | 9 +- src/client/graphics/AnimatedSpriteLoader.ts | 40 +++++++++ src/client/graphics/fx/Fx.ts | 4 + src/client/graphics/fx/NukeFx.ts | 94 ++++++++++++++------ src/client/graphics/fx/SpriteFx.ts | 68 ++++++++++++++ src/client/graphics/layers/FxLayer.ts | 22 +++-- 10 files changed, 197 insertions(+), 40 deletions(-) create mode 100644 resources/sprites/bigsmoke.png create mode 100644 resources/sprites/minifire.png create mode 100644 resources/sprites/smoke.png create mode 100644 resources/sprites/smokeAndFire.png create mode 100644 src/client/graphics/fx/SpriteFx.ts diff --git a/resources/sprites/bigsmoke.png b/resources/sprites/bigsmoke.png new file mode 100644 index 0000000000000000000000000000000000000000..01d601570670acb420cf6cec8eec90ad1539fd30 GIT binary patch literal 647 zcmV;20(kw2P)Px%K}keGR9J=WS4(OuF%YZ?zK0=aN#JE+n44(pBV^k*5zN8^@#bA*K=%kt7MZp? ziatG(CVqLiz%a<3s#f(!GH}Nox5QxWeCrrWF#p39E>p?5zWRJHOCsA z$+^)O^U{+0Coot$ANsxnKAQl*XpDK`q#XMGxrSdBrN*Dt37(u|@}`O18XW(j@4Ll# z0KnsZf9B^}^ZnxF1k2yFC|V*qNuE^ksFTE|Y}K&=>{!Y5e5elW$xJ7Ow1C4W!{RxsUrj@9EDLTEMkP{jpktkS9cR;!o(E z-5SD(K=DOO^7+Qc^h&|}EsBzn>MW@v6RYW7|GwkB z<}Vks hn&NNe&mI3MJ^&JV8q%R_8SVf8002ovPDHLkV1iMyI(Yy9 literal 0 HcmV?d00001 diff --git a/resources/sprites/minifire.png b/resources/sprites/minifire.png new file mode 100644 index 0000000000000000000000000000000000000000..a0948a8fe228743e1bb5a1bd60c807d34c2e1215 GIT binary patch literal 256 zcmV+b0ssDqP)Px#x=BPqR7i>KQo9j@Fbowr7V!w_7{>)JC^JE3prX|dsnU6jtU*cjQrX8HvP~SH zq2N_MOYiCFLv$exX2#JTJW83ll@O_p-0o|va5K?R(Ht|(jN+@03FgF72KU6*IpU4( zox;DT@=KH6sMfma#$FV5{~_TNFNALFH=4{0iRX=e?k$ux-t;<2Srav*tPz`VJSpaQ z5@PjenFjz6Ck6n^JczMcyw*M5&xwd8v5D8C5&w_iogQEPc9$+b-!oMJ0000Px$6G=otR7i>Kls#_4Fbsw1pg9KtUBq2F8qiI+^$^{769O8Yp(in*lc@&3DXH~_%n{gkhlygePjh3e-Z zvM%0#)d2RHxsd_PoOGR;lS90fGBsDdwhYxpjSlf{!y(?b7*7Klw#5hz5%M`AIyGi< zYnfVt@z2(G*RYxw?Px%rb$FWRA_5? z@);r*bR5711x@A^(V$^ivkMD;KjM=tFT3pb4f72HHo$@f3*H8dF=UJ(M5MVin0ro* z1rFTX+&s^prr_kbr{G!;0YoJJu(`3t)MxH{F+F+d0DRy?mt_go+9c2Or>d$x^KlF( z06>;yi7XUrZDNd}7=D0u@x}1Gc09yvzGZHfWup_O&6P$PW2mYsh~byUmSx$@r)k>K zk2U?)eng~u6x6+R1{ILYT)9?NRa7wAT=~wh*=+t?E|(~N$t_{6ZMx7@N#`ymHF!LQ zxqbY69HSH39D>Oak@zlU(!O_uF@d}I(lmwDY6SoQbzQ?$k1n13*hfyt`x5hxDOPEy z2IuH-I84_0r5^jvYjWS90@8ER+@dI2Yh#kb(2o|r2%Zn+?n}B4AsWUQlF4>yagI1Y zL-V5F(OifvpfNud_&S_|oDfdVEdyj__O;VK4O!tl_9ZQp-&Z)=+^PAc1^^5)IFw&v zuFlz~(-D+`>jIHofY)7jobM%fOmZZAi3&}MtAmD^5cBhXZvH7X(jQ`^uzpv{rL#%)3&q5VsbsCJIM20dUDOB>Ej<8 zf9RbX5wqxHw~kB#5g)?a)4FOmO53jhFo`e-{Re);+T+~41TsA;ZTYoc=b@?q0000> = { + [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, diff --git a/src/client/graphics/fx/Fx.ts b/src/client/graphics/fx/Fx.ts index a80bae2af..09e4b7fd9 100644 --- a/src/client/graphics/fx/Fx.ts +++ b/src/client/graphics/fx/Fx.ts @@ -3,6 +3,10 @@ export interface Fx { } export enum FxType { + MiniFire = "MiniFire", + MiniSmoke = "MiniSmoke", + MiniBigSmoke = "MiniBigSmoke", + MiniSmokeAndFire = "MiniSmokeAndFire", Nuke = "Nuke", SAMExplosion = "SAMExplosion", } diff --git a/src/client/graphics/fx/NukeFx.ts b/src/client/graphics/fx/NukeFx.ts index 680d4d64d..df6f714d0 100644 --- a/src/client/graphics/fx/NukeFx.ts +++ b/src/client/graphics/fx/NukeFx.ts @@ -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; +} diff --git a/src/client/graphics/fx/SpriteFx.ts b/src/client/graphics/fx/SpriteFx.ts new file mode 100644 index 000000000..a28c39411 --- /dev/null +++ b/src/client/graphics/fx/SpriteFx.ts @@ -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; + } +} diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index 34b46b458..58c74f84f 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -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() {