diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index dd4cf64b4..41bfe13b3 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -12,6 +12,7 @@ import { BuildMenu } from "./layers/BuildMenu"; import { ChatDisplay } from "./layers/ChatDisplay"; import { ChatModal } from "./layers/ChatModal"; import { ControlPanel } from "./layers/ControlPanel"; +import { DynamicUILayer } from "./layers/DynamicUILayer"; import { EmojiTable } from "./layers/EmojiTable"; import { EventsDisplay } from "./layers/EventsDisplay"; import { FxLayer } from "./layers/FxLayer"; @@ -257,6 +258,7 @@ export function createRenderer( new UILayer(game, eventBus, transformHandler), new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState), new StructureIconsLayer(game, eventBus, uiState, transformHandler), + new DynamicUILayer(game, transformHandler), new NameLayer(game, transformHandler, eventBus), eventsDisplay, chatDisplay, diff --git a/src/client/graphics/fx/ConquestFx.ts b/src/client/graphics/fx/ConquestFx.ts index a55ca77c7..192b4c763 100644 --- a/src/client/graphics/fx/ConquestFx.ts +++ b/src/client/graphics/fx/ConquestFx.ts @@ -1,22 +1,18 @@ import { ConquestUpdate } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; -import { renderNumber } from "../../Utils"; import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; import { Fx, FxType } from "./Fx"; import { FadeFx, SpriteFx } from "./SpriteFx"; -import { TextFx } from "./TextFx"; /** * Conquest FX: * - conquest sprite - * - gold displayed */ export function conquestFxFactory( animatedSpriteLoader: AnimatedSpriteLoader, conquest: ConquestUpdate, game: GameView, -): Fx[] { - const conquestFx: Fx[] = []; +): Fx { const conquered = game.player(conquest.conqueredId); const x = conquered.nameLocation().x; const y = conquered.nameLocation().y; @@ -28,19 +24,5 @@ export function conquestFxFactory( FxType.ConquestChampagne, 2500, ); - const fadeAnimation = new FadeFx(swordAnimation, 0.1, 0.6); - conquestFx.push(fadeAnimation); - - const shortenedGold = renderNumber(conquest.gold); - const goldText = new TextFx( - `+ ${shortenedGold}`, - x, - y + 8, - 2500, - 0, - "11px sans-serif", - ); - conquestFx.push(goldText); - - return conquestFx; + return new FadeFx(swordAnimation, 0.1, 0.6); } diff --git a/src/client/graphics/fx/NukeAreaFx.ts b/src/client/graphics/fx/NukeAreaFx.ts deleted file mode 100644 index 7aca6460b..000000000 --- a/src/client/graphics/fx/NukeAreaFx.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { NukeMagnitude } from "../../../core/configuration/Config"; -import { Fx } from "./Fx"; - -export class NukeAreaFx implements Fx { - private lifeTime = 0; - private ended = false; - private readonly endAnimationDuration = 300; // in ms - private readonly startAnimationDuration = 200; // in ms - - private readonly innerDiameter: number; - private readonly outerDiameter: number; - - private offset = 0; - private readonly dashSize: number; - private readonly rotationSpeed = 20; // px per seconds - private readonly baseAlpha = 0.9; - - constructor( - private x: number, - private y: number, - magnitude: NukeMagnitude, - ) { - this.innerDiameter = magnitude.inner; - this.outerDiameter = magnitude.outer; - const numDash = Math.max(1, Math.floor(this.outerDiameter / 3)); - this.dashSize = (Math.PI / numDash) * this.outerDiameter; - } - - end() { - this.ended = true; - this.lifeTime = 0; // reset for fade-out timing - } - - renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean { - this.lifeTime += frameTime; - - if (this.ended && this.lifeTime >= this.endAnimationDuration) return false; - let t: number; - if (this.ended) { - t = Math.max(0, 1 - this.lifeTime / this.endAnimationDuration); - } else { - t = Math.min(1, this.lifeTime / this.startAnimationDuration); - } - const alpha = Math.max(0, Math.min(1, this.baseAlpha * t)); - - ctx.save(); - ctx.globalAlpha = alpha; - ctx.lineWidth = 1; - ctx.strokeStyle = `rgba(255,0,0,${alpha})`; - ctx.fillStyle = `rgba(255,0,0,${Math.max(0, alpha - 0.6)})`; - - // Inner circle - ctx.beginPath(); - ctx.lineWidth = 1; - const innerDiameter = - (this.innerDiameter / 2) * (1 - t) + this.innerDiameter * t; - ctx.arc(this.x, this.y, innerDiameter, 0, Math.PI * 2); - ctx.stroke(); - ctx.fill(); - - // Outer circle - this.offset += this.rotationSpeed * (frameTime / 1000); - ctx.beginPath(); - ctx.strokeStyle = `rgba(255,0,0,${alpha})`; - ctx.lineWidth = 1; - ctx.lineDashOffset = this.offset; - ctx.setLineDash([this.dashSize]); - const outerDiameter = - (this.outerDiameter + 20) * (1 - t) + this.outerDiameter * t; - ctx.arc(this.x, this.y, outerDiameter, 0, Math.PI * 2); - ctx.stroke(); - - ctx.restore(); - return true; - } -} diff --git a/src/client/graphics/fx/TargetFx.ts b/src/client/graphics/fx/TargetFx.ts deleted file mode 100644 index 7760445b4..000000000 --- a/src/client/graphics/fx/TargetFx.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Fx } from "./Fx"; - -export class TargetFx implements Fx { - private lifeTime = 0; - private ended = false; - private endFade = 300; - private offset = 0; - private rotationSpeed = 14; // px per seconds - private radius = 4; - - constructor( - private x: number, - private y: number, - private duration = 0, - 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)); - - ctx.save(); - ctx.globalAlpha = alpha; - ctx.lineWidth = 1; - ctx.strokeStyle = `rgba(255,0,0,${alpha})`; - this.offset += this.rotationSpeed * (frameTime / 1000); - - ctx.beginPath(); - ctx.lineWidth = 1; - ctx.lineDashOffset = this.offset; - ctx.setLineDash([3, 3]); - ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); - ctx.stroke(); - - ctx.beginPath(); - ctx.strokeStyle = `rgba(255,0,0,${alpha})`; - ctx.lineWidth = 2; - ctx.lineDashOffset = -this.offset / 2; - ctx.setLineDash([19, 3]); - ctx.arc(this.x, this.y, 7, 0, Math.PI * 2); - ctx.stroke(); - - ctx.restore(); - return true; - } -} diff --git a/src/client/graphics/fx/TextFx.ts b/src/client/graphics/fx/TextFx.ts deleted file mode 100644 index c1a82e212..000000000 --- a/src/client/graphics/fx/TextFx.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Fx } from "./Fx"; - -export class TextFx implements Fx { - private lifeTime: number = 0; - - constructor( - private text: string, - private x: number, - private y: number, - private duration: number, - private riseDistance: number = 30, - private font: string = "11px sans-serif", - private color: { r: number; g: number; b: number } = { - r: 255, - g: 255, - b: 255, - }, - ) {} - - renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean { - this.lifeTime += frameTime; - if (this.lifeTime >= this.duration) { - return false; - } - - const t = this.lifeTime / this.duration; - const currentY = this.y - t * this.riseDistance; - const alpha = 1 - t; - - ctx.save(); - ctx.font = this.font; - ctx.fillStyle = `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, ${alpha})`; - ctx.textAlign = "center"; - ctx.fillText(this.text, this.x, currentY); - ctx.restore(); - - return true; - } -} diff --git a/src/client/graphics/layers/DynamicUILayer.ts b/src/client/graphics/layers/DynamicUILayer.ts new file mode 100644 index 000000000..8d555d855 --- /dev/null +++ b/src/client/graphics/layers/DynamicUILayer.ts @@ -0,0 +1,168 @@ +import { renderNumber } from "src/client/Utils"; +import { UnitType } from "src/core/game/Game"; +import { + BonusEventUpdate, + ConquestUpdate, + GameUpdateType, +} from "src/core/game/GameUpdates"; +import type { GameView, UnitView } from "../../../core/game/GameView"; +import { TransformHandler } from "../TransformHandler"; +import { NavalTarget } from "../ui/NavalTarget"; +import { NukeTelegraph } from "../ui/NukeTelegraph"; +import { TextIndicator } from "../ui/TextIndicator"; +import { UIElement } from "../ui/UIElement"; +import { Layer } from "./Layer"; + +const TEXT_OFFSET_Y = -5; +const TEXT_STACK_SPACING = 8; +const TEXT_DURATION = 2500; + +export class DynamicUILayer implements Layer { + private readonly allElements: Array = []; + private lastRefresh = Date.now(); + + constructor( + private readonly game: GameView, + private transformHandler: TransformHandler, + ) {} + + shouldTransform(): boolean { + return false; + } + + tick() { + if (!this.game.config().userSettings()?.fxLayer()) { + return; + } + + const updates = this.game.updatesSinceLastTick(); + if (!updates) return; + + updates[GameUpdateType.Unit]?.forEach((unit) => { + const unitView = this.game.unit(unit.id); + if (!unitView) return; + this.onUnitEvent(unitView); + }); + + updates[GameUpdateType.BonusEvent]?.forEach((bonusEvent) => { + if (bonusEvent === undefined) return; + this.onBonusEvent(bonusEvent); + }); + + updates[GameUpdateType.ConquestEvent]?.forEach((update) => { + if (update === undefined) return; + this.onConquestEvent(update); + }); + } + + onBonusEvent(bonus: BonusEventUpdate) { + // Only display text fx for the current player + if (this.game.player(bonus.player) !== this.game.myPlayer()) { + return; + } + const tile = bonus.tile; + const x = this.game.x(tile); + let y = this.game.y(tile) + TEXT_OFFSET_Y; + const gold = bonus.gold; + const troops = bonus.troops; + + if (gold !== 0) { + this.addNumber(gold, x, y, 1000, 10); + y += TEXT_STACK_SPACING; // increase y so the next popup starts below + } + + if (troops !== 0) { + this.addNumber(troops, x, y, 1000, 10); + } + } + + onConquestEvent(conquest: ConquestUpdate) { + // Only display text for the current player + const conqueror = this.game.player(conquest.conquerorId); + if (conqueror !== this.game.myPlayer()) { + return; + } + const nameLocation = this.game.player(conquest.conqueredId).nameLocation(); + const x = nameLocation.x; + const y = nameLocation.y; + this.addNumber(conquest.gold, x, y + 8, TEXT_DURATION, 0); + } + + onUnitEvent(unit: UnitView) { + switch (unit.type()) { + case UnitType.HydrogenBomb: + case UnitType.AtomBomb: { + this.onBombEvent(unit); + break; + } + case UnitType.TransportShip: { + this.onTransportShipEvent(unit); + break; + } + } + } + + onBombEvent(unit: UnitView) { + if (this.createdThisTick(unit) && this.isOwnedByPlayer(unit)) { + const target = new NukeTelegraph(this.transformHandler, this.game, unit); + this.allElements.push(target); + } + } + + onTransportShipEvent(unit: UnitView) { + if (this.createdThisTick(unit) && this.isOwnedByPlayer(unit)) { + const target = new NavalTarget(this.transformHandler, this.game, unit); + this.allElements.push(target); + } + } + + renderLayer(context: CanvasRenderingContext2D) { + const now = Date.now(); + const dt = now - this.lastRefresh; + this.lastRefresh = now; + if (this.game.config().userSettings()?.fxLayer()) { + this.renderAllTargets(context, dt); + } + } + + renderAllTargets(context: CanvasRenderingContext2D, delta: number) { + for (let i = this.allElements.length - 1; i >= 0; i--) { + if (!this.allElements[i].render(context, delta)) { + this.allElements.splice(i, 1); + } + } + } + + private isOwnedByPlayer(unit: UnitView): boolean { + const my = this.game.myPlayer(); + return my !== null && unit.owner() === my; + } + + private createdThisTick(unit: UnitView): boolean { + return unit.createdAt() === this.game.ticks(); + } + + private addNumber( + num: bigint | number, + x: number, + y: number, + duration: number, + riseDistance: number, + ) { + if (BigInt(num) === 0n) return; // Don't show anything for 0 + const absNum = + typeof num === "bigint" ? (num < 0n ? -num : num) : Math.abs(num); + const shortened = renderNumber(absNum, 0); + const sign = num >= 0 ? "+" : "-"; + this.allElements.push( + new TextIndicator( + this.transformHandler, + `${sign} ${shortened}`, + x, + y, + duration, + riseDistance, + ), + ); + } +} diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts index a2e0990a2..ed3602c15 100644 --- a/src/client/graphics/layers/FxLayer.ts +++ b/src/client/graphics/layers/FxLayer.ts @@ -1,22 +1,17 @@ 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 SoundManager, { SoundEffect } from "../../sound/SoundManager"; -import { renderNumber } from "../../Utils"; import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; import { conquestFxFactory } from "../fx/ConquestFx"; import { Fx, FxType } from "../fx/Fx"; -import { NukeAreaFx } from "../fx/NukeAreaFx"; 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 { @@ -30,8 +25,6 @@ export class FxLayer implements Layer { new AnimatedSpriteLoader(); private allFx: Fx[] = []; - private boatTargetFxByUnitId: Map = new Map(); - private nukeTargetFxByUnitId: Map = new Map(); constructor(private game: GameView) { this.theme = this.game.config().theme(); @@ -42,7 +35,9 @@ export class FxLayer implements Layer { } tick() { - this.manageBoatTargetFx(); + if (!this.game.config().userSettings()?.fxLayer()) { + return; + } this.game .updatesSinceLastTick() ?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id)) @@ -50,13 +45,6 @@ export class FxLayer implements Layer { 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) => { @@ -71,100 +59,9 @@ 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); - } - } - } - - // Register a persistent nuke target marker for the current player or teammates - private createNukeTargetFxIfOwned(unit: UnitView) { - const my = this.game.myPlayer(); - if (!my) return; - // Show nuke marker owned by the player or by players on the same team - if ( - (unit.owner() === my || my.isOnSameTeam(unit.owner())) && - unit.isActive() - ) { - if (!this.nukeTargetFxByUnitId.has(unit.id())) { - const t = unit.targetTile(); - if (t !== undefined) { - const x = this.game.x(t); - const y = this.game.y(t); - const fx = new NukeAreaFx( - x, - y, - this.game.config().nukeMagnitudes(unit.type()), - ); - this.allFx.push(fx); - this.nukeTargetFxByUnitId.set(unit.id(), fx); - } - } - } - } - - 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 below - } - - 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); - } - 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() || unit.retreating()) 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, true); - this.allFx.push(fx); - this.boatTargetFxByUnitId.set(unit.id(), fx); - } - break; - } case UnitType.AtomBomb: { - this.createNukeTargetFxIfOwned(unit); this.onNukeEvent(unit, 70); break; } @@ -172,7 +69,6 @@ export class FxLayer implements Layer { this.onNukeEvent(unit, 70); break; case UnitType.HydrogenBomb: { - this.createNukeTargetFxIfOwned(unit); this.onNukeEvent(unit, 160); break; } @@ -256,12 +152,9 @@ export class FxLayer implements Layer { SoundManager.playSoundEffect(SoundEffect.KaChing); - const conquestFx = conquestFxFactory( - this.animatedSpriteLoader, - conquest, - this.game, + this.allFx.push( + conquestFxFactory(this.animatedSpriteLoader, conquest, this.game), ); - this.allFx = this.allFx.concat(conquestFx); } onWarshipEvent(unit: UnitView) { @@ -304,11 +197,6 @@ export class FxLayer implements Layer { onNukeEvent(unit: UnitView, radius: number) { if (!unit.isActive()) { - const fx = this.nukeTargetFxByUnitId.get(unit.id()); - if (fx) { - fx.end(); - this.nukeTargetFxByUnitId.delete(unit.id()); - } if (!unit.reachedTarget()) { this.handleSAMInterception(unit); } else { diff --git a/src/client/graphics/ui/NavalTarget.ts b/src/client/graphics/ui/NavalTarget.ts new file mode 100644 index 000000000..55e97a158 --- /dev/null +++ b/src/client/graphics/ui/NavalTarget.ts @@ -0,0 +1,131 @@ +import { Cell, UnitType } from "src/core/game/Game"; +import { GameView, UnitView } from "src/core/game/GameView"; +import { TransformHandler } from "../TransformHandler"; +import { UIElement } from "./UIElement"; + +const BASE_ALPHA = 0.9; +const SHADOW_OFFSET_Y = 2; + +/** + * Draw a simple zoom-aware target + */ +export class Target implements UIElement { + private offset = 0; + private readonly rotationSpeed = 20; + private readonly dashSize: number; + private readonly outerRadius: number; + private readonly cell: Cell; + private readonly animationDuration = 150; + private animationElapsedTime = 0; + protected ended: boolean = false; + protected lifeTime: number = 0; + + constructor( + private transformHandler: TransformHandler, + public x: number, + public y: number, + private radius: number, + ) { + this.outerRadius = radius * 2 - 4; + // 2 dashes per circle, with a 10 pixel gap + this.dashSize = Math.PI * this.outerRadius - 10; + this.cell = new Cell(this.x + 0.5, this.y + 0.5); + } + render(ctx: CanvasRenderingContext2D, delta: number): boolean { + this.lifeTime += delta; + + if (this.ended) { + this.animationElapsedTime += delta; + if (this.animationElapsedTime >= this.animationDuration) return false; + } + + let t: number; + if (this.ended) { + // end animation + t = Math.max(0, 1 - this.lifeTime / this.animationDuration); + } else { + t = 1; // No start fade feels more reactive + } + const alpha = Math.max(0, Math.min(1, BASE_ALPHA * t)); + + const screenPos = this.transformHandler.worldToScreenCoordinates(this.cell); + screenPos.x = Math.round(screenPos.x); + screenPos.y = Math.round(screenPos.y); + const transformScale = this.transformHandler.scale; + const scale = transformScale > 10 ? 1 + (transformScale - 10) / 10 : 1; + this.offset += this.rotationSpeed * (delta / 1000); + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.lineWidth = 1; + ctx.strokeStyle = `rgba(255,0,0,${alpha})`; + + this.drawInnerRing(ctx, screenPos.x, screenPos.y, scale); + this.drawOuterRing(ctx, screenPos.x, screenPos.y, scale); + + ctx.restore(); + return true; + } + + private drawInnerRing( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + scale: number, + ) { + ctx.beginPath(); + ctx.lineWidth = 2; + ctx.lineDashOffset = this.offset * scale; + ctx.setLineDash([8 * scale, 8 * scale]); + ctx.arc(x, y, this.radius * scale, 0, Math.PI * 2); + ctx.stroke(); + } + + private drawOuterRing( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + scale: number, + ) { + ctx.beginPath(); + ctx.lineWidth = 4 * scale; + ctx.lineDashOffset = (-this.offset / 2) * scale; + ctx.setLineDash([this.dashSize * scale, 10 * scale]); + ctx.arc(x, y, this.outerRadius * scale, 0, Math.PI * 2); + ctx.stroke(); + + // Small shadow under the outer circle + ctx.beginPath(); + ctx.strokeStyle = `rgba(0,0,0,0.2)`; + ctx.arc(x, y + SHADOW_OFFSET_Y, this.outerRadius * scale, 0, Math.PI * 2); + ctx.stroke(); + } +} + +/** + * Bind a target to a naval invasion + */ +export class NavalTarget extends Target { + constructor( + transformHandler: TransformHandler, + readonly game: GameView, + private unit: UnitView, + ) { + const tile = unit.targetTile(); + if (tile === undefined) { + throw new Error("NavalTarget requires a target tile"); + } + super(transformHandler, game.x(tile), game.y(tile), 10); + } + + render(ctx: CanvasRenderingContext2D, delta: number): boolean { + if ( + !this.ended && + (!this.unit.isActive() || + (this.unit.type() === UnitType.TransportShip && this.unit.retreating())) + ) { + this.ended = true; + } + return super.render(ctx, delta); + } +} diff --git a/src/client/graphics/ui/NukeTelegraph.ts b/src/client/graphics/ui/NukeTelegraph.ts new file mode 100644 index 000000000..562f35907 --- /dev/null +++ b/src/client/graphics/ui/NukeTelegraph.ts @@ -0,0 +1,113 @@ +import { Cell } from "src/core/game/Game"; +import { GameView, UnitView } from "src/core/game/GameView"; +import { TransformHandler } from "../TransformHandler"; +import { UIElement } from "./UIElement"; + +const OUTER_EXPAND = 20; +const FILL_ALPHA_OFFSET = 0.6; + +/** + * Draw an area with two disks + */ +export class CircleArea implements UIElement { + private offset = 0; + private readonly dashSize: number; + private readonly rotationSpeed = 20; + private readonly baseAlpha = 0.9; + private readonly cell: Cell; + private readonly animationDuration = 150; + protected ended: boolean = false; + protected lifeTime: number = 0; + + constructor( + private transformHandler: TransformHandler, + public x: number, + public y: number, + private innerDiameter: number, + private outerDiameter: number, + ) { + this.cell = new Cell(this.x + 0.5, this.y + 0.5); + // Compute a dash length that produces N dashes around the circle + const numDash = Math.max(1, Math.floor(this.outerDiameter / 3)); + this.dashSize = (Math.PI / numDash) * this.outerDiameter; + } + render(ctx: CanvasRenderingContext2D, delta: number): boolean { + this.lifeTime += delta; + + if (this.ended && this.lifeTime >= this.animationDuration) return false; + let t: number; + if (this.ended) { + t = Math.max(0, 1 - this.lifeTime / this.animationDuration); + } else { + t = Math.min(1, this.lifeTime / this.animationDuration); + } + const alpha = Math.max(0, Math.min(1, this.baseAlpha * t)); + const scale = this.transformHandler.scale; + + const innerDiameter = + (this.innerDiameter / 2) * (1 - t) + this.innerDiameter * t; + const screenPos = this.transformHandler.worldToScreenCoordinates(this.cell); + screenPos.x = Math.round(screenPos.x); + screenPos.y = Math.round(screenPos.y); + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.lineWidth = 2; + ctx.strokeStyle = `rgba(255,0,0,${alpha})`; + ctx.fillStyle = `rgba(255,0,0,${Math.max(0, alpha - FILL_ALPHA_OFFSET)})`; + + // Inner circle + ctx.beginPath(); + ctx.lineWidth = 1; + ctx.arc(screenPos.x, screenPos.y, innerDiameter * scale, 0, Math.PI * 2); + ctx.stroke(); + ctx.fill(); + + // Outer circle + this.offset += this.rotationSpeed * (delta / 1000); + ctx.beginPath(); + ctx.strokeStyle = `rgba(255,0,0,${alpha})`; + ctx.lineWidth = Math.max(2, 1 * scale); + ctx.lineDashOffset = this.offset * scale; + ctx.setLineDash([this.dashSize * scale]); + const outerDiameter = + (this.outerDiameter + OUTER_EXPAND) * (1 - t) + this.outerDiameter * t; + ctx.arc(screenPos.x, screenPos.y, outerDiameter * scale, 0, Math.PI * 2); + ctx.stroke(); + + ctx.restore(); + return true; + } +} + +/** + * Bind a nuke destination to an area + */ +export class NukeTelegraph extends CircleArea { + constructor( + transformHandler: TransformHandler, + private readonly game: GameView, + private nuke: UnitView, + ) { + const tile = nuke.targetTile(); + if (tile === undefined) { + throw new Error("NukeArea requires a target tile"); + } + const magnitude = game.config().nukeMagnitudes(nuke.type()); + super( + transformHandler, + game.x(tile), + game.y(tile), + magnitude.inner, + magnitude.outer, + ); + } + + render(ctx: CanvasRenderingContext2D, delta: number): boolean { + if (!this.ended && !this.nuke.isActive()) { + this.ended = true; + this.lifeTime = 0; // reset lifetime to reuse animation logic + } + return super.render(ctx, delta); + } +} diff --git a/src/client/graphics/ui/TextIndicator.ts b/src/client/graphics/ui/TextIndicator.ts new file mode 100644 index 000000000..beee16a25 --- /dev/null +++ b/src/client/graphics/ui/TextIndicator.ts @@ -0,0 +1,58 @@ +import { Cell } from "src/core/game/Game"; +import { TransformHandler } from "../TransformHandler"; +import { UIElement } from "./UIElement"; + +const MIN_TEXT_ZOOM = 1.1; + +export class TextIndicator implements UIElement { + private fontSize: number = 8; + private font: string = "Overpass, sans-serif"; + private cell: Cell; + private lifeTime: number = 0; + + constructor( + private transformHandler: TransformHandler, + private text: string, + public x: number, + public y: number, + private duration: number, + private riseDistance: number = 15, + private color: { r: number; g: number; b: number } = { + r: 255, + g: 255, + b: 255, + }, + ) { + this.cell = new Cell(this.x + 0.5, this.y + 0.5); + } + render(ctx: CanvasRenderingContext2D, delta: number): boolean { + this.lifeTime += delta; + if (this.lifeTime >= this.duration) { + return false; + } + + const transformScale = this.transformHandler.scale; + if (transformScale < MIN_TEXT_ZOOM) { + // Reduce visual noise when dezoomed enough + return true; + } + + const screenPos = this.transformHandler.worldToScreenCoordinates(this.cell); + screenPos.x = Math.round(screenPos.x); + screenPos.y = Math.round(screenPos.y); + + const size = Math.round(this.fontSize * transformScale); + const t = this.lifeTime / this.duration; + const currentY = screenPos.y - t * this.riseDistance * transformScale; + const alpha = Math.max(0, 1 - t); + + ctx.save(); + ctx.font = `${size}px ${this.font}`; + ctx.fillStyle = `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, ${alpha})`; + ctx.textAlign = "center"; + ctx.fillText(this.text, screenPos.x, currentY); + ctx.restore(); + + return true; + } +} diff --git a/src/client/graphics/ui/UIElement.ts b/src/client/graphics/ui/UIElement.ts new file mode 100644 index 000000000..c024c83f4 --- /dev/null +++ b/src/client/graphics/ui/UIElement.ts @@ -0,0 +1,5 @@ +export interface UIElement { + x: number; + y: number; + render(ctx: CanvasRenderingContext2D, delta: number): boolean; +} diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index a4b8c8888..afa6c9c39 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -140,14 +140,9 @@ export class TransportShipExecution implements Execution { this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, { troops: this.startTroops, + targetTile: this.dst ?? undefined, }); - 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( diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index d0c61320c..485936448 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -262,7 +262,7 @@ export type TrajectoryTile = { export interface UnitParamsMap { [UnitType.TransportShip]: { troops?: number; - destination?: TileRef; + targetTile?: TileRef; }; [UnitType.Warship]: {