mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user