Merge pull request #102 from BrunoJurkovic/warship-selection

Warship Selection System
This commit is contained in:
evanpelle
2025-03-02 20:11:11 -08:00
committed by GitHub
4 changed files with 323 additions and 2 deletions
+12
View File
@@ -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,
+3 -1
View File
@@ -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,
+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);
}
}
+101 -1
View File
@@ -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<UnitView, TileRef>();
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);