refactor: move selection rendering to dedicated UILayer

This commit is contained in:
Bruno Jurković
2025-03-02 23:31:19 +01:00
parent 1d7b03dde7
commit 3d4ea515de
5 changed files with 249 additions and 254 deletions
+12
View File
@@ -1,5 +1,7 @@
import { EventBus, GameEvent } from "../core/EventBus"; import { EventBus, GameEvent } from "../core/EventBus";
import { UserSettings } from "../core/game/UserSettings"; import { UserSettings } from "../core/game/UserSettings";
import { Game } from "../core/game/Game";
import { UnitView } from "../core/game/GameView";
export class MouseUpEvent implements GameEvent { export class MouseUpEvent implements GameEvent {
constructor( 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 { export class MouseDownEvent implements GameEvent {
constructor( constructor(
public readonly x: number, public readonly x: number,
+2
View File
@@ -14,6 +14,7 @@ import { ControlPanel } from "./layers/ControlPanel";
import { UIState } from "./UIState"; import { UIState } from "./UIState";
import { BuildMenu } from "./layers/BuildMenu"; import { BuildMenu } from "./layers/BuildMenu";
import { UnitLayer } from "./layers/UnitLayer"; import { UnitLayer } from "./layers/UnitLayer";
import { UILayer } from "./layers/UILayer";
import { StructureLayer } from "./layers/StructureLayer"; import { StructureLayer } from "./layers/StructureLayer";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { consolex } from "../../core/Consolex"; import { consolex } from "../../core/Consolex";
@@ -118,6 +119,7 @@ export function createRenderer(
new TerritoryLayer(game, eventBus), new TerritoryLayer(game, eventBus),
new StructureLayer(game, eventBus), new StructureLayer(game, eventBus),
new UnitLayer(game, eventBus, clientID, transformHandler), new UnitLayer(game, eventBus, clientID, transformHandler),
new UILayer(game, eventBus, clientID, transformHandler),
new NameLayer(game, transformHandler, clientID), new NameLayer(game, transformHandler, clientID),
eventsDisplay, eventsDisplay,
buildMenu, buildMenu,
-94
View File
@@ -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;
}
}
+207
View File
@@ -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);
}
}
+28 -160
View File
@@ -3,7 +3,11 @@ import { Theme } from "../../../core/configuration/Config";
import { Unit, UnitType, Player } from "../../../core/game/Game"; import { Unit, UnitType, Player } from "../../../core/game/Game";
import { Layer } from "./Layer"; import { Layer } from "./Layer";
import { EventBus } from "../../../core/EventBus"; import { EventBus } from "../../../core/EventBus";
import { AlternateViewEvent, MouseUpEvent } from "../../InputHandler"; import {
AlternateViewEvent,
MouseUpEvent,
UnitSelectionEvent,
} from "../../InputHandler";
import { ClientID } from "../../../core/Schemas"; import { ClientID } from "../../../core/Schemas";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { import {
@@ -12,7 +16,6 @@ import {
TileRef, TileRef,
} from "../../../core/game/GameMap"; } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameUpdateType } from "../../../core/game/GameUpdates";
import { SelectedUnits, UnitSelectionEvent } from "../SelectedUnits";
import { TransformHandler } from "../TransformHandler"; import { TransformHandler } from "../TransformHandler";
enum Relationship { enum Relationship {
@@ -35,10 +38,11 @@ export class UnitLayer implements Layer {
private oldShellTile = new Map<UnitView, TileRef>(); private oldShellTile = new Map<UnitView, TileRef>();
private selectedUnits: SelectedUnits;
private selectionAnimTime = 0;
private transformHandler: TransformHandler; private transformHandler: TransformHandler;
// Selected unit property as suggested in the review comment
private selectedUnit: UnitView | null = null;
// Configuration for unit selection // Configuration for unit selection
private readonly WARSHIP_SELECTION_RADIUS = 3; // Radius in game cells for warship selection hit zone 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, transformHandler: TransformHandler,
) { ) {
this.theme = game.config().theme(); this.theme = game.config().theme();
this.selectedUnits = new SelectedUnits(eventBus);
this.transformHandler = transformHandler; this.transformHandler = transformHandler;
} }
@@ -64,20 +67,12 @@ export class UnitLayer implements Layer {
this.game.updatesSinceLastTick()?.[GameUpdateType.Unit]?.forEach((unit) => { this.game.updatesSinceLastTick()?.[GameUpdateType.Unit]?.forEach((unit) => {
this.onUnitEvent(this.game.unit(unit.id)); 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() { init() {
this.eventBus.on(AlternateViewEvent, (e) => this.onAlternativeViewEvent(e)); this.eventBus.on(AlternateViewEvent, (e) => this.onAlternativeViewEvent(e));
this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(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(); this.redraw();
} }
@@ -116,48 +111,29 @@ export class UnitLayer implements Layer {
const nearbyWarships = this.findWarshipsNearCell(cell); const nearbyWarships = this.findWarshipsNearCell(cell);
if (nearbyWarships.length > 0) { if (nearbyWarships.length > 0) {
// Select/deselect the closest warship // Toggle selection of the closest warship
this.selectedUnits.toggleUnitSelection(nearbyWarships[0]); const clickedUnit = nearbyWarships[0];
} else if (this.selectedUnits.getSelectedUnit()) { 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 // 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) { private onUnitSelectionChange(event: UnitSelectionEvent) {
for (let px = x - size; px <= x + size; px++) { if (event.isSelected) {
for (let py = y - size; py <= y + size; py++) { this.selectedUnit = event.unit;
if ( } else if (this.selectedUnit === event.unit) {
px === x - size || this.selectedUnit = null;
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;
}
}
} }
} }
@@ -166,24 +142,8 @@ export class UnitLayer implements Layer {
* If the selected unit is removed from the game, deselect it * If the selected unit is removed from the game, deselect it
*/ */
private handleUnitDeactivation(unit: UnitView) { private handleUnitDeactivation(unit: UnitView) {
if (this.selectedUnits.isSelected(unit) && !unit.isActive()) { if (this.selectedUnit === unit && !unit.isActive()) {
// Clear the selection box before deselecting this.eventBus.emit(new UnitSelectionEvent(unit, false));
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);
} }
} }
@@ -308,98 +268,6 @@ export class UnitLayer implements Layer {
255, 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) { private handleShellEvent(unit: UnitView) {