From 85c03d659c6ac00583757ce3c9318cbcfd6dcb4a Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Thu, 22 May 2025 23:11:31 +0200 Subject: [PATCH] Add SAM interception FX (#830) ## Description: Add SAM interception animation: https://github.com/user-attachments/assets/5bfae4f2-f040-41cb-8fba-790538091807 Previously an intercepted nuke detonated with the regular nuke explosion, which was confusing. ## 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/samExplosion.png | Bin 0 -> 2334 bytes src/client/graphics/AnimatedSpriteLoader.ts | 10 ++++++ src/client/graphics/fx/Fx.ts | 1 + src/client/graphics/fx/SAMExplosionFx.ts | 34 ++++++++++++++++++ src/client/graphics/layers/FxLayer.ts | 37 +++++++++++++++----- src/core/execution/SAMLauncherExecution.ts | 5 ++- src/core/execution/SAMMissileExecution.ts | 1 + src/core/game/Game.ts | 2 ++ src/core/game/GameUpdates.ts | 1 + src/core/game/GameView.ts | 3 ++ src/core/game/UnitImpl.ts | 10 ++++++ 11 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 resources/sprites/samExplosion.png create mode 100644 src/client/graphics/fx/SAMExplosionFx.ts diff --git a/resources/sprites/samExplosion.png b/resources/sprites/samExplosion.png new file mode 100644 index 0000000000000000000000000000000000000000..0e6cb854482dd76602f9a6604df9c9ac944e7aca GIT binary patch literal 2334 zcmV+(3E}pMP)Px-*hxe|RCt{2oxM&QO%#UDesYCGDw3WWatkE5f|9~1kfPiHmU1CT&K*RND1stN zsDN+_ftn&!AQh2ZS(>jP$2lI)&d&dj_dJrNWbK_fv+T~h@B9KV48t%C!!S(Wm`#Ra zHW`ZCaky}>w=3>^pR^F-O|M~3~{QUMS zsGIq?_6lOV7v${Xs_Bb}9qjFjn#-Chezu)Vb}q2gy%?-8@fP^5n2Z^OsMpa>7f zm)XjJF^tZ|(%W&D11Q-cs45j~x|5sma>q_9eAmh_R~dY-W3Img-Up?mSJcD9*kf9m zqj&-2b1oF>1m)OO52su~l~Q@iv9t+*>bsm}kmIjO075&D)B+GX|IngR6NB6VFn*vY z%>V=nfvRb4nmDd52II>HMNIc5T|!fj3L2}*oltbgOQ?8Cy~ZKYx#S$Qz0~-m6`NA- zS4vUAF!oTfT06O&3!07+9GLFG{ru(iLf?>FrVsCb06;korj676X5QDOqJ-uvUs9K$ zEA}p-YIDme%0XY$Vx%3j3@OCRC6DYC8RL%cFzlQ{CRZ%7Xz7?9Zo`N7KZ+Bu(|xmW zWKkErCMT*54Ys zJf%9A+TmgJmGI)_rzXY__*|2L5uah?I>$4c3`L5`&Nku&@K3mC>^f0NHSM8m*f#y{ z$!O_ffq6Q=tnUI09ZqZJ{5=}lnp3in>Tk#;qIdt)CfB;g181f)HpYAvC^mw#1DRMBrY+_XSG#^{{MNtWt+ypcjUp6>zFGN}7Youxl zH0A8#s*x*yRdd|g#Z_}WeSqWX1Nf|)sB%QE;)TfPnoO>Bab~HS_F_%a&f!K)^W?XT zzbuH>_jl?az42)r4YKbaBhE zsJ^oqA}yy8q6{j>ql!i4pnUQ}+S1d_{WDEJg!|H^ugXXDd(&%{x?d{v$jY4kY9@KJ z0|O#n-`_od-h5Sm@OxNpVvG&Cqc#Eh^Lq@+_e_OUGt%Q6Gv!^Yd;!t zJblPo(c(38jiIy=i>oeLl;8rU6ROfgEX06}l!H33Q=hWFF#&l5p-Eb)9dX-3)iyZ8 z1ze${Hrhra){IRVO5NIJ6`I*r{ay+xU?u`pl!%3JPzha_40TXAQq8srEvE|$AzY<6 zAyZdVa%I;?R-uAd(U<#-2XXWq-Tbi7#CWLafRNBlV-%CVEtAU7WG%m#%MFDdX5AKR zjFl{6hz(9!R`tV$-ZmsuS~jeLkm<_60@hQW9*>lRH~{{wVi6VG_G8 zhLYa)L&OqGR8m3b-o;)BH&)txhXt%MLXEO}@$yq6Pj1GvV@(2%0rq>9*8gSdcI1(i zwN!Sw;tcH`SHYpE2z8xGzaN7Nn90C^l7`cVh$Uxv&_Vh1E2eBhL`vzma)DNOZPNi% zdzzUHpHI#G&X$vd!gJczLrjJ1svf%YORun*MS?ppU4>qfc-2e@cO;8mO2-xQt(S^G z>bGfJ)6nO9nV4me)5hWNHvM<>cfP~tZID+LIoR73zWK{rtHfS-xJGfzy|8gt-o2m5 z4B+lyN?A^7ePd!B3!TUua`g>UYTi^4AWmhJb5AYP{B1Xs3{8y5#bUpCb6VCvrByDIz= zHX*dR#-%J%l|gR0niF$38>;%MsEC>LW#PJc)5GoJ z1*`^KEA`xK0LqwD9wi7a#bqUVli#JnHGb6#QVHd8VHL_y9O8N;=AV*Q4^e>gwp@1a z&AtEEY%+9;JJk8U_;&v1o=a8ZzyeC`@dMaRFpn`Ay=GYLR)pBQD97UI@ksc%K;HrZk(7T5$EawE-D zf5-csEdd@oGAq-Aau?=h;^DHNTC7+YhDl70!0o8=gW zVR}po0ShU-lvI67ER+nxFiaF;31li*AQ^^X7^ZOk2bB{H@3%-s!T> = { originX: 30, originY: 30, }, + [FxType.SAMExplosion]: { + url: SAMExplosion, + frameWidth: 48, + frameCount: 9, + frameDuration: 70, + looping: false, + originX: 23, + originY: 19, + }, }; const animatedSpriteImageMap: Map = new Map(); diff --git a/src/client/graphics/fx/Fx.ts b/src/client/graphics/fx/Fx.ts index 8956c4ca4..a80bae2af 100644 --- a/src/client/graphics/fx/Fx.ts +++ b/src/client/graphics/fx/Fx.ts @@ -4,4 +4,5 @@ export interface Fx { export enum FxType { Nuke = "Nuke", + SAMExplosion = "SAMExplosion", } diff --git a/src/client/graphics/fx/SAMExplosionFx.ts b/src/client/graphics/fx/SAMExplosionFx.ts new file mode 100644 index 000000000..3be5c3a79 --- /dev/null +++ b/src/client/graphics/fx/SAMExplosionFx.ts @@ -0,0 +1,34 @@ +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; + } +} diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index f19147b65..34b46b458 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -4,6 +4,7 @@ import { GameView, UnitView } from "../../../core/game/GameView"; import { loadAllAnimatedSpriteImages } from "../AnimatedSpriteLoader"; import { Fx } from "../fx/Fx"; import { NukeExplosionFx, ShockwaveFx } from "../fx/NukeFx"; +import { SAMExplosionFx } from "../fx/SAMExplosionFx"; import { Layer } from "./Layer"; export class FxLayer implements Layer { @@ -35,25 +36,43 @@ export class FxLayer implements Layer { switch (unit.type()) { case UnitType.AtomBomb: case UnitType.MIRVWarhead: - this.handleNukeExplosion(unit, 70); + this.handleNukes(unit, 70); break; case UnitType.HydrogenBomb: - this.handleNukeExplosion(unit, 250); + this.handleNukes(unit, 250); break; } } - handleNukeExplosion(unit: UnitView, shockwaveRadius: number) { + handleNukes(unit: UnitView, shockwaveRadius: number) { if (!unit.isActive()) { - 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); + if (unit.wasInterceptedBySAM()) { + this.handleSAMInterception(unit); + } else { + // Kaboom + this.handleNukeExplosion(unit, shockwaveRadius); + } } } + handleNukeExplosion(unit: UnitView, shockwaveRadius: 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); + } + + 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); + const shockwave = new ShockwaveFx(x, y, 800, 40); + this.allFx.push(shockwave as Fx); + } + async init() { this.redraw(); try { diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 2439f4462..9910a6aa3 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -180,7 +180,10 @@ export class SAMLauncherExecution implements Execution { this.sam.owner().id(), ); // Delete warheads - mirvWarheadTargets.forEach((u) => u.delete()); + mirvWarheadTargets.forEach((u) => { + u.setInterceptedBySam(); + u.delete(); + }); } else if (target !== null) { target.setTargetedBySAM(true); this.mg.addExecution( diff --git a/src/core/execution/SAMMissileExecution.ts b/src/core/execution/SAMMissileExecution.ts index fdfe635da..9e1ad5b0d 100644 --- a/src/core/execution/SAMMissileExecution.ts +++ b/src/core/execution/SAMMissileExecution.ts @@ -66,6 +66,7 @@ export class SAMMissileExecution implements Execution { this._owner.id(), ); this.active = false; + this.target.setInterceptedBySam(); this.target.delete(true, this._owner); this.SAMMissile.delete(false); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 39956ac14..31f581586 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -345,6 +345,8 @@ export interface Unit { targetUnit(): Unit | undefined; setTargetedBySAM(targeted: boolean): void; targetedBySAM(): boolean; + setInterceptedBySam(): void; + interceptedBySam(): boolean; // Health hasHealth(): boolean; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 8c3df8906..839ca330c 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -73,6 +73,7 @@ export interface UnitUpdate { pos: TileRef; lastPos: TileRef; isActive: boolean; + wasIntercepted: boolean; retreating: boolean; targetUnitId?: number; // Only for trade ships targetTile?: TileRef; // Only for nukes diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 060142dfe..5ce1266f5 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -93,6 +93,9 @@ export class UnitView { isActive(): boolean { return this.data.isActive; } + wasInterceptedBySAM(): boolean { + return this.data.wasIntercepted; + } hasHealth(): boolean { return this.data.health !== undefined; } diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 44564880c..a68f566d7 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -21,6 +21,7 @@ export class UnitImpl implements Unit { private _lastTile: TileRef; private _retreating: boolean = false; private _targetedBySAM = false; + private _interceptedBySAM = false; private _lastSetSafeFromPirates: number; // Only for trade ships private _constructionType: UnitType | undefined; private _lastOwner: PlayerImpl | null = null; @@ -85,6 +86,7 @@ export class UnitImpl implements Unit { ownerID: this._owner.smallID(), lastOwnerID: this._lastOwner?.smallID(), isActive: this._active, + wasIntercepted: this._interceptedBySAM, retreating: this._retreating, pos: this._tile, lastPos: this._lastTile, @@ -304,6 +306,14 @@ export class UnitImpl implements Unit { return this._targetedBySAM; } + setInterceptedBySam(): void { + this._interceptedBySAM = true; + } + + interceptedBySam(): boolean { + return this._interceptedBySAM; + } + setSafeFromPirates(): void { this._lastSetSafeFromPirates = this.mg.ticks(); }