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) {