Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ resources/.DS_Store
.clinic/
CLAUDE.md
.idea/
.direnv/
.devenv/
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
196 changes: 196 additions & 0 deletions src/client/graphics/layers/WarshipRadiusLayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
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;
}

// Note: Animation timing is handled in renderLayer() for smooth frame-rate animation
}

renderLayer(context: CanvasRenderingContext2D) {
// Animate dash offset every frame for smooth animation on high refresh rate displays
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;
}

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;
}
Comment on lines +108 to +120
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix: clear selection on deselect events where unit is null.
Right now !e.isSelected only clears when e.unit === this.selectedWarship; a UnitSelectionEvent(null, false) will leave the radius stuck on.

 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) {
+  } else if (!e.isSelected && (!e.unit || 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;
 }
🤖 Prompt for AI Agents
In src/client/graphics/layers/WarshipRadiusLayer.ts around lines 108 to 120, the
deselect path only clears selection when e.unit === this.selectedWarship, so a
UnitSelectionEvent with unit === null and isSelected === false leaves the
previous selection active; update the handler so that any deselect event
(e.isSelected === false) clears this.selectedWarship and this.selectedShow (or
at minimum clear when e.unit is null), while preserving the existing logic for
selecting a warship when e.isSelected === true and non-warship selects clearing
the selection; ensure this.needsRedraw is still set to true after changes.


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;
}
Comment on lines +122 to +136
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the TransformHandler class definition
echo "=== Finding TransformHandler class ==="
rg -n "class\s+TransformHandler" --type=ts
echo ""

# Find screenToWorldCoordinates method
echo "=== Finding screenToWorldCoordinates method ==="
rg -n "screenToWorldCoordinates\s*\(" --type=ts -B2 -A8

Repository: openfrontio/OpenFrontIO

Length of output: 13857


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Get the complete screenToWorldCoordinates method
echo "=== Full screenToWorldCoordinates implementation ==="
sed -n '100,120p' src/client/graphics/TransformHandler.ts

Repository: openfrontio/OpenFrontIO

Length of output: 900


Remove the rect offset — screenToWorldCoordinates() already subtracts it internally.

The TransformHandler.screenToWorldCoordinates() method (lines 101–103) calls boundingRect() and subtracts canvasRect.left and canvasRect.top from the input coordinates. You are subtracting these offsets again before calling the method, which causes a double offset. Pass e.x and e.y directly instead.

🤖 Prompt for AI Agents
In src/client/graphics/layers/WarshipRadiusLayer.ts around lines 122–136, the
code subtracts the canvas bounding rect from e.x/e.y before calling
screenToWorldCoordinates, but that method already calls boundingRect() and
subtracts left/top internally; remove the subtraction and call
screenToWorldCoordinates(e.x, e.y) instead (you can keep the existing rect
existence check but do not use rect.left/rect.top when passing coordinates),
then assign the returned worldPos to this.mouseWorldPos and set needsRedraw as
before.


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
Loading