diff --git a/.gitignore b/.gitignore index 18fa251ae7..5cf75f9da9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ resources/.DS_Store .clinic/ CLAUDE.md .idea/ +.direnv/ +.devenv/ diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 1410cdbbd9..3aa93c3844 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -40,6 +40,7 @@ import { TerritoryLayer } from "./layers/TerritoryLayer"; import { UILayer } from "./layers/UILayer"; import { UnitDisplay } from "./layers/UnitDisplay"; import { UnitLayer } from "./layers/UnitLayer"; +import { WarshipRadiusLayer } from "./layers/WarshipRadiusLayer"; import { WinModal } from "./layers/WinModal"; export function createRenderer( @@ -210,6 +211,12 @@ export function createRenderer( transformHandler, uiState, ); + const warshipRadiusLayer = new WarshipRadiusLayer( + game, + eventBus, + transformHandler, + uiState, + ); const performanceOverlay = document.querySelector( "performance-overlay", @@ -242,6 +249,7 @@ export function createRenderer( new RailroadLayer(game, transformHandler), structureLayer, samRadiusLayer, + warshipRadiusLayer, new UnitLayer(game, eventBus, transformHandler), new FxLayer(game), new UILayer(game, eventBus, transformHandler), diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index e7f69089d8..77bdb86265 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -7,6 +7,7 @@ import { GameView, UnitView } from "../../../core/game/GameView"; import { BezenhamLine } from "../../../core/utilities/Line"; import { AlternateViewEvent, + CloseViewEvent, ContextMenuEvent, MouseUpEvent, TouchEvent, @@ -77,6 +78,7 @@ export class UnitLayer implements Layer { this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e)); this.eventBus.on(TouchEvent, (e) => this.onTouch(e)); this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelectionChange(e)); + this.eventBus.on(CloseViewEvent, () => this.onCloseView()); this.redraw(); loadAllSprites(); @@ -189,6 +191,15 @@ export class UnitLayer implements Layer { } } + /** + * Handle close view event (ESC key) - deselect warship + */ + private onCloseView() { + if (this.selectedUnit) { + this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); + } + } + /** * Handle unit deactivation or destruction * If the selected unit is removed from the game, deselect it diff --git a/src/client/graphics/layers/WarshipRadiusLayer.ts b/src/client/graphics/layers/WarshipRadiusLayer.ts new file mode 100644 index 0000000000..bd68db7b10 --- /dev/null +++ b/src/client/graphics/layers/WarshipRadiusLayer.ts @@ -0,0 +1,194 @@ +import type { EventBus } from "../../../core/EventBus"; +import { UnitType } from "../../../core/game/Game"; +import type { GameView, UnitView } from "../../../core/game/GameView"; +import { MouseMoveEvent, UnitSelectionEvent } from "../../InputHandler"; +import { TransformHandler } from "../TransformHandler"; +import { UIState } from "../UIState"; +import { Layer } from "./Layer"; + +/** + * Layer responsible for rendering warship patrol area indicators. + * Shows: + * - Current patrol area (solid line square) - centered on warship's patrolTile + * - Preview patrol area (dashed line square) - follows cursor for placement preview + */ +export class WarshipRadiusLayer implements Layer { + private readonly canvas: HTMLCanvasElement; + private readonly context: CanvasRenderingContext2D; + + // State tracking + private selectedWarship: UnitView | null = null; + private needsRedraw = true; + private selectedShow = false; // Warship is selected + private ghostShow = false; // In warship spawn mode + + // Animation for dashed preview squares + private dashOffset = 0; + private animationSpeed = 14; // px per second (matches SAMRadiusLayer) + private lastTickTime = Date.now(); + + // Cursor tracking for preview squares + private mouseWorldPos: { x: number; y: number } | null = null; + + constructor( + private readonly game: GameView, + private readonly eventBus: EventBus, + private readonly transformHandler: TransformHandler, + private readonly uiState: UIState, + ) { + this.canvas = document.createElement("canvas"); + const ctx = this.canvas.getContext("2d"); + if (!ctx) { + throw new Error("2d context not supported"); + } + this.context = ctx; + this.canvas.width = this.game.width(); + this.canvas.height = this.game.height(); + } + + shouldTransform(): boolean { + return true; + } + + init() { + this.eventBus.on(UnitSelectionEvent, (e) => this.handleUnitSelection(e)); + this.eventBus.on(MouseMoveEvent, (e) => this.handleMouseMove(e)); + this.redraw(); + } + + tick() { + // Update ghost mode state + const wasGhostShow = this.ghostShow; + this.ghostShow = this.uiState.ghostStructure === UnitType.Warship; + + // Clear mouse position when ghost mode ends (e.g., after placing warship) + if (wasGhostShow && !this.ghostShow) { + this.mouseWorldPos = null; + this.needsRedraw = true; + } + + // Check if selected warship was destroyed + if (this.selectedWarship && !this.selectedWarship.isActive()) { + this.selectedWarship = null; + this.selectedShow = false; + this.needsRedraw = true; + } + + // Animate dash offset only when preview square is visible + const now = Date.now(); + const dt = now - this.lastTickTime; + this.lastTickTime = now; + + const previewVisible = + (this.selectedShow || this.ghostShow) && this.mouseWorldPos; + if (previewVisible) { + this.dashOffset += (this.animationSpeed * dt) / 1000; + if (this.dashOffset > 1e6) this.dashOffset = this.dashOffset % 1000000; + this.needsRedraw = true; + } + + if (this.transformHandler.hasChanged() || this.needsRedraw) { + this.redraw(); + this.needsRedraw = false; + } + } + + renderLayer(context: CanvasRenderingContext2D) { + context.drawImage( + this.canvas, + -this.game.width() / 2, + -this.game.height() / 2, + this.game.width(), + this.game.height(), + ); + } + + private handleUnitSelection(e: UnitSelectionEvent) { + if (e.unit?.type() === UnitType.Warship && e.isSelected) { + this.selectedWarship = e.unit; + this.selectedShow = true; + } else if (!e.isSelected && this.selectedWarship === e.unit) { + this.selectedWarship = null; + this.selectedShow = false; + } else if (e.isSelected && e.unit && e.unit.type() !== UnitType.Warship) { + this.selectedWarship = null; + this.selectedShow = false; + } + this.needsRedraw = true; + } + + private handleMouseMove(e: MouseMoveEvent) { + if (!this.selectedShow && !this.ghostShow) return; + + const rect = this.transformHandler.boundingRect(); + if (!rect) return; + + // Convert screen coordinates to world coordinates + const worldPos = this.transformHandler.screenToWorldCoordinates( + e.x - rect.left, + e.y - rect.top, + ); + + this.mouseWorldPos = worldPos; + this.needsRedraw = true; + } + + redraw() { + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); + + // Draw current patrol area (solid) when warship selected + if (this.selectedWarship && this.selectedShow) { + const patrolTile = this.selectedWarship.patrolTile(); + if (patrolTile) { + const x = this.game.x(patrolTile); + const y = this.game.y(patrolTile); + this.drawCurrentPatrol(x, y); + } + } + + // Draw preview at cursor (dashed) when warship selected OR ghost mode + if ((this.selectedShow || this.ghostShow) && this.mouseWorldPos) { + this.drawPreviewPatrol(this.mouseWorldPos.x, this.mouseWorldPos.y); + } + } + + /** + * Draw current patrol area with solid line square + */ + private drawCurrentPatrol(centerX: number, centerY: number) { + const ctx = this.context; + const patrolRange = this.game.config().warshipPatrolRange(); + const halfSize = patrolRange / 2; + + ctx.save(); + ctx.lineWidth = 2; + ctx.strokeStyle = "rgba(0, 0, 0, 0.2)"; + + ctx.beginPath(); + ctx.rect(centerX - halfSize, centerY - halfSize, patrolRange, patrolRange); + ctx.stroke(); + + ctx.restore(); + } + + /** + * Draw preview patrol area with dashed line square (animated) + */ + private drawPreviewPatrol(centerX: number, centerY: number) { + const ctx = this.context; + const patrolRange = this.game.config().warshipPatrolRange(); + const halfSize = patrolRange / 2; + + ctx.save(); + ctx.lineWidth = 2; + ctx.setLineDash([12, 6]); + ctx.lineDashOffset = this.dashOffset; + ctx.strokeStyle = "rgba(0, 0, 0, 0.2)"; + + ctx.beginPath(); + ctx.rect(centerX - halfSize, centerY - halfSize, patrolRange, patrolRange); + ctx.stroke(); + + ctx.restore(); + } +} diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index c558a33911..b5d8ee862d 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -134,6 +134,7 @@ export interface UnitUpdate { hasTrainStation: boolean; trainType?: TrainType; // Only for trains loaded?: boolean; // Only for trains + patrolTile?: TileRef; // Only for warships - center of patrol area } export interface AttackUpdate { diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 63ce987de5..b22a3c8dd5 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -130,6 +130,9 @@ export class UnitView { targetTile(): TileRef | undefined { return this.data.targetTile; } + patrolTile(): TileRef | undefined { + return this.data.patrolTile; + } // How "ready" this unit is from 0 to 1. missileReadinesss(): number { diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index a41b83a973..3755afe5c9 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -140,6 +140,7 @@ export class UnitImpl implements Unit { hasTrainStation: this._hasTrainStation, trainType: this._trainType, loaded: this._loaded, + patrolTile: this._patrolTile, }; } diff --git a/tests/client/graphics/WarshipRadiusLayer.test.ts b/tests/client/graphics/WarshipRadiusLayer.test.ts new file mode 100644 index 0000000000..f7ad6c9ea7 --- /dev/null +++ b/tests/client/graphics/WarshipRadiusLayer.test.ts @@ -0,0 +1,235 @@ +/** + * @jest-environment jsdom + */ +import { WarshipRadiusLayer } from "../../../src/client/graphics/layers/WarshipRadiusLayer"; +import { + MouseMoveEvent, + UnitSelectionEvent, +} from "../../../src/client/InputHandler"; +import { UnitType } from "../../../src/core/game/Game"; + +// Mock gradient object +const mockGradient = { + addColorStop: jest.fn(), +}; + +// Mock canvas context since jsdom doesn't support it +const mockContext = { + clearRect: jest.fn(), + save: jest.fn(), + restore: jest.fn(), + beginPath: jest.fn(), + closePath: jest.fn(), + rect: jest.fn(), + arc: jest.fn(), + arcTo: jest.fn(), + moveTo: jest.fn(), + lineTo: jest.fn(), + stroke: jest.fn(), + fill: jest.fn(), + setLineDash: jest.fn(), + createRadialGradient: jest.fn().mockReturnValue(mockGradient), + lineDashOffset: 0, + lineWidth: 1, + strokeStyle: "", + fillStyle: "", +}; + +// Store original createElement +const originalCreateElement = document.createElement.bind(document); + +describe("WarshipRadiusLayer", () => { + let game: any; + let eventBus: any; + let transformHandler: any; + let uiState: any; + let layer: WarshipRadiusLayer; + + beforeAll(() => { + // Mock createElement to return a canvas with working getContext + jest.spyOn(document, "createElement").mockImplementation((tagName) => { + const element = originalCreateElement(tagName); + if (tagName === "canvas") { + (element as HTMLCanvasElement).getContext = jest + .fn() + .mockReturnValue(mockContext); + } + return element; + }); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + // Reset mock context + Object.values(mockContext).forEach((fn) => { + if (typeof fn === "function") { + (fn as jest.Mock).mockClear(); + } + }); + + game = { + width: () => 100, + height: () => 100, + config: () => ({ + warshipPatrolRange: () => 50, + warshipTargettingRange: () => 130, + }), + x: (tile: any) => 10, + y: (tile: any) => 10, + }; + eventBus = { on: jest.fn() }; + transformHandler = { + hasChanged: () => false, + boundingRect: () => ({ left: 0, top: 0 }), + screenToWorldCoordinates: (x: number, y: number) => ({ x, y }), + }; + uiState = { ghostStructure: null }; + + layer = new WarshipRadiusLayer(game, eventBus, transformHandler, uiState); + }); + + it("should initialize canvas with correct dimensions", () => { + expect(layer["canvas"].width).toBe(100); + expect(layer["canvas"].height).toBe(100); + expect(layer["context"]).not.toBeNull(); + }); + + it("should register event listeners on init", () => { + layer.init(); + expect(eventBus.on).toHaveBeenCalledTimes(2); + expect(eventBus.on).toHaveBeenCalledWith( + UnitSelectionEvent, + expect.any(Function), + ); + expect(eventBus.on).toHaveBeenCalledWith( + MouseMoveEvent, + expect.any(Function), + ); + }); + + it("should handle warship selection", () => { + const unit = { + type: () => UnitType.Warship, + isActive: () => true, + patrolTile: () => ({ x: 10, y: 10 }), + }; + + layer["handleUnitSelection"]({ + unit, + isSelected: true, + } as unknown as UnitSelectionEvent); + + expect(layer["selectedWarship"]).toBe(unit); + expect(layer["selectedShow"]).toBe(true); + }); + + it("should handle warship deselection", () => { + const unit = { + type: () => UnitType.Warship, + isActive: () => true, + patrolTile: () => ({ x: 10, y: 10 }), + }; + + // First select + layer["handleUnitSelection"]({ + unit, + isSelected: true, + } as unknown as UnitSelectionEvent); + + // Then deselect + layer["handleUnitSelection"]({ + unit, + isSelected: false, + } as unknown as UnitSelectionEvent); + + expect(layer["selectedWarship"]).toBeNull(); + expect(layer["selectedShow"]).toBe(false); + }); + + it("should ignore selection of non-warship units", () => { + const unit = { + type: () => UnitType.Port, + isActive: () => true, + }; + + layer["handleUnitSelection"]({ + unit, + isSelected: true, + } as unknown as UnitSelectionEvent); + + expect(layer["selectedWarship"]).toBeNull(); + expect(layer["selectedShow"]).toBe(false); + }); + + it("should track mouse position when warship is selected", () => { + // Select a warship first + layer["selectedShow"] = true; + + layer["handleMouseMove"]({ x: 50, y: 60 } as MouseMoveEvent); + + expect(layer["mouseWorldPos"]).toEqual({ x: 50, y: 60 }); + }); + + it("should not track mouse position when no warship selected and not in ghost mode", () => { + layer["selectedShow"] = false; + layer["ghostShow"] = false; + + layer["handleMouseMove"]({ x: 50, y: 60 } as MouseMoveEvent); + + expect(layer["mouseWorldPos"]).toBeNull(); + }); + + it("should track mouse position in ghost mode", () => { + layer["ghostShow"] = true; + + layer["handleMouseMove"]({ x: 50, y: 60 } as MouseMoveEvent); + + expect(layer["mouseWorldPos"]).toEqual({ x: 50, y: 60 }); + }); + + it("should detect ghost mode from uiState", () => { + uiState.ghostStructure = UnitType.Warship; + + layer.tick(); + + expect(layer["ghostShow"]).toBe(true); + }); + + it("should clear mouse position when ghost mode ends", () => { + // Start in ghost mode with mouse position + layer["ghostShow"] = true; + layer["mouseWorldPos"] = { x: 50, y: 60 }; + + // End ghost mode + uiState.ghostStructure = null; + layer.tick(); + + expect(layer["ghostShow"]).toBe(false); + expect(layer["mouseWorldPos"]).toBeNull(); + }); + + it("should clear selection when warship becomes inactive", () => { + const unit = { + type: () => UnitType.Warship, + isActive: () => true, + patrolTile: () => ({ x: 10, y: 10 }), + }; + + layer["selectedWarship"] = unit as any; + layer["selectedShow"] = true; + + // Warship becomes inactive + unit.isActive = () => false; + layer.tick(); + + expect(layer["selectedWarship"]).toBeNull(); + expect(layer["selectedShow"]).toBe(false); + }); + + it("should return true for shouldTransform", () => { + expect(layer.shouldTransform()).toBe(true); + }); +});