retire DynamicUILayer, restore warship UX on WebGL

DynamicUILayer was a canvas2D mix of: bonus-event gold/troops popups
(already duplicated by WebGL BonusPopupPass), nuke/transport telegraph
indicators (duplicated by WebGL passes), and a warship move-indicator
chevron drawn via MoveIndicatorUI. Delete the layer outright along
with its three orphan UI helpers (MoveIndicatorUI, NavalTarget,
NukeTelegraph).

That deletion uncovered a pre-existing bug from the "migrate away from
canvas" commit: warship select/move no longer worked. The deleted
UnitLayer had owned the click flow that emits MoveWarshipIntentEvent.
Re-add the flow inside UILayer (which already tracks selected /
multi-selected warships for its selection box): MouseUpEvent →
move-multi → move-single → select-nearest, plus shift+drag box
complete and select-all hotkey.

Wire MoveWarshipIntentEvent → view.showMoveIndicator(tx, ty, ownerID)
in mountWebGLDebugRenderer so the WebGL MoveIndicatorPass draws the
converging-chevron animation at the move target, colored by the
warship's owner. mountWebGLDebugRenderer now takes gameView + eventBus
to resolve the owner and subscribe.
This commit is contained in:
evanpelle
2026-05-16 18:51:34 -07:00
parent 8955be7667
commit 2fec1e994e
7 changed files with 190 additions and 493 deletions
+19
View File
@@ -47,6 +47,7 @@ import {
import { endGame, startGame, startTime } from "./LocalPersistantStats";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
import {
MoveWarshipIntentEvent,
SendAllianceExtensionIntentEvent,
SendAllianceRequestIntentEvent,
SendAttackIntentEvent,
@@ -231,6 +232,8 @@ export function joinLobby(
function mountWebGLDebugRenderer(
terrainMap: TerrainMapData,
transformHandler: import("./graphics/TransformHandler").TransformHandler,
gameView: GameView,
eventBus: EventBus,
): { builder: WebGLFrameBuilder; syncCamera: () => void } {
const gameMap = terrainMap.gameMap;
const mapWidth = gameMap.width();
@@ -332,6 +335,20 @@ function mountWebGLDebugRenderer(
(window as unknown as { __webglView?: unknown }).__webglView = view;
// Move-target chevrons: when the player issues a warship move, show the
// animated chevron pass at the target tile. The renderer needs the target's
// tile x/y and the warship's owner smallID (so the chevrons use the right
// color).
eventBus.on(MoveWarshipIntentEvent, (e) => {
const tile = e.tile;
const tx = gameView.x(tile);
const ty = gameView.y(tile);
// Resolve owner via the first unit in the move set.
const firstUnit = gameView.unit(e.unitIds[0]);
if (firstUnit === undefined) return;
view.showMoveIndicator(tx, ty, firstUnit.owner().smallID());
});
return { builder: new WebGLFrameBuilder(view), syncCamera };
}
@@ -389,6 +406,8 @@ async function createClientGame(
const { builder: webglBuilder, syncCamera } = mountWebGLDebugRenderer(
gameMap,
gameRenderer.transformHandler,
gameView,
eventBus,
);
gameRenderer.onPreRender = syncCamera;
-2
View File
@@ -13,7 +13,6 @@ 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 { GameLeftSidebar } from "./layers/GameLeftSidebar";
@@ -265,7 +264,6 @@ export function createRenderer(
const layers: Layer[] = [
new UILayer(game, eventBus, transformHandler),
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
new DynamicUILayer(game, transformHandler, eventBus),
new NameLayer(game, transformHandler, eventBus),
new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings),
eventsDisplay,
@@ -1,165 +0,0 @@
import { renderNumber } from "src/client/Utils";
import { EventBus } from "src/core/EventBus";
import { UnitType } from "src/core/game/Game";
import { BonusEventUpdate, GameUpdateType } from "src/core/game/GameUpdates";
import type { GameView, UnitView } from "../../../core/game/GameView";
import { MoveWarshipIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
import { MoveIndicatorUI } from "../ui/MoveIndicatorUI";
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;
export class DynamicUILayer implements Layer {
private readonly uiElements: Array<UIElement> = [];
private lastRefresh = Date.now();
constructor(
private readonly game: GameView,
private transformHandler: TransformHandler,
private eventBus: EventBus,
) {}
init() {
// Listen for warship move clicks for MoveIndicatorUI
this.eventBus.on(MoveWarshipIntentEvent, (e) => {
const x = this.game.x(e.tile);
const y = this.game.y(e.tile);
this.uiElements.push(new MoveIndicatorUI(this.transformHandler, x, y));
});
}
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);
});
}
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);
}
}
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) {
const myPlayer = this.game.myPlayer();
if (!myPlayer) {
return;
}
if (
this.createdThisTick(unit) &&
(unit.owner() === myPlayer || unit.owner().isOnSameTeam(myPlayer))
) {
const target = new NukeTelegraph(this.transformHandler, this.game, unit);
this.uiElements.push(target);
}
}
onTransportShipEvent(unit: UnitView) {
const myPlayer = this.game.myPlayer();
if (!myPlayer) {
return;
}
if (this.createdThisTick(unit) && unit.owner() === myPlayer) {
const target = new NavalTarget(this.transformHandler, this.game, unit);
this.uiElements.push(target);
}
}
renderLayer(context: CanvasRenderingContext2D) {
const now = Date.now();
const dt = now - this.lastRefresh;
this.lastRefresh = now;
if (this.game.config().userSettings()?.fxLayer()) {
this.renderUIElements(context, dt);
}
}
renderUIElements(context: CanvasRenderingContext2D, delta: number) {
for (let i = this.uiElements.length - 1; i >= 0; i--) {
if (!this.uiElements[i].render(context, delta)) {
this.uiElements.splice(i, 1);
}
}
}
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.uiElements.push(
new TextIndicator(
this.transformHandler,
`${sign} ${shortened}`,
x,
y,
duration,
riseDistance,
),
);
}
}
+171
View File
@@ -1,18 +1,27 @@
import { Colord } from "colord";
import { Theme } from "src/core/configuration/Theme";
import { Cell } from "src/core/game/Game";
import { EventBus } from "../../../core/EventBus";
import { UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, UnitView } from "../../../core/game/GameView";
import {
CloseViewEvent,
ContextMenuEvent,
MouseUpEvent,
SelectAllWarshipsEvent,
TouchEvent,
UnitSelectionEvent,
WarshipSelectionBoxCancelEvent,
WarshipSelectionBoxCompleteEvent,
WarshipSelectionBoxUpdateEvent,
} from "../../InputHandler";
import { MoveWarshipIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
const WARSHIP_SELECTION_RADIUS = 10;
/**
* Layer responsible for drawing UI elements that overlay the game.
* Currently: warship selection boxes + drag-rectangle selection.
@@ -116,9 +125,171 @@ export class UILayer implements Layer {
this.eventBus.on(WarshipSelectionBoxCompleteEvent, clearBox);
this.eventBus.on(WarshipSelectionBoxCancelEvent, clearBox);
this.eventBus.on(CloseViewEvent, clearBox);
// Warship select/move click flow (previously in the deleted UnitLayer).
this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e));
this.eventBus.on(TouchEvent, (e) => this.onTouch(e));
this.eventBus.on(WarshipSelectionBoxCompleteEvent, (e) =>
this.onSelectionBoxComplete(e),
);
this.eventBus.on(SelectAllWarshipsEvent, () => this.onSelectAllWarships());
this.redraw();
}
/**
* Find player-owned warships near the given cell, sorted by distance.
*/
private findWarshipsNearCell(clickRef: TileRef): UnitView[] {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return [];
return this.game
.units(UnitType.Warship)
.filter(
(unit) =>
unit.isActive() &&
unit.owner() === myPlayer &&
this.game.manhattanDist(unit.tile(), clickRef) <=
WARSHIP_SELECTION_RADIUS,
)
.sort(
(a, b) =>
this.game.manhattanDist(a.tile(), clickRef) -
this.game.manhattanDist(b.tile(), clickRef),
);
}
/**
* Resolve a left-click in the world:
* - multi-selected warships present + clicked water → move them all
* - single selected warship + clicked water → move it, then deselect
* - otherwise → if there's a nearby warship, select the closest one
*/
private onMouseUp(
event: MouseUpEvent,
clickRef?: TileRef,
nearbyWarships?: UnitView[],
) {
if (clickRef === undefined) {
const cell = this.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
);
if (!this.game.isValidCoord(cell.x, cell.y)) return;
clickRef = this.game.ref(cell.x, cell.y);
}
if (!this.game.isWater(clickRef)) return;
if (this.multiSelectedWarships.length > 0) {
const myPlayer = this.game.myPlayer();
const activeIds = this.multiSelectedWarships
.filter((u) => u.isActive() && u.owner() === myPlayer)
.map((u) => u.id());
if (activeIds.length > 0) {
this.eventBus.emit(new MoveWarshipIntentEvent(activeIds, clickRef));
}
this.eventBus.emit(new UnitSelectionEvent(null, false));
return;
}
if (this.selectedUnit) {
this.eventBus.emit(
new MoveWarshipIntentEvent([this.selectedUnit.id()], clickRef),
);
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
return;
}
nearbyWarships ??= this.findWarshipsNearCell(clickRef);
if (nearbyWarships.length > 0) {
this.eventBus.emit(new UnitSelectionEvent(nearbyWarships[0], true));
}
}
/**
* Touch handler mirroring mouse-up. On dry land with no selection, falls
* back to opening the radial menu.
*/
private onTouch(event: TouchEvent) {
const cell = this.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
);
if (!this.game.isValidCoord(cell.x, cell.y)) return;
const clickRef = this.game.ref(cell.x, cell.y);
if (this.game.inSpawnPhase()) {
if (!this.game.isWater(clickRef)) {
this.eventBus.emit(new MouseUpEvent(event.x, event.y));
}
return;
}
if (!this.game.isWater(clickRef)) {
this.eventBus.emit(new ContextMenuEvent(event.x, event.y));
return;
}
if (this.selectedUnit || this.multiSelectedWarships.length > 0) {
this.onMouseUp(new MouseUpEvent(event.x, event.y), clickRef);
return;
}
const nearbyWarships = this.findWarshipsNearCell(clickRef);
if (nearbyWarships.length > 0) {
this.onMouseUp(
new MouseUpEvent(event.x, event.y),
clickRef,
nearbyWarships,
);
} else {
this.eventBus.emit(new ContextMenuEvent(event.x, event.y));
}
}
/**
* Resolve a shift+drag selection box: gather all player-owned warships
* whose screen position falls inside the rectangle.
*/
private onSelectionBoxComplete(event: WarshipSelectionBoxCompleteEvent) {
const x1 = Math.min(event.startX, event.endX);
const y1 = Math.min(event.startY, event.endY);
const x2 = Math.max(event.startX, event.endX);
const y2 = Math.max(event.startY, event.endY);
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
const selected = this.game.units(UnitType.Warship).filter((unit) => {
if (!unit.isActive() || unit.owner() !== myPlayer) return false;
const screen = this.transformHandler.worldToScreenCoordinates(
new Cell(this.game.x(unit.tile()), this.game.y(unit.tile())),
);
return (
screen.x >= x1 && screen.x <= x2 && screen.y >= y1 && screen.y <= y2
);
});
// Clear single selection if we got a box selection
if (selected.length > 0 && this.selectedUnit) {
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
}
this.eventBus.emit(new UnitSelectionEvent(null, true, selected));
}
private onSelectAllWarships() {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
const allWarships = this.game
.units(UnitType.Warship)
.filter((u) => u.isActive() && u.owner() === myPlayer);
if (allWarships.length === 0) return;
if (this.selectedUnit) {
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
}
this.eventBus.emit(new UnitSelectionEvent(null, true, allWarships));
}
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.canvas,
-81
View File
@@ -1,81 +0,0 @@
import { Cell } from "src/core/game/Game";
import { TransformHandler } from "../TransformHandler";
import { UIElement } from "./UIElement";
/**
* move indicator fx for warship, similar to moba games.
*/
export class MoveIndicatorUI implements UIElement {
private lifeTime = 0;
private readonly duration = 800; // ms
private readonly startRadius = 13; // starting distance from center (screen pixels)
private readonly chevronSize = 5; // size in screen pixels
private readonly cell: Cell;
constructor(
private transformHandler: TransformHandler,
public x: number,
public y: number,
) {
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 t = this.lifeTime / this.duration;
const alpha = 1 - t; // fade out
// Scale with zoom level (same pattern as NavalTarget)
const transformScale = this.transformHandler.scale;
const scale = transformScale > 10 ? 1 + (transformScale - 10) / 10 : 1;
const radius = this.startRadius * scale * (1 - t * 0.7); // converge inward
const chevronSize = this.chevronSize * scale;
// Get screen coordinates
const screenPos = this.transformHandler.worldToCanvasCoordinates(this.cell);
const centerX = screenPos.x;
const centerY = screenPos.y;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = "#ff0000";
ctx.lineWidth = 2 * scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
// pre calculation of offsets
const tipOffset = chevronSize * 0.4;
const wingOffset = chevronSize * 0.6;
const width = chevronSize;
ctx.beginPath();
// Top (pointing down)
ctx.moveTo(centerX - width, centerY - radius - wingOffset);
ctx.lineTo(centerX, centerY - radius + tipOffset);
ctx.lineTo(centerX + width, centerY - radius - wingOffset);
// Bottom (pointing up)
ctx.moveTo(centerX - width, centerY + radius + wingOffset);
ctx.lineTo(centerX, centerY + radius - tipOffset);
ctx.lineTo(centerX + width, centerY + radius + wingOffset);
// Left (pointing right)
ctx.moveTo(centerX - radius - wingOffset, centerY - width);
ctx.lineTo(centerX - radius + tipOffset, centerY);
ctx.lineTo(centerX - radius - wingOffset, centerY + width);
// Right (pointing left)
ctx.moveTo(centerX + radius + wingOffset, centerY - width);
ctx.lineTo(centerX + radius - tipOffset, centerY);
ctx.lineTo(centerX + radius + wingOffset, centerY + width);
ctx.stroke();
ctx.restore();
return true;
}
}
-132
View File
@@ -1,132 +0,0 @@
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.animationElapsedTime / 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.worldToCanvasCoordinates(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.transportShipState().isRetreating))
) {
this.ended = true;
}
return super.render(ctx, delta);
}
}
-113
View File
@@ -1,113 +0,0 @@
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.worldToCanvasCoordinates(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);
}
}