Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/client/graphics/GameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -210,6 +211,12 @@ export function createRenderer(
transformHandler,
uiState,
);
const warshipRadiusLayer = new WarshipRadiusLayer(
game,
eventBus,
transformHandler,
uiState,
);

const performanceOverlay = document.querySelector(
"performance-overlay",
Expand Down Expand Up @@ -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),
Expand Down
11 changes: 11 additions & 0 deletions src/client/graphics/layers/UnitLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { GameView, UnitView } from "../../../core/game/GameView";
import { BezenhamLine } from "../../../core/utilities/Line";
import {
AlternateViewEvent,
CloseViewEvent,
ContextMenuEvent,
MouseUpEvent,
TouchEvent,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
191 changes: 191 additions & 0 deletions src/client/graphics/layers/WarshipRadiusLayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
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;
}
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();
}
}
1 change: 1 addition & 0 deletions src/core/game/GameUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions src/core/game/GameView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/core/game/UnitImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export class UnitImpl implements Unit {
hasTrainStation: this._hasTrainStation,
trainType: this._trainType,
loaded: this._loaded,
patrolTile: this._patrolTile,
};
}

Expand Down
Loading