From eeb9f0c279cba175fe394a4c81e862e91deacf26 Mon Sep 17 00:00:00 2001 From: Will Dunlop <54122840+wjdunlop@users.noreply.github.com> Date: Wed, 24 Sep 2025 19:08:52 +0100 Subject: [PATCH] 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) --- src/client/graphics/fx/TargetFx.ts | 64 ++++++++++++++++++++ src/client/graphics/layers/FxLayer.ts | 43 +++++++++++++ src/core/execution/TransportShipExecution.ts | 10 +++ 3 files changed, 117 insertions(+) create mode 100644 src/client/graphics/fx/TargetFx.ts diff --git a/src/client/graphics/fx/TargetFx.ts b/src/client/graphics/fx/TargetFx.ts new file mode 100644 index 000000000..f4d5867c1 --- /dev/null +++ b/src/client/graphics/fx/TargetFx.ts @@ -0,0 +1,64 @@ +import { Fx } from "./Fx"; + +export class TargetFx implements Fx { + private lifeTime = 0; + private ended = false; + private endFade = 300; + + constructor( + private x: number, + private y: number, + private duration = 0, + private radius = 8, + private persistent = false, + ) {} + + end() { + if (this.persistent) { + this.ended = true; + this.lifeTime = 0; // reuse for fade-out timing + } + } + + renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean { + this.lifeTime += frameTime; + + if (!this.persistent) { + if (this.lifeTime >= this.duration) return false; + } else if (this.ended) { + if (this.lifeTime >= this.endFade) return false; + } + + const t = this.persistent + ? (this.lifeTime % 1000) / 1000 // looping for pulse + : this.lifeTime / this.duration; + const baseAlpha = this.persistent ? 0.9 : 1 - t; + const fadeAlpha = + this.persistent && this.ended ? 1 - this.lifeTime / this.endFade : 1; + const alpha = Math.max(0, Math.min(1, baseAlpha * fadeAlpha)); + const pulse = 1 + 0.2 * Math.sin(t * Math.PI * 2); + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.lineWidth = 1; + ctx.strokeStyle = `rgba(255,0,0,${alpha})`; + + // size follows the pulsing radius so crosshair scales with it + const size = this.radius * pulse; + + ctx.beginPath(); + ctx.arc(this.x, this.y, size, 0, Math.PI * 2); + ctx.stroke(); + + // crosshair (fixed size, does not scale with pulse) + ctx.beginPath(); + ctx.moveTo(this.x - this.radius * 1.2, this.y); + ctx.lineTo(this.x + this.radius * 1.2, this.y); + ctx.moveTo(this.x, this.y - this.radius * 1.2); + ctx.lineTo(this.x, this.y + this.radius * 1.2); + ctx.stroke(); + + ctx.restore(); + return true; + } +} diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index 1d33ab123..0c39d9802 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -13,6 +13,7 @@ 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"; @@ -27,6 +28,7 @@ export class FxLayer implements Layer { new AnimatedSpriteLoader(); private allFx: Fx[] = []; + private boatTargetFxByUnitId: Map = new Map(); constructor(private game: GameView) { this.theme = this.game.config().theme(); @@ -37,6 +39,7 @@ export class FxLayer implements Layer { } tick() { + this.manageBoatTargetFx(); this.game .updatesSinceLastTick() ?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id)) @@ -65,6 +68,24 @@ export class FxLayer implements Layer { }); } + 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 @@ -94,8 +115,30 @@ export class FxLayer implements Layer { 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); diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 543dac9ea..913abb817 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -133,6 +133,12 @@ export class TransportShipExecution implements Execution { troops: this.startTroops, }); + if (this.dst !== null) { + this.boat.setTargetTile(this.dst); + } else { + this.boat.setTargetTile(undefined); + } + // Notify the target player about the incoming naval invasion if (this.targetID && this.targetID !== mg.terraNullius().id()) { mg.displayIncomingUnit( @@ -169,6 +175,10 @@ export class TransportShipExecution implements Execution { if (this.boat.retreating()) { this.dst = this.src!; // src is guaranteed to be set at this point + + if (this.boat.targetTile() !== this.dst) { + this.boat.setTargetTile(this.dst); + } } const result = this.pathFinder.nextTile(this.boat.tile(), this.dst);