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();