diff --git a/src/components/game/WebGPUGameCanvas.tsx b/src/components/game/WebGPUGameCanvas.tsx
index 189c552e..7df69e33 100644
--- a/src/components/game/WebGPUGameCanvas.tsx
+++ b/src/components/game/WebGPUGameCanvas.tsx
@@ -61,6 +61,7 @@ import { OverlayScene } from '@/phaser/scenes/OverlayScene';
import { debugInitialization, debugNetworking } from '@/utils/debugLogger';
import AssetManager from '@/assets/AssetManager';
import { useUIStore, UIState } from '@/store/uiStore';
+import { getOverlayCoordinator, resetOverlayCoordinator } from '@/engine/overlay';
import { useWebGPURenderer, useGameInput, useCameraControl, usePostProcessing } from './hooks';
@@ -409,6 +410,14 @@ export function WebGPUGameCanvas() {
setLoadingStatus('Initializing overlay system');
setLoadingProgress(80);
+ // Initialize OverlayCoordinator with renderer and event bus
+ const overlayCoordinator = getOverlayCoordinator();
+ if (refs.overlayManager.current) {
+ overlayCoordinator.setOverlayManager(refs.overlayManager.current);
+ }
+ overlayCoordinator.setEventBus(bridge.eventBus);
+ overlayCoordinator.initializeFromStore();
+
// Initialize Phaser overlay
initializePhaserOverlay();
@@ -554,6 +563,8 @@ export function WebGPUGameCanvas() {
WorkerBridge.resetInstance();
workerBridgeRef.current = null;
}
+ // Reset overlay coordinator
+ resetOverlayCoordinator();
RenderStateWorldAdapter.resetInstance();
worldProviderRef.current = null;
eventBusRef.current = null;
@@ -621,7 +632,8 @@ export function WebGPUGameCanvas() {
return () => unsubscribe();
}, []);
- // Subscribe to overlay settings changes
+ // Subscribe to overlay settings changes from UI (e.g., HUD overlay menu)
+ // The OverlayCoordinator handles forwarding to TSLGameOverlayManager
useEffect(() => {
const unsubscribe = useUIStore.subscribe((state: UIState, prevState: UIState) => {
const overlaySettings = state.overlaySettings;
@@ -629,26 +641,27 @@ export function WebGPUGameCanvas() {
if (overlaySettings === prevOverlaySettings) return;
- if (refs.overlayManager.current) {
- refs.overlayManager.current.setActiveOverlay(overlaySettings.activeOverlay);
+ // Use coordinator to handle all overlay changes
+ const coordinator = getOverlayCoordinator();
- const opacityKey = `${overlaySettings.activeOverlay}OverlayOpacity` as keyof typeof overlaySettings;
- if (opacityKey in overlaySettings && typeof overlaySettings[opacityKey] === 'number') {
- refs.overlayManager.current.setOpacity(overlaySettings[opacityKey] as number);
- }
+ // Active overlay changed
+ if (overlaySettings.activeOverlay !== prevOverlaySettings.activeOverlay) {
+ coordinator.setActiveOverlay(overlaySettings.activeOverlay);
+ }
- // Wire up range overlay toggles (Alt+A, Alt+V shortcuts)
- if (overlaySettings.showAttackRange !== prevOverlaySettings.showAttackRange) {
- refs.overlayManager.current.setShowAttackRange(overlaySettings.showAttackRange);
- }
- if (overlaySettings.showVisionRange !== prevOverlaySettings.showVisionRange) {
- refs.overlayManager.current.setShowVisionRange(overlaySettings.showVisionRange);
- }
+ // Attack range visibility changed (from UI, not keyboard)
+ if (overlaySettings.showAttackRange !== prevOverlaySettings.showAttackRange) {
+ coordinator.setShowAttackRange(overlaySettings.showAttackRange);
+ }
+
+ // Vision range visibility changed (from UI, not keyboard)
+ if (overlaySettings.showVisionRange !== prevOverlaySettings.showVisionRange) {
+ coordinator.setShowVisionRange(overlaySettings.showVisionRange);
}
});
return () => unsubscribe();
- }, [refs.overlayManager]);
+ }, []);
return (
= {
+ none: 0,
+ elevation: 0.7,
+ threat: 0.5,
+ navmesh: 0.8,
+ resource: 0.7,
+ buildable: 0.6,
+ };
+
+ private constructor() {}
+
+ public static getInstance(): OverlayCoordinator {
+ if (!OverlayCoordinator.instance) {
+ OverlayCoordinator.instance = new OverlayCoordinator();
+ }
+ return OverlayCoordinator.instance;
+ }
+
+ public static resetInstance(): void {
+ OverlayCoordinator.instance = null;
+ }
+
+ // ==========================================================================
+ // INITIALIZATION
+ // ==========================================================================
+
+ /**
+ * Set the TSLGameOverlayManager reference.
+ * Called after renderer initialization.
+ */
+ public setOverlayManager(manager: TSLGameOverlayManager): void {
+ this.overlayManager = manager;
+
+ // Wire up navmesh progress callbacks
+ manager.setNavmeshProgressCallback((progress) => {
+ this.handleNavmeshProgress(progress, true);
+ });
+
+ manager.setNavmeshCompleteCallback((stats) => {
+ this.handleNavmeshProgress(1, false);
+ debugPathfinding.log('[OverlayCoordinator] Navmesh complete:', stats);
+ });
+
+ // Apply current state to manager
+ this.syncToManager();
+
+ debugPathfinding.log('[OverlayCoordinator] Overlay manager connected');
+ }
+
+ /**
+ * Set the EventBus reference for emitting typed overlay events.
+ * Called during game initialization.
+ */
+ public setEventBus(eventBus: EventBus): void {
+ this.eventBus = eventBus;
+ debugPathfinding.log('[OverlayCoordinator] EventBus connected');
+ }
+
+ /**
+ * Initialize coordinator from current UIStore state.
+ * Call this after setting up manager and eventBus.
+ */
+ public initializeFromStore(): void {
+ const settings = useUIStore.getState().overlaySettings;
+ this.activeOverlay = settings.activeOverlay;
+ this.showAttackRange = settings.showAttackRange;
+ this.showVisionRange = settings.showVisionRange;
+ this.opacities.elevation = settings.elevationOverlayOpacity;
+ this.opacities.threat = settings.threatOverlayOpacity;
+ this.opacities.navmesh = settings.navmeshOverlayOpacity;
+ this.opacities.resource = settings.resourceOverlayOpacity;
+ this.opacities.buildable = settings.buildableOverlayOpacity;
+
+ this.syncToManager();
+ }
+
+ // ==========================================================================
+ // PUBLIC API - Called by input handlers
+ // ==========================================================================
+
+ /**
+ * Set the active overlay type.
+ * Updates TSLGameOverlayManager, emits event, syncs UIStore.
+ */
+ public setActiveOverlay(overlay: GameOverlayType): void {
+ const previousOverlay = this.activeOverlay;
+ if (overlay === previousOverlay) return;
+
+ this.activeOverlay = overlay;
+
+ // Update TSLGameOverlayManager
+ if (this.overlayManager) {
+ this.overlayManager.setActiveOverlay(overlay);
+ if (overlay !== 'none') {
+ this.overlayManager.setOpacity(this.opacities[overlay]);
+ }
+ }
+
+ // Emit typed event for Phaser/other listeners
+ this.emitEvent('overlay:typeChanged', {
+ overlay,
+ previousOverlay,
+ });
+
+ // Sync to UIStore for React components
+ useUIStore.getState().setActiveOverlay(overlay);
+
+ debugPathfinding.log(`[OverlayCoordinator] Active overlay: ${previousOverlay} -> ${overlay}`);
+ }
+
+ /**
+ * Toggle between an overlay type and 'none'.
+ */
+ public toggleOverlay(overlay: GameOverlayType): void {
+ if (this.activeOverlay === overlay) {
+ this.setActiveOverlay('none');
+ } else {
+ this.setActiveOverlay(overlay);
+ }
+ }
+
+ /**
+ * Cycle to the next overlay type.
+ */
+ public cycleOverlay(): void {
+ const order: GameOverlayType[] = ['none', 'elevation', 'threat', 'navmesh', 'resource', 'buildable'];
+ const currentIndex = order.indexOf(this.activeOverlay);
+ const nextIndex = (currentIndex + 1) % order.length;
+ this.setActiveOverlay(order[nextIndex]);
+ }
+
+ /**
+ * Set overlay opacity for a specific type.
+ */
+ public setOverlayOpacity(overlay: GameOverlayType, opacity: number): void {
+ const clampedOpacity = Math.max(0, Math.min(1, opacity));
+ this.opacities[overlay] = clampedOpacity;
+
+ // Update manager if this is the active overlay
+ if (this.overlayManager && overlay === this.activeOverlay && overlay !== 'none') {
+ this.overlayManager.setOpacity(clampedOpacity);
+ }
+
+ // Emit event
+ this.emitEvent('overlay:opacityChanged', {
+ overlay,
+ opacity: clampedOpacity,
+ });
+
+ // Sync to UIStore
+ useUIStore.getState().setOverlayOpacity(overlay, clampedOpacity);
+ }
+
+ /**
+ * Show/hide attack range rings for selected units.
+ * SC2-style hold-to-show behavior.
+ */
+ public setShowAttackRange(show: boolean): void {
+ if (this.showAttackRange === show) return;
+
+ this.showAttackRange = show;
+
+ // Update TSLGameOverlayManager
+ if (this.overlayManager) {
+ this.overlayManager.setShowAttackRange(show);
+ }
+
+ // Emit typed event
+ this.emitEvent('overlay:rangeToggle', {
+ rangeType: 'attack',
+ show,
+ });
+
+ // Sync to UIStore
+ useUIStore.getState().setShowAttackRange(show);
+
+ debugPathfinding.log(`[OverlayCoordinator] Attack range: ${show ? 'ON' : 'OFF'}`);
+ }
+
+ /**
+ * Show/hide vision range rings for selected units.
+ * SC2-style hold-to-show behavior.
+ */
+ public setShowVisionRange(show: boolean): void {
+ if (this.showVisionRange === show) return;
+
+ this.showVisionRange = show;
+
+ // Update TSLGameOverlayManager
+ if (this.overlayManager) {
+ this.overlayManager.setShowVisionRange(show);
+ }
+
+ // Emit typed event
+ this.emitEvent('overlay:rangeToggle', {
+ rangeType: 'vision',
+ show,
+ });
+
+ // Sync to UIStore
+ useUIStore.getState().setShowVisionRange(show);
+
+ debugPathfinding.log(`[OverlayCoordinator] Vision range: ${show ? 'ON' : 'OFF'}`);
+ }
+
+ /**
+ * Toggle attack range visibility.
+ */
+ public toggleAttackRange(): void {
+ this.setShowAttackRange(!this.showAttackRange);
+ }
+
+ /**
+ * Toggle vision range visibility.
+ */
+ public toggleVisionRange(): void {
+ this.setShowVisionRange(!this.showVisionRange);
+ }
+
+ // ==========================================================================
+ // STATE GETTERS
+ // ==========================================================================
+
+ public getActiveOverlay(): GameOverlayType {
+ return this.activeOverlay;
+ }
+
+ public isShowingAttackRange(): boolean {
+ return this.showAttackRange;
+ }
+
+ public isShowingVisionRange(): boolean {
+ return this.showVisionRange;
+ }
+
+ public getOverlayOpacity(overlay: GameOverlayType): number {
+ return this.opacities[overlay];
+ }
+
+ public getNavmeshState(): { isComputing: boolean; progress: number; cached: boolean } | null {
+ return this.overlayManager?.getNavmeshState() ?? null;
+ }
+
+ // ==========================================================================
+ // SELECTED ENTITIES
+ // ==========================================================================
+
+ /**
+ * Update selected entity IDs for range ring display.
+ * Called by selection system when selection changes.
+ */
+ public setSelectedEntities(entityIds: number[]): void {
+ if (this.overlayManager) {
+ this.overlayManager.setSelectedEntities(entityIds);
+ }
+ }
+
+ // ==========================================================================
+ // PRIVATE HELPERS
+ // ==========================================================================
+
+ /**
+ * Sync current state to TSLGameOverlayManager.
+ */
+ private syncToManager(): void {
+ if (!this.overlayManager) return;
+
+ this.overlayManager.setActiveOverlay(this.activeOverlay);
+ if (this.activeOverlay !== 'none') {
+ this.overlayManager.setOpacity(this.opacities[this.activeOverlay]);
+ }
+ this.overlayManager.setShowAttackRange(this.showAttackRange);
+ this.overlayManager.setShowVisionRange(this.showVisionRange);
+ }
+
+ /**
+ * Emit a typed event through the EventBus.
+ */
+ private emitEvent(event: string, data: T): void {
+ if (this.eventBus) {
+ this.eventBus.emit(event, data);
+ }
+ }
+
+ /**
+ * Handle navmesh computation progress updates.
+ */
+ private handleNavmeshProgress(progress: number, isComputing: boolean): void {
+ // Emit event
+ this.emitEvent('overlay:navmeshProgress', {
+ progress,
+ isComputing,
+ });
+
+ // Sync to UIStore
+ const uiStore = useUIStore.getState();
+ uiStore.setNavmeshComputeProgress(progress);
+ uiStore.setNavmeshIsComputing(isComputing);
+ }
+
+ // ==========================================================================
+ // CLEANUP
+ // ==========================================================================
+
+ public dispose(): void {
+ this.overlayManager = null;
+ this.eventBus = null;
+ }
+}
+
+// Convenience functions for backward compatibility
+export function getOverlayCoordinator(): OverlayCoordinator {
+ return OverlayCoordinator.getInstance();
+}
+
+export function resetOverlayCoordinator(): void {
+ OverlayCoordinator.resetInstance();
+}
diff --git a/src/engine/overlay/index.ts b/src/engine/overlay/index.ts
new file mode 100644
index 00000000..ce293d70
--- /dev/null
+++ b/src/engine/overlay/index.ts
@@ -0,0 +1,18 @@
+/**
+ * Overlay Pipeline - Unified overlay coordination
+ *
+ * Provides a single API for managing game overlays across:
+ * - TSL (3D terrain-conforming overlays in WebGPU renderer)
+ * - Phaser (2D UI effects in overlay scene)
+ * - UIStore (React component state)
+ */
+
+export {
+ OverlayCoordinator,
+ getOverlayCoordinator,
+ resetOverlayCoordinator,
+ type OverlayTypeChangedEvent,
+ type OverlayRangeToggleEvent,
+ type OverlayOpacityChangedEvent,
+ type OverlayNavmeshProgressEvent,
+} from './OverlayCoordinator';
diff --git a/src/phaser/scenes/OverlayScene.ts b/src/phaser/scenes/OverlayScene.ts
index 13218d7b..9bc2d390 100644
--- a/src/phaser/scenes/OverlayScene.ts
+++ b/src/phaser/scenes/OverlayScene.ts
@@ -1,5 +1,5 @@
import * as Phaser from 'phaser';
-import { debugAudio } from '@/utils/debugLogger';
+import { debugAudio, debugPathfinding } from '@/utils/debugLogger';
import { EventBus } from '@/engine/core/EventBus';
import { Game } from '@/engine/core/Game';
import { Transform } from '@/engine/components/Transform';
@@ -9,6 +9,10 @@ import { useProjectionStore } from '@/store/projectionStore';
import { MusicPlayer } from '@/audio/MusicPlayer';
import { DamageNumberSystem } from '../systems/DamageNumberSystem';
import { ScreenEffectsSystem } from '../systems/ScreenEffectsSystem';
+import type {
+ OverlayTypeChangedEvent,
+ OverlayRangeToggleEvent,
+} from '@/engine/overlay';
/**
* Phaser 4 Overlay Scene
@@ -150,11 +154,8 @@ export class OverlayScene extends Phaser.Scene {
private damageNumberSystem: DamageNumberSystem | null = null;
private screenEffectsSystem: ScreenEffectsSystem | null = null;
- // Note: SC2-style range overlays now handled by TSLGameOverlayManager
- // The following graphics are kept for destroy() cleanup but no longer used:
- private attackRangeGraphics!: Phaser.GameObjects.Graphics;
- private visionRangeGraphics!: Phaser.GameObjects.Graphics;
- private resourceOverlayGraphics!: Phaser.GameObjects.Graphics;
+ // Note: SC2-style range overlays and resource overlays are now handled
+ // exclusively by TSLGameOverlayManager in the WebGPU renderer.
constructor() {
// Set active: false to prevent auto-start before eventBus is passed
@@ -196,15 +197,8 @@ export class OverlayScene extends Phaser.Scene {
this.groundClickGraphics = this.add.graphics();
this.groundClickGraphics.setDepth(36);
- // SC2-style range preview graphics
- this.attackRangeGraphics = this.add.graphics();
- this.attackRangeGraphics.setDepth(37);
-
- this.visionRangeGraphics = this.add.graphics();
- this.visionRangeGraphics.setDepth(38);
-
- this.resourceOverlayGraphics = this.add.graphics();
- this.resourceOverlayGraphics.setDepth(39);
+ // Note: Attack range, vision range, and resource overlay graphics
+ // are now handled by TSLGameOverlayManager in the WebGPU renderer.
// Ability splash container
this.splashContainer = this.add.container(0, 0);
@@ -508,6 +502,15 @@ export class OverlayScene extends Phaser.Scene {
if (data.playerId && !isLocalPlayer(data.playerId)) return;
this.showAlert(data.message.toUpperCase(), 0xff4444, 2000);
});
+
+ // Overlay system events (typed events from OverlayCoordinator)
+ this.registerEvent('overlay:typeChanged', (data) => {
+ debugPathfinding.log(`[OverlayScene] Overlay changed: ${data.previousOverlay} -> ${data.overlay}`);
+ });
+
+ this.registerEvent('overlay:rangeToggle', (data) => {
+ debugPathfinding.log(`[OverlayScene] Range toggle: ${data.rangeType} = ${data.show}`);
+ });
}
private setupKeyboardShortcuts(): void {
@@ -1607,9 +1610,6 @@ export class OverlayScene extends Phaser.Scene {
this.threatZoneGraphics.clear();
this.rallyPathGraphics.clear();
this.vignetteGraphics.clear();
- this.attackRangeGraphics.clear();
- this.visionRangeGraphics.clear();
- this.resourceOverlayGraphics.clear();
// Draw tactical overlay if enabled
if (this.tacticalMode) {
@@ -2272,9 +2272,6 @@ export class OverlayScene extends Phaser.Scene {
this.countdownContainer?.destroy();
this.attackTargetGraphics?.destroy();
this.groundClickGraphics?.destroy();
- this.attackRangeGraphics?.destroy();
- this.visionRangeGraphics?.destroy();
- this.resourceOverlayGraphics?.destroy();
for (const alert of this.alerts) {
alert.graphics?.destroy();