diff --git a/src/components/game/CommandCard.tsx b/src/components/game/CommandCard.tsx deleted file mode 100644 index ab54cb4e..00000000 --- a/src/components/game/CommandCard.tsx +++ /dev/null @@ -1,1204 +0,0 @@ -'use client'; - -import { useGameStore } from '@/store/gameStore'; -import { getLocalPlayerId } from '@/store/gameSetupStore'; -import { Game } from '@/engine/core/Game'; -import { getRenderStateAdapter, getWorkerBridge } from '@/engine/workers'; -import { useEffect, useState, memo, useCallback } from 'react'; -import { UNIT_DEFINITIONS } from '@/data/units/dominion'; -import { BUILDING_DEFINITIONS, RESEARCH_MODULE_UNITS } from '@/data/buildings/dominion'; -import { WALL_DEFINITIONS } from '@/data/buildings/walls'; -import { RESEARCH_DEFINITIONS } from '@/data/research/dominion'; - -// Icon mappings for commands and units -const COMMAND_ICONS: Record = { - // Basic commands - move: '➤', - stop: '■', - hold: '⛊', - attack: '⚔', - patrol: '↻', - gather: '⛏', - repair: '🔧', - rally: '⚑', - build: '🔨', - build_basic: '🏗', - build_advanced: '🏭', - cancel: '✕', - demolish: '🗑', - back: '◀', - liftoff: '🚀', - land: '🛬', - // Units - fabricator: '🔧', - trooper: '🎖', - breacher: '💪', - vanguard: '💀', - operative: '👻', - scorcher: '🔥', - devastator: '🎯', - colossus: '⚡', - lifter: '✚', - valkyrie: '✈', - specter: '🦇', - dreadnought: '🚀', - overseer: '🦅', - // Buildings - headquarters: '🏛', - orbital_station: '🛰', - bastion: '🏰', - supply_cache: '📦', - extractor: '⛽', - infantry_bay: '🏠', - tech_center: '🔬', - garrison: '🏰', - forge: '🏭', - arsenal: '⚙', - hangar: '🛫', - power_core: '⚛', - ops_center: '🎓', - radar_array: '📡', - defense_turret: '🗼', - // Walls - wall: '🧱', - wall_segment: '🧱', - wall_gate: '🚪', - gate: '🚪', - // Gate commands - open: '📖', - close: '📕', - lock: '🔒', - unlock: '🔓', - auto: '🔄', - // Upgrades - stim: '💉', - combat: '🛡', - infantry: '⚔', - vehicle: '💥', - ship: '🚀', - siege: '🎯', - cloak: '👁', - // Abilities - mule: '🔧', - scanner_sweep: '📡', - supply_drop: '📦', - scanner: '📡', - power_cannon: '⚡', - warp_jump: '🌀', - // Transform modes - transform_fighter: '✈', - transform_assault: '⬇', - fighter: '✈', - assault: '⬇', - default: '◆', -}; - -function getIcon(id: string): string { - const lc = id.toLowerCase(); - if (COMMAND_ICONS[lc]) return COMMAND_ICONS[lc]; - for (const [key, icon] of Object.entries(COMMAND_ICONS)) { - if (lc.includes(key)) return icon; - } - return COMMAND_ICONS.default; -} - -/** - * Get attack type indicator text for unit tooltips - */ -function getAttackTypeText(unitDef: typeof UNIT_DEFINITIONS[string]): string { - if (!unitDef) return ''; - - const canAttackGround = unitDef.canAttackGround ?? (unitDef.attackDamage > 0); - const canAttackAir = unitDef.canAttackAir ?? false; - - if (!canAttackGround && !canAttackAir) { - return '⊘ No attack'; - } else if (canAttackGround && canAttackAir) { - return '⬡ Attacks: Ground & Air'; - } else if (canAttackGround) { - return '⬢ Attacks: Ground only'; - } else { - return '✈ Attacks: Air only'; - } -} - -interface CommandButton { - id: string; - label: string; - shortcut: string; - action: () => void; - isDisabled?: boolean; - tooltip?: string; - cost?: { minerals: number; plasma: number; supply?: number }; -} - -type MenuMode = 'main' | 'build_basic' | 'build_advanced' | 'build_walls'; - -// Basic structures (no tech requirements) -const BASIC_BUILDINGS = ['headquarters', 'supply_cache', 'extractor', 'infantry_bay', 'tech_center', 'garrison', 'defense_turret']; -// Advanced structures (tech requirements) -const ADVANCED_BUILDINGS = ['forge', 'arsenal', 'hangar', 'power_core', 'ops_center', 'radar_array']; -// Wall buildings -const WALL_BUILDINGS = ['wall_segment', 'wall_gate']; - -/** - * PERF: Memoized CommandCard component to prevent unnecessary re-renders - * Only re-renders when selectedUnits, resources, or menu state changes - */ -function CommandCardInner() { - // PERF: Use selector functions to minimize re-renders from Zustand - const selectedUnits = useGameStore((state) => state.selectedUnits); - const minerals = useGameStore((state) => state.minerals); - const plasma = useGameStore((state) => state.plasma); - const supply = useGameStore((state) => state.supply); - const maxSupply = useGameStore((state) => state.maxSupply); - const isBuilding = useGameStore((state) => state.isBuilding); - - const [commands, setCommands] = useState([]); - const [hoveredCmd, setHoveredCmd] = useState(null); - const [menuMode, setMenuMode] = useState('main'); - // Track building state changes to force re-render when buildings lift off/land - const [buildingStateVersion, setBuildingStateVersion] = useState(0); - - // Subscribe to building state change events to update command menu immediately - useEffect(() => { - const bridge = getWorkerBridge(); - if (!bridge) return; - - const handleBuildingStateChange = () => { - setBuildingStateVersion((v) => v + 1); - }; - - // Listen to all building flight state change events via worker bridge event bus - const unsub1 = bridge.eventBus.on('building:liftOffStart', handleBuildingStateChange); - const unsub2 = bridge.eventBus.on('building:liftOffComplete', handleBuildingStateChange); - const unsub3 = bridge.eventBus.on('building:landingStart', handleBuildingStateChange); - const unsub4 = bridge.eventBus.on('building:landingComplete', handleBuildingStateChange); - - return () => { - unsub1(); - unsub2(); - unsub3(); - unsub4(); - }; - }, []); - - // Reset menu when selection changes or building mode exits - useEffect(() => { - if (!isBuilding) { - // Don't reset menu mode when exiting building mode - let user stay in build menu - } - }, [isBuilding]); - - useEffect(() => { - // Reset to main menu when selection changes - // eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional reset on selection change - setMenuMode('main'); - }, [selectedUnits]); - - useEffect(() => { - const bridge = getWorkerBridge(); - const worldAdapter = getRenderStateAdapter(); - // Keep game reference for eventBus.emit (for now, until fully migrated) - const game = Game.getInstance(); - - if (!bridge || selectedUnits.length === 0) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional reset when no selection - setCommands([]); - return; - } - - const buttons: CommandButton[] = []; - const entity = worldAdapter.getEntity(selectedUnits[0]); - if (!entity) { - setCommands([]); - return; - } - - // Get components from adapter (worker mode) - const unit = entity.get<{ - unitId: string; - playerId: string; - state: string; - isWorker: boolean; - canRepair?: boolean; - canTransform?: boolean; - transformModes?: { id: string; name: string; isFlying?: boolean; transformTime?: number }[]; - currentMode?: string; - transformProgress?: number; - getCurrentMode?: () => { id: string; name: string }; - }>('Unit'); - const building = entity.get<{ - buildingId: string; - playerId: string; - state: string; - buildProgress: number; - width: number; - height: number; - isFlying?: boolean; - canProduce?: string[]; - canLiftOff?: boolean; - canUpgradeTo?: string[]; - canHaveAddon?: boolean; - productionQueue?: { id: string; type: string; progress: number; buildTime: number; supplyAllocated: boolean; produceCount?: number }[]; - isComplete?: () => boolean; - hasAddon?: () => boolean; - hasTechLab?: () => boolean; - }>('Building'); - - if (unit) { - if (menuMode === 'main') { - // Basic unit commands - buttons.push({ - id: 'move', - label: 'Move', - shortcut: 'M', - action: () => {}, - tooltip: 'Move to location (right-click)', - }); - - buttons.push({ - id: 'stop', - label: 'Stop', - shortcut: 'S', - action: () => { - const localPlayer = getLocalPlayerId(); - if (localPlayer) { - bridge.issueCommand({ - tick: bridge.currentTick, - playerId: localPlayer, - type: 'STOP', - entityIds: selectedUnits, - }); - } - }, - tooltip: 'Stop current action', - }); - - buttons.push({ - id: 'hold', - label: 'Hold', - shortcut: 'H', - action: () => { - const localPlayer = getLocalPlayerId(); - if (localPlayer) { - bridge.issueCommand({ - tick: bridge.currentTick, - playerId: localPlayer, - type: 'HOLD', - entityIds: selectedUnits, - }); - } - }, - tooltip: 'Hold position - do not move to attack', - }); - - buttons.push({ - id: 'attack', - label: 'Attack', - shortcut: 'A', - action: () => {}, - tooltip: 'Attack-move to location', - }); - - buttons.push({ - id: 'patrol', - label: 'Patrol', - shortcut: 'P', - action: () => {}, - tooltip: 'Patrol between points', - }); - - // Transform commands for units that can transform (e.g., Valkyrie) - if (unit.canTransform && unit.transformModes && unit.transformModes.length > 0) { - const _currentMode = unit.getCurrentMode?.(); - const isTransforming = unit.state === 'transforming'; - - // Add a button for each available transform mode (except current mode) - for (const mode of unit.transformModes) { - if (mode.id === unit.currentMode) continue; // Skip current mode - - // Determine icon and shortcut based on mode - const isAirMode = mode.isFlying === true; - const _icon = isAirMode ? '✈' : '⬇'; - const shortcut = isAirMode ? 'F' : 'E'; - - // Build tooltip with mode stats - let tooltip = `Transform to ${mode.name}`; - if (isAirMode) { - tooltip += ' - Flying, attacks air units only'; - } else { - tooltip += ' - Ground, attacks ground units only'; - } - tooltip += ` (${mode.transformTime}s)`; - - buttons.push({ - id: `transform_${mode.id}`, - label: mode.name.replace(' Mode', ''), - shortcut, - action: () => { - const localPlayer = getLocalPlayerId(); - if (localPlayer && bridge) { - bridge.issueCommand({ - tick: bridge.currentTick, - playerId: localPlayer, - type: 'TRANSFORM', - entityIds: selectedUnits, - abilityId: mode.id, // Use abilityId for transform mode - }); - } - }, - isDisabled: isTransforming, - tooltip: isTransforming - ? `Transforming... (${Math.round((unit.transformProgress ?? 0) * 100)}%)` - : tooltip, - }); - } - } - - // Unit abilities (e.g., Dreadnought Power Cannon, Warp Jump) - const abilityComponent = entity.get<{ - getAbilityList?: () => Array<{ definition: { id: string; name: string; hotkey: string; targetType: string; energyCost?: number; description?: string }; cooldownRemaining?: number; currentCooldown?: number }>; - canUseAbility?: (id: string) => boolean; - }>('Ability'); - if (abilityComponent && abilityComponent.getAbilityList) { - const abilities = abilityComponent.getAbilityList(); - for (const abilityState of abilities) { - const def = abilityState.definition; - const canUse = abilityComponent.canUseAbility?.(def.id) ?? true; - const energyCost = def.energyCost; - - buttons.push({ - id: `ability_${def.id}`, - label: def.name, - shortcut: def.hotkey, - action: () => { - if (def.targetType === 'point') { - // Point-targeted ability (e.g., Warp Jump) - useGameStore.getState().setAbilityTargetMode(def.id); - } else if (def.targetType === 'unit') { - // Unit-targeted ability (e.g., Power Cannon) - useGameStore.getState().setAbilityTargetMode(def.id); - } else { - // Instant cast (e.g., self-buff) - bridge.eventBus.emit('command:ability', { - entityIds: selectedUnits, - abilityId: def.id, - }); - } - }, - isDisabled: !canUse, - tooltip: (def.description ?? def.name) + ((abilityState.currentCooldown ?? 0) > 0 ? ` (CD: ${Math.ceil(abilityState.currentCooldown ?? 0)}s)` : ''), - cost: (energyCost ?? 0) > 0 ? { minerals: 0, plasma: 0, supply: energyCost ?? 0 } : undefined, - }); - } - } - - // Worker-specific commands - if (unit.isWorker) { - buttons.push({ - id: 'gather', - label: 'Gather', - shortcut: 'G', - action: () => {}, - tooltip: 'Gather resources (right-click on minerals/gas)', - }); - - // Repair command for workers - if (unit.canRepair) { - buttons.push({ - id: 'repair', - label: 'Repair', - shortcut: 'R', - action: () => { - useGameStore.getState().setRepairMode(true); - }, - tooltip: 'Repair buildings and mechanical units (right-click on damaged target)', - }); - } - - // Build Basic submenu button - buttons.push({ - id: 'build_basic', - label: 'Build Basic', - shortcut: 'B', - action: () => setMenuMode('build_basic'), - tooltip: 'Build basic structures', - }); - - // Build Advanced submenu button - buttons.push({ - id: 'build_advanced', - label: 'Advanced', - shortcut: 'V', - action: () => setMenuMode('build_advanced'), - tooltip: 'Build advanced structures', - }); - - // Build Walls submenu button - buttons.push({ - id: 'build_walls', - label: 'Build Walls', - shortcut: 'W', - action: () => setMenuMode('build_walls'), - tooltip: 'Build walls and gates (click+drag for lines)', - }); - } - } else if (menuMode === 'build_basic' || menuMode === 'build_advanced' || menuMode === 'build_walls') { - // Back button - buttons.push({ - id: 'back', - label: 'Back', - shortcut: 'ESC', - action: () => setMenuMode('main'), - tooltip: 'Return to main commands', - }); - - // Building buttons for the selected category - const buildingList = menuMode === 'build_basic' - ? BASIC_BUILDINGS - : menuMode === 'build_advanced' - ? ADVANCED_BUILDINGS - : WALL_BUILDINGS; - - // Helper to check if player has completed required buildings - const checkRequirementsMet = (requirements: string[] | undefined): { met: boolean; missing: string[] } => { - if (!requirements || requirements.length === 0) { - return { met: true, missing: [] }; - } - - const localPlayerId = getLocalPlayerId(); - if (!localPlayerId) return { met: false, missing: requirements }; - - const playerBuildings = worldAdapter.getEntitiesWith('Building', 'Selectable'); - const missing: string[] = []; - - for (const reqBuildingId of requirements) { - let found = false; - for (const buildingEntity of playerBuildings) { - const b = buildingEntity.get<{ buildingId: string; state: string; isComplete?: () => boolean }>('Building'); - const sel = buildingEntity.get<{ playerId: string }>('Selectable'); - const isComplete = b?.isComplete?.() ?? b?.state === 'complete'; - if (sel?.playerId === localPlayerId && b?.buildingId === reqBuildingId && isComplete) { - found = true; - break; - } - } - if (!found) { - missing.push(BUILDING_DEFINITIONS[reqBuildingId]?.name || reqBuildingId); - } - } - - return { met: missing.length === 0, missing }; - }; - - buildingList.forEach((buildingId) => { - // Check both building and wall definitions - const def = BUILDING_DEFINITIONS[buildingId] || WALL_DEFINITIONS[buildingId]; - if (!def) return; - - // Check tech requirements against actual player buildings - const reqCheck = checkRequirementsMet(def.requirements); - const requirementsMet = reqCheck.met; - const reqText = reqCheck.missing.length > 0 ? `Requires: ${reqCheck.missing.join(', ')}` : ''; - - const canAfford = minerals >= def.mineralCost && plasma >= def.plasmaCost; - - // Build tooltip with description and requirements - let tooltip = def.description || `Build ${def.name}`; - if (reqText) { - tooltip += ` (${reqText})`; - } - - // Check if this is a wall building - const isWall = 'isWall' in def && def.isWall; - if (isWall) { - tooltip += ' (Click+drag to draw wall line)'; - } - - buttons.push({ - id: `build_${buildingId}`, - label: def.name, - shortcut: def.name.charAt(0).toUpperCase(), - action: () => { - if (requirementsMet) { - // Use wall placement mode for walls, regular mode for other buildings - if (isWall) { - useGameStore.getState().setWallPlacementMode(true, buildingId); - } else { - useGameStore.getState().setBuildingMode(buildingId); - } - } - }, - isDisabled: !canAfford || !requirementsMet, - tooltip, - cost: { minerals: def.mineralCost, plasma: def.plasmaCost }, - }); - }); - } - } else if (building && !(building.isComplete?.() ?? building.state === 'complete') && building.state !== 'destroyed' && building.state !== 'flying' && building.state !== 'lifting' && building.state !== 'landing') { - // Building under construction - show cancel/demolish button - buttons.push({ - id: 'demolish', - label: 'Cancel', - shortcut: 'ESC', - action: () => { - const localPlayer = getLocalPlayerId(); - if (localPlayer && bridge) { - bridge.issueCommand({ - tick: bridge.currentTick, - playerId: localPlayer, - type: 'DEMOLISH', - entityIds: selectedUnits, - }); - } - }, - tooltip: 'Cancel construction (refunds 75% of resources spent)', - }); - } else if (building && ((building.isComplete?.() ?? building.state === 'complete') || building.state === 'flying' || building.state === 'lifting' || building.state === 'landing')) { - // Show commands for complete OR flying buildings - const isFlying = building.state === 'flying' || building.state === 'lifting' || building.state === 'landing'; - - // Check if this is a wall or gate - const wall = entity.get<{ isWall?: boolean; isGate?: boolean; gateOpenProgress?: number; gateState?: string; appliedUpgrade?: string; upgradeInProgress?: boolean; mountedTurretId?: string }>('Wall'); - if (wall && !isFlying) { - // Gate commands - if (wall.isGate) { - // Open/Close toggle - buttons.push({ - id: 'gate_toggle', - label: (wall.gateOpenProgress ?? 0) > 0.5 ? 'Close' : 'Open', - shortcut: 'O', - action: () => { - bridge.eventBus.emit('command:gate_toggle', { - entityIds: selectedUnits, - }); - }, - tooltip: (wall.gateOpenProgress ?? 0) > 0.5 ? 'Close the gate' : 'Open the gate', - }); - - // Lock toggle - buttons.push({ - id: 'gate_lock', - label: wall.gateState === 'locked' ? 'Unlock' : 'Lock', - shortcut: 'L', - action: () => { - bridge.eventBus.emit('command:gate_lock', { - entityIds: selectedUnits, - }); - }, - tooltip: wall.gateState === 'locked' ? 'Unlock the gate' : 'Lock the gate (prevents opening)', - }); - - // Auto mode - if (wall.gateState !== 'auto') { - buttons.push({ - id: 'gate_auto', - label: 'Auto', - shortcut: 'A', - action: () => { - bridge.eventBus.emit('command:gate_auto', { - entityIds: selectedUnits, - }); - }, - tooltip: 'Set gate to auto-open for friendly units', - }); - } - } - - // Wall upgrade buttons (if upgrades are researched) - const store = useGameStore.getState(); - const localPlayer = getLocalPlayerId() ?? 'player1'; - - if (!wall.appliedUpgrade && wall.upgradeInProgress === null) { - // Reinforced upgrade - if (store.hasResearch(localPlayer, 'wall_reinforced')) { - buttons.push({ - id: 'wall_upgrade_reinforced', - label: 'Reinforce', - shortcut: 'R', - action: () => { - bridge.eventBus.emit('command:wall_upgrade', { - entityIds: selectedUnits, - upgradeType: 'reinforced', - }); - }, - tooltip: 'Reinforce wall: +400 HP, +2 armor', - cost: { minerals: 25, plasma: 0 }, - }); - } - - // Shield upgrade - if (store.hasResearch(localPlayer, 'wall_shielded')) { - buttons.push({ - id: 'wall_upgrade_shielded', - label: 'Shield', - shortcut: 'S', - action: () => { - bridge.eventBus.emit('command:wall_upgrade', { - entityIds: selectedUnits, - upgradeType: 'shielded', - }); - }, - tooltip: 'Add shield: +200 regenerating shield', - cost: { minerals: 50, plasma: 25 }, - }); - } - - // Weapon upgrade (if no turret mounted) - if (store.hasResearch(localPlayer, 'wall_weapon') && wall.mountedTurretId === null) { - buttons.push({ - id: 'wall_upgrade_weapon', - label: 'Weapon', - shortcut: 'W', - action: () => { - bridge.eventBus.emit('command:wall_upgrade', { - entityIds: selectedUnits, - upgradeType: 'weapon', - }); - }, - tooltip: 'Add auto-turret: 5 damage, 6 range', - cost: { minerals: 40, plasma: 25 }, - }); - } - } - } - - // Get tech-gated units for this building - const techUnits = RESEARCH_MODULE_UNITS[building.buildingId] || []; - const hasTechLab = (building.hasAddon?.() ?? false) && (building.hasTechLab?.() ?? false); - - // Building commands - train units (basic units from canProduce) - // Skip training when building is flying - if (!isFlying && building.canProduce) { - building.canProduce.forEach((unitId) => { - const unitDef = UNIT_DEFINITIONS[unitId]; - if (!unitDef) return; - - const canAfford = minerals >= unitDef.mineralCost && plasma >= unitDef.plasmaCost; - const hasSupply = supply + unitDef.supplyCost <= maxSupply; - const attackTypeText = getAttackTypeText(unitDef); - - buttons.push({ - id: `train_${unitId}`, - label: unitDef.name, - shortcut: unitDef.name.charAt(0).toUpperCase(), - action: () => { - // Play supply alert if queuing while supply blocked (unit will still queue) - if (!hasSupply) { - bridge.eventBus.emit('alert:supplyBlocked', {}); - } - bridge.eventBus.emit('command:train', { - entityIds: selectedUnits, - unitType: unitId, - }); - }, - isDisabled: !canAfford, - tooltip: (unitDef.description || `Train ${unitDef.name}`) + ` [${attackTypeText}]` + (!hasSupply ? ' (Need more supply)' : ''), - cost: { minerals: unitDef.mineralCost, plasma: unitDef.plasmaCost, supply: unitDef.supplyCost }, - }); - }); - } - - // Skip training, research, and upgrades when building is flying - if (!isFlying) { - // Tech-gated units (from Research Module) - techUnits.forEach((unitId) => { - const unitDef = UNIT_DEFINITIONS[unitId]; - if (!unitDef) return; - - const canAfford = minerals >= unitDef.mineralCost && plasma >= unitDef.plasmaCost; - const hasSupply = supply + unitDef.supplyCost <= maxSupply; - const canTrain = hasTechLab && canAfford; - const attackTypeText = getAttackTypeText(unitDef); - - // Build tooltip with description and requirements info - let tooltipText = (unitDef.description || `Train ${unitDef.name}`) + ` [${attackTypeText}]`; - if (!hasTechLab) { - tooltipText += ' - Requires Research Module'; - } else if (!hasSupply) { - tooltipText += ' (Need more supply)'; - } - - buttons.push({ - id: `train_${unitId}`, - label: unitDef.name, - shortcut: unitDef.name.charAt(0).toUpperCase(), - action: () => { - if (hasTechLab) { - // Play supply alert if queuing while supply blocked (unit will still queue) - if (!hasSupply) { - bridge.eventBus.emit('alert:supplyBlocked', {}); - } - bridge.eventBus.emit('command:train', { - entityIds: selectedUnits, - unitType: unitId, - }); - } - }, - isDisabled: !canTrain, - tooltip: tooltipText, - cost: { minerals: unitDef.mineralCost, plasma: unitDef.plasmaCost, supply: unitDef.supplyCost }, - }); - }); - - // Build Research Module addon button (if building supports addons and doesn't have one) - if (building.canHaveAddon && !(building.hasAddon?.() ?? false)) { - const moduleDef = BUILDING_DEFINITIONS['research_module']; - if (moduleDef) { - const canAffordModule = minerals >= moduleDef.mineralCost && plasma >= moduleDef.plasmaCost; - const localPlayer = getLocalPlayerId(); - buttons.push({ - id: 'build_research_module', - label: 'Tech Lab', - shortcut: 'T', - action: () => { - // Get fresh bridge instance and selected units to avoid stale closure - const currentBridge = getWorkerBridge(); - const currentSelectedUnits = useGameStore.getState().selectedUnits; - if (currentBridge && currentSelectedUnits.length > 0) { - currentBridge.eventBus.emit('building:build_addon', { - buildingId: currentSelectedUnits[0], - addonType: 'research_module', - playerId: localPlayer, - }); - } - }, - isDisabled: !canAffordModule, - tooltip: moduleDef.description || 'Addon that unlocks advanced units and research.', - cost: { minerals: moduleDef.mineralCost, plasma: moduleDef.plasmaCost }, - }); - } - - // Build Production Module (Reactor) addon button - const reactorDef = BUILDING_DEFINITIONS['production_module']; - if (reactorDef) { - const canAffordReactor = minerals >= reactorDef.mineralCost && plasma >= reactorDef.plasmaCost; - const localPlayer = getLocalPlayerId(); - buttons.push({ - id: 'build_production_module', - label: 'Reactor', - shortcut: 'C', - action: () => { - const currentGame = Game.getInstance(); - const currentSelectedUnits = useGameStore.getState().selectedUnits; - if (currentGame && currentSelectedUnits.length > 0) { - currentGame.eventBus.emit('building:build_addon', { - buildingId: currentSelectedUnits[0], - addonType: 'production_module', - playerId: localPlayer, - }); - } - }, - isDisabled: !canAffordReactor, - tooltip: reactorDef.description || 'Addon that enables double production of basic units.', - cost: { minerals: reactorDef.mineralCost, plasma: reactorDef.plasmaCost }, - }); - } - } - - // Research commands - const store = useGameStore.getState(); - const researchMap: Record = { - tech_center: ['infantry_weapons_1', 'infantry_armor_1'], - arsenal: ['vehicle_weapons_1', 'vehicle_armor_1'], - infantry_bay: ['combat_stim', 'combat_shield'], - forge: ['bombardment_systems'], - hangar: ['cloaking_field'], - }; - - const availableResearch = researchMap[building.buildingId] || []; - const localPlayerForResearch = getLocalPlayerId() ?? 'player1'; - availableResearch.forEach((upgradeId) => { - const upgrade = RESEARCH_DEFINITIONS[upgradeId]; - if (!upgrade) return; - - const isResearched = store.hasResearch(localPlayerForResearch, upgradeId); - if (isResearched) return; - - let reqMet = true; - if (upgrade.requirements) { - for (const req of upgrade.requirements) { - if (RESEARCH_DEFINITIONS[req] && !store.hasResearch(localPlayerForResearch, req)) { - reqMet = false; - break; - } - } - } - - const isResearching = (building.productionQueue ?? []).some( - (item) => item.type === 'upgrade' && item.id === upgradeId - ); - - buttons.push({ - id: `research_${upgradeId}`, - label: upgrade.name, - shortcut: upgrade.name.charAt(0).toUpperCase(), - action: () => { - bridge.eventBus.emit('command:research', { - entityIds: selectedUnits, - upgradeId, - }); - }, - isDisabled: minerals < upgrade.mineralCost || plasma < upgrade.plasmaCost || !reqMet || isResearching, - tooltip: upgrade.description + (isResearching ? ' (In progress)' : ''), - cost: { minerals: upgrade.mineralCost, plasma: upgrade.plasmaCost }, - }); - }); - - // Building upgrade buttons (e.g., CC -> Orbital/Planetary) - if (building.canUpgradeTo && building.canUpgradeTo.length > 0) { - const isUpgrading = (building.productionQueue ?? []).some( - (item) => item.type === 'upgrade' && (building.canUpgradeTo ?? []).includes(item.id) - ); - - building.canUpgradeTo.forEach((upgradeBuildingId) => { - const upgradeDef = BUILDING_DEFINITIONS[upgradeBuildingId]; - if (!upgradeDef) return; - - const canAfford = minerals >= upgradeDef.mineralCost && plasma >= upgradeDef.plasmaCost; - - // Create shortcut from first letter of last word (O for Orbital, P for Planetary) - const words = upgradeDef.name.split(' '); - const shortcut = words[words.length - 1].charAt(0).toUpperCase(); - - buttons.push({ - id: `upgrade_${upgradeBuildingId}`, - label: upgradeDef.name, - shortcut, - action: () => { - bridge.eventBus.emit('command:upgrade_building', { - entityIds: selectedUnits, - upgradeTo: upgradeBuildingId, - }); - }, - isDisabled: !canAfford || isUpgrading, - tooltip: (upgradeDef.description || `Upgrade to ${upgradeDef.name}`) + (isUpgrading ? ' (Upgrading...)' : ''), - cost: { minerals: upgradeDef.mineralCost, plasma: upgradeDef.plasmaCost }, - }); - }); - } - - // Rally point for production buildings - if ((building.canProduce ?? []).length > 0) { - buttons.push({ - id: 'rally', - label: 'Rally', - shortcut: 'R', - action: () => { - useGameStore.getState().setRallyPointMode(true); - }, - tooltip: 'Set rally point for new units', - }); - } - - // Demolish button for complete buildings (salvage) - buttons.push({ - id: 'demolish', - label: 'Demolish', - shortcut: 'DEL', - action: () => { - const localPlayer = getLocalPlayerId(); - if (localPlayer) { - bridge.issueCommand({ - tick: bridge.currentTick, - playerId: localPlayer, - type: 'DEMOLISH', - entityIds: selectedUnits, - }); - } - }, - tooltip: 'Demolish building (refunds 50% of resources)', - }); - } - - // Lift-off button for buildings that can fly (and are complete, not flying) - if (building.canLiftOff && building.state === 'complete' && !building.isFlying) { - const hasQueue = (building.productionQueue ?? []).length > 0; - buttons.push({ - id: 'liftoff', - label: 'Lift Off', - shortcut: 'L', - action: () => { - if (!hasQueue) { - const localPlayer = getLocalPlayerId(); - if (localPlayer) { - game.issueCommand({ - tick: game.getCurrentTick(), - playerId: localPlayer, - type: 'LIFTOFF', - entityIds: [selectedUnits[0]], - buildingId: selectedUnits[0], - }); - } - } - }, - isDisabled: hasQueue, - tooltip: hasQueue ? 'Cannot lift off while producing' : 'Lift off to relocate building', - }); - } - - // Land button for flying buildings - if (building.canLiftOff && building.isFlying && building.state === 'flying') { - buttons.push({ - id: 'land', - label: 'Land', - shortcut: 'L', - action: () => { - useGameStore.getState().setLandingMode(true, selectedUnits[0]); - }, - tooltip: 'Click to choose landing location', - }); - } - - // Building abilities (e.g., Orbital Command abilities) - const buildingAbilityComponent = entity.get<{ - getAbilityList?: () => Array<{ definition: { id: string; name: string; hotkey: string; targetType: string; energyCost?: number; description?: string }; cooldownRemaining?: number; currentCooldown?: number }>; - canUseAbility?: (id: string) => boolean; - }>('Ability'); - if (buildingAbilityComponent && buildingAbilityComponent.getAbilityList) { - const abilities = buildingAbilityComponent.getAbilityList(); - for (const abilityState of abilities) { - const def = abilityState.definition; - const canUse = buildingAbilityComponent.canUseAbility?.(def.id) ?? true; - const energyCost = def.energyCost; - - buttons.push({ - id: `ability_${def.id}`, - label: def.name, - shortcut: def.hotkey, - action: () => { - if (def.targetType === 'point') { - // Need to enable targeting mode for point abilities - useGameStore.getState().setAbilityTargetMode(def.id); - } else if (def.targetType === 'unit') { - // Need to enable unit targeting mode - useGameStore.getState().setAbilityTargetMode(def.id); - } else { - // Instant cast - bridge.eventBus.emit('command:ability', { - entityIds: selectedUnits, - abilityId: def.id, - }); - } - }, - isDisabled: !canUse, - tooltip: (def.description ?? def.name) + ((abilityState.currentCooldown ?? 0) > 0 ? ` (CD: ${Math.ceil(abilityState.currentCooldown ?? 0)}s)` : ''), - cost: (energyCost ?? 0) > 0 ? { minerals: 0, plasma: 0, supply: energyCost ?? 0 } : undefined, - }); - } - } - } - - setCommands(buttons.slice(0, 12)); // Max 4x3 grid - }, [selectedUnits, minerals, plasma, supply, maxSupply, menuMode, buildingStateVersion]); - - // Handle ESC to go back in menus and building shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Don't handle if typing in an input field - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - return; - } - - if (e.key === 'Escape' && menuMode !== 'main') { - e.stopPropagation(); - setMenuMode('main'); - return; - } - // Hotkey B for Build Basic - if (e.key.toLowerCase() === 'b' && menuMode === 'main') { - const worldAdapter = getRenderStateAdapter(); - if (selectedUnits.length > 0) { - const entity = worldAdapter.getEntity(selectedUnits[0]); - const unit = entity?.get<{ isWorker: boolean }>('Unit'); - if (unit?.isWorker) { - setMenuMode('build_basic'); - return; - } - } - } - // Hotkey V for Build Advanced - if (e.key.toLowerCase() === 'v' && menuMode === 'main') { - const worldAdapter = getRenderStateAdapter(); - if (selectedUnits.length > 0) { - const entity = worldAdapter.getEntity(selectedUnits[0]); - const unit = entity?.get<{ isWorker: boolean }>('Unit'); - if (unit?.isWorker) { - setMenuMode('build_advanced'); - return; - } - } - } - // Hotkey W for Build Walls - if (e.key.toLowerCase() === 'w' && menuMode === 'main') { - const worldAdapter = getRenderStateAdapter(); - if (selectedUnits.length > 0) { - const entity = worldAdapter.getEntity(selectedUnits[0]); - const unit = entity?.get<{ isWorker: boolean }>('Unit'); - if (unit?.isWorker) { - setMenuMode('build_walls'); - return; - } - } - } - - // Handle building shortcuts when in build submenus - if (menuMode === 'build_basic' || menuMode === 'build_advanced' || menuMode === 'build_walls') { - const pressedKey = e.key.toUpperCase(); - // Find a matching command by shortcut (skip the "back" button) - const matchingCommand = commands.find( - (cmd) => cmd.shortcut === pressedKey && cmd.id !== 'back' && !cmd.isDisabled - ); - if (matchingCommand) { - e.preventDefault(); - e.stopPropagation(); - matchingCommand.action(); - } - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [menuMode, selectedUnits, commands]); - - // Handle clicks on disabled buttons to play resource alerts - // Must be defined before early return to satisfy React hooks rules - const handleDisabledClick = useCallback((cmd: CommandButton) => { - if (!cmd.isDisabled || !cmd.cost) return; - - const bridge = getWorkerBridge(); - if (!bridge) return; - - // Check which resource is insufficient and emit appropriate alert - if (cmd.cost.minerals > 0 && minerals < cmd.cost.minerals) { - bridge.eventBus.emit('alert:notEnoughMinerals', {}); - } else if (cmd.cost.plasma > 0 && plasma < cmd.cost.plasma) { - bridge.eventBus.emit('alert:notEnoughPlasma', {}); - } else if (cmd.cost.supply && cmd.cost.supply > 0 && supply + cmd.cost.supply > maxSupply) { - bridge.eventBus.emit('alert:supplyBlocked', {}); - } - }, [minerals, plasma, supply, maxSupply]); - - if (commands.length === 0) { - return ( -
- Select units or buildings -
- ); - } - - const hoveredCommand = commands.find((c) => c.id === hoveredCmd); - - return ( -
- {/* Menu title when in submenu */} - {menuMode !== 'main' && ( -
- {menuMode === 'build_basic' ? '🏗 Basic Structures' : menuMode === 'build_advanced' ? '🏭 Advanced Structures' : '🧱 Walls & Gates'} -
- )} - - {/* Command grid - 4 columns, 3 rows */} -
-
- {commands.map((cmd) => ( -
setHoveredCmd(cmd.id)} - onMouseLeave={() => setHoveredCmd(null)} - onClick={() => cmd.isDisabled && handleDisabledClick(cmd)} - > - -
- ))} - - {/* Empty slots */} - {Array.from({ length: Math.max(0, 12 - commands.length) }).map((_, i) => ( -
- ))} -
-
- - {/* Tooltip */} - {hoveredCommand && ( -
-
- {/* Header */} -
- {getIcon(hoveredCommand.id)} - {hoveredCommand.label} - [ {hoveredCommand.shortcut} ] -
- - {/* Description */} - {hoveredCommand.tooltip && ( -

{hoveredCommand.tooltip}

- )} - - {/* Cost */} - {hoveredCommand.cost && ( -
- - 💎 - {hoveredCommand.cost.minerals} - - {hoveredCommand.cost.plasma > 0 && ( - - 💚 - {hoveredCommand.cost.plasma} - - )} - {hoveredCommand.cost.supply && hoveredCommand.cost.supply > 0 && ( - maxSupply ? 'text-red-400' : 'text-yellow-300'}`}> - 👤 - {hoveredCommand.cost.supply} - - )} -
- )} -
-
- )} -
- ); -} - -// PERF: Export memoized component to prevent unnecessary re-renders -export const CommandCard = memo(CommandCardInner); diff --git a/src/components/game/CommandCard/CommandGrid.tsx b/src/components/game/CommandCard/CommandGrid.tsx new file mode 100644 index 00000000..d4241f21 --- /dev/null +++ b/src/components/game/CommandCard/CommandGrid.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { memo, useCallback } from 'react'; +import { getWorkerBridge } from '@/engine/workers'; +import { CommandButton } from '@/components/ui/CommandButton'; +import { EmptySlot } from '@/components/ui/EmptySlot'; +import { getCommandIcon } from '@/utils/commandIcons'; +import { CommandButtonData } from './types'; + +interface CommandGridProps { + commands: CommandButtonData[]; + minerals: number; + plasma: number; + supply: number; + maxSupply: number; + hoveredCmd: string | null; + setHoveredCmd: (id: string | null) => void; +} + +/** + * Grid layout for command buttons (4 columns, 3 rows). + */ +function CommandGridInner({ + commands, + minerals, + plasma, + supply, + maxSupply, + hoveredCmd, + setHoveredCmd, +}: CommandGridProps) { + // Handle clicks on disabled buttons to play resource alerts + const handleDisabledClick = useCallback( + (cmd: CommandButtonData) => { + if (!cmd.isDisabled || !cmd.cost) return; + + const bridge = getWorkerBridge(); + if (!bridge) return; + + if (cmd.cost.minerals > 0 && minerals < cmd.cost.minerals) { + bridge.eventBus.emit('alert:notEnoughMinerals', {}); + } else if (cmd.cost.plasma > 0 && plasma < cmd.cost.plasma) { + bridge.eventBus.emit('alert:notEnoughPlasma', {}); + } else if (cmd.cost.supply && cmd.cost.supply > 0 && supply + cmd.cost.supply > maxSupply) { + bridge.eventBus.emit('alert:supplyBlocked', {}); + } + }, + [minerals, plasma, supply, maxSupply] + ); + + return ( +
+ {commands.map((cmd) => ( +
setHoveredCmd(cmd.id)} + onMouseLeave={() => setHoveredCmd(null)} + > + handleDisabledClick(cmd)} + isDisabled={cmd.isDisabled} + hasCost={!!cmd.cost} + isBackButton={cmd.id === 'back'} + /> +
+ ))} + + {/* Empty slots */} + {Array.from({ length: Math.max(0, 12 - commands.length) }).map((_, i) => ( + + ))} +
+ ); +} + +export const CommandGrid = memo(CommandGridInner); diff --git a/src/components/game/CommandCard/constants.ts b/src/components/game/CommandCard/constants.ts new file mode 100644 index 00000000..43035141 --- /dev/null +++ b/src/components/game/CommandCard/constants.ts @@ -0,0 +1,40 @@ +/** + * Basic structures (no tech requirements) + */ +export const BASIC_BUILDINGS = [ + 'headquarters', + 'supply_cache', + 'extractor', + 'infantry_bay', + 'tech_center', + 'garrison', + 'defense_turret', +]; + +/** + * Advanced structures (tech requirements) + */ +export const ADVANCED_BUILDINGS = [ + 'forge', + 'arsenal', + 'hangar', + 'power_core', + 'ops_center', + 'radar_array', +]; + +/** + * Wall buildings + */ +export const WALL_BUILDINGS = ['wall_segment', 'wall_gate']; + +/** + * Research available at each building type. + */ +export const BUILDING_RESEARCH_MAP: Record = { + tech_center: ['infantry_weapons_1', 'infantry_armor_1'], + arsenal: ['vehicle_weapons_1', 'vehicle_armor_1'], + infantry_bay: ['combat_stim', 'combat_shield'], + forge: ['bombardment_systems'], + hangar: ['cloaking_field'], +}; diff --git a/src/components/game/CommandCard/hooks/index.ts b/src/components/game/CommandCard/hooks/index.ts new file mode 100644 index 00000000..f6fcb928 --- /dev/null +++ b/src/components/game/CommandCard/hooks/index.ts @@ -0,0 +1,3 @@ +export { useUnitCommands } from './useUnitCommands'; +export { useBuildingCommands } from './useBuildingCommands'; +export { useCommandKeyboard } from './useCommandKeyboard'; diff --git a/src/components/game/CommandCard/hooks/useBuildingCommands.ts b/src/components/game/CommandCard/hooks/useBuildingCommands.ts new file mode 100644 index 00000000..fc29f7ff --- /dev/null +++ b/src/components/game/CommandCard/hooks/useBuildingCommands.ts @@ -0,0 +1,523 @@ +import { useGameStore } from '@/store/gameStore'; +import { getLocalPlayerId } from '@/store/gameSetupStore'; +import { Game } from '@/engine/core/Game'; +import { getWorkerBridge, getRenderStateAdapter } from '@/engine/workers'; +import { UNIT_DEFINITIONS } from '@/data/units/dominion'; +import { BUILDING_DEFINITIONS, RESEARCH_MODULE_UNITS } from '@/data/buildings/dominion'; +import { RESEARCH_DEFINITIONS } from '@/data/research/dominion'; +import { getAttackTypeText } from '@/utils/commandIcons'; +import { CommandButtonData } from '../types'; +import { BUILDING_RESEARCH_MAP } from '../constants'; + +interface UseBuildingCommandsParams { + selectedUnits: number[]; + minerals: number; + plasma: number; + supply: number; + maxSupply: number; +} + +/** + * Generate commands for selected buildings. + */ +export function useBuildingCommands({ + selectedUnits, + minerals, + plasma, + supply, + maxSupply, +}: UseBuildingCommandsParams): CommandButtonData[] { + const bridge = getWorkerBridge(); + const worldAdapter = getRenderStateAdapter(); + const game = Game.getInstance(); + + if (!bridge || selectedUnits.length === 0) return []; + + const entity = worldAdapter.getEntity(selectedUnits[0]); + if (!entity) return []; + + const building = entity.get<{ + buildingId: string; + playerId: string; + state: string; + buildProgress: number; + width: number; + height: number; + isFlying?: boolean; + canProduce?: string[]; + canLiftOff?: boolean; + canUpgradeTo?: string[]; + canHaveAddon?: boolean; + productionQueue?: { + id: string; + type: string; + progress: number; + buildTime: number; + supplyAllocated: boolean; + produceCount?: number; + }[]; + isComplete?: () => boolean; + hasAddon?: () => boolean; + hasTechLab?: () => boolean; + }>('Building'); + + if (!building) return []; + + const buttons: CommandButtonData[] = []; + const isComplete = building.isComplete?.() ?? building.state === 'complete'; + const isFlying = + building.state === 'flying' || building.state === 'lifting' || building.state === 'landing'; + + // Building under construction + if (!isComplete && building.state !== 'destroyed' && !isFlying) { + buttons.push({ + id: 'demolish', + label: 'Cancel', + shortcut: 'ESC', + action: () => { + const localPlayer = getLocalPlayerId(); + if (localPlayer && bridge) { + bridge.issueCommand({ + tick: bridge.currentTick, + playerId: localPlayer, + type: 'DEMOLISH', + entityIds: selectedUnits, + }); + } + }, + tooltip: 'Cancel construction (refunds 75% of resources spent)', + }); + return buttons; + } + + // Complete or flying buildings + if (!isComplete && !isFlying) return []; + + // Wall/gate commands + const wall = entity.get<{ + isWall?: boolean; + isGate?: boolean; + gateOpenProgress?: number; + gateState?: string; + appliedUpgrade?: string; + upgradeInProgress?: boolean; + mountedTurretId?: string; + }>('Wall'); + + if (wall && !isFlying) { + if (wall.isGate) { + buttons.push({ + id: 'gate_toggle', + label: (wall.gateOpenProgress ?? 0) > 0.5 ? 'Close' : 'Open', + shortcut: 'O', + action: () => { + bridge.eventBus.emit('command:gate_toggle', { entityIds: selectedUnits }); + }, + tooltip: (wall.gateOpenProgress ?? 0) > 0.5 ? 'Close the gate' : 'Open the gate', + }); + + buttons.push({ + id: 'gate_lock', + label: wall.gateState === 'locked' ? 'Unlock' : 'Lock', + shortcut: 'L', + action: () => { + bridge.eventBus.emit('command:gate_lock', { entityIds: selectedUnits }); + }, + tooltip: wall.gateState === 'locked' ? 'Unlock the gate' : 'Lock the gate (prevents opening)', + }); + + if (wall.gateState !== 'auto') { + buttons.push({ + id: 'gate_auto', + label: 'Auto', + shortcut: 'A', + action: () => { + bridge.eventBus.emit('command:gate_auto', { entityIds: selectedUnits }); + }, + tooltip: 'Set gate to auto-open for friendly units', + }); + } + } + + // Wall upgrades + const store = useGameStore.getState(); + const localPlayer = getLocalPlayerId() ?? 'player1'; + + if (!wall.appliedUpgrade && wall.upgradeInProgress === null) { + if (store.hasResearch(localPlayer, 'wall_reinforced')) { + buttons.push({ + id: 'wall_upgrade_reinforced', + label: 'Reinforce', + shortcut: 'R', + action: () => { + bridge.eventBus.emit('command:wall_upgrade', { + entityIds: selectedUnits, + upgradeType: 'reinforced', + }); + }, + tooltip: 'Reinforce wall: +400 HP, +2 armor', + cost: { minerals: 25, plasma: 0 }, + }); + } + + if (store.hasResearch(localPlayer, 'wall_shielded')) { + buttons.push({ + id: 'wall_upgrade_shielded', + label: 'Shield', + shortcut: 'S', + action: () => { + bridge.eventBus.emit('command:wall_upgrade', { + entityIds: selectedUnits, + upgradeType: 'shielded', + }); + }, + tooltip: 'Add shield: +200 regenerating shield', + cost: { minerals: 50, plasma: 25 }, + }); + } + + if (store.hasResearch(localPlayer, 'wall_weapon') && wall.mountedTurretId === null) { + buttons.push({ + id: 'wall_upgrade_weapon', + label: 'Weapon', + shortcut: 'W', + action: () => { + bridge.eventBus.emit('command:wall_upgrade', { + entityIds: selectedUnits, + upgradeType: 'weapon', + }); + }, + tooltip: 'Add auto-turret: 5 damage, 6 range', + cost: { minerals: 40, plasma: 25 }, + }); + } + } + } + + // Tech-gated units + const techUnits = RESEARCH_MODULE_UNITS[building.buildingId] || []; + const hasTechLab = (building.hasAddon?.() ?? false) && (building.hasTechLab?.() ?? false); + + // Training commands (skip when flying) + if (!isFlying && building.canProduce) { + building.canProduce.forEach((unitId) => { + const unitDef = UNIT_DEFINITIONS[unitId]; + if (!unitDef) return; + + const canAfford = minerals >= unitDef.mineralCost && plasma >= unitDef.plasmaCost; + const hasSupply = supply + unitDef.supplyCost <= maxSupply; + const attackTypeText = getAttackTypeText(unitDef); + + buttons.push({ + id: `train_${unitId}`, + label: unitDef.name, + shortcut: unitDef.name.charAt(0).toUpperCase(), + action: () => { + if (!hasSupply) { + bridge.eventBus.emit('alert:supplyBlocked', {}); + } + bridge.eventBus.emit('command:train', { + entityIds: selectedUnits, + unitType: unitId, + }); + }, + isDisabled: !canAfford, + tooltip: + (unitDef.description || `Train ${unitDef.name}`) + + ` [${attackTypeText}]` + + (!hasSupply ? ' (Need more supply)' : ''), + cost: { minerals: unitDef.mineralCost, plasma: unitDef.plasmaCost, supply: unitDef.supplyCost }, + }); + }); + } + + if (!isFlying) { + // Tech-gated units from Research Module + techUnits.forEach((unitId) => { + const unitDef = UNIT_DEFINITIONS[unitId]; + if (!unitDef) return; + + const canAfford = minerals >= unitDef.mineralCost && plasma >= unitDef.plasmaCost; + const hasSupply = supply + unitDef.supplyCost <= maxSupply; + const canTrain = hasTechLab && canAfford; + const attackTypeText = getAttackTypeText(unitDef); + + let tooltipText = (unitDef.description || `Train ${unitDef.name}`) + ` [${attackTypeText}]`; + if (!hasTechLab) { + tooltipText += ' - Requires Research Module'; + } else if (!hasSupply) { + tooltipText += ' (Need more supply)'; + } + + buttons.push({ + id: `train_${unitId}`, + label: unitDef.name, + shortcut: unitDef.name.charAt(0).toUpperCase(), + action: () => { + if (hasTechLab) { + if (!hasSupply) { + bridge.eventBus.emit('alert:supplyBlocked', {}); + } + bridge.eventBus.emit('command:train', { + entityIds: selectedUnits, + unitType: unitId, + }); + } + }, + isDisabled: !canTrain, + tooltip: tooltipText, + cost: { minerals: unitDef.mineralCost, plasma: unitDef.plasmaCost, supply: unitDef.supplyCost }, + }); + }); + + // Addon buttons + if (building.canHaveAddon && !(building.hasAddon?.() ?? false)) { + const moduleDef = BUILDING_DEFINITIONS['research_module']; + if (moduleDef) { + const canAffordModule = minerals >= moduleDef.mineralCost && plasma >= moduleDef.plasmaCost; + const localPlayer = getLocalPlayerId(); + buttons.push({ + id: 'build_research_module', + label: 'Tech Lab', + shortcut: 'T', + action: () => { + const currentBridge = getWorkerBridge(); + const currentSelectedUnits = useGameStore.getState().selectedUnits; + if (currentBridge && currentSelectedUnits.length > 0) { + currentBridge.eventBus.emit('building:build_addon', { + buildingId: currentSelectedUnits[0], + addonType: 'research_module', + playerId: localPlayer, + }); + } + }, + isDisabled: !canAffordModule, + tooltip: moduleDef.description || 'Addon that unlocks advanced units and research.', + cost: { minerals: moduleDef.mineralCost, plasma: moduleDef.plasmaCost }, + }); + } + + const reactorDef = BUILDING_DEFINITIONS['production_module']; + if (reactorDef) { + const canAffordReactor = minerals >= reactorDef.mineralCost && plasma >= reactorDef.plasmaCost; + const localPlayer = getLocalPlayerId(); + buttons.push({ + id: 'build_production_module', + label: 'Reactor', + shortcut: 'C', + action: () => { + const currentGame = Game.getInstance(); + const currentSelectedUnits = useGameStore.getState().selectedUnits; + if (currentGame && currentSelectedUnits.length > 0) { + currentGame.eventBus.emit('building:build_addon', { + buildingId: currentSelectedUnits[0], + addonType: 'production_module', + playerId: localPlayer, + }); + } + }, + isDisabled: !canAffordReactor, + tooltip: reactorDef.description || 'Addon that enables double production of basic units.', + cost: { minerals: reactorDef.mineralCost, plasma: reactorDef.plasmaCost }, + }); + } + } + + // Research commands + const store = useGameStore.getState(); + const availableResearch = BUILDING_RESEARCH_MAP[building.buildingId] || []; + const localPlayerForResearch = getLocalPlayerId() ?? 'player1'; + + availableResearch.forEach((upgradeId) => { + const upgrade = RESEARCH_DEFINITIONS[upgradeId]; + if (!upgrade) return; + + const isResearched = store.hasResearch(localPlayerForResearch, upgradeId); + if (isResearched) return; + + let reqMet = true; + if (upgrade.requirements) { + for (const req of upgrade.requirements) { + if (RESEARCH_DEFINITIONS[req] && !store.hasResearch(localPlayerForResearch, req)) { + reqMet = false; + break; + } + } + } + + const isResearching = (building.productionQueue ?? []).some( + (item) => item.type === 'upgrade' && item.id === upgradeId + ); + + buttons.push({ + id: `research_${upgradeId}`, + label: upgrade.name, + shortcut: upgrade.name.charAt(0).toUpperCase(), + action: () => { + bridge.eventBus.emit('command:research', { + entityIds: selectedUnits, + upgradeId, + }); + }, + isDisabled: minerals < upgrade.mineralCost || plasma < upgrade.plasmaCost || !reqMet || isResearching, + tooltip: upgrade.description + (isResearching ? ' (In progress)' : ''), + cost: { minerals: upgrade.mineralCost, plasma: upgrade.plasmaCost }, + }); + }); + + // Building upgrade buttons + if (building.canUpgradeTo && building.canUpgradeTo.length > 0) { + const isUpgrading = (building.productionQueue ?? []).some( + (item) => item.type === 'upgrade' && (building.canUpgradeTo ?? []).includes(item.id) + ); + + building.canUpgradeTo.forEach((upgradeBuildingId) => { + const upgradeDef = BUILDING_DEFINITIONS[upgradeBuildingId]; + if (!upgradeDef) return; + + const canAfford = minerals >= upgradeDef.mineralCost && plasma >= upgradeDef.plasmaCost; + const words = upgradeDef.name.split(' '); + const shortcut = words[words.length - 1].charAt(0).toUpperCase(); + + buttons.push({ + id: `upgrade_${upgradeBuildingId}`, + label: upgradeDef.name, + shortcut, + action: () => { + bridge.eventBus.emit('command:upgrade_building', { + entityIds: selectedUnits, + upgradeTo: upgradeBuildingId, + }); + }, + isDisabled: !canAfford || isUpgrading, + tooltip: + (upgradeDef.description || `Upgrade to ${upgradeDef.name}`) + + (isUpgrading ? ' (Upgrading...)' : ''), + cost: { minerals: upgradeDef.mineralCost, plasma: upgradeDef.plasmaCost }, + }); + }); + } + + // Rally point + if ((building.canProduce ?? []).length > 0) { + buttons.push({ + id: 'rally', + label: 'Rally', + shortcut: 'R', + action: () => { + useGameStore.getState().setRallyPointMode(true); + }, + tooltip: 'Set rally point for new units', + }); + } + + // Demolish button + buttons.push({ + id: 'demolish', + label: 'Demolish', + shortcut: 'DEL', + action: () => { + const localPlayer = getLocalPlayerId(); + if (localPlayer) { + bridge.issueCommand({ + tick: bridge.currentTick, + playerId: localPlayer, + type: 'DEMOLISH', + entityIds: selectedUnits, + }); + } + }, + tooltip: 'Demolish building (refunds 50% of resources)', + }); + } + + // Lift-off button + if (building.canLiftOff && building.state === 'complete' && !building.isFlying) { + const hasQueue = (building.productionQueue ?? []).length > 0; + buttons.push({ + id: 'liftoff', + label: 'Lift Off', + shortcut: 'L', + action: () => { + if (!hasQueue) { + const localPlayer = getLocalPlayerId(); + if (localPlayer) { + game.issueCommand({ + tick: game.getCurrentTick(), + playerId: localPlayer, + type: 'LIFTOFF', + entityIds: [selectedUnits[0]], + buildingId: selectedUnits[0], + }); + } + } + }, + isDisabled: hasQueue, + tooltip: hasQueue ? 'Cannot lift off while producing' : 'Lift off to relocate building', + }); + } + + // Land button + if (building.canLiftOff && building.isFlying && building.state === 'flying') { + buttons.push({ + id: 'land', + label: 'Land', + shortcut: 'L', + action: () => { + useGameStore.getState().setLandingMode(true, selectedUnits[0]); + }, + tooltip: 'Click to choose landing location', + }); + } + + // Building abilities + const buildingAbilityComponent = entity.get<{ + getAbilityList?: () => Array<{ + definition: { + id: string; + name: string; + hotkey: string; + targetType: string; + energyCost?: number; + description?: string; + }; + cooldownRemaining?: number; + currentCooldown?: number; + }>; + canUseAbility?: (id: string) => boolean; + }>('Ability'); + + if (buildingAbilityComponent && buildingAbilityComponent.getAbilityList) { + const abilities = buildingAbilityComponent.getAbilityList(); + for (const abilityState of abilities) { + const def = abilityState.definition; + const canUse = buildingAbilityComponent.canUseAbility?.(def.id) ?? true; + const energyCost = def.energyCost; + + buttons.push({ + id: `ability_${def.id}`, + label: def.name, + shortcut: def.hotkey, + action: () => { + if (def.targetType === 'point' || def.targetType === 'unit') { + useGameStore.getState().setAbilityTargetMode(def.id); + } else { + bridge.eventBus.emit('command:ability', { + entityIds: selectedUnits, + abilityId: def.id, + }); + } + }, + isDisabled: !canUse, + tooltip: + (def.description ?? def.name) + + ((abilityState.currentCooldown ?? 0) > 0 + ? ` (CD: ${Math.ceil(abilityState.currentCooldown ?? 0)}s)` + : ''), + cost: (energyCost ?? 0) > 0 ? { minerals: 0, plasma: 0, supply: energyCost ?? 0 } : undefined, + }); + } + } + + return buttons; +} diff --git a/src/components/game/CommandCard/hooks/useCommandKeyboard.ts b/src/components/game/CommandCard/hooks/useCommandKeyboard.ts new file mode 100644 index 00000000..5f996acd --- /dev/null +++ b/src/components/game/CommandCard/hooks/useCommandKeyboard.ts @@ -0,0 +1,79 @@ +import { useEffect } from 'react'; +import { getRenderStateAdapter } from '@/engine/workers'; +import { CommandButtonData, MenuMode } from '../types'; + +interface UseCommandKeyboardParams { + menuMode: MenuMode; + setMenuMode: (mode: MenuMode) => void; + selectedUnits: number[]; + commands: CommandButtonData[]; +} + +/** + * Handle keyboard shortcuts for the command card. + */ +export function useCommandKeyboard({ + menuMode, + setMenuMode, + selectedUnits, + commands, +}: UseCommandKeyboardParams): void { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't handle if typing in an input field + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + + // ESC to go back in menus + if (e.key === 'Escape' && menuMode !== 'main') { + e.stopPropagation(); + setMenuMode('main'); + return; + } + + // Helper to check if selected entity is a worker + const isWorkerSelected = (): boolean => { + if (selectedUnits.length === 0) return false; + const worldAdapter = getRenderStateAdapter(); + const entity = worldAdapter.getEntity(selectedUnits[0]); + const unit = entity?.get<{ isWorker: boolean }>('Unit'); + return unit?.isWorker ?? false; + }; + + // Hotkey B for Build Basic + if (e.key.toLowerCase() === 'b' && menuMode === 'main' && isWorkerSelected()) { + setMenuMode('build_basic'); + return; + } + + // Hotkey V for Build Advanced + if (e.key.toLowerCase() === 'v' && menuMode === 'main' && isWorkerSelected()) { + setMenuMode('build_advanced'); + return; + } + + // Hotkey W for Build Walls + if (e.key.toLowerCase() === 'w' && menuMode === 'main' && isWorkerSelected()) { + setMenuMode('build_walls'); + return; + } + + // Handle building shortcuts when in build submenus + if (menuMode === 'build_basic' || menuMode === 'build_advanced' || menuMode === 'build_walls') { + const pressedKey = e.key.toUpperCase(); + const matchingCommand = commands.find( + (cmd) => cmd.shortcut === pressedKey && cmd.id !== 'back' && !cmd.isDisabled + ); + if (matchingCommand) { + e.preventDefault(); + e.stopPropagation(); + matchingCommand.action(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [menuMode, selectedUnits, commands, setMenuMode]); +} diff --git a/src/components/game/CommandCard/hooks/useUnitCommands.ts b/src/components/game/CommandCard/hooks/useUnitCommands.ts new file mode 100644 index 00000000..280ac349 --- /dev/null +++ b/src/components/game/CommandCard/hooks/useUnitCommands.ts @@ -0,0 +1,352 @@ +import { useGameStore } from '@/store/gameStore'; +import { getLocalPlayerId } from '@/store/gameSetupStore'; +import { getWorkerBridge, getRenderStateAdapter } from '@/engine/workers'; +import { UNIT_DEFINITIONS } from '@/data/units/dominion'; +import { BUILDING_DEFINITIONS } from '@/data/buildings/dominion'; +import { WALL_DEFINITIONS } from '@/data/buildings/walls'; +import { getAttackTypeText } from '@/utils/commandIcons'; +import { CommandButtonData, MenuMode } from '../types'; +import { BASIC_BUILDINGS, ADVANCED_BUILDINGS, WALL_BUILDINGS } from '../constants'; + +interface UseUnitCommandsParams { + selectedUnits: number[]; + minerals: number; + plasma: number; + menuMode: MenuMode; + setMenuMode: (mode: MenuMode) => void; +} + +/** + * Generate commands for selected units. + */ +export function useUnitCommands({ + selectedUnits, + minerals, + plasma, + menuMode, + setMenuMode, +}: UseUnitCommandsParams): CommandButtonData[] { + const bridge = getWorkerBridge(); + const worldAdapter = getRenderStateAdapter(); + + if (!bridge || selectedUnits.length === 0) return []; + + const entity = worldAdapter.getEntity(selectedUnits[0]); + if (!entity) return []; + + const unit = entity.get<{ + unitId: string; + playerId: string; + state: string; + isWorker: boolean; + canRepair?: boolean; + canTransform?: boolean; + transformModes?: { id: string; name: string; isFlying?: boolean; transformTime?: number }[]; + currentMode?: string; + transformProgress?: number; + getCurrentMode?: () => { id: string; name: string }; + }>('Unit'); + + if (!unit) return []; + + const buttons: CommandButtonData[] = []; + + if (menuMode === 'main') { + // Basic unit commands + buttons.push({ + id: 'move', + label: 'Move', + shortcut: 'M', + action: () => {}, + tooltip: 'Move to location (right-click)', + }); + + buttons.push({ + id: 'stop', + label: 'Stop', + shortcut: 'S', + action: () => { + const localPlayer = getLocalPlayerId(); + if (localPlayer) { + bridge.issueCommand({ + tick: bridge.currentTick, + playerId: localPlayer, + type: 'STOP', + entityIds: selectedUnits, + }); + } + }, + tooltip: 'Stop current action', + }); + + buttons.push({ + id: 'hold', + label: 'Hold', + shortcut: 'H', + action: () => { + const localPlayer = getLocalPlayerId(); + if (localPlayer) { + bridge.issueCommand({ + tick: bridge.currentTick, + playerId: localPlayer, + type: 'HOLD', + entityIds: selectedUnits, + }); + } + }, + tooltip: 'Hold position - do not move to attack', + }); + + buttons.push({ + id: 'attack', + label: 'Attack', + shortcut: 'A', + action: () => {}, + tooltip: 'Attack-move to location', + }); + + buttons.push({ + id: 'patrol', + label: 'Patrol', + shortcut: 'P', + action: () => {}, + tooltip: 'Patrol between points', + }); + + // Transform commands for units that can transform (e.g., Valkyrie) + if (unit.canTransform && unit.transformModes && unit.transformModes.length > 0) { + const isTransforming = unit.state === 'transforming'; + + for (const mode of unit.transformModes) { + if (mode.id === unit.currentMode) continue; + + const isAirMode = mode.isFlying === true; + const shortcut = isAirMode ? 'F' : 'E'; + + let tooltip = `Transform to ${mode.name}`; + if (isAirMode) { + tooltip += ' - Flying, attacks air units only'; + } else { + tooltip += ' - Ground, attacks ground units only'; + } + tooltip += ` (${mode.transformTime}s)`; + + buttons.push({ + id: `transform_${mode.id}`, + label: mode.name.replace(' Mode', ''), + shortcut, + action: () => { + const localPlayer = getLocalPlayerId(); + if (localPlayer && bridge) { + bridge.issueCommand({ + tick: bridge.currentTick, + playerId: localPlayer, + type: 'TRANSFORM', + entityIds: selectedUnits, + abilityId: mode.id, + }); + } + }, + isDisabled: isTransforming, + tooltip: isTransforming + ? `Transforming... (${Math.round((unit.transformProgress ?? 0) * 100)}%)` + : tooltip, + }); + } + } + + // Unit abilities + const abilityComponent = entity.get<{ + getAbilityList?: () => Array<{ + definition: { + id: string; + name: string; + hotkey: string; + targetType: string; + energyCost?: number; + description?: string; + }; + cooldownRemaining?: number; + currentCooldown?: number; + }>; + canUseAbility?: (id: string) => boolean; + }>('Ability'); + + if (abilityComponent && abilityComponent.getAbilityList) { + const abilities = abilityComponent.getAbilityList(); + for (const abilityState of abilities) { + const def = abilityState.definition; + const canUse = abilityComponent.canUseAbility?.(def.id) ?? true; + const energyCost = def.energyCost; + + buttons.push({ + id: `ability_${def.id}`, + label: def.name, + shortcut: def.hotkey, + action: () => { + if (def.targetType === 'point' || def.targetType === 'unit') { + useGameStore.getState().setAbilityTargetMode(def.id); + } else { + bridge.eventBus.emit('command:ability', { + entityIds: selectedUnits, + abilityId: def.id, + }); + } + }, + isDisabled: !canUse, + tooltip: + (def.description ?? def.name) + + ((abilityState.currentCooldown ?? 0) > 0 + ? ` (CD: ${Math.ceil(abilityState.currentCooldown ?? 0)}s)` + : ''), + cost: (energyCost ?? 0) > 0 ? { minerals: 0, plasma: 0, supply: energyCost ?? 0 } : undefined, + }); + } + } + + // Worker-specific commands + if (unit.isWorker) { + buttons.push({ + id: 'gather', + label: 'Gather', + shortcut: 'G', + action: () => {}, + tooltip: 'Gather resources (right-click on minerals/gas)', + }); + + if (unit.canRepair) { + buttons.push({ + id: 'repair', + label: 'Repair', + shortcut: 'R', + action: () => { + useGameStore.getState().setRepairMode(true); + }, + tooltip: 'Repair buildings and mechanical units (right-click on damaged target)', + }); + } + + buttons.push({ + id: 'build_basic', + label: 'Build Basic', + shortcut: 'B', + action: () => setMenuMode('build_basic'), + tooltip: 'Build basic structures', + }); + + buttons.push({ + id: 'build_advanced', + label: 'Advanced', + shortcut: 'V', + action: () => setMenuMode('build_advanced'), + tooltip: 'Build advanced structures', + }); + + buttons.push({ + id: 'build_walls', + label: 'Build Walls', + shortcut: 'W', + action: () => setMenuMode('build_walls'), + tooltip: 'Build walls and gates (click+drag for lines)', + }); + } + } else if ( + menuMode === 'build_basic' || + menuMode === 'build_advanced' || + menuMode === 'build_walls' + ) { + // Back button + buttons.push({ + id: 'back', + label: 'Back', + shortcut: 'ESC', + action: () => setMenuMode('main'), + tooltip: 'Return to main commands', + }); + + // Building buttons for the selected category + const buildingList = + menuMode === 'build_basic' + ? BASIC_BUILDINGS + : menuMode === 'build_advanced' + ? ADVANCED_BUILDINGS + : WALL_BUILDINGS; + + const checkRequirementsMet = ( + requirements: string[] | undefined + ): { met: boolean; missing: string[] } => { + if (!requirements || requirements.length === 0) { + return { met: true, missing: [] }; + } + + const localPlayerId = getLocalPlayerId(); + if (!localPlayerId) return { met: false, missing: requirements }; + + const playerBuildings = worldAdapter.getEntitiesWith('Building', 'Selectable'); + const missing: string[] = []; + + for (const reqBuildingId of requirements) { + let found = false; + for (const buildingEntity of playerBuildings) { + const b = buildingEntity.get<{ + buildingId: string; + state: string; + isComplete?: () => boolean; + }>('Building'); + const sel = buildingEntity.get<{ playerId: string }>('Selectable'); + const isComplete = b?.isComplete?.() ?? b?.state === 'complete'; + if (sel?.playerId === localPlayerId && b?.buildingId === reqBuildingId && isComplete) { + found = true; + break; + } + } + if (!found) { + missing.push(BUILDING_DEFINITIONS[reqBuildingId]?.name || reqBuildingId); + } + } + + return { met: missing.length === 0, missing }; + }; + + buildingList.forEach((buildingId) => { + const def = BUILDING_DEFINITIONS[buildingId] || WALL_DEFINITIONS[buildingId]; + if (!def) return; + + const reqCheck = checkRequirementsMet(def.requirements); + const requirementsMet = reqCheck.met; + const reqText = reqCheck.missing.length > 0 ? `Requires: ${reqCheck.missing.join(', ')}` : ''; + + const canAfford = minerals >= def.mineralCost && plasma >= def.plasmaCost; + + let tooltip = def.description || `Build ${def.name}`; + if (reqText) { + tooltip += ` (${reqText})`; + } + + const isWall = 'isWall' in def && def.isWall; + if (isWall) { + tooltip += ' (Click+drag to draw wall line)'; + } + + buttons.push({ + id: `build_${buildingId}`, + label: def.name, + shortcut: def.name.charAt(0).toUpperCase(), + action: () => { + if (requirementsMet) { + if (isWall) { + useGameStore.getState().setWallPlacementMode(true, buildingId); + } else { + useGameStore.getState().setBuildingMode(buildingId); + } + } + }, + isDisabled: !canAfford || !requirementsMet, + tooltip, + cost: { minerals: def.mineralCost, plasma: def.plasmaCost }, + }); + }); + } + + return buttons; +} diff --git a/src/components/game/CommandCard/index.tsx b/src/components/game/CommandCard/index.tsx new file mode 100644 index 00000000..a0fb58da --- /dev/null +++ b/src/components/game/CommandCard/index.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useEffect, useState, memo, useMemo } from 'react'; +import { useGameStore } from '@/store/gameStore'; +import { getWorkerBridge, getRenderStateAdapter } from '@/engine/workers'; +import { CommandTooltip } from '@/components/ui/CommandTooltip'; +import { getCommandIcon } from '@/utils/commandIcons'; +import { CommandGrid } from './CommandGrid'; +import { useUnitCommands, useBuildingCommands, useCommandKeyboard } from './hooks'; +import { CommandButtonData, MenuMode } from './types'; + +/** + * Command card displaying available actions for selected units/buildings. + * Supports 4x3 grid of commands with submenus for building placement. + */ +function CommandCardInner() { + const selectedUnits = useGameStore((state) => state.selectedUnits); + const minerals = useGameStore((state) => state.minerals); + const plasma = useGameStore((state) => state.plasma); + const supply = useGameStore((state) => state.supply); + const maxSupply = useGameStore((state) => state.maxSupply); + + const [hoveredCmd, setHoveredCmd] = useState(null); + const [menuMode, setMenuMode] = useState('main'); + const [buildingStateVersion, setBuildingStateVersion] = useState(0); + + // Subscribe to building state change events + useEffect(() => { + const bridge = getWorkerBridge(); + if (!bridge) return; + + const handleBuildingStateChange = () => { + setBuildingStateVersion((v) => v + 1); + }; + + const unsub1 = bridge.eventBus.on('building:liftOffStart', handleBuildingStateChange); + const unsub2 = bridge.eventBus.on('building:liftOffComplete', handleBuildingStateChange); + const unsub3 = bridge.eventBus.on('building:landingStart', handleBuildingStateChange); + const unsub4 = bridge.eventBus.on('building:landingComplete', handleBuildingStateChange); + + return () => { + unsub1(); + unsub2(); + unsub3(); + unsub4(); + }; + }, []); + + // Reset to main menu when selection changes + useEffect(() => { + setMenuMode('main'); + }, [selectedUnits]); + + // Determine if selection is a unit or building + const selectionType = useMemo(() => { + if (selectedUnits.length === 0) return null; + const worldAdapter = getRenderStateAdapter(); + const entity = worldAdapter.getEntity(selectedUnits[0]); + if (!entity) return null; + if (entity.get('Unit')) return 'unit'; + if (entity.get('Building')) return 'building'; + return null; + }, [selectedUnits, buildingStateVersion]); + + // Get commands from appropriate hook + const unitCommands = useUnitCommands({ + selectedUnits, + minerals, + plasma, + menuMode, + setMenuMode, + }); + + const buildingCommands = useBuildingCommands({ + selectedUnits, + minerals, + plasma, + supply, + maxSupply, + }); + + // Merge commands: prefer unit commands if unit selected, otherwise building commands + const commands: CommandButtonData[] = useMemo(() => { + if (selectionType === 'unit') { + return unitCommands.slice(0, 12); + } else if (selectionType === 'building') { + return buildingCommands.slice(0, 12); + } + return []; + }, [selectionType, unitCommands, buildingCommands]); + + // Keyboard shortcuts + useCommandKeyboard({ + menuMode, + setMenuMode, + selectedUnits, + commands, + }); + + if (commands.length === 0) { + return ( +
+ Select units or buildings +
+ ); + } + + const hoveredCommand = commands.find((c) => c.id === hoveredCmd); + + return ( +
+ {/* Menu title when in submenu */} + {menuMode !== 'main' && ( +
+ {menuMode === 'build_basic' + ? '🏗 Basic Structures' + : menuMode === 'build_advanced' + ? '🏭 Advanced Structures' + : '🧱 Walls & Gates'} +
+ )} + + {/* Command grid */} +
+ +
+ + {/* Tooltip */} + {hoveredCommand && ( +
+ +
+ )} +
+ ); +} + +export const CommandCard = memo(CommandCardInner); diff --git a/src/components/game/CommandCard/types.ts b/src/components/game/CommandCard/types.ts new file mode 100644 index 00000000..d20da357 --- /dev/null +++ b/src/components/game/CommandCard/types.ts @@ -0,0 +1,17 @@ +/** + * Command button definition for the command card grid. + */ +export interface CommandButtonData { + id: string; + label: string; + shortcut: string; + action: () => void; + isDisabled?: boolean; + tooltip?: string; + cost?: { minerals: number; plasma: number; supply?: number }; +} + +/** + * Menu modes for the command card. + */ +export type MenuMode = 'main' | 'build_basic' | 'build_advanced' | 'build_walls'; diff --git a/src/components/ui/CommandButton.tsx b/src/components/ui/CommandButton.tsx new file mode 100644 index 00000000..d4104961 --- /dev/null +++ b/src/components/ui/CommandButton.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { memo, ReactNode, MouseEvent } from 'react'; + +interface CommandButtonProps { + icon: ReactNode; + label: string; + shortcut: string; + onClick: () => void; + onDisabledClick?: () => void; + isDisabled?: boolean; + hasCost?: boolean; + isBackButton?: boolean; + width?: number; + height?: number; + className?: string; +} + +/** + * Reusable command button for the command card grid. + * Displays icon, label, and hotkey badge with disabled/back button variants. + */ +function CommandButtonInner({ + icon, + label, + shortcut, + onClick, + onDisabledClick, + isDisabled = false, + hasCost = false, + isBackButton = false, + width = 72, + height = 58, + className = '', +}: CommandButtonProps) { + const handleClick = (e: MouseEvent) => { + if (isDisabled) { + e.preventDefault(); + onDisabledClick?.(); + return; + } + onClick(); + }; + + return ( + + ); +} + +export const CommandButton = memo(CommandButtonInner); diff --git a/src/components/ui/CommandTooltip.tsx b/src/components/ui/CommandTooltip.tsx new file mode 100644 index 00000000..d91692ea --- /dev/null +++ b/src/components/ui/CommandTooltip.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { memo, ReactNode } from 'react'; +import { CostDisplay, Cost } from './CostDisplay'; + +interface CommandTooltipProps { + icon: ReactNode; + label: string; + shortcut: string; + description?: string; + cost?: Cost; + currentMinerals?: number; + currentPlasma?: number; + currentSupply?: number; + maxSupply?: number; + className?: string; +} + +/** + * Tooltip content for command buttons showing icon, label, shortcut, description, and cost. + */ +function CommandTooltipInner({ + icon, + label, + shortcut, + description, + cost, + currentMinerals, + currentPlasma, + currentSupply, + maxSupply, + className = '', +}: CommandTooltipProps) { + return ( +
+ {/* Header */} +
+ {icon} + {label} + [ {shortcut} ] +
+ + {/* Description */} + {description && ( +

{description}

+ )} + + {/* Cost */} + {cost && ( +
+ +
+ )} +
+ ); +} + +export const CommandTooltip = memo(CommandTooltipInner); diff --git a/src/components/ui/CostDisplay.tsx b/src/components/ui/CostDisplay.tsx new file mode 100644 index 00000000..9aa3bade --- /dev/null +++ b/src/components/ui/CostDisplay.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { memo } from 'react'; + +export interface Cost { + minerals: number; + plasma: number; + supply?: number; +} + +interface CostDisplayProps { + cost: Cost; + currentMinerals?: number; + currentPlasma?: number; + currentSupply?: number; + maxSupply?: number; + size?: 'sm' | 'md'; + className?: string; +} + +/** + * Reusable cost display showing minerals, plasma, and optionally supply. + * Highlights insufficient resources in red. + */ +function CostDisplayInner({ + cost, + currentMinerals = Infinity, + currentPlasma = Infinity, + currentSupply = 0, + maxSupply = Infinity, + size = 'sm', + className = '', +}: CostDisplayProps) { + const textSize = size === 'sm' ? 'text-xs' : 'text-sm'; + const canAffordMinerals = currentMinerals >= cost.minerals; + const canAffordPlasma = currentPlasma >= cost.plasma; + const hasSupply = cost.supply ? currentSupply + cost.supply <= maxSupply : true; + + return ( +
+ + 💎 + {cost.minerals} + + {cost.plasma > 0 && ( + + 💚 + {cost.plasma} + + )} + {cost.supply !== undefined && cost.supply > 0 && ( + + 👤 + {cost.supply} + + )} +
+ ); +} + +export const CostDisplay = memo(CostDisplayInner); diff --git a/src/components/ui/EmptySlot.tsx b/src/components/ui/EmptySlot.tsx new file mode 100644 index 00000000..7624177b --- /dev/null +++ b/src/components/ui/EmptySlot.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { memo } from 'react'; + +interface EmptySlotProps { + width?: number; + height?: number; + className?: string; +} + +/** + * Empty placeholder slot for command grid. + */ +function EmptySlotInner({ + width = 72, + height = 58, + className = '', +}: EmptySlotProps) { + return ( +
+ ); +} + +export const EmptySlot = memo(EmptySlotInner); diff --git a/src/components/ui/IconBadge.tsx b/src/components/ui/IconBadge.tsx new file mode 100644 index 00000000..e106c5e6 --- /dev/null +++ b/src/components/ui/IconBadge.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { memo, ReactNode } from 'react'; + +interface IconBadgeProps { + icon: ReactNode; + badge?: string; + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +/** + * Icon with optional hotkey badge overlay in bottom-right corner. + */ +function IconBadgeInner({ icon, badge, size = 'md', className = '' }: IconBadgeProps) { + const iconSize = { + sm: 'text-sm', + md: 'text-lg', + lg: 'text-xl', + }[size]; + + const badgeSize = { + sm: 'text-[6px]', + md: 'text-[7px]', + lg: 'text-[8px]', + }[size]; + + return ( +
+ {icon} + {badge && ( + + {badge} + + )} +
+ ); +} + +export const IconBadge = memo(IconBadgeInner); diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 00000000..73bae5e7 --- /dev/null +++ b/src/components/ui/index.ts @@ -0,0 +1,6 @@ +export { Tooltip } from './Tooltip'; +export { CostDisplay, type Cost } from './CostDisplay'; +export { IconBadge } from './IconBadge'; +export { EmptySlot } from './EmptySlot'; +export { CommandButton } from './CommandButton'; +export { CommandTooltip } from './CommandTooltip'; diff --git a/src/utils/commandIcons.ts b/src/utils/commandIcons.ts new file mode 100644 index 00000000..563146ef --- /dev/null +++ b/src/utils/commandIcons.ts @@ -0,0 +1,121 @@ +/** + * Icon mappings for commands, units, and buildings. + */ +export const COMMAND_ICONS: Record = { + // Basic commands + move: '➤', + stop: '■', + hold: '⛊', + attack: '⚔', + patrol: '↻', + gather: '⛏', + repair: '🔧', + rally: '⚑', + build: '🔨', + build_basic: '🏗', + build_advanced: '🏭', + cancel: '✕', + demolish: '🗑', + back: '◀', + liftoff: '🚀', + land: '🛬', + // Units + fabricator: '🔧', + trooper: '🎖', + breacher: '💪', + vanguard: '💀', + operative: '👻', + scorcher: '🔥', + devastator: '🎯', + colossus: '⚡', + lifter: '✚', + valkyrie: '✈', + specter: '🦇', + dreadnought: '🚀', + overseer: '🦅', + // Buildings + headquarters: '🏛', + orbital_station: '🛰', + bastion: '🏰', + supply_cache: '📦', + extractor: '⛽', + infantry_bay: '🏠', + tech_center: '🔬', + garrison: '🏰', + forge: '🏭', + arsenal: '⚙', + hangar: '🛫', + power_core: '⚛', + ops_center: '🎓', + radar_array: '📡', + defense_turret: '🗼', + // Walls + wall: '🧱', + wall_segment: '🧱', + wall_gate: '🚪', + gate: '🚪', + // Gate commands + open: '📖', + close: '📕', + lock: '🔒', + unlock: '🔓', + auto: '🔄', + // Upgrades + stim: '💉', + combat: '🛡', + infantry: '⚔', + vehicle: '💥', + ship: '🚀', + siege: '🎯', + cloak: '👁', + // Abilities + mule: '🔧', + scanner_sweep: '📡', + supply_drop: '📦', + scanner: '📡', + power_cannon: '⚡', + warp_jump: '🌀', + // Transform modes + transform_fighter: '✈', + transform_assault: '⬇', + fighter: '✈', + assault: '⬇', + default: '◆', +}; + +/** + * Get icon for a command/unit/building ID. + * Falls back to partial matching, then default icon. + */ +export function getCommandIcon(id: string): string { + const lc = id.toLowerCase(); + if (COMMAND_ICONS[lc]) return COMMAND_ICONS[lc]; + for (const [key, icon] of Object.entries(COMMAND_ICONS)) { + if (lc.includes(key)) return icon; + } + return COMMAND_ICONS.default; +} + +/** + * Get attack type indicator text for unit tooltips. + */ +export function getAttackTypeText(unitDef: { + attackDamage?: number; + canAttackGround?: boolean; + canAttackAir?: boolean; +}): string { + if (!unitDef) return ''; + + const canAttackGround = unitDef.canAttackGround ?? ((unitDef.attackDamage ?? 0) > 0); + const canAttackAir = unitDef.canAttackAir ?? false; + + if (!canAttackGround && !canAttackAir) { + return '⊘ No attack'; + } else if (canAttackGround && canAttackAir) { + return '⬡ Attacks: Ground & Air'; + } else if (canAttackGround) { + return '⬢ Attacks: Ground only'; + } else { + return '✈ Attacks: Air only'; + } +}