From c3dfcc63a53cc807b8890a19100ea84f55673489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Jurkovi=C4=87?= Date: Fri, 28 Feb 2025 20:52:38 +0100 Subject: [PATCH 1/4] Added warship selection mechanic --- src/client/graphics/GameRenderer.ts | 2 +- src/client/graphics/SelectedUnits.ts | 94 +++++++++ src/client/graphics/layers/UnitLayer.ts | 266 +++++++++++++++++++++++- 3 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 src/client/graphics/SelectedUnits.ts diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index a0c34bb01..6b3922208 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -117,7 +117,7 @@ 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 NameLayer(game, transformHandler, clientID), eventsDisplay, buildMenu, diff --git a/src/client/graphics/SelectedUnits.ts b/src/client/graphics/SelectedUnits.ts new file mode 100644 index 000000000..c3f7bcc1c --- /dev/null +++ b/src/client/graphics/SelectedUnits.ts @@ -0,0 +1,94 @@ +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/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 5c900fdce..7596ae519 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -3,7 +3,7 @@ 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 } from "../../InputHandler"; import { ClientID } from "../../../core/Schemas"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { @@ -12,6 +12,8 @@ import { TileRef, } from "../../../core/game/GameMap"; import { GameUpdateType } from "../../../core/game/GameUpdates"; +import { SelectedUnits, UnitSelectionEvent } from "../SelectedUnits"; +import { TransformHandler } from "../TransformHandler"; enum Relationship { Self, @@ -33,12 +35,22 @@ export class UnitLayer implements Layer { private oldShellTile = new Map(); + private selectedUnits: SelectedUnits; + private selectionAnimTime = 0; + private transformHandler: TransformHandler; + + // 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.selectedUnits = new SelectedUnits(eventBus); + this.transformHandler = transformHandler; } shouldTransform(): boolean { @@ -52,13 +64,133 @@ 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.redraw(); } + /** + * Find warships near the given cell within a configurable radius + * @param cell The cell to check + * @returns Array of 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); + + return this.game + .units(UnitType.Warship) + .filter( + (unit) => + unit.isActive() && + 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) { + // Select/deselect the closest warship + this.selectedUnits.toggleUnitSelection(nearbyWarships[0]); + } else if (this.selectedUnits.getSelectedUnit()) { + // If clicked elsewhere and there's a selection, deselect it + this.selectedUnits.deselectCurrentUnit(); + } + } + + 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; + 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); + } + } + } + this.lastSelectionBoxCenter = null; + } + } + } + } + + /** + * Handle unit deactivation or destruction + * 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; + 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); + } + } + } + this.lastSelectionBoxCenter = null; + } + + this.selectedUnits.deselectCurrentUnit(); + } + } + + private redrawSelectedUnit(unit: UnitView) { + if (unit && unit.type() === UnitType.Warship && unit.isActive()) { + this.onUnitEvent(unit); + } + } + renderLayer(context: CanvasRenderingContext2D) { context.drawImage( this.canvas, @@ -101,6 +233,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); @@ -175,6 +312,133 @@ 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 + for (let x = lastX - lastSize; x <= lastX + lastSize; x++) { + for (let y = lastY - lastSize; y <= lastY + lastSize; y++) { + if ( + x === lastX - lastSize || + x === lastX + lastSize || + y === lastY - lastSize || + y === lastY + lastSize + ) { + this.clearCell(x, y); + } + } + } + + // Redraw the tiles at the previous location + for (const t of this.game.bfs( + this.lastSelectionBoxCenter.unit.lastTile(), + euclDistFN(this.lastSelectionBoxCenter.unit.lastTile(), 5), + )) { + const tileX = this.game.x(t); + const tileY = this.game.y(t); + // Only redraw if it's near the selection border + if ( + Math.abs(tileX - lastX) <= lastSize + 1 && + Math.abs(tileY - lastY) <= lastSize + 1 + ) { + if (this.game.hasOwner(t)) { + const owner = this.game.owner(t); + if (owner.isPlayer()) { + this.paintCell( + tileX, + tileY, + this.relationship(unit), + this.theme.territoryColor(owner.info()), + 255, + ); + } + } + } + } + } + + // 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) { From 1d7b03dde754d9ce9af21d5d9d269815cd040bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Jurkovi=C4=87?= Date: Fri, 28 Feb 2025 23:57:39 +0100 Subject: [PATCH 2/4] Reusable clearSelectionBox method, modified terrain redraw --- src/client/graphics/layers/UnitLayer.ts | 85 +++++++------------------ 1 file changed, 23 insertions(+), 62 deletions(-) diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 7596ae519..b62e6d7a8 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -124,6 +124,24 @@ export class UnitLayer implements Layer { } } + /** + * 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); + } + } + } + } + private onUnitSelection(event: UnitSelectionEvent) { if (event.unit && event.unit.type() === UnitType.Warship) { if (event.isSelected) { @@ -136,18 +154,7 @@ export class UnitLayer implements Layer { // Also clear any lingering selection box if (this.lastSelectionBoxCenter) { const { x, y, size } = this.lastSelectionBoxCenter; - 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); - } - } - } + this.clearSelectionBox(x, y, size); this.lastSelectionBoxCenter = null; } } @@ -166,18 +173,7 @@ export class UnitLayer implements Layer { this.lastSelectionBoxCenter.unit === unit ) { const { x, y, size } = this.lastSelectionBoxCenter; - 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); - } - } - } + this.clearSelectionBox(x, y, size); this.lastSelectionBoxCenter = null; } @@ -366,45 +362,10 @@ export class UnitLayer implements Layer { const lastY = this.lastSelectionBoxCenter.y; // Clear the previous selection box - for (let x = lastX - lastSize; x <= lastX + lastSize; x++) { - for (let y = lastY - lastSize; y <= lastY + lastSize; y++) { - if ( - x === lastX - lastSize || - x === lastX + lastSize || - y === lastY - lastSize || - y === lastY + lastSize - ) { - this.clearCell(x, y); - } - } - } + this.clearSelectionBox(lastX, lastY, lastSize); - // Redraw the tiles at the previous location - for (const t of this.game.bfs( - this.lastSelectionBoxCenter.unit.lastTile(), - euclDistFN(this.lastSelectionBoxCenter.unit.lastTile(), 5), - )) { - const tileX = this.game.x(t); - const tileY = this.game.y(t); - // Only redraw if it's near the selection border - if ( - Math.abs(tileX - lastX) <= lastSize + 1 && - Math.abs(tileY - lastY) <= lastSize + 1 - ) { - if (this.game.hasOwner(t)) { - const owner = this.game.owner(t); - if (owner.isPlayer()) { - this.paintCell( - tileX, - tileY, - this.relationship(unit), - this.theme.territoryColor(owner.info()), - 255, - ); - } - } - } - } + // 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 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 3/4] 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) { From f84a18aa951c0bc17b73f74477c72383627f2944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Jurkovi=C4=87?= Date: Sat, 1 Mar 2025 00:20:31 +0100 Subject: [PATCH 4/4] prevent selecting other players' warships --- src/client/graphics/layers/UnitLayer.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index a0bd2d385..5cae90c36 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -77,18 +77,25 @@ export class UnitLayer implements Layer { } /** - * Find warships near the given cell within a configurable radius + * Find player-owned warships near the given cell within a configurable radius * @param cell The cell to check - * @returns Array of warships in range, sorted by distance (closest first) + * @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, )