diff --git a/src/components/game/BattleSimulatorPanel.tsx b/src/components/game/BattleSimulatorPanel.tsx index 58bba315..516e6b98 100644 --- a/src/components/game/BattleSimulatorPanel.tsx +++ b/src/components/game/BattleSimulatorPanel.tsx @@ -5,91 +5,69 @@ import { Game } from '@/engine/core/Game'; import { UNIT_DEFINITIONS, DOMINION_UNITS } from '@/data/units/dominion'; import { useGameSetupStore, PLAYER_COLORS } from '@/store/gameSetupStore'; import { useGameStore } from '@/store/gameStore'; -import { Selectable } from '@/engine/components/Selectable'; -import { Unit } from '@/engine/components/Unit'; -import { Transform } from '@/engine/components/Transform'; -import { Health } from '@/engine/components/Health'; +import { RenderStateWorldAdapter } from '@/engine/workers/RenderStateAdapter'; +import { getWorkerBridge } from '@/engine/workers/WorkerBridge'; type SpawnTeam = 'player1' | 'player2'; type SpawnQuantity = 1 | 5 | 10 | 20; const SPAWN_QUANTITIES: SpawnQuantity[] = [1, 5, 10, 20]; -// Arena dimensions (must match battle_arena.json) -const ARENA_WIDTH = 256; -const ARENA_HEIGHT = 64; - /** * Find the best enemy target for a unit, respecting targeting restrictions. - * Naval units find naval enemies, ground finds ground, air can attack based on capabilities. + * Uses RenderStateWorldAdapter for entity queries in worker mode. */ function findValidTarget( - game: Game, + worldAdapter: RenderStateWorldAdapter, entityId: number, - unit: Unit, - transform: Transform, + unitData: { sightRange: number; isNaval: boolean; isFlying: boolean; canAttackAir: boolean; canAttackGround: boolean }, + transformData: { x: number; y: number }, myPlayerId: string ): number | null { - const world = game.world; - - // Use sight range to find targets - const searchRange = unit.sightRange * 1.5; - - // For naval units, only look for naval targets they can actually reach - // For ground units, look for ground targets - // For air units, they can go anywhere so use normal targeting - const isNavalUnit = unit.isNaval; - const isAirUnit = unit.isFlying; - - // Query nearby enemy units and filter by what this unit can actually attack - const nearbyUnitIds = world.unitGrid.queryRadius(transform.x, transform.y, searchRange); + const searchRange = unitData.sightRange * 1.5; + const isNavalUnit = unitData.isNaval; + const isAirUnit = unitData.isFlying; + const entities = worldAdapter.getEntitiesWith('Unit', 'Selectable', 'Transform', 'Health'); let bestTarget: { entityId: number; score: number } | null = null; - for (const targetId of nearbyUnitIds) { - if (targetId === entityId) continue; - - const targetEntity = world.getEntity(targetId); - if (!targetEntity) continue; + for (const entity of entities) { + if (entity.id === entityId) continue; - const targetUnit = targetEntity.get('Unit'); - const targetTransform = targetEntity.get('Transform'); - const targetSelectable = targetEntity.get('Selectable'); - const targetHealth = targetEntity.get('Health'); + const targetUnit = entity.get<{ isFlying: boolean; isNaval: boolean }>('Unit'); + const targetTransform = entity.get<{ x: number; y: number }>('Transform'); + const targetSelectable = entity.get<{ playerId: string }>('Selectable'); + const targetHealth = entity.get<{ current: number; max: number; isDead: () => boolean }>('Health'); if (!targetUnit || !targetTransform || !targetSelectable || !targetHealth) continue; if (targetSelectable.playerId === myPlayerId) continue; if (targetHealth.isDead()) continue; - // Check if this unit can attack the target type const targetIsFlying = targetUnit.isFlying; const targetIsNaval = targetUnit.isNaval; - if (!unit.canAttackTarget(targetIsFlying, targetIsNaval)) continue; + // Check if this unit can attack the target type + const canAttack = (targetIsFlying && unitData.canAttackAir) || (!targetIsFlying && unitData.canAttackGround); + if (!canAttack) continue; - // Naval units should prefer naval targets (they can't path to land) - // Ground units should prefer ground targets (they can't path to water) - // Air units can attack anything they're capable of + // Naval units should prefer naval targets if (isNavalUnit && !isAirUnit) { - // Naval unit - strongly prefer naval targets, can also attack air if capable if (!targetIsNaval && !targetIsFlying) continue; } else if (!isNavalUnit && !isAirUnit) { - // Ground unit - strongly prefer ground/air targets, skip naval if (targetIsNaval) continue; } - // Air units can attack anything they're capable of (already checked above) - // Calculate score (simpler than full TargetAcquisition - just distance + health) - const dx = targetTransform.x - transform.x; - const dy = targetTransform.y - transform.y; + const dx = targetTransform.x - transformData.x; + const dy = targetTransform.y - transformData.y; const distance = Math.sqrt(dx * dx + dy * dy); - const healthPercent = targetHealth.current / targetHealth.max; - // Score: closer is better, lower health is better + if (distance > searchRange) continue; + + const healthPercent = targetHealth.current / targetHealth.max; const score = (1 - distance / searchRange) * 50 + (1 - healthPercent) * 30; if (!bestTarget || score > bestTarget.score) { - bestTarget = { entityId: targetId, score }; + bestTarget = { entityId: entity.id, score }; } } @@ -98,37 +76,36 @@ function findValidTarget( /** * Find the average position of enemy units that this unit can attack. - * Used as a fallback movement target when no direct target is found. */ function findEnemyCenter( - game: Game, - unit: Unit, + worldAdapter: RenderStateWorldAdapter, + unitData: { isNaval: boolean; isFlying: boolean; canAttackAir: boolean; canAttackGround: boolean }, myPlayerId: string ): { x: number; y: number } | null { - const world = game.world; - const entities = world.getEntitiesWith('Unit', 'Transform', 'Selectable', 'Health'); + const entities = worldAdapter.getEntitiesWith('Unit', 'Transform', 'Selectable', 'Health'); let sumX = 0; let sumY = 0; let count = 0; - const isNavalUnit = unit.isNaval; - const isAirUnit = unit.isFlying; + const isNavalUnit = unitData.isNaval; + const isAirUnit = unitData.isFlying; for (const entity of entities) { - const targetUnit = entity.get('Unit')!; - const targetTransform = entity.get('Transform')!; - const targetSelectable = entity.get('Selectable')!; - const targetHealth = entity.get('Health')!; + const targetUnit = entity.get<{ isFlying: boolean; isNaval: boolean }>('Unit'); + const targetTransform = entity.get<{ x: number; y: number }>('Transform'); + const targetSelectable = entity.get<{ playerId: string }>('Selectable'); + const targetHealth = entity.get<{ isDead: () => boolean }>('Health'); + if (!targetUnit || !targetTransform || !targetSelectable || !targetHealth) continue; if (targetSelectable.playerId === myPlayerId) continue; if (targetHealth.isDead()) continue; const targetIsFlying = targetUnit.isFlying; const targetIsNaval = targetUnit.isNaval; - // Only consider enemies this unit can actually attack and reach - if (!unit.canAttackTarget(targetIsFlying, targetIsNaval)) continue; + const canAttack = (targetIsFlying && unitData.canAttackAir) || (!targetIsFlying && unitData.canAttackGround); + if (!canAttack) continue; if (isNavalUnit && !isAirUnit) { if (!targetIsNaval && !targetIsFlying) continue; @@ -142,7 +119,6 @@ function findEnemyCenter( } if (count === 0) return null; - return { x: sumX / count, y: sumY / count }; } @@ -150,10 +126,9 @@ export const BattleSimulatorPanel = memo(function BattleSimulatorPanel() { const [selectedUnit, setSelectedUnit] = useState(null); const [selectedTeam, setSelectedTeam] = useState('player1'); const [spawnQuantity, setSpawnQuantity] = useState(1); - const [isPaused, setIsPaused] = useState(true); // Start paused so user can place units + const [isPaused, setIsPaused] = useState(true); const playerSlots = useGameSetupStore((state) => state.playerSlots); - // Get player colors const team1Color = PLAYER_COLORS.find(c => c.id === playerSlots[0]?.colorId); const team2Color = PLAYER_COLORS.find(c => c.id === playerSlots[1]?.colorId); @@ -170,8 +145,7 @@ export const BattleSimulatorPanel = memo(function BattleSimulatorPanel() { const game = Game.getInstance(); const handleSpawnClick = (data: { worldX: number; worldY: number }) => { - // Spawn units in a grid formation around the click point - const spacing = 2; // Units apart + const spacing = 2; const cols = Math.ceil(Math.sqrt(spawnQuantity)); const rows = Math.ceil(spawnQuantity / cols); const offsetX = ((cols - 1) * spacing) / 2; @@ -194,9 +168,7 @@ export const BattleSimulatorPanel = memo(function BattleSimulatorPanel() { } }; - // eventBus.on returns an unsubscribe function const unsubscribe = game.eventBus.on('simulator:spawn', handleSpawnClick); - return () => { unsubscribe(); }; @@ -204,43 +176,51 @@ export const BattleSimulatorPanel = memo(function BattleSimulatorPanel() { const handleFight = useCallback(() => { const game = Game.getInstance(); + const worldAdapter = RenderStateWorldAdapter.getInstance(); + const bridge = getWorkerBridge(); + + if (!worldAdapter) { + console.warn('[BattleSimulator] No world adapter available'); + return; + } - // Register both players as AI-controlled so AIMicroSystem handles combat behavior - // This enables automatic target acquisition, focus fire, and re-targeting + // Register both players as AI-controlled game.eventBus.emit('ai:registered', { playerId: 'player1' }); game.eventBus.emit('ai:registered', { playerId: 'player2' }); - // Get all units with required components - const entities = game.world.getEntitiesWith('Unit', 'Selectable', 'Transform', 'Health'); + // Get all units from render state adapter + const entities = worldAdapter.getEntitiesWith('Unit', 'Selectable', 'Transform', 'Health'); - // Give each unit an initial target or move order to start the battle for (const entity of entities) { - const selectable = entity.get('Selectable'); - const unit = entity.get('Unit'); - const transform = entity.get('Transform'); - const health = entity.get('Health'); + const selectable = entity.get<{ playerId: string }>('Selectable'); + const unit = entity.get<{ + unitId: string; + isWorker: boolean; + sightRange: number; + isNaval: boolean; + isFlying: boolean; + canAttackAir: boolean; + canAttackGround: boolean; + }>('Unit'); + const transform = entity.get<{ x: number; y: number }>('Transform'); + const health = entity.get<{ isDead: () => boolean }>('Health'); if (!selectable || !unit || !transform || !health) continue; if (health.isDead()) continue; - if (unit.isWorker) continue; // Skip workers + if (unit.isWorker) continue; const playerId = selectable.playerId; if (playerId !== 'player1' && playerId !== 'player2') continue; - // Find a valid target this unit can actually attack - const targetId = findValidTarget(game, entity.id, unit, transform, playerId); + const targetId = findValidTarget(worldAdapter, entity.id, unit, transform, playerId); if (targetId !== null) { - // Direct attack command to specific enemy - unit enters 'attacking' state game.eventBus.emit('command:attack', { entityIds: [entity.id], targetEntityId: targetId, }); } else { - // No target in sight - move towards enemies (not attack-move) - // Using 'move' puts unit in 'moving' state which AIMicroSystem can process - // The AI will handle target acquisition once unit is moving - const enemyCenter = findEnemyCenter(game, unit, playerId); + const enemyCenter = findEnemyCenter(worldAdapter, unit, playerId); if (enemyCenter) { game.eventBus.emit('command:move', { entityIds: [entity.id], @@ -250,11 +230,8 @@ export const BattleSimulatorPanel = memo(function BattleSimulatorPanel() { } } - // Unpause the game game.resume(); setIsPaused(false); - - // Deselect unit so user isn't accidentally spawning more setSelectedUnit(null); }, []); @@ -270,37 +247,51 @@ export const BattleSimulatorPanel = memo(function BattleSimulatorPanel() { const handleClearAll = useCallback(() => { const game = Game.getInstance(); + const worldAdapter = RenderStateWorldAdapter.getInstance(); - // Clear selection first (important: must happen before destroying entities) - // This ensures selection rings are properly cleaned up even when game is paused + // Clear selection first useGameStore.getState().selectUnits([]); game.eventBus.emit('selection:clear', {}); - // Get all unit entities and destroy them - const entities = game.world.getEntitiesWith('Unit', 'Selectable'); - for (const entity of entities) { - game.world.destroyEntity(entity.id); + // Get all unit entity IDs and request worker to destroy them + if (worldAdapter) { + const entities = worldAdapter.getEntitiesWith('Unit', 'Selectable'); + const entityIds = entities.map(e => e.id); + + // Emit destroy command for each entity - worker will handle actual destruction + for (const entityId of entityIds) { + game.eventBus.emit('entity:destroy', { entityId }); + } } }, []); const handleSelectTeam = useCallback((team: 'player1' | 'player2') => { const game = Game.getInstance(); - const entities = game.world.getEntitiesWith('Unit', 'Selectable'); + const worldAdapter = RenderStateWorldAdapter.getInstance(); + const bridge = getWorkerBridge(); + + if (!worldAdapter) return; + + const entities = worldAdapter.getEntitiesWith('Unit', 'Selectable'); const teamUnits: number[] = []; for (const entity of entities) { - const selectable = entity.get('Selectable'); + const selectable = entity.get<{ playerId: string }>('Selectable'); if (selectable?.playerId === team) { teamUnits.push(entity.id); } } useGameStore.getState().selectUnits(teamUnits); - // Deselect spawning so clicking doesn't place more units + + // Sync selection to worker + if (bridge) { + bridge.setSelection(teamUnits, team); + } + setSelectedUnit(null); }, []); - // Filter out worker unit for cleaner list const combatUnits = DOMINION_UNITS.filter(u => !u.isWorker); return ( diff --git a/src/components/game/PlayerStatusPanel.tsx b/src/components/game/PlayerStatusPanel.tsx index 5a75b1f4..1fd27192 100644 --- a/src/components/game/PlayerStatusPanel.tsx +++ b/src/components/game/PlayerStatusPanel.tsx @@ -2,10 +2,8 @@ import { useEffect, useState, memo, useRef } from 'react'; import { useGameSetupStore, PLAYER_COLORS } from '@/store/gameSetupStore'; -import { Game } from '@/engine/core/Game'; -import { Selectable } from '@/engine/components/Selectable'; -import { Health } from '@/engine/components/Health'; -import { Unit } from '@/engine/components/Unit'; +import { RenderStateWorldAdapter } from '@/engine/workers/RenderStateAdapter'; +import { getWorkerBridge } from '@/engine/workers/WorkerBridge'; interface PlayerStatus { playerId: string; @@ -30,8 +28,9 @@ export const PlayerStatusPanel = memo(function PlayerStatusPanel() { // Update player statuses with tick-based caching useEffect(() => { - const game = Game.getInstance(); - if (!game) return; + const worldAdapter = RenderStateWorldAdapter.getInstance(); + const workerBridge = getWorkerBridge(); + if (!worldAdapter) return; // Get active player slots (human or AI) and sort by player ID for consistent ordering const activeSlots = playerSlots @@ -44,18 +43,18 @@ export const PlayerStatusPanel = memo(function PlayerStatusPanel() { }); const computeStatuses = (): PlayerStatus[] => { - const currentTick = game.getCurrentTick(); + const currentTick = worldAdapter.getTick(); // Skip if already computed for this tick if (currentTick === lastTickRef.current) { return cachedStatusesRef.current; } - const world = game.world; const statuses: PlayerStatus[] = []; // PERF: Query entities once, then filter by player - const buildings = world.getEntitiesWith('Building', 'Selectable', 'Health'); - const units = world.getEntitiesWith('Unit', 'Selectable', 'Health'); + // RenderStateWorldAdapter provides entity data from the worker + const buildings = worldAdapter.getEntitiesWith('Building'); + const units = worldAdapter.getEntitiesWith('Unit'); for (const slot of activeSlots) { let buildingCount = 0; @@ -65,8 +64,8 @@ export const PlayerStatusPanel = memo(function PlayerStatusPanel() { // Count buildings for this player for (const entity of buildings) { - const selectable = entity.get('Selectable'); - const health = entity.get('Health'); + const selectable = entity.get<{ playerId: string }>('Selectable'); + const health = entity.get<{ isDead: () => boolean }>('Health'); if (selectable?.playerId === slot.id && !health?.isDead()) { buildingCount++; } @@ -74,9 +73,9 @@ export const PlayerStatusPanel = memo(function PlayerStatusPanel() { // Count units for this player for (const entity of units) { - const selectable = entity.get('Selectable'); - const unit = entity.get('Unit'); - const health = entity.get('Health'); + const selectable = entity.get<{ playerId: string }>('Selectable'); + const unit = entity.get<{ isWorker: boolean }>('Unit'); + const health = entity.get<{ isDead: () => boolean }>('Health'); if (selectable?.playerId === slot.id && !health?.isDead()) { unitCount++; if (unit?.isWorker) { @@ -112,9 +111,9 @@ export const PlayerStatusPanel = memo(function PlayerStatusPanel() { }; // Subscribe to events that could change player stats - const eventBus = game.eventBus; - const unsubSpawned = eventBus.on('unit:spawned', updateStatuses); - const unsubDied = eventBus.on('unit:died', updateStatuses); + const eventBus = workerBridge?.eventBus; + const unsubSpawned = eventBus?.on('unit:spawned', updateStatuses); + const unsubDied = eventBus?.on('unit:died', updateStatuses); // Update immediately updateStatuses(); @@ -123,8 +122,8 @@ export const PlayerStatusPanel = memo(function PlayerStatusPanel() { const interval = setInterval(updateStatuses, 2000); return () => { - unsubSpawned(); - unsubDied(); + unsubSpawned?.(); + unsubDied?.(); clearInterval(interval); }; }, [playerSlots]); diff --git a/src/components/game/hooks/useCameraControl.ts b/src/components/game/hooks/useCameraControl.ts index 5a7adf3d..fb4c6a29 100644 --- a/src/components/game/hooks/useCameraControl.ts +++ b/src/components/game/hooks/useCameraControl.ts @@ -10,7 +10,7 @@ import type { MutableRefObject } from 'react'; import { useEffect, useRef, useCallback } from 'react'; import { RTSCamera } from '@/rendering/Camera'; import { Game } from '@/engine/core/Game'; -import { Transform } from '@/engine/components/Transform'; +import { RenderStateWorldAdapter } from '@/engine/workers/RenderStateAdapter'; import { useGameStore, GameState } from '@/store/gameStore'; export interface UseCameraControlProps { @@ -34,20 +34,22 @@ export function useCameraControl({ cameraRef, gameRef }: UseCameraControlProps): // Handle control group selection with optional camera centering const handleControlGroupSelect = useCallback( (groupNumber: number, isDoubleClick: boolean) => { - const game = gameRef.current; const camera = cameraRef.current; - if (!game || !camera) return; + if (!camera) return; const store = useGameStore.getState(); const group = store.controlGroups.get(groupNumber); if (group && group.length > 0) { if (isDoubleClick) { - // Center camera on first unit in the group - const firstEntity = game.world.getEntity(group[0]); - const transform = firstEntity?.get('Transform'); - if (transform) { - camera.setPosition(transform.x, transform.y); + // Center camera on first unit in the group using RenderStateWorldAdapter + const worldAdapter = RenderStateWorldAdapter.getInstance(); + if (worldAdapter) { + const firstEntity = worldAdapter.getEntity(group[0]); + const transform = firstEntity?.get<{ x: number; y: number }>('Transform'); + if (transform) { + camera.setPosition(transform.x, transform.y); + } } } @@ -56,23 +58,26 @@ export function useCameraControl({ cameraRef, gameRef }: UseCameraControlProps): store.selectUnits(group); } }, - [gameRef, cameraRef] + [cameraRef] ); // Center camera on a specific entity const centerOnEntity = useCallback( (entityId: number) => { - const game = gameRef.current; const camera = cameraRef.current; - if (!game || !camera) return; + if (!camera) return; - const entity = game.world.getEntity(entityId); - const transform = entity?.get('Transform'); + // Use RenderStateWorldAdapter for entity lookup (has actual entity data from worker) + const worldAdapter = RenderStateWorldAdapter.getInstance(); + if (!worldAdapter) return; + + const entity = worldAdapter.getEntity(entityId); + const transform = entity?.get<{ x: number; y: number }>('Transform'); if (transform) { camera.setPosition(transform.x, transform.y); } }, - [gameRef, cameraRef] + [cameraRef] ); // Center camera on a world position diff --git a/src/components/game/hooks/useWorkerBridge.ts b/src/components/game/hooks/useWorkerBridge.ts index cb226807..dfc29235 100644 --- a/src/components/game/hooks/useWorkerBridge.ts +++ b/src/components/game/hooks/useWorkerBridge.ts @@ -197,6 +197,18 @@ export function useWorkerBridge({ // Set terrain data on game instance game.setTerrainGrid(currentMap.terrain); + // Configure SelectionSystem for worker mode: + // - Use RenderStateWorldAdapter for entity queries (has actual entity data from worker) + // - Sync selection changes to worker via WorkerBridge.setSelection + const selectionSystem = game.selectionSystem; + selectionSystem.setWorldProvider(worldProviderRef.current!); + selectionSystem.setSelectionSyncCallback((entityIds, playerId) => { + bridge.setSelection(entityIds, playerId); + }); + if (localPlayerId) { + selectionSystem.setPlayerId(localPlayerId); + } + setIsInitialized(true); return true; } catch (error) { diff --git a/src/engine/debug/ConsoleEngine.ts b/src/engine/debug/ConsoleEngine.ts index f114c538..b220b9d7 100644 --- a/src/engine/debug/ConsoleEngine.ts +++ b/src/engine/debug/ConsoleEngine.ts @@ -7,6 +7,8 @@ import { Game } from '@/engine/core/Game'; import { RTSCamera } from '@/rendering/Camera'; +import { RenderStateWorldAdapter } from '@/engine/workers/RenderStateAdapter'; +import { getWorkerBridge } from '@/engine/workers/WorkerBridge'; import { CONSOLE_COMMANDS, COMMAND_MAP, @@ -21,11 +23,6 @@ import { import { getLocalPlayerId } from '@/store/gameSetupStore'; import { useGameStore } from '@/store/gameStore'; import { DefinitionRegistry } from '@/engine/definitions'; -import { Transform } from '@/engine/components/Transform'; -import { Health } from '@/engine/components/Health'; -import { Selectable } from '@/engine/components/Selectable'; -import { Unit } from '@/engine/components/Unit'; -import { Building } from '@/engine/components/Building'; // Console output entry export interface ConsoleEntry { @@ -588,45 +585,61 @@ export class ConsoleEngine { case 'killUnits': { const targetPlayer = args.player as string | undefined; - const units = ctx.game.world.getEntitiesWith('Unit', 'Health', 'Selectable'); + const worldAdapter = RenderStateWorldAdapter.getInstance(); + if (!worldAdapter) { + return { success: false, message: 'Game not initialized' }; + } + + const units = worldAdapter.getEntitiesWith('Unit', 'Health', 'Selectable'); + const entityIds: number[] = []; - let killed = 0; for (const entity of units) { - const selectable = entity.get('Selectable'); + const selectable = entity.get<{ playerId: string }>('Selectable'); + const health = entity.get<{ isDead: () => boolean }>('Health'); if (targetPlayer && selectable?.playerId !== targetPlayer) continue; - - const health = entity.get('Health'); if (health && !health.isDead()) { - health.current = 0; - killed++; + entityIds.push(entity.id); } } + // Emit debug event - worker will handle the actual killing + for (const entityId of entityIds) { + ctx.game.eventBus.emit('debug:killEntity', { entityId }); + } + return { success: true, - message: `Killed ${killed} units${targetPlayer ? ` belonging to ${targetPlayer}` : ''}`, + message: `Killing ${entityIds.length} units${targetPlayer ? ` belonging to ${targetPlayer}` : ''}`, }; } case 'killBuildings': { const targetPlayer = args.player as string | undefined; - const buildings = ctx.game.world.getEntitiesWith('Building', 'Health', 'Selectable'); + const worldAdapter = RenderStateWorldAdapter.getInstance(); + if (!worldAdapter) { + return { success: false, message: 'Game not initialized' }; + } + + const buildings = worldAdapter.getEntitiesWith('Building', 'Health', 'Selectable'); + const entityIds: number[] = []; - let destroyed = 0; for (const entity of buildings) { - const selectable = entity.get('Selectable'); + const selectable = entity.get<{ playerId: string }>('Selectable'); + const health = entity.get<{ isDead: () => boolean }>('Health'); if (targetPlayer && selectable?.playerId !== targetPlayer) continue; - - const health = entity.get('Health'); if (health && !health.isDead()) { - health.current = 0; - destroyed++; + entityIds.push(entity.id); } } + // Emit debug event - worker will handle the actual destruction + for (const entityId of entityIds) { + ctx.game.eventBus.emit('debug:killEntity', { entityId }); + } + return { success: true, - message: `Destroyed ${destroyed} buildings${targetPlayer ? ` belonging to ${targetPlayer}` : ''}`, + message: `Destroying ${entityIds.length} buildings${targetPlayer ? ` belonging to ${targetPlayer}` : ''}`, }; } @@ -634,20 +647,22 @@ export class ConsoleEngine { const entityId = args.entityId as number; const amount = args.amount as number; - const entity = ctx.game.world.getEntity(entityId); + const worldAdapter = RenderStateWorldAdapter.getInstance(); + if (!worldAdapter) { + return { success: false, message: 'Game not initialized' }; + } + + const entity = worldAdapter.getEntity(entityId); if (!entity) { return { success: false, message: `Entity ${entityId} not found` }; } - const health = entity.get('Health'); - if (!health) { - return { success: false, message: `Entity ${entityId} has no health component` }; - } + // Emit debug event - worker will handle the damage + ctx.game.eventBus.emit('debug:damageEntity', { entityId, amount }); - const actual = health.takeDamage(amount, ctx.game.getGameTime()); return { success: true, - message: `Dealt ${actual} damage to entity ${entityId} (HP: ${health.current.toFixed(0)}/${health.max})`, + message: `Dealing ${amount} damage to entity ${entityId}`, }; } @@ -655,21 +670,22 @@ export class ConsoleEngine { const entityId = args.entityId as number; const amount = args.amount as number | undefined; - const entity = ctx.game.world.getEntity(entityId); + const worldAdapter = RenderStateWorldAdapter.getInstance(); + if (!worldAdapter) { + return { success: false, message: 'Game not initialized' }; + } + + const entity = worldAdapter.getEntity(entityId); if (!entity) { return { success: false, message: `Entity ${entityId} not found` }; } - const health = entity.get('Health'); - if (!health) { - return { success: false, message: `Entity ${entityId} has no health component` }; - } + // Emit debug event - worker will handle the healing + ctx.game.eventBus.emit('debug:healEntity', { entityId, amount }); - const healAmount = amount ?? health.max; - const actual = health.heal(healAmount); return { success: true, - message: `Healed entity ${entityId} for ${actual} HP (HP: ${health.current.toFixed(0)}/${health.max})`, + message: `Healing entity ${entityId}${amount ? ` for ${amount} HP` : ' to full'}`, }; } @@ -678,33 +694,46 @@ export class ConsoleEngine { const x = args.x as number; const y = args.y as number; - const entity = ctx.game.world.getEntity(entityId); + const worldAdapter = RenderStateWorldAdapter.getInstance(); + if (!worldAdapter) { + return { success: false, message: 'Game not initialized' }; + } + + const entity = worldAdapter.getEntity(entityId); if (!entity) { return { success: false, message: `Entity ${entityId} not found` }; } - const transform = entity.get('Transform'); - if (!transform) { - return { success: false, message: `Entity ${entityId} has no transform component` }; - } + // Emit debug event - worker will handle the teleport + ctx.game.eventBus.emit('debug:teleportEntity', { entityId, x, y }); - transform.x = x; - transform.y = y; return { success: true, - message: `Teleported entity ${entityId} to (${x.toFixed(1)}, ${y.toFixed(1)})`, + message: `Teleporting entity ${entityId} to (${x.toFixed(1)}, ${y.toFixed(1)})`, }; } case 'selectEntity': { const entityId = args.entityId as number; - const entity = ctx.game.world.getEntity(entityId); + const worldAdapter = RenderStateWorldAdapter.getInstance(); + if (!worldAdapter) { + return { success: false, message: 'Game not initialized' }; + } + + const entity = worldAdapter.getEntity(entityId); if (!entity) { return { success: false, message: `Entity ${entityId} not found` }; } useGameStore.getState().selectUnits([entityId]); + + // Sync selection to worker + const bridge = getWorkerBridge(); + if (bridge) { + bridge.setSelection([entityId], ctx.playerId); + } + return { success: true, message: `Selected entity ${entityId}`, @@ -753,12 +782,15 @@ export class ConsoleEngine { const result = action.result as 'victory' | 'defeat'; const duration = ctx.game.getGameTime(); - // Get opponent player ID for loser field + // Get opponent player ID from render state + const worldAdapter = RenderStateWorldAdapter.getInstance(); const allPlayers = new Set(); - const allEntities = ctx.game.world.getEntitiesWith('Selectable'); - for (const entity of allEntities) { - const selectable = entity.get('Selectable'); - if (selectable) allPlayers.add(selectable.playerId); + if (worldAdapter) { + const allEntities = worldAdapter.getEntitiesWith('Selectable'); + for (const entity of allEntities) { + const selectable = entity.get<{ playerId: string }>('Selectable'); + if (selectable) allPlayers.add(selectable.playerId); + } } const otherPlayers = [...allPlayers].filter(p => p !== ctx.playerId); const opponent = otherPlayers[0] || 'opponent'; @@ -787,32 +819,12 @@ export class ConsoleEngine { // --------------------------------------------------------------------------- /** - * Apply god mode to all units and buildings owned by the player + * Apply god mode to all units and buildings owned by the player. + * Emits debug event for worker to handle the actual state change. */ private applyGodMode(game: Game, playerId: string, enabled: boolean): void { - // Apply to units - const units = game.world.getEntitiesWith('Unit', 'Health', 'Selectable'); - for (const entity of units) { - const selectable = entity.get('Selectable'); - if (selectable?.playerId === playerId) { - const health = entity.get('Health'); - if (health) { - health.isInvincible = enabled; - } - } - } - - // Apply to buildings - const buildings = game.world.getEntitiesWith('Building', 'Health', 'Selectable'); - for (const entity of buildings) { - const selectable = entity.get('Selectable'); - if (selectable?.playerId === playerId) { - const health = entity.get('Health'); - if (health) { - health.isInvincible = enabled; - } - } - } + // Emit debug event - worker will handle setting invincibility on entities + game.eventBus.emit('debug:setGodMode', { playerId, enabled }); } // --------------------------------------------------------------------------- diff --git a/src/engine/systems/SelectionSystem.ts b/src/engine/systems/SelectionSystem.ts index 7605bd9b..ca9b9ca2 100644 --- a/src/engine/systems/SelectionSystem.ts +++ b/src/engine/systems/SelectionSystem.ts @@ -1,15 +1,14 @@ import { System } from '../ecs/System'; -import { Transform } from '../components/Transform'; -import { Selectable } from '../components/Selectable'; -import { Unit } from '../components/Unit'; -import { Building } from '../components/Building'; -import { Health } from '../components/Health'; import { Game } from '../core/Game'; import { distance, clamp } from '@/utils/math'; +import type { IWorldProvider } from '../ecs/IWorldProvider'; /** * Selection system with screen-space box selection, selection radius buffer, * visual bounds support, and priority-based selection (units over buildings). + * + * In worker mode, this system queries entities from RenderStateWorldAdapter + * and notifies the worker of selection changes via a callback. */ export class SelectionSystem extends System { public readonly name = 'SelectionSystem'; @@ -27,11 +26,43 @@ export class SelectionSystem extends System { // Updated by camera system when viewport changes private viewportBounds: { minX: number; maxX: number; minZ: number; maxZ: number } | null = null; + // World provider for entity queries (RenderStateWorldAdapter in worker mode) + private worldProvider: IWorldProvider | null = null; + + // Callback to notify worker of selection changes + private onSelectionSync: ((entityIds: number[], playerId: string) => void) | null = null; + + // Current player ID for selection sync + private currentPlayerId: string | null = null; + constructor(game: Game) { super(game); this.setupEventListeners(); } + /** + * Set the world provider for entity queries. + * In worker mode, this should be RenderStateWorldAdapter. + */ + public setWorldProvider(provider: IWorldProvider): void { + this.worldProvider = provider; + } + + /** + * Set the callback for syncing selection to the worker. + * This should be WorkerBridge.setSelection in worker mode. + */ + public setSelectionSyncCallback(callback: (entityIds: number[], playerId: string) => void): void { + this.onSelectionSync = callback; + } + + /** + * Set the current player ID for selection operations. + */ + public setPlayerId(playerId: string): void { + this.currentPlayerId = playerId; + } + /** * Set the world-to-screen projection function for accurate screen-space selection */ @@ -54,6 +85,13 @@ export class SelectionSystem extends System { this.viewportBounds = { minX, maxX, minZ, maxZ }; } + /** + * Get the world provider - uses injected provider or falls back to this.world + */ + private getWorldProvider(): IWorldProvider { + return this.worldProvider ?? (this.world as unknown as IWorldProvider); + } + /** * PERF: Check if a world position is potentially within the viewport * Uses a buffer to account for entity radius and camera perspective @@ -79,6 +117,22 @@ export class SelectionSystem extends System { this.game.eventBus.on('selection:clear', this.handleClearSelection.bind(this)); } + /** + * Sync selection to worker and update local state + */ + private syncSelection(selectedIds: number[], playerId: string): void { + // Update local state port (Zustand store) + this.game.statePort.selectUnits(selectedIds); + + // Notify worker to update isSelected on actual entities + if (this.onSelectionSync) { + this.onSelectionSync(selectedIds, playerId); + } + + // Emit event for UI updates + this.game.eventBus.emit('selection:changed', { selectedIds, playerId }); + } + /** * Screen-space box selection - the most accurate method * Converts entity positions to screen space and checks against screen-space box @@ -92,6 +146,7 @@ export class SelectionSystem extends System { playerId: string; }): void { const { screenStartX, screenStartY, screenEndX, screenEndY, additive, playerId } = data; + const world = this.getWorldProvider(); // Screen-space box bounds const screenMinX = Math.min(screenStartX, screenEndX); @@ -103,38 +158,37 @@ export class SelectionSystem extends System { const boxWidth = screenMaxX - screenMinX; const boxHeight = screenMaxY - screenMinY; - const entities = this.world.getEntitiesWith('Transform', 'Selectable'); - - // First, deselect all if not additive - if (!additive) { - for (const entity of entities) { - const selectable = entity.get('Selectable')!; - selectable.deselect(); - } - } + const entities = world.getEntitiesWith('Transform', 'Selectable'); // Collect entities within box, separating units from buildings const unitIds: number[] = []; const buildingIds: number[] = []; for (const entity of entities) { - const transform = entity.get('Transform')!; - const selectable = entity.get('Selectable')!; - const health = entity.get('Health'); - const unit = entity.get('Unit'); - const building = entity.get('Building'); + const transform = entity.get<{ x: number; y: number }>('Transform'); + const selectable = entity.get<{ + playerId: string; + selectionRadius: number; + selectionPriority: number; + visualHeight?: number; + visualScale?: number; + }>('Selectable'); + const health = entity.get<{ isDead: () => boolean }>('Health'); + const unit = entity.get<{ unitId: string }>('Unit'); + const building = entity.get<{ buildingId: string }>('Building'); + + if (!transform || !selectable) continue; // Only select own units/buildings if (selectable.playerId !== playerId) continue; // Skip dead entities - if (health && health.isDead()) continue; + if (health?.isDead()) continue; // PERF: Early reject entities outside viewport bounds (world space culling) if (!this.isInViewport(transform.x, transform.y)) continue; // Convert entity world position to screen space - // Must include terrain height + visual height offset for accurate projection if (!this.worldToScreenFn) continue; const terrainHeight = this.getTerrainHeightFn ? this.getTerrainHeightFn(transform.x, transform.y) : 0; @@ -144,13 +198,11 @@ export class SelectionSystem extends System { if (!screenPos) continue; // Behind camera // Calculate screen-space selection buffer based on visual size - // Larger units get bigger screen-space hitboxes const visualScale = selectable.visualScale ?? 1; - const baseScreenRadius = selectable.selectionRadius * 25; // Pixels per world unit (increased for better feel) + const baseScreenRadius = selectable.selectionRadius * 25; const screenRadius = baseScreenRadius * visualScale; // Check if entity's screen-space circle intersects the selection box - // Using circle-rectangle intersection for smooth selection feel const isInBox = this.circleIntersectsRect( screenPos.x, screenPos.y, @@ -179,38 +231,23 @@ export class SelectionSystem extends System { } // Prioritize units over buildings - if any units are selected, ignore buildings - const selectedIds = unitIds.length > 0 ? unitIds : buildingIds; - - // Mark entities as selected - for (const entityId of selectedIds) { - const entity = this.world.getEntity(entityId); - if (entity) { - const selectable = entity.get('Selectable'); - if (selectable) { - selectable.select(); - } - } - } + let selectedIds = unitIds.length > 0 ? unitIds : buildingIds; - // Update store + // Handle additive selection if (additive) { const current = this.game.statePort.getSelectedUnits(); - // PERF: Avoid multiple spread operations by using Set directly const combinedSet = new Set(current); - for (let i = 0; i < selectedIds.length; i++) { - combinedSet.add(selectedIds[i]); + for (const id of selectedIds) { + combinedSet.add(id); } - this.game.statePort.selectUnits(Array.from(combinedSet)); - } else { - this.game.statePort.selectUnits(selectedIds); + selectedIds = Array.from(combinedSet); } - this.game.eventBus.emit('selection:changed', { selectedIds }); + this.syncSelection(selectedIds, playerId); } /** * Check if a circle intersects a rectangle - * Used for smooth selection feel where entities near the box edge are selected */ private circleIntersectsRect( cx: number, @@ -221,16 +258,11 @@ export class SelectionSystem extends System { rectMaxX: number, rectMaxY: number ): boolean { - // Find the closest point on the rectangle to the circle center const closestX = clamp(cx, rectMinX, rectMaxX); const closestY = clamp(cy, rectMinY, rectMaxY); - - // Calculate distance from circle center to closest point const dx = cx - closestX; const dy = cy - closestY; const distanceSquared = dx * dx + dy * dy; - - // Circle intersects if distance is less than radius return distanceSquared <= radius * radius; } @@ -245,57 +277,67 @@ export class SelectionSystem extends System { playerId: string; }): void { const { screenX, screenY, additive, selectAllOfType, playerId } = data; + const world = this.getWorldProvider(); if (!this.worldToScreenFn) { - // Fallback to world-space click (shouldn't happen normally) return; } - const entities = this.world.getEntitiesWith('Transform', 'Selectable'); - let closestEntity: { id: number; distance: number; priority: number } | null = null; + const entities = world.getEntitiesWith('Transform', 'Selectable'); + let closestEntity: { id: number; distance: number; priority: number; unitId?: string; buildingId?: string } | null = null; // Find closest entity to click point in screen space for (const entity of entities) { - const transform = entity.get('Transform')!; - const selectable = entity.get('Selectable')!; - const health = entity.get('Health'); + const transform = entity.get<{ x: number; y: number }>('Transform'); + const selectable = entity.get<{ + playerId: string; + selectionRadius: number; + selectionPriority: number; + visualHeight?: number; + visualScale?: number; + }>('Selectable'); + const health = entity.get<{ isDead: () => boolean }>('Health'); + const unit = entity.get<{ unitId: string }>('Unit'); + const building = entity.get<{ buildingId: string }>('Building'); + + if (!transform || !selectable) continue; // Allow selecting own units/buildings OR neutral entities (resources) if (selectable.playerId !== playerId && selectable.playerId !== 'neutral') continue; // Skip dead units - if (health && health.isDead()) continue; + if (health?.isDead()) continue; - // PERF: Early reject entities outside viewport bounds (world space culling) + // PERF: Early reject entities outside viewport bounds if (!this.isInViewport(transform.x, transform.y)) continue; - // Convert entity to screen space - must include terrain height + visual offset + // Convert entity to screen space const terrainHeight = this.getTerrainHeightFn ? this.getTerrainHeightFn(transform.x, transform.y) : 0; const visualHeight = selectable.visualHeight ?? 0; const worldY = terrainHeight + visualHeight; const screenPos = this.worldToScreenFn(transform.x, transform.y, worldY); - if (!screenPos) continue; // Behind camera + if (!screenPos) continue; // Calculate screen-space distance const screenDistance = distance(screenX, screenY, screenPos.x, screenPos.y); - // Calculate screen-space selection radius (more generous for better feel) + // Calculate screen-space selection radius const visualScale = selectable.visualScale ?? 1; - const baseScreenRadius = selectable.selectionRadius * 30; // Pixels per world unit + const baseScreenRadius = selectable.selectionRadius * 30; const screenRadius = baseScreenRadius * visualScale; if (screenDistance <= screenRadius) { - // Check if this is closer or higher priority if ( !closestEntity || selectable.selectionPriority > closestEntity.priority || - (selectable.selectionPriority === closestEntity.priority && - screenDistance < closestEntity.distance) + (selectable.selectionPriority === closestEntity.priority && screenDistance < closestEntity.distance) ) { closestEntity = { id: entity.id, distance: screenDistance, priority: selectable.selectionPriority, + unitId: unit?.unitId, + buildingId: building?.buildingId, }; } } @@ -303,191 +345,130 @@ export class SelectionSystem extends System { // Handle Ctrl+click or double-click - select all of same type if (selectAllOfType && closestEntity) { - const clickedEntity = this.world.getEntity(closestEntity.id); - if (clickedEntity) { - const clickedUnit = clickedEntity.get('Unit'); - if (clickedUnit) { - this.selectAllOfUnitType(clickedUnit.unitId, playerId, additive); - return; - } - const clickedBuilding = clickedEntity.get('Building'); - if (clickedBuilding) { - this.selectAllOfBuildingType(clickedBuilding.buildingId, playerId, additive); - return; - } + if (closestEntity.unitId) { + this.selectAllOfUnitType(closestEntity.unitId, playerId, additive); + return; } - } - - // Clear selection if not additive - if (!additive) { - for (const entity of entities) { - const selectable = entity.get('Selectable')!; - selectable.deselect(); + if (closestEntity.buildingId) { + this.selectAllOfBuildingType(closestEntity.buildingId, playerId, additive); + return; } } - // Select the clicked entity - const selectedIds: number[] = []; + // Build selected IDs + let selectedIds: number[] = []; if (closestEntity) { - const entity = this.world.getEntity(closestEntity.id); - if (entity) { - const selectable = entity.get('Selectable')!; - - // If additive and already selected, deselect - if (additive && selectable.isSelected) { - selectable.deselect(); + if (additive) { + const current = this.game.statePort.getSelectedUnits(); + const isAlreadySelected = current.includes(closestEntity.id); + if (isAlreadySelected) { + // Deselect if already selected + selectedIds = current.filter(id => id !== closestEntity!.id); } else { - selectable.select(); - selectedIds.push(entity.id); + // Add to selection + selectedIds = [...current, closestEntity.id]; } + } else { + selectedIds = [closestEntity.id]; } - } - - // Update store - if (!additive) { - this.game.statePort.selectUnits(selectedIds); + } else if (!additive) { + // Clicked empty space - clear selection + selectedIds = []; } else { - const current = this.game.statePort.getSelectedUnits(); - if (closestEntity && selectedIds.length > 0) { - this.game.statePort.selectUnits([...current, ...selectedIds]); - } else if (closestEntity) { - this.game.statePort.selectUnits(current.filter((id) => id !== closestEntity!.id)); - } + // Additive click on empty space - keep current selection + return; } - this.game.eventBus.emit('selection:changed', { selectedIds }); + this.syncSelection(selectedIds, playerId); } private selectAllOfUnitType(unitId: string, playerId: string, additive: boolean): void { - const entities = this.world.getEntitiesWith('Unit', 'Selectable'); - const selectedIds: number[] = []; - - // Clear selection if not additive - if (!additive) { - for (const entity of entities) { - const selectable = entity.get('Selectable')!; - selectable.deselect(); - } - } + const world = this.getWorldProvider(); + const entities = world.getEntitiesWith('Unit', 'Selectable'); + const newSelectedIds: number[] = []; - // Select all units of the same type belonging to the player within viewport for (const entity of entities) { - const unit = entity.get('Unit')!; - const selectable = entity.get('Selectable')!; - const health = entity.get('Health'); - const transform = entity.get('Transform'); - - // Skip dead units - if (health && health.isDead()) continue; + const unit = entity.get<{ unitId: string }>('Unit'); + const selectable = entity.get<{ playerId: string }>('Selectable'); + const health = entity.get<{ isDead: () => boolean }>('Health'); + const transform = entity.get<{ x: number; y: number }>('Transform'); - // Only select units within the current viewport + if (!unit || !selectable) continue; + if (health?.isDead()) continue; if (transform && !this.isInViewport(transform.x, transform.y)) continue; if (unit.unitId === unitId && selectable.playerId === playerId) { - selectable.select(); - selectedIds.push(entity.id); + newSelectedIds.push(entity.id); } } - // Update store + let selectedIds = newSelectedIds; if (additive) { const current = this.game.statePort.getSelectedUnits(); - // PERF: Avoid multiple spread operations by using Set directly const combinedSet = new Set(current); - for (let i = 0; i < selectedIds.length; i++) { - combinedSet.add(selectedIds[i]); + for (const id of newSelectedIds) { + combinedSet.add(id); } - this.game.statePort.selectUnits(Array.from(combinedSet)); - } else { - this.game.statePort.selectUnits(selectedIds); + selectedIds = Array.from(combinedSet); } - this.game.eventBus.emit('selection:changed', { selectedIds }); + this.syncSelection(selectedIds, playerId); } private selectAllOfBuildingType(buildingId: string, playerId: string, additive: boolean): void { - const entities = this.world.getEntitiesWith('Building', 'Selectable'); - const selectedIds: number[] = []; - - // Clear selection if not additive (clear all selectable entities, not just buildings) - if (!additive) { - const allSelectable = this.world.getEntitiesWith('Selectable'); - for (const entity of allSelectable) { - const selectable = entity.get('Selectable')!; - selectable.deselect(); - } - } + const world = this.getWorldProvider(); + const entities = world.getEntitiesWith('Building', 'Selectable'); + const newSelectedIds: number[] = []; - // Select all buildings of the same type belonging to the player within viewport for (const entity of entities) { - const building = entity.get('Building')!; - const selectable = entity.get('Selectable')!; - const health = entity.get('Health'); - const transform = entity.get('Transform'); - - // Skip destroyed buildings - if (health && health.isDead()) continue; + const building = entity.get<{ buildingId: string }>('Building'); + const selectable = entity.get<{ playerId: string }>('Selectable'); + const health = entity.get<{ isDead: () => boolean }>('Health'); + const transform = entity.get<{ x: number; y: number }>('Transform'); - // Only select buildings within the current viewport + if (!building || !selectable) continue; + if (health?.isDead()) continue; if (transform && !this.isInViewport(transform.x, transform.y)) continue; if (building.buildingId === buildingId && selectable.playerId === playerId) { - selectable.select(); - selectedIds.push(entity.id); + newSelectedIds.push(entity.id); } } - // Update store + let selectedIds = newSelectedIds; if (additive) { const current = this.game.statePort.getSelectedUnits(); - // PERF: Avoid multiple spread operations by using Set directly const combinedSet = new Set(current); - for (let i = 0; i < selectedIds.length; i++) { - combinedSet.add(selectedIds[i]); + for (const id of newSelectedIds) { + combinedSet.add(id); } - this.game.statePort.selectUnits(Array.from(combinedSet)); - } else { - this.game.statePort.selectUnits(selectedIds); + selectedIds = Array.from(combinedSet); } - this.game.eventBus.emit('selection:changed', { selectedIds }); + this.syncSelection(selectedIds, playerId); } private handleSetControlGroup(data: { group: number; entityIds: number[] }): void { const { group, entityIds } = data; - // Clear previous control group assignments for this group - const entities = this.world.getEntitiesWith('Selectable'); - for (const entity of entities) { - const selectable = entity.get('Selectable')!; - if (selectable.controlGroup === group) { - selectable.controlGroup = null; - } - } - - // Assign new control group - for (const entityId of entityIds) { - const entity = this.world.getEntity(entityId); - if (entity) { - const selectable = entity.get('Selectable'); - if (selectable) { - selectable.controlGroup = group; - } - } - } - this.controlGroups.set(group, entityIds); this.game.statePort.setControlGroup(group, entityIds); + + // Notify worker of control group change + this.game.eventBus.emit('controlGroup:set', { group, entityIds }); } private handleGetControlGroup(data: { group: number }): void { const { group } = data; + const world = this.getWorldProvider(); const entityIds = this.controlGroups.get(group) || []; - // Filter out dead entities + // Filter out dead/destroyed entities const validIds = entityIds.filter((id) => { - const entity = this.world.getEntity(id); - return entity && !entity.isDestroyed(); + const entity = world.getEntity(id); + if (!entity) return false; + const health = entity.get<{ isDead: () => boolean }>('Health'); + return !health?.isDead(); }); // Update the stored group if entities were removed @@ -495,58 +476,31 @@ export class SelectionSystem extends System { this.controlGroups.set(group, validIds); } - // Clear current selection and select control group - const entities = this.world.getEntitiesWith('Selectable'); - for (const entity of entities) { - const selectable = entity.get('Selectable')!; - selectable.deselect(); - } - - for (const entityId of validIds) { - const entity = this.world.getEntity(entityId); - if (entity) { - const selectable = entity.get('Selectable'); - if (selectable) { - selectable.select(); - } - } - } - - this.game.statePort.selectUnits(validIds); - this.game.eventBus.emit('selection:changed', { selectedIds: validIds }); + const playerId = this.currentPlayerId ?? this.game.config.playerId ?? 'player1'; + this.syncSelection(validIds, playerId); } private handleClearSelection(): void { - const entities = this.world.getEntitiesWith('Selectable'); - for (const entity of entities) { - const selectable = entity.get('Selectable')!; - selectable.deselect(); - } - - this.game.statePort.selectUnits([]); - this.game.eventBus.emit('selection:changed', { selectedIds: [] }); + const playerId = this.currentPlayerId ?? this.game.config.playerId ?? 'player1'; + this.syncSelection([], playerId); } public update(_deltaTime: number): void { // Auto-deselect dead units + const world = this.getWorldProvider(); const selectedUnits = this.game.statePort.getSelectedUnits(); let needsUpdate = false; const validSelectedIds: number[] = []; for (const entityId of selectedUnits) { - const entity = this.world.getEntity(entityId); - if (!entity || entity.isDestroyed()) { + const entity = world.getEntity(entityId); + if (!entity) { needsUpdate = true; continue; } - const health = entity.get('Health'); - if (health && health.isDead()) { - // Deselect dead unit - const selectable = entity.get('Selectable'); - if (selectable) { - selectable.deselect(); - } + const health = entity.get<{ isDead: () => boolean }>('Health'); + if (health?.isDead()) { needsUpdate = true; continue; } @@ -554,10 +508,10 @@ export class SelectionSystem extends System { validSelectedIds.push(entityId); } - // Update store if selection changed + // Update if selection changed if (needsUpdate) { - this.game.statePort.selectUnits(validSelectedIds); - this.game.eventBus.emit('selection:changed', { selectedIds: validSelectedIds }); + const playerId = this.currentPlayerId ?? this.game.config.playerId ?? 'player1'; + this.syncSelection(validSelectedIds, playerId); } } } diff --git a/src/phaser/scenes/OverlayScene.ts b/src/phaser/scenes/OverlayScene.ts index 9bc2d390..59bb8e4f 100644 --- a/src/phaser/scenes/OverlayScene.ts +++ b/src/phaser/scenes/OverlayScene.ts @@ -2,7 +2,7 @@ import * as Phaser from 'phaser'; import { debugAudio, debugPathfinding } from '@/utils/debugLogger'; import { EventBus } from '@/engine/core/EventBus'; import { Game } from '@/engine/core/Game'; -import { Transform } from '@/engine/components/Transform'; +import { RenderStateWorldAdapter } from '@/engine/workers/RenderStateAdapter'; import { useGameStore } from '@/store/gameStore'; import { useGameSetupStore, isLocalPlayer, getLocalPlayerId, enableSpectatorMode, isBattleSimulatorMode } from '@/store/gameSetupStore'; import { useProjectionStore } from '@/store/projectionStore'; @@ -1955,14 +1955,14 @@ export class OverlayScene extends Phaser.Scene { continue; } - // Get entity position from game world - const game = Game.getInstance(); - if (!game) continue; + // Get entity position from RenderStateWorldAdapter (has entity data from worker) + const worldAdapter = RenderStateWorldAdapter.getInstance(); + if (!worldAdapter) continue; let entityPos: { x: number; y: number } | null = null; - const entity = game.world.getEntity(indicator.entityId); + const entity = worldAdapter.getEntity(indicator.entityId); if (entity) { - const transform = entity.get('Transform'); + const transform = entity.get<{ x: number; y: number }>('Transform'); if (transform) { entityPos = { x: transform.x, y: transform.y }; }