import { colord, Colord } from "colord"; import { EventBus } from "../../../core/EventBus"; import { Theme } from "../../../core/configuration/Config"; import { Cell, UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, UnitView } from "../../../core/game/GameView"; import { BezenhamLine } from "../../../core/utilities/Line"; import { AlternateViewEvent, ContextMenuEvent, MouseUpEvent, TouchEvent, UnitSelectionEvent, } from "../../InputHandler"; import { MoveWarshipIntentEvent } from "../../Transport"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; import { sampleGridSegmentPlan } from "./SegmentMotionSample"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { getColoredSprite, isSpriteReady, loadAllSprites, } from "../SpriteLoader"; enum Relationship { Self, Ally, Enemy, } export class UnitLayer implements Layer { private canvas: HTMLCanvasElement; private context: CanvasRenderingContext2D; private unitTrailCanvas: HTMLCanvasElement; private unitTrailContext: CanvasRenderingContext2D; private transportShipTrailCanvas: HTMLCanvasElement; private transportShipTrailContext: CanvasRenderingContext2D; private motionTrailCanvas: HTMLCanvasElement; private motionTrailContext: CanvasRenderingContext2D; // Pixel trails (currently only used for nukes). private unitToTrail = new Map(); private gridMoverUnitIds = new Set(); private moverTrailLast = new Map< number, { x: number; y: number; planId: number; onScreen: boolean } >(); private transportShipTrails = new Map< number, { xy: number[]; planId: number; lastX: number; lastY: number; lastOnScreen: boolean; } >(); private transportShipTrailDirty = false; private lastMotionTrailFadeTickFloat: number | null = null; private theme: Theme; private alternateView = false; private oldShellTile = new Map(); 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 = 10; // Radius in game cells for warship selection hit zone constructor( private game: GameView, private eventBus: EventBus, transformHandler: TransformHandler, ) { this.theme = game.config().theme(); this.transformHandler = transformHandler; } shouldTransform(): boolean { return true; } tick() { // Cleanup trails for nukes that were removed without a final inactive update event. // These trails are stored outside of the normal unit sprite lifecycle. const trailUnits = Array.from(this.unitToTrail.keys()); for (const unit of trailUnits) { const current = this.game.unit(unit.id()); if (!current || !current.isActive()) { this.clearTrail(unit); } } const gridMoverUnitIds = new Set(); for (const id of this.game.motionPlans().keys()) { gridMoverUnitIds.add(id); } const moverSetChanged = !this.setsEqual( gridMoverUnitIds, this.gridMoverUnitIds, ); if (moverSetChanged) { this.gridMoverUnitIds = gridMoverUnitIds; for (const id of this.moverTrailLast.keys()) { if (!gridMoverUnitIds.has(id)) { this.moverTrailLast.delete(id); } } this.redrawStaticSprites(); return; } const updatedUnitIds = this.game .updatesSinceLastTick() ?.[GameUpdateType.Unit]?.map((unit) => unit.id) ?? []; const motionPlanUnitIds = this.game.motionPlannedUnitIds(); const unitIds = new Set(); for (const id of updatedUnitIds) { if (!gridMoverUnitIds.has(id)) { unitIds.add(id); } } for (const id of motionPlanUnitIds) { // Train plans still rely on discrete tick updates; grid movers are rendered smoothly in renderLayer(). if (!gridMoverUnitIds.has(id)) { unitIds.add(id); } } if (unitIds.size > 0) { this.updateUnitsSprites(Array.from(unitIds)); } } init() { this.eventBus.on(AlternateViewEvent, (e) => this.onAlternativeViewEvent(e)); 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.redraw(); loadAllSprites(); } /** * Find player-owned warships near the given cell within a configurable radius * @param clickRef The tile to check * @returns Array of player's warships in range, sorted by distance (closest first) */ private findWarshipsNearCell(clickRef: TileRef): UnitView[] { // Only select warships owned by the player return this.game .units(UnitType.Warship) .filter( (unit) => unit.isActive() && unit.owner() === this.game.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, clickRef?: TileRef, nearbyWarships?: UnitView[], ) { if (clickRef === undefined) { // Convert screen coordinates to world coordinates const cell = this.transformHandler.screenToWorldCoordinates( event.x, event.y, ); if (!this.game.isValidCoord(cell.x, cell.y)) return; clickRef = this.game.ref(cell.x, cell.y); } if (!this.game.isOcean(clickRef)) return; if (this.selectedUnit) { this.eventBus.emit( new MoveWarshipIntentEvent(this.selectedUnit.id(), clickRef), ); // Deselect this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); return; } // Find warships near this tile, sorted by distance nearbyWarships ??= this.findWarshipsNearCell(clickRef); if (nearbyWarships.length > 0) { // Toggle selection of the closest warship this.eventBus.emit(new UnitSelectionEvent(nearbyWarships[0], true)); } } private onTouch(event: TouchEvent) { const cell = this.transformHandler.screenToWorldCoordinates( event.x, event.y, ); if (!this.game.isValidCoord(cell.x, cell.y)) { return; } const clickRef = this.game.ref(cell.x, cell.y); if (!this.game.isOcean(clickRef)) { // No isValidCoord/Ref check yet, that is done for ContextMenuEvent later // No warship to find because no Ocean tile, open Radial Menu this.eventBus.emit(new ContextMenuEvent(event.x, event.y)); return; } if (!this.game.isValidRef(clickRef)) { return; } if (this.selectedUnit) { // Reuse the mouse logic, send clickRef to avoid fetching it again this.onMouseUp(new MouseUpEvent(event.x, event.y), clickRef); return; } const nearbyWarships = this.findWarshipsNearCell(clickRef); if (nearbyWarships.length > 0) { this.onMouseUp( new MouseUpEvent(event.x, event.y), clickRef, nearbyWarships, ); } else { // No warships selected or nearby, open Radial Menu this.eventBus.emit(new ContextMenuEvent(event.x, event.y)); } } /** * 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) { const moversToDraw: Array<{ unit: UnitView; x: number; y: number }> = []; const tickAlpha = this.computeTickAlpha(); const tickFloat = this.game.ticks() + tickAlpha; if (this.game.motionPlans().size > 0) { this.fadeMotionTrailCanvas(tickFloat); } for (const [unitId, plan] of this.game.motionPlans()) { const unit = this.game.unit(unitId); if (!unit || !unit.isActive()) { this.moverTrailLast.delete(unitId); if (this.transportShipTrails.delete(unitId)) { this.transportShipTrailDirty = true; } continue; } const sampled = sampleGridSegmentPlan(this.game, plan, tickFloat); if (!sampled) { continue; } const onScreen = this.transformHandler.isOnScreen( new Cell(Math.floor(sampled.x), Math.floor(sampled.y)), ); if (unit.type() === UnitType.TransportShip) { const existing = this.transportShipTrails.get(unitId); if (!existing || existing.planId !== plan.planId) { const xy: number[] = onScreen ? [sampled.x, sampled.y] : []; this.transportShipTrails.set(unitId, { xy, planId: plan.planId, lastX: sampled.x, lastY: sampled.y, lastOnScreen: onScreen, }); if (onScreen) { this.transportShipTrailDirty = true; } } else { if ( onScreen && (existing.lastX !== sampled.x || existing.lastY !== sampled.y) ) { if (!existing.lastOnScreen && existing.xy.length > 0) { existing.xy.push(Number.NaN, Number.NaN); } existing.xy.push(sampled.x, sampled.y); this.transportShipTrailDirty = true; } else if (onScreen && existing.xy.length === 0) { existing.xy.push(sampled.x, sampled.y); this.transportShipTrailDirty = true; } existing.lastX = sampled.x; existing.lastY = sampled.y; existing.lastOnScreen = onScreen; } if (onScreen) { moversToDraw.push({ unit, x: sampled.x, y: sampled.y }); } continue; } const last = this.moverTrailLast.get(unitId); if (last && last.planId === plan.planId) { if ( last.onScreen && onScreen && (last.x !== sampled.x || last.y !== sampled.y) ) { this.motionTrailContext.save(); this.motionTrailContext.lineCap = "round"; this.motionTrailContext.lineJoin = "round"; this.motionTrailContext.lineWidth = 1.5; this.motionTrailContext.strokeStyle = this.motionTrailColor(unit); this.motionTrailContext.beginPath(); this.motionTrailContext.moveTo(last.x, last.y); this.motionTrailContext.lineTo(sampled.x, sampled.y); this.motionTrailContext.stroke(); this.motionTrailContext.restore(); } } this.moverTrailLast.set(unitId, { x: sampled.x, y: sampled.y, planId: plan.planId, onScreen, }); if (onScreen) { moversToDraw.push({ unit, x: sampled.x, y: sampled.y }); } } // Remove transport-ship trails when the unit is gone (no fade during movement). for (const unitId of this.transportShipTrails.keys()) { const unit = this.game.unit(unitId); if (!unit || !unit.isActive()) { this.transportShipTrails.delete(unitId); this.transportShipTrailDirty = true; } } this.rebuildTransportShipTrailCanvasIfDirty(); context.drawImage( this.unitTrailCanvas, -this.game.width() / 2, -this.game.height() / 2, this.game.width(), this.game.height(), ); context.drawImage( this.motionTrailCanvas, -this.game.width() / 2, -this.game.height() / 2, this.game.width(), this.game.height(), ); context.drawImage( this.transportShipTrailCanvas, -this.game.width() / 2, -this.game.height() / 2, this.game.width(), this.game.height(), ); context.drawImage( this.canvas, -this.game.width() / 2, -this.game.height() / 2, this.game.width(), this.game.height(), ); for (const mover of moversToDraw) { this.drawSpriteAt( mover.unit, mover.x - this.game.width() / 2, mover.y - this.game.height() / 2, context, false, ); } } onAlternativeViewEvent(event: AlternateViewEvent) { this.alternateView = event.alternateView; this.redraw(); } redraw() { this.canvas = document.createElement("canvas"); const context = this.canvas.getContext("2d"); if (context === null) throw new Error("2d context not supported"); this.context = context; this.unitTrailCanvas = document.createElement("canvas"); const unitTrailContext = this.unitTrailCanvas.getContext("2d"); if (unitTrailContext === null) throw new Error("2d context not supported"); this.unitTrailContext = unitTrailContext; this.transportShipTrailCanvas = document.createElement("canvas"); const transportTrailContext = this.transportShipTrailCanvas.getContext("2d"); if (transportTrailContext === null) throw new Error("2d context not supported"); this.transportShipTrailContext = transportTrailContext; this.motionTrailCanvas = document.createElement("canvas"); const motionTrailContext = this.motionTrailCanvas.getContext("2d"); if (motionTrailContext === null) throw new Error("2d context not supported"); this.motionTrailContext = motionTrailContext; this.canvas.width = this.game.width(); this.canvas.height = this.game.height(); this.unitTrailCanvas.width = this.game.width(); this.unitTrailCanvas.height = this.game.height(); this.transportShipTrailCanvas.width = this.game.width(); this.transportShipTrailCanvas.height = this.game.height(); this.motionTrailCanvas.width = this.game.width(); this.motionTrailCanvas.height = this.game.height(); this.gridMoverUnitIds = new Set(this.game.motionPlans().keys()); this.moverTrailLast.clear(); this.lastMotionTrailFadeTickFloat = null; this.transportShipTrailDirty = true; this.redrawStaticSprites(); this.unitToTrail.forEach((trail, unit) => { for (const t of trail) { this.paintCell( this.game.x(t), this.game.y(t), this.relationship(unit), unit.owner().territoryColor(), 150, this.unitTrailContext, ); } }); } private setsEqual(a: Set, b: Set): boolean { if (a.size !== b.size) { return false; } for (const v of a) { if (!b.has(v)) { return false; } } return true; } private redrawStaticSprites(): void { this.context.clearRect(0, 0, this.game.width(), this.game.height()); const units = this.game .units() .filter((u) => !this.gridMoverUnitIds.has(u.id())); this.drawUnitsCells(units); } private computeTickAlpha(): number { if (this.game.isCatchingUp()) { return 1; } const dt = Math.max(1, this.game.tickDtEmaMs()); const alpha = (performance.now() - this.game.lastUpdateAtMs()) / dt; return Math.max(0, Math.min(1, alpha)); } private fadeMotionTrailCanvas(tickFloat: number): void { // Tick-based fade (independent of RAF rate). const fadePerTick = 0.12; if (this.lastMotionTrailFadeTickFloat === null) { this.lastMotionTrailFadeTickFloat = tickFloat; return; } const deltaTicks = Math.max( 0, tickFloat - this.lastMotionTrailFadeTickFloat, ); if (deltaTicks <= 0) { return; } this.lastMotionTrailFadeTickFloat = tickFloat; const removeAlpha = 1 - Math.pow(1 - fadePerTick, deltaTicks); if (removeAlpha <= 0) { return; } const ctx = this.motionTrailContext; ctx.save(); ctx.globalCompositeOperation = "destination-out"; ctx.fillStyle = `rgba(0,0,0,${Math.min(1, removeAlpha)})`; ctx.fillRect(0, 0, this.game.width(), this.game.height()); ctx.restore(); } private rebuildTransportShipTrailCanvasIfDirty(): void { if (!this.transportShipTrailDirty) { return; } this.transportShipTrailDirty = false; const ctx = this.transportShipTrailContext; ctx.clearRect(0, 0, this.game.width(), this.game.height()); for (const [unitId, trail] of this.transportShipTrails) { const unit = this.game.unit(unitId); if (!unit || !unit.isActive()) { continue; } if (trail.xy.length < 4) { continue; } ctx.save(); ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.lineWidth = 2.0; ctx.strokeStyle = this.motionTrailColor(unit); ctx.beginPath(); let needMove = true; for (let i = 0; i < trail.xy.length; i += 2) { const x = trail.xy[i]; const y = trail.xy[i + 1]; if (!Number.isFinite(x) || !Number.isFinite(y)) { needMove = true; continue; } if (needMove) { ctx.moveTo(x, y); needMove = false; } else { ctx.lineTo(x, y); } } ctx.stroke(); ctx.restore(); } } private relationshipForAlternateView(unit: UnitView): Relationship { let rel = this.relationship(unit); const dstPortId = unit.targetUnitId(); if (unit.type() === UnitType.TradeShip && dstPortId !== undefined) { const target = this.game.unit(dstPortId)?.owner(); const myPlayer = this.game.myPlayer(); if (myPlayer !== null && target !== undefined) { if (myPlayer === target) { rel = Relationship.Self; } else if (myPlayer.isFriendly(target)) { rel = Relationship.Ally; } } } return rel; } private motionTrailColor(unit: UnitView): string { if (this.alternateView) { const rel = this.relationshipForAlternateView(unit); switch (rel) { case Relationship.Self: return this.theme.selfColor().alpha(0.65).toRgbString(); case Relationship.Ally: return this.theme.allyColor().alpha(0.65).toRgbString(); case Relationship.Enemy: return this.theme.enemyColor().alpha(0.65).toRgbString(); } } return unit.owner().territoryColor().alpha(0.55).toRgbString(); } private updateUnitsSprites(unitIds: number[]) { const unitsToUpdate = unitIds ?.map((id) => this.game.unit(id)) .filter((unit) => unit !== undefined); if (unitsToUpdate) { // the clearing and drawing of unit sprites need to be done in 2 passes // otherwise the sprite of a unit can be drawn on top of another unit this.clearUnitsCells(unitsToUpdate); this.drawUnitsCells(unitsToUpdate); } } private clearUnitsCells(unitViews: UnitView[]) { unitViews .filter((unitView) => isSpriteReady(unitView)) .forEach((unitView) => { const sprite = getColoredSprite(unitView, this.theme); const clearsize = sprite.width + 1; const lastX = this.game.x(unitView.lastTile()); const lastY = this.game.y(unitView.lastTile()); this.context.clearRect( lastX - clearsize / 2, lastY - clearsize / 2, clearsize, clearsize, ); }); } private drawUnitsCells(unitViews: UnitView[]) { unitViews.forEach((unitView) => this.onUnitEvent(unitView)); } private relationship(unit: UnitView): Relationship { const myPlayer = this.game.myPlayer(); if (myPlayer === null) { return Relationship.Enemy; } if (myPlayer === unit.owner()) { return Relationship.Self; } if (myPlayer.isFriendly(unit.owner())) { return Relationship.Ally; } return Relationship.Enemy; } onUnitEvent(unit: UnitView) { // Check if unit was deactivated if (!unit.isActive()) { this.handleUnitDeactivation(unit); } switch (unit.type()) { case UnitType.TransportShip: this.handleBoatEvent(unit); break; case UnitType.Warship: this.handleWarShipEvent(unit); break; case UnitType.Shell: this.handleShellEvent(unit); break; case UnitType.SAMMissile: this.handleMissileEvent(unit); break; case UnitType.TradeShip: this.handleTradeShipEvent(unit); break; case UnitType.Train: this.handleTrainEvent(unit); break; case UnitType.MIRVWarhead: this.handleMIRVWarhead(unit); break; case UnitType.AtomBomb: case UnitType.HydrogenBomb: case UnitType.MIRV: this.handleNuke(unit); break; } } private handleWarShipEvent(unit: UnitView) { if (unit.targetUnitId()) { this.drawSprite(unit, colord("rgb(200,0,0)")); } else { this.drawSprite(unit); } } private handleShellEvent(unit: UnitView) { const rel = this.relationship(unit); // Clear current and previous positions this.clearCell(this.game.x(unit.lastTile()), this.game.y(unit.lastTile())); const oldTile = this.oldShellTile.get(unit); if (oldTile !== undefined) { this.clearCell(this.game.x(oldTile), this.game.y(oldTile)); } this.oldShellTile.set(unit, unit.lastTile()); if (!unit.isActive()) { return; } // Paint current and previous positions this.paintCell( this.game.x(unit.tile()), this.game.y(unit.tile()), rel, unit.owner().borderColor(), 255, ); this.paintCell( this.game.x(unit.lastTile()), this.game.y(unit.lastTile()), rel, unit.owner().borderColor(), 255, ); } // interception missile from SAM private handleMissileEvent(unit: UnitView) { this.drawSprite(unit); } private drawTrail(trail: number[], color: Colord, rel: Relationship) { // Paint new trail for (const t of trail) { this.paintCell( this.game.x(t), this.game.y(t), rel, color, 150, this.unitTrailContext, ); } } private clearTrail(unit: UnitView) { const trail = this.unitToTrail.get(unit) ?? []; const rel = this.relationship(unit); for (const t of trail) { this.clearCell(this.game.x(t), this.game.y(t), this.unitTrailContext); } this.unitToTrail.delete(unit); // Repaint overlapping trails const trailSet = new Set(trail); for (const [other, trail] of this.unitToTrail) { for (const t of trail) { if (trailSet.has(t)) { this.paintCell( this.game.x(t), this.game.y(t), rel, other.owner().territoryColor(), 150, this.unitTrailContext, ); } } } } private handleNuke(unit: UnitView) { const rel = this.relationship(unit); if (!this.unitToTrail.has(unit)) { this.unitToTrail.set(unit, []); } let newTrailSize = 1; const trail = this.unitToTrail.get(unit) ?? []; // It can move faster than 1 pixel, draw a line for the trail or else it will be dotted if (trail.length >= 1) { const cur = { x: this.game.x(unit.lastTile()), y: this.game.y(unit.lastTile()), }; const prev = { x: this.game.x(trail[trail.length - 1]), y: this.game.y(trail[trail.length - 1]), }; const line = new BezenhamLine(prev, cur); let point = line.increment(); while (point !== true) { trail.push(this.game.ref(point.x, point.y)); point = line.increment(); } newTrailSize = line.size(); } else { trail.push(unit.lastTile()); } this.drawTrail( trail.slice(-newTrailSize), unit.owner().territoryColor(), rel, ); this.drawSprite(unit); if (!unit.isActive()) { this.clearTrail(unit); } } private handleMIRVWarhead(unit: UnitView) { const rel = this.relationship(unit); this.clearCell(this.game.x(unit.lastTile()), this.game.y(unit.lastTile())); if (unit.isActive()) { // Paint area this.paintCell( this.game.x(unit.tile()), this.game.y(unit.tile()), rel, unit.owner().borderColor(), 255, ); } } private handleTradeShipEvent(unit: UnitView) { this.drawSprite(unit); } private handleTrainEvent(unit: UnitView) { this.drawSprite(unit); } private handleBoatEvent(unit: UnitView) { this.drawSprite(unit); } paintCell( x: number, y: number, relationship: Relationship, color: Colord, alpha: number, context: CanvasRenderingContext2D = this.context, ) { this.clearCell(x, y, context); if (this.alternateView) { switch (relationship) { case Relationship.Self: context.fillStyle = this.theme.selfColor().toRgbString(); break; case Relationship.Ally: context.fillStyle = this.theme.allyColor().toRgbString(); break; case Relationship.Enemy: context.fillStyle = this.theme.enemyColor().toRgbString(); break; } } else { context.fillStyle = color.alpha(alpha / 255).toRgbString(); } context.fillRect(x, y, 1, 1); } clearCell( x: number, y: number, context: CanvasRenderingContext2D = this.context, ) { context.clearRect(x, y, 1, 1); } private drawSpriteAt( unit: UnitView, x: number, y: number, ctx: CanvasRenderingContext2D = this.context, roundCoords: boolean = true, customTerritoryColor?: Colord, ) { let alternateViewColor: Colord | null = null; if (this.alternateView) { const rel = this.relationshipForAlternateView(unit); switch (rel) { case Relationship.Self: alternateViewColor = this.theme.selfColor(); break; case Relationship.Ally: alternateViewColor = this.theme.allyColor(); break; case Relationship.Enemy: alternateViewColor = this.theme.enemyColor(); break; } } const sprite = getColoredSprite( unit, this.theme, alternateViewColor ?? customTerritoryColor, alternateViewColor ?? undefined, ); if (!unit.isActive()) { return; } const targetable = unit.targetable(); ctx.save(); if (!targetable) { ctx.globalAlpha = 0.5; } const drawX = x - sprite.width / 2; const drawY = y - sprite.height / 2; ctx.drawImage( sprite, roundCoords ? Math.round(drawX) : drawX, roundCoords ? Math.round(drawY) : drawY, sprite.width, sprite.width, ); ctx.restore(); } private drawSprite(unit: UnitView, customTerritoryColor?: Colord) { this.drawSpriteAt( unit, this.game.x(unit.tile()), this.game.y(unit.tile()), this.context, true, customTerritoryColor, ); } }