From 3d4ea515de0c4de7689da66a86bbe5c8783c2393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Jurkovi=C4=87?= Date: Sat, 1 Mar 2025 00:13:52 +0100 Subject: [PATCH] refactor: move selection rendering to dedicated UILayer --- src/client/InputHandler.ts | 12 ++ src/client/graphics/GameRenderer.ts | 2 + src/client/graphics/SelectedUnits.ts | 94 ----------- src/client/graphics/layers/UILayer.ts | 207 ++++++++++++++++++++++++ src/client/graphics/layers/UnitLayer.ts | 188 ++++----------------- 5 files changed, 249 insertions(+), 254 deletions(-) delete mode 100644 src/client/graphics/SelectedUnits.ts create mode 100644 src/client/graphics/layers/UILayer.ts 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 6b3922208..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"; @@ -118,6 +119,7 @@ export function createRenderer( new TerritoryLayer(game, eventBus), new StructureLayer(game, eventBus), 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/SelectedUnits.ts b/src/client/graphics/SelectedUnits.ts deleted file mode 100644 index c3f7bcc1c..000000000 --- a/src/client/graphics/SelectedUnits.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { EventBus, GameEvent } from "../../core/EventBus"; -import { UnitType } from "../../core/game/Game"; -import { UnitView } from "../../core/game/GameView"; - -/** - * Event emitted when a unit is selected or deselected - */ -export class UnitSelectionEvent implements GameEvent { - constructor( - public readonly unit: UnitView | null, - public readonly isSelected: boolean, - ) {} -} - -/** - * Manages the currently selected units in the game - */ -export class SelectedUnits { - private selectedUnit: UnitView | null = null; - - constructor(private eventBus: EventBus) {} - - /** - * Select a unit. Deselects any previously selected unit. - * @param unit The unit to select - * @returns true if the selection changed, false otherwise - */ - selectUnit(unit: UnitView): boolean { - if (this.selectedUnit === unit) { - return false; - } - - if (this.selectedUnit) { - this.deselectCurrentUnit(); - } - - this.selectedUnit = unit; - this.eventBus.emit(new UnitSelectionEvent(unit, true)); - return true; - } - - /** - * Deselect the currently selected unit, if any - * @returns true if a unit was deselected, false otherwise - */ - deselectCurrentUnit(): boolean { - if (!this.selectedUnit) { - return false; - } - - const unit = this.selectedUnit; - this.selectedUnit = null; - this.eventBus.emit(new UnitSelectionEvent(unit, false)); - return true; - } - - /** - * Toggle selection for the given unit - * @param unit The unit to toggle selection for - * @returns true if the unit is now selected, false if it was deselected - */ - toggleUnitSelection(unit: UnitView): boolean { - if (this.selectedUnit === unit) { - this.deselectCurrentUnit(); - return false; - } else { - this.selectUnit(unit); - return true; - } - } - - /** - * Get the currently selected unit, if any - */ - getSelectedUnit(): UnitView | null { - return this.selectedUnit; - } - - /** - * Check if the given unit is currently selected - * @param unit The unit to check - */ - isSelected(unit: UnitView): boolean { - return this.selectedUnit === unit; - } - - /** - * Check if a unit of the specified type is currently selected - * @param type The unit type to check for - */ - hasSelectedUnitOfType(type: UnitType): boolean { - return this.selectedUnit !== null && this.selectedUnit.type() === type; - } -} 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 b62e6d7a8..a0bd2d385 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, MouseUpEvent } from "../../InputHandler"; +import { + AlternateViewEvent, + MouseUpEvent, + UnitSelectionEvent, +} from "../../InputHandler"; import { ClientID } from "../../../core/Schemas"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { @@ -12,7 +16,6 @@ import { TileRef, } from "../../../core/game/GameMap"; import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { SelectedUnits, UnitSelectionEvent } from "../SelectedUnits"; import { TransformHandler } from "../TransformHandler"; enum Relationship { @@ -35,10 +38,11 @@ export class UnitLayer implements Layer { private oldShellTile = new Map(); - private selectedUnits: SelectedUnits; - private selectionAnimTime = 0; 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 @@ -49,7 +53,6 @@ export class UnitLayer implements Layer { transformHandler: TransformHandler, ) { this.theme = game.config().theme(); - this.selectedUnits = new SelectedUnits(eventBus); this.transformHandler = transformHandler; } @@ -64,20 +67,12 @@ export class UnitLayer implements Layer { this.game.updatesSinceLastTick()?.[GameUpdateType.Unit]?.forEach((unit) => { this.onUnitEvent(this.game.unit(unit.id)); }); - - // 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.selectedUnits.hasSelectedUnitOfType(UnitType.Warship)) { - this.redrawSelectedUnit(this.selectedUnits.getSelectedUnit()); - } } init() { this.eventBus.on(AlternateViewEvent, (e) => this.onAlternativeViewEvent(e)); this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e)); - this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelection(e)); + this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelectionChange(e)); this.redraw(); } @@ -116,48 +111,29 @@ export class UnitLayer implements Layer { const nearbyWarships = this.findWarshipsNearCell(cell); if (nearbyWarships.length > 0) { - // Select/deselect the closest warship - this.selectedUnits.toggleUnitSelection(nearbyWarships[0]); - } else if (this.selectedUnits.getSelectedUnit()) { + // 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.selectedUnits.deselectCurrentUnit(); + this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); } } /** - * Clear the selection box at a specific position + * Handle unit selection changes */ - 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); - } - } - } - } - - private onUnitSelection(event: UnitSelectionEvent) { - if (event.unit && event.unit.type() === UnitType.Warship) { - if (event.isSelected) { - // Highlight the selected warship - this.redrawSelectedUnit(event.unit); - } else { - // Remove the highlight - this.onUnitEvent(event.unit); - - // Also clear any lingering selection box - if (this.lastSelectionBoxCenter) { - const { x, y, size } = this.lastSelectionBoxCenter; - this.clearSelectionBox(x, y, size); - this.lastSelectionBoxCenter = null; - } - } + private onUnitSelectionChange(event: UnitSelectionEvent) { + if (event.isSelected) { + this.selectedUnit = event.unit; + } else if (this.selectedUnit === event.unit) { + this.selectedUnit = null; } } @@ -166,24 +142,8 @@ export class UnitLayer implements Layer { * If the selected unit is removed from the game, deselect it */ private handleUnitDeactivation(unit: UnitView) { - if (this.selectedUnits.isSelected(unit) && !unit.isActive()) { - // Clear the selection box before deselecting - if ( - this.lastSelectionBoxCenter && - this.lastSelectionBoxCenter.unit === unit - ) { - const { x, y, size } = this.lastSelectionBoxCenter; - this.clearSelectionBox(x, y, size); - this.lastSelectionBoxCenter = null; - } - - this.selectedUnits.deselectCurrentUnit(); - } - } - - private redrawSelectedUnit(unit: UnitView) { - if (unit && unit.type() === UnitType.Warship && unit.isActive()) { - this.onUnitEvent(unit); + if (this.selectedUnit === unit && !unit.isActive()) { + this.eventBus.emit(new UnitSelectionEvent(unit, false)); } } @@ -308,98 +268,6 @@ export class UnitLayer implements Layer { 255, ); } - - // If this is a selected warship, draw the selection box - if (this.selectedUnits.isSelected(unit)) { - this.drawSelectionBox(unit); - } - } - - // Keep track of previous selection box positions for cleanup - private lastSelectionBoxCenter: { - unit: UnitView; - 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) - - /** - * Draw a selection box around the warship - */ - private drawSelectionBox(unit: UnitView) { - // 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 warship'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 || - this.lastSelectionBoxCenter.unit !== unit) - ) { - 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); - - // We don't need to redraw the territory since the unit layer sits on top of the territory layer - // and clearing just the selection box pixels won't affect the territory underneath - } - - // 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, - this.relationship(unit), - selectionColor, - opacity, - ); - } - } - } - } - - // Store current selection box position for next cleanup - this.lastSelectionBoxCenter = { - unit, - x: centerX, - y: centerY, - size: selectionSize, - }; } private handleShellEvent(unit: UnitView) {