diff --git a/src/components/game/WebGPUGameCanvas.tsx b/src/components/game/WebGPUGameCanvas.tsx index 4cbd1a8e..a4cbc228 100644 --- a/src/components/game/WebGPUGameCanvas.tsx +++ b/src/components/game/WebGPUGameCanvas.tsx @@ -28,42 +28,32 @@ * │ │ * │ ↑ GameCommands (user input) │ * └────────────────────────────────────────┘ - * - * This component is a thin orchestrator that delegates to specialized hooks: - * - useWebGPURenderer: Renderer setup, scene, and game loop - * - useGameInput: Mouse and keyboard input handling - * - useCameraControl: Camera position management - * - usePostProcessing: Effects pipeline and graphics settings */ -import { useRef, useEffect, useCallback, useState } from 'react'; -import * as Phaser from 'phaser'; +import { useRef, useEffect } from 'react'; -import { Game } from '@/engine/core/Game'; -import type { EventBus } from '@/engine/core/EventBus'; -import type { IWorldProvider } from '@/engine/ecs/IWorldProvider'; -import { - WorkerBridge, - MainThreadEventHandler, - RenderStateWorldAdapter, - type RenderState, - type GameEvent, -} from '@/engine/workers'; +import { RenderStateWorldAdapter } from '@/engine/workers'; import { useGameStore } from '@/store/gameStore'; -import { useGameSetupStore, getLocalPlayerId, isSpectatorMode, isBattleSimulatorMode } from '@/store/gameSetupStore'; -import { useMultiplayerStore, isMultiplayerMode } from '@/store/multiplayerStore'; +import { useGameSetupStore, getLocalPlayerId } from '@/store/gameSetupStore'; import { SelectionBox } from './SelectionBox'; import { LoadingScreen } from './LoadingScreen'; import { GraphicsOptionsPanel } from './GraphicsOptionsPanel'; import { DebugMenuPanel } from './DebugMenuPanel'; import { ALL_MAPS, DEFAULT_MAP, MapData, getMapById } from '@/data/maps'; -import { OverlayScene } from '@/phaser/scenes/OverlayScene'; -import { debugInitialization, debugNetworking } from '@/utils/debugLogger'; +import { debugInitialization } 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'; +import { + useWebGPURenderer, + useGameInput, + useCameraControl, + usePostProcessing, + useWorkerBridge, + usePhaserOverlay, + useLoadingState, +} from './hooks'; // Map reference let CURRENT_MAP: MapData = DEFAULT_MAP; @@ -71,33 +61,7 @@ let CURRENT_MAP: MapData = DEFAULT_MAP; export function WebGPUGameCanvas() { const containerRef = useRef(null); const threeCanvasRef = useRef(null); - - // Phaser refs const phaserContainerRef = useRef(null); - const phaserGameRef = useRef(null); - const overlaySceneRef = useRef(null); - const phaserLoopWorkerRef = useRef(null); - const lastPhaserUpdateTimeRef = useRef(0); - - // Game engine refs - worker mode is the only mode - // Game instance is kept for selection system and placement validation - const gameRef = useRef(null); - const workerBridgeRef = useRef(null); - const eventHandlerRef = useRef(null); - - // Refs for hook consumption - these point to worker mode data sources - const worldProviderRef = useRef(null); - const eventBusRef = useRef(null); - - // Event listener cleanup - const eventUnsubscribersRef = useRef<(() => void)[]>([]); - - // Loading state - const [isLoading, setIsLoading] = useState(true); - const [loadingProgress, setLoadingProgress] = useState(0); - const [loadingStatus, setLoadingStatus] = useState('Initializing'); - const [isWebGPU, setIsWebGPU] = useState(false); - const [fadeInOpacity, setFadeInOpacity] = useState(1); // Get store state const { @@ -113,75 +77,36 @@ export function WebGPUGameCanvas() { commandTargetMode, } = useGameStore(); - // Progress callback for renderer initialization - const handleProgress = useCallback((progress: number, status: string) => { - setLoadingProgress(progress); - setLoadingStatus(status); - }, []); - - // WebGPU detection callback - const handleWebGPUDetected = useCallback((detected: boolean) => { - setIsWebGPU(detected); - }, []); - - // Handle render state updates from worker (only in worker mode) - // Note: WorkerBridge now directly updates the RenderStateWorldAdapter singleton - // This callback is just for additional main-thread side effects - const firstRenderStateLoggedRef = useRef(false); - const handleRenderState = useCallback((state: RenderState) => { - // Debug: log first render state received - if (!firstRenderStateLoggedRef.current && (state.units.length > 0 || state.buildings.length > 0 || state.resources.length > 0)) { - debugInitialization.log('[WebGPUGameCanvas] Received first render state:', { - tick: state.tick, - units: state.units.length, - buildings: state.buildings.length, - resources: state.resources.length, - }); - firstRenderStateLoggedRef.current = true; - } - - // Note: Adapter update is now handled directly by WorkerBridge - // to ensure all code-split bundles see the same singleton instance - - // Update game time in store - useGameStore.getState().setGameTime(state.gameTime); - }, []); - - // Handle game events from worker (only in worker mode) - const handleGameEvent = useCallback((event: GameEvent) => { - // Events are dispatched through eventBus by MainThreadEventHandler - }, []); - - // Handle game over - const handleGameOver = useCallback((winnerId: string | null, reason: string) => { - debugInitialization.log(`[WebGPUGameCanvas] Game over: winner=${winnerId}, reason=${reason}`); - // Notify UI of game over - const message = winnerId - ? `Game over! ${winnerId === getLocalPlayerId() ? 'Victory!' : 'Defeat!'}` - : `Game over: ${reason}`; - useUIStore.getState().addNotification( - winnerId === getLocalPlayerId() ? 'success' : 'warning', - message, - 10000 - ); - }, []); - - // Handle worker errors - const handleWorkerError = useCallback((message: string, stack?: string) => { - console.error('[GameWorker Error]', message, stack); - useUIStore.getState().addNotification('error', `Game error: ${message}`, 5000); - }, []); + // Worker bridge hook - manages worker lifecycle, game instance, event bus + const { + workerBridgeRef, + eventHandlerRef, + gameRef, + worldProviderRef, + eventBusRef, + isGameFinished, + initializeWorkerBridge, + spawnEntities, + getGameTime, + } = useWorkerBridge({ + map: CURRENT_MAP, + }); - // Game time getter - uses render state adapter (synced from worker) - const getGameTime = useCallback(() => { - return RenderStateWorldAdapter.getInstance().getGameTime(); - }, []); + // Loading state hook - manages loading screen, progress, fade-in + const { + isLoading, + loadingProgress, + loadingStatus, + fadeInOpacity, + setProgress, + setWebGPUDetected, + handleLoadingComplete, + } = useLoadingState({ + eventBusRef, + }); - // Game finished checker - uses game state from worker - const [isGameFinished, setIsGameFinished] = useState(false); - const checkGameFinished = useCallback(() => { - return isGameFinished; - }, [isGameFinished]); + // Check if game is finished + const checkGameFinished = () => isGameFinished; // Initialize renderer hook const { refs, initializeRenderer } = useWebGPURenderer({ @@ -193,8 +118,16 @@ export function WebGPUGameCanvas() { getGameTime, isGameFinished: checkGameFinished, map: CURRENT_MAP, - onProgress: handleProgress, - onWebGPUDetected: handleWebGPUDetected, + onProgress: setProgress, + onWebGPUDetected: setWebGPUDetected, + }); + + // Phaser overlay hook - manages Phaser game, scene, loop worker + const { initializePhaserOverlay } = usePhaserOverlay({ + containerRef, + phaserContainerRef, + workerBridgeRef, + environmentRef: refs.environment, }); // Camera control hook @@ -229,37 +162,6 @@ export function WebGPUGameCanvas() { map: CURRENT_MAP, }); - // Callback for when loading screen completes - const handleLoadingComplete = useCallback(() => { - setIsLoading(false); - useGameStore.getState().setGameReady(true); - // Sync playerId with game setup (handles spectator mode where localPlayerId is null) - useGameStore.getState().syncWithGameSetup(); - - setTimeout(() => { - const eventBus = workerBridgeRef.current?.eventBus; - if (eventBus) { - eventBus.emit('game:countdown'); - } - }, 50); - - const startTime = Date.now(); - const duration = 1000; - - const animateFadeIn = () => { - const elapsed = Date.now() - startTime; - const progress = Math.min(elapsed / duration, 1); - const eased = 1 - Math.pow(1 - progress, 2); - setFadeInOpacity(1 - eased); - - if (progress < 1) { - requestAnimationFrame(animateFadeIn); - } - }; - - requestAnimationFrame(animateFadeIn); - }, []); - // Initialize game useEffect(() => { if (!containerRef.current || !threeCanvasRef.current || !phaserContainerRef.current) return; @@ -285,118 +187,47 @@ export function WebGPUGameCanvas() { } } - setLoadingStatus('Loading 3D models'); - setLoadingProgress(10); + setProgress(10, 'Loading 3D models'); // Wait for asset preloading const preloadStarted = AssetManager.isPreloadingStarted(); if (preloadStarted) { - setLoadingStatus('Finishing 3D model loading'); + setProgress(10, 'Finishing 3D model loading'); debugInitialization.log('[WebGPUGameCanvas] Waiting for lobby preloading to complete...'); } await AssetManager.waitForPreloading(); - setLoadingProgress(50); + setProgress(50, 'Initializing worker'); - setLoadingStatus('Initializing WebGPU renderer'); - setLoadingProgress(60); + // Initialize worker bridge (creates WorkerBridge, MainThreadEventHandler, Game) + const workerSuccess = await initializeWorkerBridge(); + if (!workerSuccess) { + debugInitialization.error('[WebGPUGameCanvas] Worker initialization failed'); + setProgress(0, 'Error - worker initialization failed'); + return; + } - // Initialize game engine - // All game logic runs in Web Worker for anti-throttling and performance - const mapWidth = CURRENT_MAP.width; - const mapHeight = CURRENT_MAP.height; - const localPlayerId = getLocalPlayerId(); - const isMultiplayer = isMultiplayerMode(); - - debugInitialization.log('[WebGPUGameCanvas] Initializing game (worker mode)'); - - // Create worker bridge for game logic communication - const bridge = WorkerBridge.getInstance({ - config: { - mapWidth, - mapHeight, - tickRate: 20, - isMultiplayer, - playerId: localPlayerId ?? 'spectator', - aiEnabled: !isBattleSimulatorMode() && !isMultiplayer, - aiDifficulty: 'medium', - }, - playerId: localPlayerId ?? 'spectator', - onRenderState: handleRenderState, - onGameEvent: handleGameEvent, - onGameOver: (winnerId, reason) => { - handleGameOver(winnerId, reason); - setIsGameFinished(true); - }, - onError: handleWorkerError, - }); - workerBridgeRef.current = bridge; - - // Set up refs for hook consumption - // IMPORTANT: Use getInstance() to get fresh singleton after Strict Mode reset - worldProviderRef.current = RenderStateWorldAdapter.getInstance(); - eventBusRef.current = bridge.eventBus; - - // Create main thread event handler for audio/effects - const eventHandler = new MainThreadEventHandler(bridge); - eventHandlerRef.current = eventHandler; - - // Initialize the worker - await bridge.initialize(); - - // Sync debug settings to worker for category filtering - bridge.setDebugSettings(useUIStore.getState().debugSettings); - - // Set terrain data on worker - bridge.setTerrainGrid(CURRENT_MAP.terrain); - - // Multiplayer: Remote player quit - eventUnsubscribersRef.current.push( - bridge.eventBus.on('multiplayer:playerQuit', () => { - debugNetworking.log('[Game] Remote player quit the game'); - useUIStore.getState().addNotification('warning', 'Remote player has left the game', 10000); - }) - ); - - // Create minimal Game instance for selection system and placement validation - // Game loop is NOT started - worker handles game logic - const game = Game.getInstance({ - mapWidth, - mapHeight, - tickRate: 20, - isMultiplayer, - playerId: localPlayerId ?? 'spectator', - aiEnabled: false, // AI runs in worker - }); - gameRef.current = game; + setProgress(60, 'Initializing WebGPU renderer'); + // Set decoration collisions from environment (after renderer creates environment) // Initialize renderer (which creates all sub-renderers) const success = await initializeRenderer(); if (!success) { debugInitialization.error('[WebGPUGameCanvas] Renderer initialization failed'); - setLoadingStatus('Error - falling back to WebGL'); + setProgress(0, 'Error - falling back to WebGL'); return; } - // Spawn entities (skip in battle simulator) - if (!isBattleSimulatorMode() && workerBridgeRef.current) { - // Map player slots to the expected type format - const playerSlots = useGameSetupStore.getState().playerSlots.map(slot => ({ - id: slot.id, - type: (slot.type === 'open' || slot.type === 'closed') ? 'empty' as const : slot.type, - faction: slot.faction, - aiDifficulty: slot.aiDifficulty, - })); - workerBridgeRef.current.spawnInitialEntities(CURRENT_MAP, playerSlots); - - // Wait for first render state with entities before completing loading - // This prevents terrain-only rendering before entities appear - setLoadingStatus('Synchronizing game state'); - setLoadingProgress(75); - await workerBridgeRef.current.waitForFirstRenderState(); - debugInitialization.log('[WebGPUGameCanvas] First render state received, entities ready'); + // Set decoration collisions on game instance + if (gameRef.current && refs.environment.current) { + gameRef.current.setDecorationCollisions(refs.environment.current.getRockCollisions()); } + // Spawn entities + setProgress(75, 'Synchronizing game state'); + await spawnEntities(); + // Re-set player ID on fog of war now that players are registered + const localPlayerId = getLocalPlayerId(); if (refs.fogOfWar.current && localPlayerId) { refs.fogOfWar.current.setPlayerId(localPlayerId); } @@ -409,173 +240,35 @@ export function WebGPUGameCanvas() { await eventHandlerRef.current.startGameplayMusic(); } - setLoadingStatus('Initializing overlay system'); - setLoadingProgress(80); + setProgress(80, 'Initializing overlay system'); // Initialize OverlayCoordinator with renderer and event bus const overlayCoordinator = getOverlayCoordinator(); if (refs.overlayManager.current) { overlayCoordinator.setOverlayManager(refs.overlayManager.current); } - overlayCoordinator.setEventBus(bridge.eventBus); + if (eventBusRef.current) { + overlayCoordinator.setEventBus(eventBusRef.current); + } overlayCoordinator.initializeFromStore(); // Initialize Phaser overlay initializePhaserOverlay(); - setLoadingProgress(100); - setLoadingStatus('Ready'); + setProgress(100, 'Ready'); } catch (error) { debugInitialization.error('[WebGPUGameCanvas] Initialization failed:', error); - setLoadingStatus('Error - falling back to WebGL'); + setProgress(0, 'Error - falling back to WebGL'); } }; - const initializePhaserOverlay = () => { - const eventBus = workerBridgeRef.current?.eventBus; - - if (!phaserContainerRef.current || !eventBus) return; - - const phaserWidth = containerRef.current?.clientWidth ?? window.innerWidth; - const phaserHeight = containerRef.current?.clientHeight ?? window.innerHeight; - - const config: Phaser.Types.Core.GameConfig = { - type: Phaser.WEBGL, - parent: phaserContainerRef.current, - width: phaserWidth, - height: phaserHeight, - transparent: true, - scale: { - mode: Phaser.Scale.RESIZE, - autoCenter: Phaser.Scale.CENTER_BOTH, - }, - render: { - pixelArt: false, - antialias: true, - }, - // Don't include OverlayScene here - we add it manually with eventBus data - scene: [], - input: { - mouse: { - preventDefaultWheel: false, - }, - }, - }; - - const phaserGame = new Phaser.Game(config); - phaserGameRef.current = phaserGame; - - phaserGame.events.once('ready', () => { - // Add and start the scene with eventBus data in one call - // This prevents the double-initialization that occurred when scene was in config - phaserGame.scene.add('OverlayScene', OverlayScene, true, { eventBus }); - const scene = phaserGame.scene.getScene('OverlayScene') as OverlayScene; - overlaySceneRef.current = scene; - - if (refs.environment.current) { - const terrain = refs.environment.current.terrain; - scene.setTerrainHeightFunction((x, z) => terrain.getHeightAt(x, z)); - } - - // Start game logic after countdown completes (runs in worker) - eventBus.once('game:countdownComplete', () => { - workerBridgeRef.current?.start(); - }); - - // Initialize Phaser loop worker for background tab immunity (ES module for Next.js 16+ Turbopack) - try { - const worker = new Worker(new URL('../../workers/phaserLoopWorker.ts', import.meta.url), { type: 'module' }); - - worker.onmessage = (e: MessageEvent) => { - if (e.data.type === 'tick') { - const { time, delta } = e.data; - - if (document.hidden && phaserGameRef.current) { - const pGame = phaserGameRef.current; - if (pGame.loop && pGame.isRunning) { - pGame.loop.now = time; - pGame.loop.delta = delta; - pGame.loop.rawDelta = delta; - - pGame.scene.scenes.forEach((s: Phaser.Scene) => { - if (s.sys.isActive() && s.sys.settings.status === Phaser.Scenes.RUNNING) { - s.sys.step(time, delta); - } - }); - - lastPhaserUpdateTimeRef.current = time; - } - } - } - }; - - worker.postMessage({ type: 'start', intervalMs: 16 }); - phaserLoopWorkerRef.current = worker; - } catch (err) { - debugInitialization.warn('[WebGPUGameCanvas] Phaser loop worker failed to initialize:', err); - } - }); - }; - - // Handle Phaser resize - const handlePhaserResize = () => { - if (phaserGameRef.current) { - const phaserWidth = containerRef.current?.clientWidth ?? window.innerWidth; - const phaserHeight = containerRef.current?.clientHeight ?? window.innerHeight; - phaserGameRef.current.scale.resize(phaserWidth, phaserHeight); - } - }; - - window.addEventListener('resize', handlePhaserResize); - - let resizeObserver: ResizeObserver | null = null; - if (containerRef.current) { - resizeObserver = new ResizeObserver(handlePhaserResize); - resizeObserver.observe(containerRef.current); - } - initializeGame(); return () => { - // Unsubscribe from events - for (const unsubscribe of eventUnsubscribersRef.current) { - unsubscribe(); - } - eventUnsubscribersRef.current = []; - - window.removeEventListener('resize', handlePhaserResize); - resizeObserver?.disconnect(); - - // Stop Phaser loop worker - if (phaserLoopWorkerRef.current) { - phaserLoopWorkerRef.current.postMessage({ type: 'stop' }); - phaserLoopWorkerRef.current.terminate(); - phaserLoopWorkerRef.current = null; - } - - phaserGameRef.current?.destroy(true); - - // Cleanup worker and game resources - if (eventHandlerRef.current) { - eventHandlerRef.current.stopGameplayMusic(); - eventHandlerRef.current.dispose(); - eventHandlerRef.current = null; - } - if (workerBridgeRef.current) { - WorkerBridge.resetInstance(); - workerBridgeRef.current = null; - } // Reset overlay coordinator resetOverlayCoordinator(); - RenderStateWorldAdapter.resetInstance(); - worldProviderRef.current = null; - eventBusRef.current = null; - - if (gameRef.current) { - Game.resetInstance(); - } }; - }, [initializeRenderer, refs.camera, refs.environment, refs.fogOfWar, refs.battleEffects, handleRenderState, handleGameEvent, handleGameOver, handleWorkerError]); + }, [initializeRenderer, initializeWorkerBridge, spawnEntities, initializePhaserOverlay, setProgress, refs.camera, refs.environment, refs.fogOfWar, refs.overlayManager, eventBusRef, eventHandlerRef, gameRef]); // Building placement preview useEffect(() => { @@ -632,7 +325,7 @@ export function WebGPUGameCanvas() { }); return () => unsubscribe(); - }, []); + }, [workerBridgeRef]); // Subscribe to overlay settings changes from UI (e.g., HUD overlay menu) // The OverlayCoordinator handles forwarding to TSLGameOverlayManager diff --git a/src/components/game/hooks/index.ts b/src/components/game/hooks/index.ts index a90b84fe..b07878f2 100644 --- a/src/components/game/hooks/index.ts +++ b/src/components/game/hooks/index.ts @@ -10,3 +10,6 @@ export { useCameraControl, type UseCameraControlProps, type UseCameraControlRetu export { useGameInput, type UseGameInputProps, type UseGameInputReturn } from './useGameInput'; export type { SelectionState } from '@/engine/input'; export { usePostProcessing, type UsePostProcessingProps } from './usePostProcessing'; +export { useWorkerBridge, type UseWorkerBridgeProps, type UseWorkerBridgeReturn } from './useWorkerBridge'; +export { usePhaserOverlay, type UsePhaserOverlayProps, type UsePhaserOverlayReturn } from './usePhaserOverlay'; +export { useLoadingState, type UseLoadingStateProps, type UseLoadingStateReturn } from './useLoadingState'; diff --git a/src/components/game/hooks/useLoadingState.ts b/src/components/game/hooks/useLoadingState.ts new file mode 100644 index 00000000..5a8e1a90 --- /dev/null +++ b/src/components/game/hooks/useLoadingState.ts @@ -0,0 +1,108 @@ +/** + * useLoadingState Hook + * + * Manages loading screen state, progress tracking, WebGPU detection, + * and fade-in animation after loading completes. + */ + +import type { MutableRefObject } from 'react'; +import { useState, useCallback, useRef } from 'react'; + +import type { EventBus } from '@/engine/core/EventBus'; +import { useGameStore } from '@/store/gameStore'; + +export interface UseLoadingStateProps { + /** Event bus ref for emitting countdown event */ + eventBusRef: MutableRefObject; +} + +export interface UseLoadingStateReturn { + /** Whether the game is still loading */ + isLoading: boolean; + /** Loading progress (0-100) */ + loadingProgress: number; + /** Current loading status message */ + loadingStatus: string; + /** Whether WebGPU is being used */ + isWebGPU: boolean; + /** Fade-in overlay opacity (1 = opaque, 0 = transparent) */ + fadeInOpacity: number; + /** Update loading progress and status */ + setProgress: (progress: number, status: string) => void; + /** Set WebGPU detection result */ + setWebGPUDetected: (detected: boolean) => void; + /** Called when loading screen animation completes */ + handleLoadingComplete: () => void; +} + +export function useLoadingState({ + eventBusRef, +}: UseLoadingStateProps): UseLoadingStateReturn { + // State + const [isLoading, setIsLoading] = useState(true); + const [loadingProgress, setLoadingProgress] = useState(0); + const [loadingStatus, setLoadingStatus] = useState('Initializing'); + const [isWebGPU, setIsWebGPU] = useState(false); + const [fadeInOpacity, setFadeInOpacity] = useState(1); + + // Track animation frame for cleanup + const animationFrameRef = useRef(null); + + // Update loading progress and status + const setProgress = useCallback((progress: number, status: string) => { + setLoadingProgress(progress); + setLoadingStatus(status); + }, []); + + // Set WebGPU detection result + const setWebGPUDetected = useCallback((detected: boolean) => { + setIsWebGPU(detected); + }, []); + + // Handle loading complete + const handleLoadingComplete = useCallback(() => { + setIsLoading(false); + useGameStore.getState().setGameReady(true); + // Sync playerId with game setup (handles spectator mode where localPlayerId is null) + useGameStore.getState().syncWithGameSetup(); + + // Emit countdown event after a short delay + setTimeout(() => { + const eventBus = eventBusRef.current; + if (eventBus) { + eventBus.emit('game:countdown'); + } + }, 50); + + // Animate fade-in + const startTime = Date.now(); + const duration = 1000; + + const animateFadeIn = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + // Ease out quad + const eased = 1 - Math.pow(1 - progress, 2); + setFadeInOpacity(1 - eased); + + if (progress < 1) { + animationFrameRef.current = requestAnimationFrame(animateFadeIn); + } else { + animationFrameRef.current = null; + } + }; + + animationFrameRef.current = requestAnimationFrame(animateFadeIn); + }, [eventBusRef]); + + return { + isLoading, + loadingProgress, + loadingStatus, + isWebGPU, + fadeInOpacity, + setProgress, + setWebGPUDetected, + handleLoadingComplete, + }; +} diff --git a/src/components/game/hooks/usePhaserOverlay.ts b/src/components/game/hooks/usePhaserOverlay.ts new file mode 100644 index 00000000..f0eaf641 --- /dev/null +++ b/src/components/game/hooks/usePhaserOverlay.ts @@ -0,0 +1,201 @@ +/** + * usePhaserOverlay Hook + * + * Manages the Phaser 4 overlay canvas lifecycle including game initialization, + * scene setup, loop worker for background tab immunity, and resize handling. + */ + +import type { MutableRefObject, RefObject } from 'react'; +import { useRef, useEffect, useCallback, useState } from 'react'; +import * as Phaser from 'phaser'; + +import type { EventBus } from '@/engine/core/EventBus'; +import type { WorkerBridge } from '@/engine/workers'; +import type { EnvironmentManager } from '@/rendering/EnvironmentManager'; +import { OverlayScene } from '@/phaser/scenes/OverlayScene'; +import { debugInitialization } from '@/utils/debugLogger'; + +export interface UsePhaserOverlayProps { + /** Container ref for sizing */ + containerRef: RefObject; + /** Phaser container element ref */ + phaserContainerRef: RefObject; + /** Worker bridge for event bus access */ + workerBridgeRef: MutableRefObject; + /** Environment manager for terrain height function */ + environmentRef: MutableRefObject; +} + +export interface UsePhaserOverlayReturn { + /** Reference to the Phaser game instance */ + phaserGameRef: MutableRefObject; + /** Reference to the overlay scene */ + overlaySceneRef: MutableRefObject; + /** Whether Phaser is initialized */ + isInitialized: boolean; + /** Initialize the Phaser overlay */ + initializePhaserOverlay: () => void; +} + +export function usePhaserOverlay({ + containerRef, + phaserContainerRef, + workerBridgeRef, + environmentRef, +}: UsePhaserOverlayProps): UsePhaserOverlayReturn { + // Refs + const phaserGameRef = useRef(null); + const overlaySceneRef = useRef(null); + const phaserLoopWorkerRef = useRef(null); + const lastPhaserUpdateTimeRef = useRef(0); + + // State + const [isInitialized, setIsInitialized] = useState(false); + + // Handle Phaser resize + const handlePhaserResize = useCallback(() => { + if (phaserGameRef.current) { + const phaserWidth = containerRef.current?.clientWidth ?? window.innerWidth; + const phaserHeight = containerRef.current?.clientHeight ?? window.innerHeight; + phaserGameRef.current.scale.resize(phaserWidth, phaserHeight); + } + }, [containerRef]); + + // Initialize Phaser overlay + const initializePhaserOverlay = useCallback(() => { + const eventBus = workerBridgeRef.current?.eventBus; + + if (!phaserContainerRef.current || !eventBus) { + debugInitialization.warn('[usePhaserOverlay] Cannot initialize - missing container or eventBus'); + return; + } + + if (phaserGameRef.current) { + debugInitialization.log('[usePhaserOverlay] Already initialized'); + return; + } + + const phaserWidth = containerRef.current?.clientWidth ?? window.innerWidth; + const phaserHeight = containerRef.current?.clientHeight ?? window.innerHeight; + + const config: Phaser.Types.Core.GameConfig = { + type: Phaser.WEBGL, + parent: phaserContainerRef.current, + width: phaserWidth, + height: phaserHeight, + transparent: true, + scale: { + mode: Phaser.Scale.RESIZE, + autoCenter: Phaser.Scale.CENTER_BOTH, + }, + render: { + pixelArt: false, + antialias: true, + }, + // Don't include OverlayScene here - we add it manually with eventBus data + scene: [], + input: { + mouse: { + preventDefaultWheel: false, + }, + }, + }; + + const phaserGame = new Phaser.Game(config); + phaserGameRef.current = phaserGame; + + phaserGame.events.once('ready', () => { + // Add and start the scene with eventBus data in one call + // This prevents the double-initialization that occurred when scene was in config + phaserGame.scene.add('OverlayScene', OverlayScene, true, { eventBus }); + const scene = phaserGame.scene.getScene('OverlayScene') as OverlayScene; + overlaySceneRef.current = scene; + + if (environmentRef.current) { + const terrain = environmentRef.current.terrain; + scene.setTerrainHeightFunction((x, z) => terrain.getHeightAt(x, z)); + } + + // Start game logic after countdown completes (runs in worker) + eventBus.once('game:countdownComplete', () => { + workerBridgeRef.current?.start(); + }); + + // Initialize Phaser loop worker for background tab immunity (ES module for Next.js 16+ Turbopack) + try { + const worker = new Worker(new URL('../../../workers/phaserLoopWorker.ts', import.meta.url), { type: 'module' }); + + worker.onmessage = (e: MessageEvent) => { + if (e.data.type === 'tick') { + const { time, delta } = e.data; + + if (document.hidden && phaserGameRef.current) { + const pGame = phaserGameRef.current; + if (pGame.loop && pGame.isRunning) { + pGame.loop.now = time; + pGame.loop.delta = delta; + pGame.loop.rawDelta = delta; + + pGame.scene.scenes.forEach((s: Phaser.Scene) => { + if (s.sys.isActive() && s.sys.settings.status === Phaser.Scenes.RUNNING) { + s.sys.step(time, delta); + } + }); + + lastPhaserUpdateTimeRef.current = time; + } + } + } + }; + + worker.postMessage({ type: 'start', intervalMs: 16 }); + phaserLoopWorkerRef.current = worker; + } catch (err) { + debugInitialization.warn('[usePhaserOverlay] Phaser loop worker failed to initialize:', err); + } + + setIsInitialized(true); + }); + }, [containerRef, phaserContainerRef, workerBridgeRef, environmentRef]); + + // Handle resize events + useEffect(() => { + if (!isInitialized) return; + + window.addEventListener('resize', handlePhaserResize); + + let resizeObserver: ResizeObserver | null = null; + if (containerRef.current) { + resizeObserver = new ResizeObserver(handlePhaserResize); + resizeObserver.observe(containerRef.current); + } + + return () => { + window.removeEventListener('resize', handlePhaserResize); + resizeObserver?.disconnect(); + }; + }, [isInitialized, handlePhaserResize, containerRef]); + + // Cleanup on unmount + useEffect(() => { + return () => { + // Stop Phaser loop worker + if (phaserLoopWorkerRef.current) { + phaserLoopWorkerRef.current.postMessage({ type: 'stop' }); + phaserLoopWorkerRef.current.terminate(); + phaserLoopWorkerRef.current = null; + } + + phaserGameRef.current?.destroy(true); + phaserGameRef.current = null; + overlaySceneRef.current = null; + }; + }, []); + + return { + phaserGameRef, + overlaySceneRef, + isInitialized, + initializePhaserOverlay, + }; +} diff --git a/src/components/game/hooks/useWorkerBridge.ts b/src/components/game/hooks/useWorkerBridge.ts new file mode 100644 index 00000000..cb226807 --- /dev/null +++ b/src/components/game/hooks/useWorkerBridge.ts @@ -0,0 +1,269 @@ +/** + * useWorkerBridge Hook + * + * Manages the game worker lifecycle including WorkerBridge creation, + * MainThreadEventHandler setup, and game instance initialization. + * Handles communication between the main thread and web worker. + */ + +import type { MutableRefObject } from 'react'; +import { useRef, useEffect, useCallback, useState } from 'react'; + +import { Game } from '@/engine/core/Game'; +import type { EventBus } from '@/engine/core/EventBus'; +import type { IWorldProvider } from '@/engine/ecs/IWorldProvider'; +import { + WorkerBridge, + MainThreadEventHandler, + RenderStateWorldAdapter, + type RenderState, + type GameEvent, +} from '@/engine/workers'; +import { useGameSetupStore, getLocalPlayerId, isBattleSimulatorMode } from '@/store/gameSetupStore'; +import { useUIStore } from '@/store/uiStore'; +import { useGameStore } from '@/store/gameStore'; +import { isMultiplayerMode } from '@/store/multiplayerStore'; +import { MapData } from '@/data/maps'; +import { debugInitialization, debugNetworking } from '@/utils/debugLogger'; + +export interface UseWorkerBridgeProps { + map: MapData; + onGameOver?: (winnerId: string | null, reason: string) => void; +} + +export interface UseWorkerBridgeReturn { + /** Reference to the WorkerBridge instance */ + workerBridgeRef: MutableRefObject; + /** Reference to the MainThreadEventHandler instance */ + eventHandlerRef: MutableRefObject; + /** Reference to the Game instance (selection system only) */ + gameRef: MutableRefObject; + /** Reference to the world provider (RenderStateWorldAdapter) */ + worldProviderRef: MutableRefObject; + /** Reference to the event bus */ + eventBusRef: MutableRefObject; + /** Whether the worker bridge is initialized */ + isInitialized: boolean; + /** Whether the game has finished */ + isGameFinished: boolean; + /** Initialize the worker bridge */ + initializeWorkerBridge: () => Promise; + /** Spawn initial entities on the map */ + spawnEntities: () => Promise; + /** Get the current game time */ + getGameTime: () => number; +} + +export function useWorkerBridge({ + map, + onGameOver, +}: UseWorkerBridgeProps): UseWorkerBridgeReturn { + // Refs + const workerBridgeRef = useRef(null); + const eventHandlerRef = useRef(null); + const gameRef = useRef(null); + const worldProviderRef = useRef(null); + const eventBusRef = useRef(null); + const eventUnsubscribersRef = useRef<(() => void)[]>([]); + const firstRenderStateLoggedRef = useRef(false); + + // State + const [isInitialized, setIsInitialized] = useState(false); + const [isGameFinished, setIsGameFinished] = useState(false); + + // Store map in a ref so we always get the latest value + const mapRef = useRef(map); + mapRef.current = map; + + // Handle render state updates from worker + const handleRenderState = useCallback((state: RenderState) => { + if (!firstRenderStateLoggedRef.current && (state.units.length > 0 || state.buildings.length > 0 || state.resources.length > 0)) { + debugInitialization.log('[useWorkerBridge] Received first render state:', { + tick: state.tick, + units: state.units.length, + buildings: state.buildings.length, + resources: state.resources.length, + }); + firstRenderStateLoggedRef.current = true; + } + + // Update game time in store + useGameStore.getState().setGameTime(state.gameTime); + }, []); + + // Handle game events from worker + const handleGameEvent = useCallback((_event: GameEvent) => { + // Events are dispatched through eventBus by MainThreadEventHandler + }, []); + + // Handle game over + const handleGameOver = useCallback((winnerId: string | null, reason: string) => { + debugInitialization.log(`[useWorkerBridge] Game over: winner=${winnerId}, reason=${reason}`); + setIsGameFinished(true); + + // Notify UI of game over + const message = winnerId + ? `Game over! ${winnerId === getLocalPlayerId() ? 'Victory!' : 'Defeat!'}` + : `Game over: ${reason}`; + useUIStore.getState().addNotification( + winnerId === getLocalPlayerId() ? 'success' : 'warning', + message, + 10000 + ); + + onGameOver?.(winnerId, reason); + }, [onGameOver]); + + // Handle worker errors + const handleWorkerError = useCallback((message: string, stack?: string) => { + console.error('[GameWorker Error]', message, stack); + useUIStore.getState().addNotification('error', `Game error: ${message}`, 5000); + }, []); + + // Get game time from render state adapter + const getGameTime = useCallback(() => { + return RenderStateWorldAdapter.getInstance().getGameTime(); + }, []); + + // Initialize worker bridge + const initializeWorkerBridge = useCallback(async (): Promise => { + if (isInitialized) return true; + + try { + const currentMap = mapRef.current; + const mapWidth = currentMap.width; + const mapHeight = currentMap.height; + const localPlayerId = getLocalPlayerId(); + const isMultiplayer = isMultiplayerMode(); + + debugInitialization.log('[useWorkerBridge] Initializing game (worker mode)'); + + // Create worker bridge for game logic communication + const bridge = WorkerBridge.getInstance({ + config: { + mapWidth, + mapHeight, + tickRate: 20, + isMultiplayer, + playerId: localPlayerId ?? 'spectator', + aiEnabled: !isBattleSimulatorMode() && !isMultiplayer, + aiDifficulty: 'medium', + }, + playerId: localPlayerId ?? 'spectator', + onRenderState: handleRenderState, + onGameEvent: handleGameEvent, + onGameOver: handleGameOver, + onError: handleWorkerError, + }); + workerBridgeRef.current = bridge; + + // Set up refs for hook consumption + worldProviderRef.current = RenderStateWorldAdapter.getInstance(); + eventBusRef.current = bridge.eventBus; + + // Create main thread event handler for audio/effects + const eventHandler = new MainThreadEventHandler(bridge); + eventHandlerRef.current = eventHandler; + + // Initialize the worker + await bridge.initialize(); + + // Sync debug settings to worker for category filtering + bridge.setDebugSettings(useUIStore.getState().debugSettings); + + // Set terrain data on worker + bridge.setTerrainGrid(currentMap.terrain); + + // Multiplayer: Remote player quit + eventUnsubscribersRef.current.push( + bridge.eventBus.on('multiplayer:playerQuit', () => { + debugNetworking.log('[Game] Remote player quit the game'); + useUIStore.getState().addNotification('warning', 'Remote player has left the game', 10000); + }) + ); + + // Create minimal Game instance for selection system and placement validation + // Game loop is NOT started - worker handles game logic + const game = Game.getInstance({ + mapWidth, + mapHeight, + tickRate: 20, + isMultiplayer, + playerId: localPlayerId ?? 'spectator', + aiEnabled: false, // AI runs in worker + }); + gameRef.current = game; + + // Set terrain data on game instance + game.setTerrainGrid(currentMap.terrain); + + setIsInitialized(true); + return true; + } catch (error) { + debugInitialization.error('[useWorkerBridge] Initialization failed:', error); + return false; + } + }, [isInitialized, handleRenderState, handleGameEvent, handleGameOver, handleWorkerError]); + + // Spawn initial entities + const spawnEntities = useCallback(async () => { + if (!workerBridgeRef.current) return; + + if (!isBattleSimulatorMode()) { + const playerSlots = useGameSetupStore.getState().playerSlots.map(slot => ({ + id: slot.id, + type: (slot.type === 'open' || slot.type === 'closed') ? 'empty' as const : slot.type, + faction: slot.faction, + aiDifficulty: slot.aiDifficulty, + })); + workerBridgeRef.current.spawnInitialEntities(mapRef.current, playerSlots); + + // Wait for first render state with entities before completing loading + await workerBridgeRef.current.waitForFirstRenderState(); + debugInitialization.log('[useWorkerBridge] First render state received, entities ready'); + } + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + // Unsubscribe from events + for (const unsubscribe of eventUnsubscribersRef.current) { + unsubscribe(); + } + eventUnsubscribersRef.current = []; + + // Cleanup worker and game resources + if (eventHandlerRef.current) { + eventHandlerRef.current.stopGameplayMusic(); + eventHandlerRef.current.dispose(); + eventHandlerRef.current = null; + } + if (workerBridgeRef.current) { + WorkerBridge.resetInstance(); + workerBridgeRef.current = null; + } + RenderStateWorldAdapter.resetInstance(); + worldProviderRef.current = null; + eventBusRef.current = null; + + if (gameRef.current) { + Game.resetInstance(); + gameRef.current = null; + } + }; + }, []); + + return { + workerBridgeRef, + eventHandlerRef, + gameRef, + worldProviderRef, + eventBusRef, + isInitialized, + isGameFinished, + initializeWorkerBridge, + spawnEntities, + getGameTime, + }; +}