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);