diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index c589862e4..8b38f6e40 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -1,5 +1,7 @@ import { EventBus, GameEvent } from "../core/EventBus"; import { UserSettings } from "../core/game/UserSettings"; +import { Game } from "../core/game/Game"; +import { UnitView } from "../core/game/GameView"; export class MouseUpEvent implements GameEvent { constructor( @@ -8,6 +10,16 @@ export class MouseUpEvent implements GameEvent { ) {} } +/** + * Event emitted when a unit is selected or deselected + */ +export class UnitSelectionEvent implements GameEvent { + constructor( + public readonly unit: UnitView | null, + public readonly isSelected: boolean, + ) {} +} + export class MouseDownEvent implements GameEvent { constructor( public readonly x: number, diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index a0c34bb01..e9263f095 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -14,6 +14,7 @@ import { ControlPanel } from "./layers/ControlPanel"; import { UIState } from "./UIState"; import { BuildMenu } from "./layers/BuildMenu"; import { UnitLayer } from "./layers/UnitLayer"; +import { UILayer } from "./layers/UILayer"; import { StructureLayer } from "./layers/StructureLayer"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { consolex } from "../../core/Consolex"; @@ -117,7 +118,8 @@ export function createRenderer( new TerrainLayer(game), new TerritoryLayer(game, eventBus), new StructureLayer(game, eventBus), - new UnitLayer(game, eventBus, clientID), + new UnitLayer(game, eventBus, clientID, transformHandler), + new UILayer(game, eventBus, clientID, transformHandler), new NameLayer(game, transformHandler, clientID), eventsDisplay, buildMenu, diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts new file mode 100644 index 000000000..b743f7687 --- /dev/null +++ b/src/client/graphics/layers/UILayer.ts @@ -0,0 +1,207 @@ +import { Colord } from "colord"; +import { Theme } from "../../../core/configuration/Config"; +import { UnitType } from "../../../core/game/Game"; +import { Layer } from "./Layer"; +import { EventBus } from "../../../core/EventBus"; +import { ClientID } from "../../../core/Schemas"; +import { GameView, UnitView } from "../../../core/game/GameView"; +import { UnitSelectionEvent } from "../../InputHandler"; +import { TransformHandler } from "../TransformHandler"; + +/** + * Layer responsible for drawing UI elements that overlay the game + * such as selection boxes, health bars, etc. + */ +export class UILayer implements Layer { + private canvas: HTMLCanvasElement; + private context: CanvasRenderingContext2D; + + private theme: Theme = null; + private selectionAnimTime = 0; + + // Keep track of currently selected unit + private selectedUnit: UnitView | null = null; + + // Keep track of previous selection box position for cleanup + private lastSelectionBoxCenter: { + x: number; + y: number; + size: number; + } | null = null; + + // Visual settings for selection + private readonly SELECTION_BOX_SIZE = 6; // Size of the selection box (should be larger than the warship) + + constructor( + private game: GameView, + private eventBus: EventBus, + private clientID: ClientID, + private transformHandler: TransformHandler, + ) { + this.theme = game.config().theme(); + } + + shouldTransform(): boolean { + return true; + } + + tick() { + // Update the selection animation time + this.selectionAnimTime = (this.selectionAnimTime + 1) % 60; + + // If there's a selected warship, redraw to update the selection box animation + if (this.selectedUnit && this.selectedUnit.type() === UnitType.Warship) { + this.drawSelectionBox(this.selectedUnit); + } + } + + init() { + this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelection(e)); + this.redraw(); + } + + renderLayer(context: CanvasRenderingContext2D) { + context.drawImage( + this.canvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height(), + ); + } + + redraw() { + this.canvas = document.createElement("canvas"); + this.context = this.canvas.getContext("2d"); + + this.canvas.width = this.game.width(); + this.canvas.height = this.game.height(); + } + + /** + * Handle the unit selection event + */ + private onUnitSelection(event: UnitSelectionEvent) { + if (event.isSelected) { + this.selectedUnit = event.unit; + if (event.unit && event.unit.type() === UnitType.Warship) { + this.drawSelectionBox(event.unit); + } + } else { + if (this.selectedUnit === event.unit) { + // Clear the selection box + if (this.lastSelectionBoxCenter) { + const { x, y, size } = this.lastSelectionBoxCenter; + this.clearSelectionBox(x, y, size); + this.lastSelectionBoxCenter = null; + } + this.selectedUnit = null; + } + } + } + + /** + * Clear the selection box at a specific position + */ + private clearSelectionBox(x: number, y: number, size: number) { + for (let px = x - size; px <= x + size; px++) { + for (let py = y - size; py <= y + size; py++) { + if ( + px === x - size || + px === x + size || + py === y - size || + py === y + size + ) { + this.clearCell(px, py); + } + } + } + } + + /** + * Draw a selection box around the given unit + */ + public drawSelectionBox(unit: UnitView) { + if (!unit || !unit.isActive()) { + return; + } + + // Use the configured selection box size + const selectionSize = this.SELECTION_BOX_SIZE; + + // Calculate pulsating effect based on animation time (25% variation in opacity) + const baseOpacity = 200; + const pulseAmount = 55; + const opacity = + baseOpacity + Math.sin(this.selectionAnimTime * 0.1) * pulseAmount; + + // Get the unit's owner color for the box + const ownerColor = this.theme.territoryColor(unit.owner().info()); + + // Create a brighter version of the owner color for the selection + const selectionColor = ownerColor.lighten(0.2); + + // Get current center position + const center = unit.tile(); + const centerX = this.game.x(center); + const centerY = this.game.y(center); + + // Clear previous selection box if it exists and is different from current position + if ( + this.lastSelectionBoxCenter && + (this.lastSelectionBoxCenter.x !== centerX || + this.lastSelectionBoxCenter.y !== centerY) + ) { + const lastSize = this.lastSelectionBoxCenter.size; + const lastX = this.lastSelectionBoxCenter.x; + const lastY = this.lastSelectionBoxCenter.y; + + // Clear the previous selection box + this.clearSelectionBox(lastX, lastY, lastSize); + } + + // Draw the selection box + for (let x = centerX - selectionSize; x <= centerX + selectionSize; x++) { + for (let y = centerY - selectionSize; y <= centerY + selectionSize; y++) { + // Only draw if it's on the border (not inside or outside the box) + if ( + x === centerX - selectionSize || + x === centerX + selectionSize || + y === centerY - selectionSize || + y === centerY + selectionSize + ) { + // Create a dashed effect by only drawing some pixels + const dashPattern = (x + y) % 2 === 0; + if (dashPattern) { + this.paintCell(x, y, selectionColor, opacity); + } + } + } + } + + // Store current selection box position for next cleanup + this.lastSelectionBoxCenter = { + x: centerX, + y: centerY, + size: selectionSize, + }; + } + + /** + * Draw health bar for a unit (placeholder for future implementation) + */ + public drawHealthBar(unit: UnitView) { + // This is a placeholder for future health bar implementation + // It would draw a health bar above units that have health + } + + paintCell(x: number, y: number, color: Colord, alpha: number) { + this.clearCell(x, y); + this.context.fillStyle = color.alpha(alpha / 255).toRgbString(); + this.context.fillRect(x, y, 1, 1); + } + + clearCell(x: number, y: number) { + this.context.clearRect(x, y, 1, 1); + } +} diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 5c900fdce..5cae90c36 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -3,7 +3,11 @@ import { Theme } from "../../../core/configuration/Config"; import { Unit, UnitType, Player } from "../../../core/game/Game"; import { Layer } from "./Layer"; import { EventBus } from "../../../core/EventBus"; -import { AlternateViewEvent } from "../../InputHandler"; +import { + AlternateViewEvent, + MouseUpEvent, + UnitSelectionEvent, +} from "../../InputHandler"; import { ClientID } from "../../../core/Schemas"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { @@ -12,6 +16,7 @@ import { TileRef, } from "../../../core/game/GameMap"; import { GameUpdateType } from "../../../core/game/GameUpdates"; +import { TransformHandler } from "../TransformHandler"; enum Relationship { Self, @@ -33,12 +38,22 @@ export class UnitLayer implements Layer { private oldShellTile = new Map(); + private transformHandler: TransformHandler; + + // Selected unit property as suggested in the review comment + private selectedUnit: UnitView | null = null; + + // Configuration for unit selection + private readonly WARSHIP_SELECTION_RADIUS = 3; // Radius in game cells for warship selection hit zone + constructor( private game: GameView, private eventBus: EventBus, private clientID: ClientID, + transformHandler: TransformHandler, ) { this.theme = game.config().theme(); + this.transformHandler = transformHandler; } shouldTransform(): boolean { @@ -56,9 +71,89 @@ export class UnitLayer implements Layer { init() { this.eventBus.on(AlternateViewEvent, (e) => this.onAlternativeViewEvent(e)); + this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e)); + this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelectionChange(e)); this.redraw(); } + /** + * Find player-owned warships near the given cell within a configurable radius + * @param cell The cell to check + * @returns Array of player's warships in range, sorted by distance (closest first) + */ + private findWarshipsNearCell(cell: { x: number; y: number }): UnitView[] { + const clickRef = this.game.ref(cell.x, cell.y); + + // Make sure we have the current player + if (this.myPlayer == null) { + this.myPlayer = this.game.playerByClientID(this.clientID); + } + + // Only select warships owned by the player + return this.game + .units(UnitType.Warship) + .filter( + (unit) => + unit.isActive() && + unit.owner() === this.myPlayer && // Only allow selecting own warships + this.game.manhattanDist(unit.tile(), clickRef) <= + this.WARSHIP_SELECTION_RADIUS, + ) + .sort((a, b) => { + // Sort by distance (closest first) + const distA = this.game.manhattanDist(a.tile(), clickRef); + const distB = this.game.manhattanDist(b.tile(), clickRef); + return distA - distB; + }); + } + + private onMouseUp(event: MouseUpEvent) { + // Convert screen coordinates to world coordinates + const cell = this.transformHandler.screenToWorldCoordinates( + event.x, + event.y, + ); + + // Find warships near this cell, sorted by distance + const nearbyWarships = this.findWarshipsNearCell(cell); + + if (nearbyWarships.length > 0) { + // Toggle selection of the closest warship + const clickedUnit = nearbyWarships[0]; + if (this.selectedUnit === clickedUnit) { + // Deselect if already selected + this.eventBus.emit(new UnitSelectionEvent(clickedUnit, false)); + } else { + // Select the new unit + this.eventBus.emit(new UnitSelectionEvent(clickedUnit, true)); + } + } else if (this.selectedUnit) { + // If clicked elsewhere and there's a selection, deselect it + this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); + } + } + + /** + * Handle unit selection changes + */ + private onUnitSelectionChange(event: UnitSelectionEvent) { + if (event.isSelected) { + this.selectedUnit = event.unit; + } else if (this.selectedUnit === event.unit) { + this.selectedUnit = null; + } + } + + /** + * Handle unit deactivation or destruction + * If the selected unit is removed from the game, deselect it + */ + private handleUnitDeactivation(unit: UnitView) { + if (this.selectedUnit === unit && !unit.isActive()) { + this.eventBus.emit(new UnitSelectionEvent(unit, false)); + } + } + renderLayer(context: CanvasRenderingContext2D) { context.drawImage( this.canvas, @@ -101,6 +196,11 @@ export class UnitLayer implements Layer { } onUnitEvent(unit: UnitView) { + // Check if unit was deactivated + if (!unit.isActive()) { + this.handleUnitDeactivation(unit); + } + switch (unit.type()) { case UnitType.TransportShip: this.handleBoatEvent(unit);