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, CloseViewEvent, ContextMenuEvent, MouseUpEvent, SelectAllWarshipsEvent, TouchEvent, UnitSelectionEvent, WarshipSelectionBoxCancelEvent, WarshipSelectionBoxCompleteEvent, } from "../../InputHandler"; import { MoveWarshipIntentEvent } from "../../Transport"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; import { sampleGridSegmentPlan } from "./SegmentMotionSample"; import { SegmentTrailPlanView, stepAtTick, strokeStepInterval, } from "./SegmentTrailRaster"; import { pruneInactiveTrails } from "./TrailLifecycle"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { getColoredSprite, isSpriteReady, loadAllSprites, } from "../SpriteLoader"; enum Relationship { Self, Ally, Enemy, } const ONSCREEN_DRAW_BUDGET_MS = 2; const OFFSCREEN_VERIFY_BUDGET_MS = 0.1; const OFFSCREEN_REFRESH_EVERY_N_FRAMES = 30; const ONSCREEN_HYSTERESIS_FRAMES = 2; const VIEW_PADDING_PX = 12; const MOVER_SPATIAL_HASH_CELL_PX = 24; const DYNAMIC_MOVER_SCALE_STEPS = [1, 2, 3, 4]; const DYNAMIC_MOVER_ZOOM_THRESHOLDS = [1.2, 2.4, 4.8] as const; const DYNAMIC_MOVER_ZOOM_HYSTERESIS = 0.2; const DYNAMIC_MOVER_SCALE_SETTLE_MS = 160; const DYNAMIC_MOVER_SCALE_COOLDOWN_MS = 300; const DYNAMIC_MOVER_SUBPIXEL_SNAP = false; const SMALL_SHIP_MASK_SIZE = 5; const TRANSPORT_SHIP_MASK = [ "..B..", ".BTB.", "BTTTB", ".BTB.", "..B..", ] as const; const TRADE_SHIP_MASK = ["..T..", ".TBT.", "TBBBT", ".TBT.", "..T.."] as const; type MotionTrailState = { activePlanId: number; epochs: MotionTrailEpoch[]; lastOnScreen: boolean; }; type MotionTrailEpoch = SegmentTrailPlanView & { planId: number; targetStep: number; drawnStep: number; sealed: boolean; }; type ActiveMotionTrailPlan = { unitId: number; unit: UnitView; plan: SegmentTrailPlanView & { planId: number }; maybeOnScreen: boolean; }; type MoverSpriteRect = { x: number; y: number; w: number; h: number; }; type MoverRenderSample = { unitId: number; unit: UnitView; planId: number; x: number; y: number; renderX: number; renderY: number; rect: MoverSpriteRect; }; type MoverSpatialIndex = { cells: Map>; unitToCells: Map; }; type MoverRenderState = { planId: number; lastSpriteRect: MoverSpriteRect | null; lastOnScreen: boolean; bucket: "on" | "off"; bucketIndex: number; skipDebt: number; lastSeenFrame: number; }; export class UnitLayer implements Layer { private canvas: HTMLCanvasElement; private context: CanvasRenderingContext2D; private dynamicMoverCanvas: HTMLCanvasElement; private dynamicMoverContext: CanvasRenderingContext2D; private trailCanvas: HTMLCanvasElement; private trailContext: CanvasRenderingContext2D; // Pixel trails (currently only used for nukes). private unitToTrail = new Map(); private gridMoverUnitIds = new Set(); private segmentTrails = new Map(); private trailDirty = false; private moverState = new Map(); private onScreenMoverIds: number[] = []; private offScreenMoverIds: number[] = []; private onScreenCursor = 0; private offScreenCursor = 0; private renderFrame = 0; private dynamicMoverCanvasScale = 1; private pendingDynamicMoverCanvasScale: number | null = null; private pendingDynamicMoverCanvasScaleSinceMs = 0; private lastDynamicMoverCanvasScaleChangeAtMs = -Infinity; private lastDynamicMoverCanvasRescaleMs = 0; private totalDynamicMoverCanvasRescaleMs = 0; private dynamicMoverCanvasRescaleCount = 0; private lastPerfCounters: Record = { moversTrackedTotal: 0, moversSampled: 0, moversDrawn: 0, moversSkipped: 0, drawTimeMs: 0, onScreenDrawTimeMs: 0, offScreenVerifyTimeMs: 0, onScreenBudgetTargetMs: ONSCREEN_DRAW_BUDGET_MS, offScreenVerifyBudgetMs: OFFSCREEN_VERIFY_BUDGET_MS, avgOnScreenDebt: 0, maxOnScreenDebt: 0, moverCanvasScale: 1, moverCanvasRescaleLastMs: 0, moverCanvasRescaleAvgMs: 0, moverCanvasRescaleCount: 0, }; 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; // Multi-selected warships (from selection box) private selectedWarships: UnitView[] = []; // 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() { const trailPrune = pruneInactiveTrails( this.unitToTrail, this.segmentTrails, (unitId) => { const current = this.game.unit(unitId); return !!current && current.isActive(); }, ); if (trailPrune.removedNukes > 0 || trailPrune.removedTransport > 0) { this.trailDirty = true; } 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; this.pruneMoverStates(gridMoverUnitIds); this.redrawStaticSprites(); } 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.eventBus.on(WarshipSelectionBoxCompleteEvent, (e) => this.onSelectionBoxComplete(e), ); this.eventBus.on(WarshipSelectionBoxCancelEvent, () => this.onSelectionBoxCancel(), ); this.eventBus.on(CloseViewEvent, () => this.onSelectionBoxCancel()); this.eventBus.on(SelectAllWarshipsEvent, () => this.onSelectAllWarships()); 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.isWater(clickRef)) return; // If we have multi-selected warships, send them all to this tile if (this.selectedWarships.length > 0) { const myPlayer = this.game.myPlayer(); const activeIds = this.selectedWarships .filter((u) => u.isActive() && u.owner() === myPlayer) .map((u) => u.id()); if (activeIds.length > 0) { this.eventBus.emit(new MoveWarshipIntentEvent(activeIds, clickRef)); } this.selectedWarships = []; this.eventBus.emit(new UnitSelectionEvent(null, false)); 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.inSpawnPhase()) { // No Radial Menu during spawn phase, only spawn point selection if (!this.game.isWater(clickRef)) { this.eventBus.emit(new MouseUpEvent(event.x, event.y)); } return; } if (!this.game.isWater(clickRef)) { // No warship to find because no Ocean tile, open Radial Menu this.eventBus.emit(new ContextMenuEvent(event.x, event.y)); 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; } // Also delegate if we have multi-selected warships if (this.selectedWarships.length > 0) { 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 completion of shift+drag selection box. * Finds all player-owned warships within the screen rectangle. */ private onSelectionBoxComplete(event: WarshipSelectionBoxCompleteEvent) { const x1 = Math.min(event.startX, event.endX); const y1 = Math.min(event.startY, event.endY); const x2 = Math.max(event.startX, event.endX); const y2 = Math.max(event.startY, event.endY); const myPlayer = this.game.myPlayer(); if (!myPlayer) return; this.selectedWarships = this.game.units(UnitType.Warship).filter((unit) => { if (!unit.isActive() || unit.owner() !== myPlayer) return false; const screen = this.transformHandler.worldToScreenCoordinates( new Cell(this.game.x(unit.tile()), this.game.y(unit.tile())), ); return ( screen.x >= x1 && screen.x <= x2 && screen.y >= y1 && screen.y <= y2 ); }); // Clear single selection if we got a box selection if (this.selectedWarships.length > 0 && this.selectedUnit) { this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); } // Notify UILayer to draw selection boxes for all selected warships this.eventBus.emit( new UnitSelectionEvent(null, true, this.selectedWarships), ); } private onSelectionBoxCancel() { this.selectedWarships = []; this.eventBus.emit(new UnitSelectionEvent(null, false)); } private onSelectAllWarships() { const myPlayer = this.game.myPlayer(); if (!myPlayer) return; const allWarships = this.game .units(UnitType.Warship) .filter((u) => u.isActive() && u.owner() === myPlayer); if (allWarships.length === 0) return; // Clear single selection if active if (this.selectedUnit) { this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); } this.selectedWarships = allWarships; this.eventBus.emit( new UnitSelectionEvent(null, true, this.selectedWarships), ); } /** * 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) { this.renderFrame++; const nowMs = performance.now(); this.maybeUpdateDynamicMoverCanvasScale(nowMs); const tickAlpha = this.computeTickAlpha(); const tickFloat = this.game.ticks() + tickAlpha; const viewBounds = this.currentViewBounds(); const activeMoverIds = new Set(); const activeMotionTrailPlans: ActiveMotionTrailPlan[] = []; for (const [unitId, plan] of this.game.motionPlans()) { const unit = this.game.unit(unitId); if (!unit || !unit.isActive()) { this.clearMoverState(unitId); if (this.segmentTrails.delete(unitId)) this.trailDirty = true; continue; } activeMoverIds.add(unitId); const state = this.ensureMoverState(unitId, plan.planId); const maybeOnScreen = this.isPotentiallyOnScreen( plan, state, tickFloat, viewBounds, ); if (this.shouldDrawSegmentTrail(unit)) { activeMotionTrailPlans.push({ unitId, unit, plan, maybeOnScreen, }); } this.moveMoverToBucket(unitId, state, maybeOnScreen ? "on" : "off"); if ( !maybeOnScreen && state.lastOnScreen && state.lastSpriteRect && this.renderFrame - state.lastSeenFrame > ONSCREEN_HYSTERESIS_FRAMES ) { this.clearMoverRect(state.lastSpriteRect); state.lastSpriteRect = null; state.lastOnScreen = false; } } this.pruneMoverStates(activeMoverIds); const moverPerf = this.drawBucketedMovers( tickFloat, activeMoverIds, viewBounds, ); this.advanceAndDrawSegmentTrails(this.game.ticks(), activeMotionTrailPlans); this.rebuildTrailCanvasIfDirty(); context.drawImage( this.trailCanvas, -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(), ); context.save(); context.imageSmoothingEnabled = true; context.drawImage( this.dynamicMoverCanvas, -this.game.width() / 2, -this.game.height() / 2, this.game.width(), this.game.height(), ); context.restore(); let totalOnScreenDebt = 0; let onScreenDebtCount = 0; let maxOnScreenDebt = 0; for (const unitId of this.onScreenMoverIds) { const state = this.moverState.get(unitId); if (!state) continue; totalOnScreenDebt += state.skipDebt; onScreenDebtCount++; if (state.skipDebt > maxOnScreenDebt) { maxOnScreenDebt = state.skipDebt; } } this.lastPerfCounters = { moversTrackedTotal: this.onScreenMoverIds.length + this.offScreenMoverIds.length, moversSampled: moverPerf.sampled, moversDrawn: moverPerf.drawn, moversSkipped: moverPerf.skipped, drawTimeMs: moverPerf.budgetUsedMs, onScreenDrawTimeMs: moverPerf.onScreenBudgetUsedMs, offScreenVerifyTimeMs: moverPerf.offScreenBudgetUsedMs, onScreenBudgetTargetMs: ONSCREEN_DRAW_BUDGET_MS, offScreenVerifyBudgetMs: OFFSCREEN_VERIFY_BUDGET_MS, avgOnScreenDebt: onScreenDebtCount > 0 ? totalOnScreenDebt / onScreenDebtCount : 0, maxOnScreenDebt, moverCanvasScale: this.dynamicMoverCanvasScale, moverCanvasRescaleLastMs: this.lastDynamicMoverCanvasRescaleMs, moverCanvasRescaleAvgMs: this.dynamicMoverCanvasRescaleCount > 0 ? this.totalDynamicMoverCanvasRescaleMs / this.dynamicMoverCanvasRescaleCount : 0, moverCanvasRescaleCount: this.dynamicMoverCanvasRescaleCount, }; } private drawBucketedMovers( tickFloat: number, activeMoverIds: Set, viewBounds: { left: number; top: number; right: number; bottom: number }, ): { sampled: number; drawn: number; skipped: number; budgetUsedMs: number; onScreenBudgetUsedMs: number; offScreenBudgetUsedMs: number; } { const frameStartMs = performance.now(); const drawnIds = new Set(); const sampledCache = new Map(); const spatial = this.buildMoverSpatialHash(); let sampled = 0; let drawn = 0; let skipped = 0; const onScreenPass = this.drawBucketPass( "on", tickFloat, activeMoverIds, drawnIds, performance.now(), ONSCREEN_DRAW_BUDGET_MS, viewBounds, sampledCache, spatial, ); sampled += onScreenPass.sampled; drawn += onScreenPass.drawn; skipped += onScreenPass.skipped; const shouldVerifyOffscreen = this.offScreenMoverIds.length > 0 && this.renderFrame % OFFSCREEN_REFRESH_EVERY_N_FRAMES === 0; let offScreenBudgetUsedMs = 0; if (shouldVerifyOffscreen) { const offscreenPass = this.drawBucketPass( "off", tickFloat, activeMoverIds, drawnIds, performance.now(), OFFSCREEN_VERIFY_BUDGET_MS, viewBounds, sampledCache, spatial, ); sampled += offscreenPass.sampled; drawn += offscreenPass.drawn; skipped += offscreenPass.skipped; offScreenBudgetUsedMs = offscreenPass.budgetUsedMs; } for (const unitId of activeMoverIds) { if (drawnIds.has(unitId)) { continue; } const state = this.moverState.get(unitId); if (state && state.bucket === "on") { state.skipDebt = (state.skipDebt + 1) >>> 0; } } return { sampled, drawn, skipped, budgetUsedMs: performance.now() - frameStartMs, onScreenBudgetUsedMs: onScreenPass.budgetUsedMs, offScreenBudgetUsedMs, }; } private drawBucketPass( bucket: "on" | "off", tickFloat: number, activeMoverIds: Set, drawnIds: Set, passStartMs: number, budgetMs: number, viewBounds: { left: number; top: number; right: number; bottom: number }, sampledCache: Map, spatial: MoverSpatialIndex, ): { sampled: number; drawn: number; skipped: number; budgetRemaining: boolean; budgetUsedMs: number; } { const bucketIds = bucket === "on" ? this.onScreenMoverIds : this.offScreenMoverIds; if (bucketIds.length === 0 || budgetMs <= 0) { return { sampled: 0, drawn: 0, skipped: 0, budgetRemaining: true, budgetUsedMs: 0, }; } const startCursor = bucket === "on" ? this.onScreenCursor : this.offScreenCursor; let sampled = 0; let drawn = 0; let skipped = 0; let budgetRemaining = true; const processed = new Set(); let scanned = 0; for (let offset = 0; offset < bucketIds.length; offset++) { if (bucketIds.length === 0) { break; } scanned++; const idx = (startCursor + offset) % bucketIds.length; const unitId = bucketIds[idx]; if (processed.has(unitId)) { continue; } const elapsedMs = performance.now() - passStartMs; if (elapsedMs >= budgetMs) { budgetRemaining = false; skipped++; break; } if (!activeMoverIds.has(unitId)) { continue; } const unit = this.game.unit(unitId); const plan = this.game.motionPlans().get(unitId); const state = this.moverState.get(unitId); if (!unit || !unit.isActive() || !plan || !state) { this.clearMoverState(unitId); skipped++; continue; } const sampledCurrent = this.getMoverSample( unitId, unit, plan.planId, tickFloat, sampledCache, ); sampled++; if (!sampledCurrent) { skipped++; continue; } const onScreen = this.pointInView( sampledCurrent.x, sampledCurrent.y, viewBounds, VIEW_PADDING_PX, ); if (!onScreen) { if (state.lastOnScreen && state.lastSpriteRect) { this.spatialRemove(spatial, unitId, state.lastSpriteRect); this.clearMoverRect(state.lastSpriteRect); state.lastSpriteRect = null; state.lastOnScreen = false; } this.moveMoverToBucket(unitId, state, "off"); skipped++; processed.add(unitId); continue; } this.moveMoverToBucket(unitId, state, "on"); const conflictIds = this.detectMoverConflictGroup( sampledCurrent, tickFloat, sampledCache, spatial, ); if (conflictIds.size > 1) { const groupResult = this.redrawConflictGroup( conflictIds, tickFloat, viewBounds, sampledCache, spatial, drawnIds, processed, ); sampled += Math.max(0, groupResult.sampled - 1); drawn += groupResult.drawn; skipped += groupResult.skipped; } else { if (state.lastSpriteRect) { this.spatialRemove(spatial, unitId, state.lastSpriteRect); this.clearMoverRect(state.lastSpriteRect); } const rect = this.drawSpriteAt( unit, sampledCurrent.renderX, sampledCurrent.renderY, this.dynamicMoverContext, false, ); if (!rect) { skipped++; processed.add(unitId); continue; } state.lastSpriteRect = rect; state.lastOnScreen = true; state.lastSeenFrame = this.renderFrame; state.skipDebt = 0; drawnIds.add(unitId); drawn++; processed.add(unitId); this.spatialAdd(spatial, unitId, rect); } } if (bucket === "on") { this.onScreenCursor = bucketIds.length > 0 ? (startCursor + Math.max(1, scanned)) % bucketIds.length : 0; } else { this.offScreenCursor = bucketIds.length > 0 ? (startCursor + Math.max(1, scanned)) % bucketIds.length : 0; } return { sampled, drawn, skipped, budgetRemaining, budgetUsedMs: performance.now() - passStartMs, }; } private buildMoverSpatialHash(): MoverSpatialIndex { const spatial: MoverSpatialIndex = { cells: new Map>(), unitToCells: new Map(), }; for (const [unitId, state] of this.moverState) { if (!state.lastSpriteRect) { continue; } this.spatialAdd(spatial, unitId, state.lastSpriteRect); } return spatial; } private getMoverSample( unitId: number, unit: UnitView, planId: number, tickFloat: number, sampledCache: Map, ): MoverRenderSample | null { if (sampledCache.has(unitId)) { return sampledCache.get(unitId) ?? null; } const plan = this.game.motionPlans().get(unitId); if (!plan || plan.planId !== planId) { sampledCache.set(unitId, null); return null; } const sampled = sampleGridSegmentPlan(this.game, plan, tickFloat); if (!sampled) { sampledCache.set(unitId, null); return null; } const renderX = this.snapDynamicMoverCoord(sampled.x); const renderY = this.snapDynamicMoverCoord(sampled.y); const rect = this.computeSpriteRect(unit, renderX, renderY, false); const result: MoverRenderSample = { unitId, unit, planId, x: sampled.x, y: sampled.y, renderX, renderY, rect, }; sampledCache.set(unitId, result); return result; } private detectMoverConflictGroup( seedSample: MoverRenderSample, tickFloat: number, sampledCache: Map, spatial: MoverSpatialIndex, ): Set { // Build a transitive overlap component starting from the triggering mover. // This avoids partial redraws in chain-overlap situations (A overlaps B, B overlaps C). const conflictIds = new Set(); const queue: number[] = [seedSample.unitId]; conflictIds.add(seedSample.unitId); sampledCache.set(seedSample.unitId, seedSample); while (queue.length > 0) { const currentId = queue.pop() as number; const currentState = this.moverState.get(currentId); if (!currentState) { continue; } const currentRects: MoverSpriteRect[] = []; if (currentState.lastSpriteRect) { currentRects.push(currentState.lastSpriteRect); } const currentSample = currentId === seedSample.unitId ? seedSample : this.getConflictSample(currentId, tickFloat, sampledCache); if (currentSample) { currentRects.push(currentSample.rect); } if (currentRects.length === 0) { continue; } const candidateIds = new Set(); for (const rect of currentRects) { this.collectSpatialCandidates(candidateIds, spatial, rect); } for (const candidateId of candidateIds) { if (conflictIds.has(candidateId) || candidateId === currentId) { continue; } const candidateState = this.moverState.get(candidateId); if (!candidateState?.lastSpriteRect) { continue; } const candidateRects: MoverSpriteRect[] = [ candidateState.lastSpriteRect, ]; const candidateSample = this.getConflictSample( candidateId, tickFloat, sampledCache, ); if (candidateSample) { candidateRects.push(candidateSample.rect); } if (this.anyRectsOverlap(currentRects, candidateRects)) { // Candidate is connected to the component; expand BFS. conflictIds.add(candidateId); queue.push(candidateId); } } } return conflictIds; } private getConflictSample( unitId: number, tickFloat: number, sampledCache: Map, ): MoverRenderSample | null { if (sampledCache.has(unitId)) { return sampledCache.get(unitId) ?? null; } const unit = this.game.unit(unitId); const plan = this.game.motionPlans().get(unitId); const state = this.moverState.get(unitId); if (!unit || !unit.isActive() || !plan || !state) { sampledCache.set(unitId, null); return null; } return this.getMoverSample( unitId, unit, plan.planId, tickFloat, sampledCache, ); } private anyRectsOverlap( aRects: readonly MoverSpriteRect[], bRects: readonly MoverSpriteRect[], ): boolean { for (const aRect of aRects) { for (const bRect of bRects) { if (this.rectsOverlap(aRect, bRect)) { return true; } } } return false; } private redrawConflictGroup( conflictIds: Set, tickFloat: number, viewBounds: { left: number; top: number; right: number; bottom: number }, sampledCache: Map, spatial: MoverSpatialIndex, drawnIds: Set, processed: Set, ): { sampled: number; drawn: number; skipped: number } { const sampledGroup: MoverRenderSample[] = []; let sampled = 0; let skipped = 0; for (const id of conflictIds) { const unit = this.game.unit(id); const plan = this.game.motionPlans().get(id); const state = this.moverState.get(id); if (!unit || !unit.isActive() || !plan || !state) { this.clearMoverState(id); processed.add(id); skipped++; continue; } const current = this.getMoverSample( id, unit, plan.planId, tickFloat, sampledCache, ); sampled++; if (!current) { processed.add(id); skipped++; continue; } const onScreen = this.pointInView( current.x, current.y, viewBounds, VIEW_PADDING_PX, ); if (!onScreen) { if (state.lastOnScreen && state.lastSpriteRect) { this.spatialRemove(spatial, id, state.lastSpriteRect); this.clearMoverRect(state.lastSpriteRect); state.lastSpriteRect = null; state.lastOnScreen = false; } this.moveMoverToBucket(id, state, "off"); processed.add(id); skipped++; continue; } this.moveMoverToBucket(id, state, "on"); sampledGroup.push(current); } if (sampledGroup.length === 0) { return { sampled, drawn: 0, skipped }; } sampledGroup.sort((a, b) => a.unitId - b.unitId); for (const sampledCurrent of sampledGroup) { const state = this.moverState.get(sampledCurrent.unitId); if (!state) { continue; } const oldRect = state.lastSpriteRect; if (oldRect) { this.spatialRemove(spatial, sampledCurrent.unitId, oldRect); // Clear each old rect individually instead of clearing one union rect. // This reduces overclear artifacts in crowded neighborhoods. this.clearMoverRect(oldRect); } } let drawn = 0; for (const sampledCurrent of sampledGroup) { const state = this.moverState.get(sampledCurrent.unitId); if (!state) { skipped++; continue; } const rect = this.drawSpriteAt( sampledCurrent.unit, sampledCurrent.renderX, sampledCurrent.renderY, this.dynamicMoverContext, false, ); if (!rect) { skipped++; processed.add(sampledCurrent.unitId); continue; } state.lastSpriteRect = rect; state.lastOnScreen = true; state.lastSeenFrame = this.renderFrame; state.skipDebt = 0; this.spatialAdd(spatial, sampledCurrent.unitId, rect); drawnIds.add(sampledCurrent.unitId); processed.add(sampledCurrent.unitId); drawn++; } return { sampled, drawn, skipped }; } private snapDynamicMoverCoord(value: number): number { if (!DYNAMIC_MOVER_SUBPIXEL_SNAP || this.dynamicMoverCanvasScale <= 0) { return value; } return ( Math.round(value * this.dynamicMoverCanvasScale) / this.dynamicMoverCanvasScale ); } private spatialAdd( spatial: MoverSpatialIndex, unitId: number, rect: MoverSpriteRect, ): void { const keys = this.rectSpatialKeys(rect); if (keys.length === 0) { spatial.unitToCells.delete(unitId); return; } spatial.unitToCells.set(unitId, keys); for (const key of keys) { let cell = spatial.cells.get(key); if (!cell) { cell = new Set(); spatial.cells.set(key, cell); } cell.add(unitId); } } private spatialRemove( spatial: MoverSpatialIndex, unitId: number, rect?: MoverSpriteRect | null, ): void { let keys = spatial.unitToCells.get(unitId); if (!keys && rect) { keys = this.rectSpatialKeys(rect); } if (!keys) { return; } for (const key of keys) { const cell = spatial.cells.get(key); if (!cell) { continue; } cell.delete(unitId); if (cell.size === 0) { spatial.cells.delete(key); } } spatial.unitToCells.delete(unitId); } private collectSpatialCandidates( candidateIds: Set, spatial: MoverSpatialIndex, rect: MoverSpriteRect, ): void { const keys = this.rectSpatialKeys(rect); for (const key of keys) { const cell = spatial.cells.get(key); if (!cell) { continue; } for (const id of cell) { candidateIds.add(id); } } } private rectSpatialKeys(rect: MoverSpriteRect): string[] { const minCellX = Math.floor(rect.x / MOVER_SPATIAL_HASH_CELL_PX); const maxCellX = Math.floor( (rect.x + Math.max(1, rect.w) - 1) / MOVER_SPATIAL_HASH_CELL_PX, ); const minCellY = Math.floor(rect.y / MOVER_SPATIAL_HASH_CELL_PX); const maxCellY = Math.floor( (rect.y + Math.max(1, rect.h) - 1) / MOVER_SPATIAL_HASH_CELL_PX, ); const keys: string[] = []; for (let cx = minCellX; cx <= maxCellX; cx++) { for (let cy = minCellY; cy <= maxCellY; cy++) { keys.push(`${cx},${cy}`); } } return keys; } private rectsOverlap(a: MoverSpriteRect, b: MoverSpriteRect): boolean { return ( a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y ); } 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; const initialDynamicScale = this.baseDynamicMoverCanvasScaleForZoom( this.transformHandler.scale, ); this.dynamicMoverCanvasScale = initialDynamicScale; this.pendingDynamicMoverCanvasScale = null; this.pendingDynamicMoverCanvasScaleSinceMs = 0; this.lastDynamicMoverCanvasScaleChangeAtMs = performance.now(); this.lastDynamicMoverCanvasRescaleMs = 0; this.totalDynamicMoverCanvasRescaleMs = 0; this.dynamicMoverCanvasRescaleCount = 0; this.initializeDynamicMoverCanvas(initialDynamicScale); this.trailCanvas = document.createElement("canvas"); const trailContext = this.trailCanvas.getContext("2d"); if (trailContext === null) throw new Error("2d context not supported"); this.trailContext = trailContext; this.canvas.width = this.game.width(); this.canvas.height = this.game.height(); this.trailCanvas.width = this.game.width(); this.trailCanvas.height = this.game.height(); this.gridMoverUnitIds = new Set(this.game.motionPlans().keys()); this.moverState.clear(); this.onScreenMoverIds = []; this.offScreenMoverIds = []; this.onScreenCursor = 0; this.offScreenCursor = 0; this.trailDirty = true; this.redrawStaticSprites(); } private baseDynamicMoverCanvasScaleForZoom(zoom: number): number { let idx = 0; while ( idx < DYNAMIC_MOVER_ZOOM_THRESHOLDS.length && zoom >= DYNAMIC_MOVER_ZOOM_THRESHOLDS[idx] ) { idx++; } return DYNAMIC_MOVER_SCALE_STEPS[idx]; } private dynamicMoverCanvasScaleForZoomWithHysteresis(zoom: number): number { let idx = DYNAMIC_MOVER_SCALE_STEPS.indexOf(this.dynamicMoverCanvasScale); if (idx < 0) { idx = 0; } while ( idx < DYNAMIC_MOVER_ZOOM_THRESHOLDS.length && zoom >= DYNAMIC_MOVER_ZOOM_THRESHOLDS[idx] + DYNAMIC_MOVER_ZOOM_HYSTERESIS ) { idx++; } while ( idx > 0 && zoom < DYNAMIC_MOVER_ZOOM_THRESHOLDS[idx - 1] - DYNAMIC_MOVER_ZOOM_HYSTERESIS ) { idx--; } return DYNAMIC_MOVER_SCALE_STEPS[idx]; } private maybeUpdateDynamicMoverCanvasScale(nowMs: number): void { const targetScale = this.dynamicMoverCanvasScaleForZoomWithHysteresis( this.transformHandler.scale, ); if (targetScale === this.dynamicMoverCanvasScale) { this.pendingDynamicMoverCanvasScale = null; this.pendingDynamicMoverCanvasScaleSinceMs = 0; return; } if ( nowMs - this.lastDynamicMoverCanvasScaleChangeAtMs < DYNAMIC_MOVER_SCALE_COOLDOWN_MS ) { return; } if (this.pendingDynamicMoverCanvasScale !== targetScale) { this.pendingDynamicMoverCanvasScale = targetScale; this.pendingDynamicMoverCanvasScaleSinceMs = nowMs; return; } if ( nowMs - this.pendingDynamicMoverCanvasScaleSinceMs < DYNAMIC_MOVER_SCALE_SETTLE_MS ) { return; } this.lastDynamicMoverCanvasRescaleMs = this.rebuildDynamicMoverCanvas(targetScale); this.totalDynamicMoverCanvasRescaleMs += this.lastDynamicMoverCanvasRescaleMs; this.dynamicMoverCanvasRescaleCount++; this.dynamicMoverCanvasScale = targetScale; this.lastDynamicMoverCanvasScaleChangeAtMs = nowMs; this.pendingDynamicMoverCanvasScale = null; this.pendingDynamicMoverCanvasScaleSinceMs = 0; } private initializeDynamicMoverCanvas(scale: number): void { this.dynamicMoverCanvas = document.createElement("canvas"); this.dynamicMoverCanvas.width = Math.max(1, this.game.width() * scale); this.dynamicMoverCanvas.height = Math.max(1, this.game.height() * scale); const dynamicMoverContext = this.dynamicMoverCanvas.getContext("2d"); if (dynamicMoverContext === null) { throw new Error("2d context not supported"); } this.dynamicMoverContext = dynamicMoverContext; this.dynamicMoverContext.imageSmoothingEnabled = false; this.dynamicMoverContext.setTransform(scale, 0, 0, scale, 0, 0); } private rebuildDynamicMoverCanvas(targetScale: number): number { const oldCanvas = this.dynamicMoverCanvas; const oldWidth = oldCanvas.width; const oldHeight = oldCanvas.height; this.dynamicMoverCanvas = document.createElement("canvas"); this.dynamicMoverCanvas.width = Math.max( 1, this.game.width() * targetScale, ); this.dynamicMoverCanvas.height = Math.max( 1, this.game.height() * targetScale, ); const dynamicMoverContext = this.dynamicMoverCanvas.getContext("2d"); if (dynamicMoverContext === null) { throw new Error("2d context not supported"); } this.dynamicMoverContext = dynamicMoverContext; this.dynamicMoverContext.imageSmoothingEnabled = false; const blitStart = performance.now(); this.dynamicMoverContext.setTransform(1, 0, 0, 1, 0, 0); this.dynamicMoverContext.drawImage( oldCanvas, 0, 0, oldWidth, oldHeight, 0, 0, this.dynamicMoverCanvas.width, this.dynamicMoverCanvas.height, ); const blitMs = performance.now() - blitStart; this.dynamicMoverContext.setTransform(targetScale, 0, 0, targetScale, 0, 0); return blitMs; } 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)); } getPerfCounters(): Record { return this.lastPerfCounters; } private currentViewBounds(): { left: number; top: number; right: number; bottom: number; } { const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect(); return { left: topLeft.x, top: topLeft.y, right: bottomRight.x, bottom: bottomRight.y, }; } private pointInView( x: number, y: number, viewBounds: { left: number; top: number; right: number; bottom: number }, pad: number = 0, ): boolean { return ( x >= viewBounds.left - pad && x <= viewBounds.right + pad && y >= viewBounds.top - pad && y <= viewBounds.bottom + pad ); } private isPotentiallyOnScreen( plan: { startTick: number; ticksPerStep: number; points: Uint32Array; segmentSteps: Uint32Array; segCumSteps: Uint32Array; }, state: MoverRenderState, tickFloat: number, viewBounds: { left: number; top: number; right: number; bottom: number }, ): boolean { if ( state.lastOnScreen && this.renderFrame - state.lastSeenFrame <= ONSCREEN_HYSTERESIS_FRAMES ) { return true; } const segment = this.currentSegmentEndpoints(plan, tickFloat); if (!segment) { return false; } if ( this.pointInView(segment.x0, segment.y0, viewBounds, VIEW_PADDING_PX) || this.pointInView(segment.x1, segment.y1, viewBounds, VIEW_PADDING_PX) ) { return true; } const segLeft = Math.min(segment.x0, segment.x1) - VIEW_PADDING_PX; const segRight = Math.max(segment.x0, segment.x1) + VIEW_PADDING_PX; const segTop = Math.min(segment.y0, segment.y1) - VIEW_PADDING_PX; const segBottom = Math.max(segment.y0, segment.y1) + VIEW_PADDING_PX; return !( segRight < viewBounds.left || segLeft > viewBounds.right || segBottom < viewBounds.top || segTop > viewBounds.bottom ); } private currentSegmentEndpoints( plan: { startTick: number; ticksPerStep: number; points: Uint32Array; segmentSteps: Uint32Array; segCumSteps: Uint32Array; }, tickFloat: number, ): { x0: number; y0: number; x1: number; y1: number } | null { const points = plan.points; if (points.length === 0) { return null; } if (points.length === 1 || plan.segmentSteps.length === 0) { const tile = points[0] as TileRef; const x = this.game.x(tile); const y = this.game.y(tile); return { x0: x, y0: y, x1: x, y1: y }; } const segCum = plan.segCumSteps; const totalSteps = segCum[segCum.length - 1] >>> 0; if (totalSteps === 0) { const tile = points[points.length - 1] as TileRef; const x = this.game.x(tile); const y = this.game.y(tile); return { x0: x, y0: y, x1: x, y1: y }; } const ticksPerStep = Math.max(1, plan.ticksPerStep); const stepFloat = (tickFloat - plan.startTick) / ticksPerStep; let seg = 0; if (stepFloat >= totalSteps) { seg = Math.max(0, plan.segmentSteps.length - 1); } else if (stepFloat > 0) { let lo = 0; let hi = plan.segmentSteps.length - 1; while (lo <= hi) { const mid = (lo + hi) >>> 1; const start = segCum[mid] >>> 0; const end = segCum[mid + 1] >>> 0; if (stepFloat < start) { hi = mid - 1; } else if (stepFloat >= end) { lo = mid + 1; } else { seg = mid; break; } } } const p0 = points[seg] as TileRef; const p1 = points[Math.min(points.length - 1, seg + 1)] as TileRef; return { x0: this.game.x(p0), y0: this.game.y(p0), x1: this.game.x(p1), y1: this.game.y(p1), }; } private ensureMoverState(unitId: number, planId: number): MoverRenderState { const existing = this.moverState.get(unitId); if (!existing) { const state: MoverRenderState = { planId, lastSpriteRect: null, lastOnScreen: false, bucket: "off", bucketIndex: -1, skipDebt: 0, lastSeenFrame: -1, }; this.moverState.set(unitId, state); this.moveMoverToBucket(unitId, state, "off"); return state; } if (existing.planId !== planId) { if (existing.lastSpriteRect) { this.clearMoverRect(existing.lastSpriteRect); } existing.planId = planId; existing.lastOnScreen = false; existing.lastSpriteRect = null; existing.skipDebt = 0; existing.lastSeenFrame = -1; this.moveMoverToBucket(unitId, existing, "off"); } return existing; } private pruneMoverStates(activeMoverIds: Set): void { for (const [unitId, state] of this.moverState) { if (activeMoverIds.has(unitId)) { continue; } if (state.lastSpriteRect) { this.clearMoverRect(state.lastSpriteRect); } this.removeFromBucket(unitId, state); this.moverState.delete(unitId); } } private clearMoverState(unitId: number): void { const state = this.moverState.get(unitId); if (state?.lastSpriteRect) { this.clearMoverRect(state.lastSpriteRect); } if (state) { this.removeFromBucket(unitId, state); } this.moverState.delete(unitId); } private moveMoverToBucket( unitId: number, state: MoverRenderState, target: "on" | "off", ): void { if (state.bucket === target && state.bucketIndex >= 0) { return; } this.removeFromBucket(unitId, state); const targetBucket = target === "on" ? this.onScreenMoverIds : this.offScreenMoverIds; state.bucket = target; state.bucketIndex = targetBucket.length; targetBucket.push(unitId); } private removeFromBucket(unitId: number, state: MoverRenderState): void { if (state.bucketIndex < 0) { return; } const bucketIds = state.bucket === "on" ? this.onScreenMoverIds : this.offScreenMoverIds; const idx = state.bucketIndex; const lastIdx = bucketIds.length - 1; if (idx < 0 || idx > lastIdx) { state.bucketIndex = -1; return; } const swappedUnitId = bucketIds[lastIdx]; bucketIds[idx] = swappedUnitId; bucketIds.pop(); if (idx !== lastIdx) { const swappedState = this.moverState.get(swappedUnitId); if (swappedState) { swappedState.bucketIndex = idx; } } state.bucketIndex = -1; if (state.bucket === "on" && this.onScreenCursor >= bucketIds.length) { this.onScreenCursor = 0; } if (state.bucket === "off" && this.offScreenCursor >= bucketIds.length) { this.offScreenCursor = 0; } } private clearMoverRect(rect: MoverSpriteRect): void { this.dynamicMoverContext.clearRect(rect.x, rect.y, rect.w, rect.h); } private advanceAndDrawSegmentTrails( currentTick: number, activePlans: readonly ActiveMotionTrailPlan[], ): void { for (const { unitId, unit, plan, maybeOnScreen } of activePlans) { const state = this.ensureSegmentTrailState(unitId, plan, currentTick); const moverState = this.moverState.get(unitId); const onScreen = moverState ? moverState.bucket === "on" : maybeOnScreen; if (onScreen) { this.drawPendingSegmentTrailEpochs(unit, state); } state.lastOnScreen = onScreen; } } private ensureSegmentTrailState( unitId: number, plan: SegmentTrailPlanView & { planId: number }, currentTick: number, ): MotionTrailState { let state = this.segmentTrails.get(unitId); if (!state) { state = { activePlanId: plan.planId, epochs: [], lastOnScreen: false, }; this.segmentTrails.set(unitId, state); } let activeEpoch = state.epochs[state.epochs.length - 1]; if ( !activeEpoch || state.activePlanId !== plan.planId || activeEpoch.planId !== plan.planId ) { if (activeEpoch && !activeEpoch.sealed) { activeEpoch.targetStep = stepAtTick(activeEpoch, currentTick); if (activeEpoch.drawnStep > activeEpoch.targetStep) { activeEpoch.drawnStep = activeEpoch.targetStep; } activeEpoch.sealed = true; } activeEpoch = this.createSegmentTrailEpoch(plan, currentTick); state.epochs.push(activeEpoch); state.activePlanId = plan.planId; return state; } activeEpoch.points = plan.points; activeEpoch.segmentSteps = plan.segmentSteps; activeEpoch.segCumSteps = plan.segCumSteps; activeEpoch.startTick = plan.startTick; activeEpoch.ticksPerStep = plan.ticksPerStep; activeEpoch.targetStep = stepAtTick(activeEpoch, currentTick); return state; } private createSegmentTrailEpoch( plan: SegmentTrailPlanView & { planId: number }, currentTick: number, ): MotionTrailEpoch { return { planId: plan.planId, startTick: plan.startTick, ticksPerStep: plan.ticksPerStep, points: plan.points, segmentSteps: plan.segmentSteps, segCumSteps: plan.segCumSteps, targetStep: stepAtTick(plan, currentTick), drawnStep: 0, sealed: false, }; } private drawPendingSegmentTrailEpochs( unit: UnitView, state: MotionTrailState, ): void { const ctx = this.trailContext; const strokeStyle = this.motionTrailColor(unit); ctx.save(); ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.lineWidth = 1.0; ctx.strokeStyle = strokeStyle; for (const epoch of state.epochs) { if (epoch.targetStep <= epoch.drawnStep) { continue; } const drew = strokeStepInterval( ctx, this.game, epoch, epoch.drawnStep, epoch.targetStep, ); if (drew) { epoch.drawnStep = epoch.targetStep; } } ctx.restore(); } private rebuildTrailCanvasIfDirty(): void { if (!this.trailDirty) { return; } this.trailDirty = false; const ctx = this.trailContext; ctx.clearRect(0, 0, this.game.width(), this.game.height()); for (const [unitId, trail] of this.unitToTrail) { const unit = this.game.unit(unitId); if (!unit || !unit.isActive()) { continue; } const rel = this.relationship(unit); for (const tile of trail) { this.paintCell( this.game.x(tile), this.game.y(tile), rel, unit.owner().territoryColor(), 150, ctx, ); } } for (const [unitId, trailState] of this.segmentTrails) { const unit = this.game.unit(unitId); if (!unit || !unit.isActive()) { continue; } ctx.save(); ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.lineWidth = 1.0; ctx.strokeStyle = this.motionTrailColor(unit); for (const epoch of trailState.epochs) { if (epoch.drawnStep <= 0) { continue; } strokeStepInterval(ctx, this.game, epoch, 0, epoch.drawnStep); } ctx.restore(); } } private shouldDrawSegmentTrail(unit: UnitView): boolean { const type = unit.type(); return ( type === UnitType.TransportShip || type === UnitType.AtomBomb || type === UnitType.HydrogenBomb || type === UnitType.MIRV || type === UnitType.MIRVWarhead ); } 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.warshipState().state !== "patrolling" && unit.isActive()) { if (unit.warshipState().isInCombat) { this.drawSprite(unit, colord("rgb(200,0,0)")); } else { this.drawSprite(unit); } this.drawRetreatCross(unit); return; } if (unit.warshipState().isInCombat) { this.drawSprite(unit, colord("rgb(200,0,0)")); return; } this.drawSprite(unit); } private drawRetreatCross(unit: UnitView) { // Blink: 500ms on, 500ms off if (Math.floor(Date.now() / 500) % 2 === 0) return; const x = this.game.x(unit.tile()); const y = this.game.y(unit.tile()); const ctx = this.context; ctx.save(); const cx = x + 0.5; const cy = y + 0.5; ctx.lineCap = "square"; ctx.strokeStyle = "rgb(36,36,36)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx, cy - 1.5); ctx.lineTo(cx, cy + 1.5); ctx.moveTo(cx - 1.5, cy); ctx.lineTo(cx + 1.5, cy); ctx.stroke(); ctx.restore(); } 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 clearTrail(unitId: number) { if (this.unitToTrail.delete(unitId)) { this.trailDirty = true; } } private handleNuke(unit: UnitView) { const unitId = unit.id(); if (!this.unitToTrail.has(unitId)) { this.unitToTrail.set(unitId, []); } const trail = this.unitToTrail.get(unitId) ?? []; // 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(); } } else { trail.push(unit.lastTile()); } this.trailDirty = true; this.drawSprite(unit); if (!unit.isActive()) { this.clearTrail(unitId); } } 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 resolveSprite( unit: UnitView, customTerritoryColor?: Colord, ): CanvasImageSource { 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; } } return getColoredSprite( unit, this.theme, alternateViewColor ?? customTerritoryColor, alternateViewColor ?? undefined, ); } private computeSpriteRect( unit: UnitView, x: number, y: number, roundCoords: boolean, customTerritoryColor?: Colord, ): MoverSpriteRect { if (this.isSmallMaskShip(unit)) { const { x: outX, y: outY } = this.smallShipTopLeft(x, y, roundCoords); const pad = 1; return { x: outX - pad, y: outY - pad, w: SMALL_SHIP_MASK_SIZE + pad * 2, h: SMALL_SHIP_MASK_SIZE + pad * 2, }; } const sprite = this.resolveSprite(unit, customTerritoryColor); const width = (sprite as { width: number }).width; const height = (sprite as { height: number }).height; const drawX = x - width / 2; const drawY = y - height / 2; const outX = roundCoords ? Math.round(drawX) : drawX; const outY = roundCoords ? Math.round(drawY) : drawY; const pad = 1; return { x: outX - pad, y: outY - pad, w: width + pad * 2, h: width + pad * 2, }; } private drawSpriteAt( unit: UnitView, x: number, y: number, ctx: CanvasRenderingContext2D = this.context, roundCoords: boolean = true, customTerritoryColor?: Colord, ): MoverSpriteRect | null { if (!unit.isActive()) { return null; } const targetable = unit.targetable(); ctx.save(); if (!targetable) { ctx.globalAlpha = 0.5; } if (this.isSmallMaskShip(unit)) { const mask = this.smallShipMask(unit); const { territory, border } = this.resolveSmallShipMaskColors( unit, customTerritoryColor, ); const { x: outX, y: outY } = this.smallShipTopLeft(x, y, roundCoords); const centerToken = mask[2][2]; const crossColor = centerToken === "T" ? territory : border; // Draw the center cross with 2 rectangles instead of 5 single pixels. ctx.fillStyle = crossColor.toRgbString(); ctx.fillRect(outX + 1, outY + 2, 3, 1); ctx.fillRect(outX + 2, outY + 1, 1, 3); // Draw remaining ring pixels from the mask. for (let row = 0; row < SMALL_SHIP_MASK_SIZE; row++) { const line = mask[row]; for (let col = 0; col < SMALL_SHIP_MASK_SIZE; col++) { if (this.isSmallShipCrossCell(col, row)) { continue; } const cellType = line[col]; if (cellType === ".") { continue; } ctx.fillStyle = cellType === "T" ? territory.toRgbString() : border.toRgbString(); ctx.fillRect(outX + col, outY + row, 1, 1); } } ctx.restore(); return this.computeSpriteRect( unit, x, y, roundCoords, customTerritoryColor, ); } const sprite = this.resolveSprite(unit, customTerritoryColor) as { width: number; height: number; }; const drawX = x - sprite.width / 2; const drawY = y - sprite.height / 2; const outX = roundCoords ? Math.round(drawX) : drawX; const outY = roundCoords ? Math.round(drawY) : drawY; ctx.drawImage( sprite as CanvasImageSource, outX, outY, sprite.width, sprite.width, ); ctx.restore(); return this.computeSpriteRect( unit, x, y, roundCoords, customTerritoryColor, ); } private drawSprite(unit: UnitView, customTerritoryColor?: Colord) { this.drawSpriteAt( unit, this.game.x(unit.tile()), this.game.y(unit.tile()), this.context, true, customTerritoryColor, ); } private isSmallMaskShip(unit: UnitView): boolean { const type = unit.type(); return type === UnitType.TransportShip || type === UnitType.TradeShip; } private smallShipMask(unit: UnitView): readonly string[] { return unit.type() === UnitType.TransportShip ? TRANSPORT_SHIP_MASK : TRADE_SHIP_MASK; } private smallShipTopLeft( x: number, y: number, roundCoords: boolean, ): { x: number; y: number } { const drawX = x - SMALL_SHIP_MASK_SIZE / 2; const drawY = y - SMALL_SHIP_MASK_SIZE / 2; return { x: roundCoords ? Math.round(drawX) : drawX, y: roundCoords ? Math.round(drawY) : drawY, }; } private isSmallShipCrossCell(col: number, row: number): boolean { return ( (row === 2 && col >= 1 && col <= 3) || (col === 2 && row >= 1 && row <= 3) ); } private resolveSmallShipMaskColors( unit: UnitView, customTerritoryColor?: Colord, ): { territory: Colord; border: Colord } { if (this.alternateView) { const rel = this.relationshipForAlternateView(unit); switch (rel) { case Relationship.Self: return { territory: this.theme.selfColor(), border: this.theme.selfColor(), }; case Relationship.Ally: return { territory: this.theme.allyColor(), border: this.theme.allyColor(), }; case Relationship.Enemy: return { territory: this.theme.enemyColor(), border: this.theme.enemyColor(), }; } } return { territory: customTerritoryColor ?? unit.owner().territoryColor(), border: unit.owner().borderColor(), }; } }