Files
OpenFrontIO/src/client/graphics/layers/FxLayer.ts
T
Will Dunlop eeb9f0c279 add target visualization for boat attacks (#2025)
## Description:
- Adds warship count, transport count (deployed out of maximum) to unit
display
- Adds a target that appears when a boat attack is dispatched, which
disappears when the boat attack arrives
- Updates the unit display alt text to pass through translation

## Please complete the following:

- [X] I have added screenshots for all UI updates (see below)
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file (in this case it is only alt-text)
- [X] I have added relevant tests to the test directory (n/a, fully
visual)
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

See new target effect and addition to units display 

As each transport ship arrives, the target draw stops, together with the
effect for the trail.


https://github.com/user-attachments/assets/c36c57d3-e2b7-456e-85ab-1e786bd28a07

## Please put your Discord username so you can be contacted if a bug or
regression is found:

@dxtron_28992 (my invite is still pending to dev discord)
2025-09-24 11:08:52 -07:00

342 lines
9.2 KiB
TypeScript

import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import {
BonusEventUpdate,
ConquestUpdate,
GameUpdateType,
RailroadUpdate,
} from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import { renderNumber } from "../../Utils";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { conquestFxFactory } from "../fx/ConquestFx";
import { Fx, FxType } from "../fx/Fx";
import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
import { SpriteFx } from "../fx/SpriteFx";
import { TargetFx } from "../fx/TargetFx";
import { TextFx } from "../fx/TextFx";
import { UnitExplosionFx } from "../fx/UnitExplosionFx";
import { Layer } from "./Layer";
export class FxLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private lastRefresh: number = 0;
private refreshRate: number = 10;
private theme: Theme;
private animatedSpriteLoader: AnimatedSpriteLoader =
new AnimatedSpriteLoader();
private allFx: Fx[] = [];
private boatTargetFxByUnitId: Map<number, TargetFx> = new Map();
constructor(private game: GameView) {
this.theme = this.game.config().theme();
}
shouldTransform(): boolean {
return true;
}
tick() {
this.manageBoatTargetFx();
this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
?.forEach((unitView) => {
if (unitView === undefined) return;
this.onUnitEvent(unitView);
});
this.game
.updatesSinceLastTick()
?.[GameUpdateType.BonusEvent]?.forEach((bonusEvent) => {
if (bonusEvent === undefined) return;
this.onBonusEvent(bonusEvent);
});
this.game
.updatesSinceLastTick()
?.[GameUpdateType.RailroadEvent]?.forEach((update) => {
if (update === undefined) return;
this.onRailroadEvent(update);
});
this.game
.updatesSinceLastTick()
?.[GameUpdateType.ConquestEvent]?.forEach((update) => {
if (update === undefined) return;
this.onConquestEvent(update);
});
}
private manageBoatTargetFx() {
// End markers for boats that arrived or retreated
for (const [unitId, fx] of Array.from(
this.boatTargetFxByUnitId.entries(),
)) {
const unit = this.game.unit(unitId);
if (
!unit ||
!unit.isActive() ||
unit.reachedTarget() ||
unit.retreating()
) {
(fx as any).end?.();
this.boatTargetFxByUnitId.delete(unitId);
}
}
}
onBonusEvent(bonus: BonusEventUpdate) {
if (this.game.player(bonus.player) !== this.game.myPlayer()) {
// Only display text fx for the current player
return;
}
const tile = bonus.tile;
const x = this.game.x(tile);
let y = this.game.y(tile);
const gold = bonus.gold;
const troops = bonus.troops;
if (gold > 0) {
const shortened = renderNumber(gold, 0);
this.addTextFx(`+ ${shortened}`, x, y);
y += 10; // increase y so the next popup starts bellow
}
if (troops > 0) {
const shortened = renderNumber(troops, 0);
this.addTextFx(`+ ${shortened} troops`, x, y);
y += 10;
}
}
addTextFx(text: string, x: number, y: number) {
const textFx = new TextFx(text, x, y, 1000, 20);
this.allFx.push(textFx);
}
addTargetFx(x: number, y: number) {
const fx = new TargetFx(x, y, 1200, 12);
this.allFx.push(fx);
}
onUnitEvent(unit: UnitView) {
switch (unit.type()) {
case UnitType.TransportShip: {
const my = this.game.myPlayer();
if (!my) return;
if (unit.owner() !== my) return;
if (!unit.isActive()) return;
if (this.boatTargetFxByUnitId.has(unit.id())) return;
const t = unit.targetTile();
if (t !== undefined) {
const x = this.game.x(t);
const y = this.game.y(t);
// persistent until boat finishes or retreats
const fx = new TargetFx(x, y, 0, 12, true);
this.allFx.push(fx);
this.boatTargetFxByUnitId.set(unit.id(), fx);
}
break;
}
case UnitType.AtomBomb:
case UnitType.MIRVWarhead:
this.onNukeEvent(unit, 70);
break;
case UnitType.HydrogenBomb:
this.onNukeEvent(unit, 160);
break;
case UnitType.Warship:
this.onWarshipEvent(unit);
break;
case UnitType.Shell:
this.onShellEvent(unit);
break;
case UnitType.Train:
this.onTrainEvent(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 explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.MiniExplosion,
);
this.allFx.push(explosion);
}
}
}
onTrainEvent(unit: UnitView) {
if (!unit.isActive()) {
if (!unit.reachedTarget()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.MiniExplosion,
);
this.allFx.push(explosion);
}
}
}
onRailroadEvent(railroad: RailroadUpdate) {
const railTiles = railroad.railTiles;
for (const rail of railTiles) {
// No need for pseudorandom, this is fx
const chanceFx = Math.floor(Math.random() * 3);
if (chanceFx === 0) {
const x = this.game.x(rail.tile);
const y = this.game.y(rail.tile);
const animation = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.Dust,
);
this.allFx.push(animation);
}
}
}
onConquestEvent(conquest: ConquestUpdate) {
// Only display fx for the current player
const conqueror = this.game.player(conquest.conquerorId);
if (conqueror !== this.game.myPlayer()) {
return;
}
const conquestFx = conquestFxFactory(
this.animatedSpriteLoader,
conquest,
this.game,
);
this.allFx = this.allFx.concat(conquestFx);
}
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.reachedTarget()) {
this.handleSAMInterception(unit);
} else {
// Kaboom
this.handleNukeExplosion(unit, radius);
}
}
}
handleNukeExplosion(unit: UnitView, radius: number) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
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 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);
}
async init() {
this.redraw();
try {
this.animatedSpriteLoader.loadAllAnimatedSpriteImages();
console.log("FX sprites loaded successfully");
} catch (err) {
console.error("Failed to load FX sprites:", err);
}
}
redraw(): void {
this.canvas = document.createElement("canvas");
const context = this.canvas.getContext("2d");
if (context === null) throw new Error("2d context not supported");
this.context = context;
this.context.imageSmoothingEnabled = false;
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
}
renderLayer(context: CanvasRenderingContext2D) {
const now = Date.now();
if (this.game.config().userSettings()?.fxLayer()) {
if (now > this.lastRefresh + this.refreshRate) {
const delta = now - this.lastRefresh;
this.renderAllFx(context, delta);
this.lastRefresh = now;
}
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
}
}
renderAllFx(context: CanvasRenderingContext2D, delta: number) {
if (this.allFx.length > 0) {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.renderContextFx(delta);
}
}
renderContextFx(duration: number) {
for (let i = this.allFx.length - 1; i >= 0; i--) {
if (!this.allFx[i].renderTick(duration, this.context)) {
this.allFx.splice(i, 1);
}
}
}
}