Spaces:
Sleeping
Sleeping
| /** | |
| * RTS Game Client - Modern WebSocket-based RTS | |
| */ | |
| // Game Configuration | |
| const CONFIG = { | |
| TILE_SIZE: 40, | |
| MAP_WIDTH: 96, | |
| MAP_HEIGHT: 72, | |
| FPS: 60, | |
| COLORS: { | |
| GRASS: '#228B22', | |
| ORE: '#FFD700', | |
| GEM: '#9B59B6', | |
| WATER: '#006994', | |
| PLAYER: '#4A90E2', | |
| ENEMY: '#E74C3C', | |
| SELECTION: '#00FF00', | |
| FOG: 'rgba(0, 0, 0, 0.7)' | |
| } | |
| }; | |
| // Game State | |
| class GameClient { | |
| constructor() { | |
| this.canvas = document.getElementById('game-canvas'); | |
| this.ctx = this.canvas.getContext('2d'); | |
| this.minimap = document.getElementById('minimap'); | |
| this.minimapCtx = this.minimap.getContext('2d'); | |
| this.ws = null; | |
| this.gameState = null; | |
| this.selectedUnits = new Set(); | |
| this.dragStart = null; | |
| this.camera = { x: 0, y: 0, zoom: 1 }; | |
| this.buildingMode = null; | |
| this.currentLanguage = 'en'; | |
| this.translations = {}; | |
| this.projectiles = []; // Combat animations: bullets, missiles, shells | |
| this.explosions = []; // Combat animations: impact effects | |
| this.sounds = null; // Sound manager | |
| this.controlGroups = {}; // Control groups 1-9: {1: [unitId1, unitId2], ...} | |
| this.hints = null; // Hint system | |
| this.nukePreparing = false; // Nuke targeting mode | |
| this.nukeFlash = 0; // Screen flash effect (0-60 frames) | |
| this.selectedProductionBuilding = null; // Explicit UI-selected production building | |
| this.initializeSoundManager(); | |
| this.initializeHintSystem(); | |
| this.loadTranslations('en'); | |
| this.initializeCanvas(); | |
| this.connectWebSocket(); | |
| this.setupEventListeners(); | |
| this.startGameLoop(); | |
| } | |
| async initializeSoundManager() { | |
| try { | |
| this.sounds = new SoundManager(); | |
| await this.sounds.loadAll(); | |
| console.log('[Game] Sound system initialized'); | |
| // Setup sound toggle button | |
| const soundToggle = document.getElementById('sound-toggle'); | |
| if (soundToggle) { | |
| soundToggle.addEventListener('click', () => { | |
| const enabled = this.sounds.toggle(); | |
| soundToggle.classList.toggle('muted', !enabled); | |
| soundToggle.querySelector('.sound-icon').textContent = enabled ? '๐' : '๐'; | |
| const msg = this.translate(enabled ? 'notification.sound.enabled' : 'notification.sound.disabled'); | |
| this.showNotification(msg, 'info'); | |
| }); | |
| } | |
| } catch (error) { | |
| console.warn('[Game] Sound system failed to initialize:', error); | |
| } | |
| } | |
| initializeHintSystem() { | |
| try { | |
| this.hints = new HintManager(); | |
| console.log('[Game] Hint system initialized'); | |
| } catch (error) { | |
| console.warn('[Game] Hint system failed to initialize:', error); | |
| } | |
| } | |
| async loadTranslations(language) { | |
| try { | |
| const response = await fetch(`/api/translations/${language}`); | |
| const data = await response.json(); | |
| this.translations = data.translations || {}; | |
| this.currentLanguage = language; | |
| // Update hint system translations | |
| if (this.hints) { | |
| this.hints.setTranslations(this.translations); | |
| } | |
| } catch (error) { | |
| console.error('Failed to load translations:', error); | |
| } | |
| } | |
| translate(key, params = {}) { | |
| let text = this.translations[key] || key; | |
| // Replace parameters like {cost}, {current}, etc. | |
| for (const [param, value] of Object.entries(params)) { | |
| text = text.replace(`{${param}}`, value); | |
| } | |
| return text; | |
| } | |
| initializeCanvas() { | |
| const container = document.getElementById('canvas-container'); | |
| this.canvas.width = container.clientWidth; | |
| this.canvas.height = container.clientHeight; | |
| this.minimap.width = 240; | |
| this.minimap.height = 180; | |
| // Center camera on player HQ | |
| this.camera.x = 200; | |
| this.camera.y = 200; | |
| } | |
| connectWebSocket() { | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const wsUrl = `${protocol}//${window.location.host}/ws`; | |
| this.ws = new WebSocket(wsUrl); | |
| this.ws.onopen = () => { | |
| console.log('โ Connected to game server'); | |
| this.updateConnectionStatus(true); | |
| this.hideLoadingScreen(); | |
| // Show welcome hints | |
| if (this.hints) { | |
| this.hints.onGameStart(); | |
| } | |
| }; | |
| this.ws.onmessage = (event) => { | |
| const data = JSON.parse(event.data); | |
| this.handleServerMessage(data); | |
| }; | |
| this.ws.onerror = (error) => { | |
| console.error('WebSocket error:', error); | |
| this.showNotification(this.translate('notification.connection_error'), 'error'); | |
| }; | |
| this.ws.onclose = () => { | |
| console.log('โ Disconnected from server'); | |
| this.updateConnectionStatus(false); | |
| this.showNotification(this.translate('notification.disconnected'), 'error'); | |
| // Attempt to reconnect after 3 seconds | |
| setTimeout(() => this.connectWebSocket(), 3000); | |
| }; | |
| } | |
| handleServerMessage(data) { | |
| switch (data.type) { | |
| case 'init': | |
| case 'state_update': | |
| // Detect attacks and create projectiles | |
| if (this.gameState && this.gameState.units && data.state.units) { | |
| this.detectAttacks(this.gameState.units, data.state.units); | |
| } | |
| // Detect production completions | |
| if (this.gameState && this.gameState.buildings && data.state.buildings) { | |
| this.detectProductionComplete(this.gameState.buildings, data.state.buildings); | |
| } | |
| // Detect new units (unit ready) | |
| if (this.gameState && this.gameState.units && data.state.units) { | |
| this.detectNewUnits(this.gameState.units, data.state.units); | |
| } | |
| this.gameState = data.state; | |
| // Clear production building if it no longer exists | |
| if (this.selectedProductionBuilding && (!this.gameState.buildings || !this.gameState.buildings[this.selectedProductionBuilding])) { | |
| this.selectedProductionBuilding = null; | |
| } | |
| this.updateProductionSourceLabel(); | |
| this.updateUI(); | |
| this.updateIntelPanel(); | |
| break; | |
| case 'nuke_launched': | |
| // Visual effects for nuke | |
| this.createNukeExplosion(data.target); | |
| // Screen flash | |
| this.nukeFlash = 60; // 1 second at 60 FPS | |
| // Sound effect | |
| if (this.sounds) { | |
| this.sounds.play('explosion', 1.0); | |
| } | |
| break; | |
| case 'ai_analysis_update': | |
| if (data.analysis) { | |
| this.gameState = this.gameState || {}; | |
| this.gameState.ai_analysis = data.analysis; | |
| this.updateIntelPanel(); | |
| } | |
| break; | |
| case 'notification': | |
| this.showNotification(data.message, data.level || 'info'); | |
| break; | |
| case 'game_over': | |
| this.onGameOver(data); | |
| break; | |
| default: | |
| console.log('Unknown message type:', data.type); | |
| } | |
| } | |
| onGameOver(payload) { | |
| // Show overlay with localized message | |
| const overlay = document.getElementById('game-over-overlay'); | |
| const msgEl = document.getElementById('game-over-message'); | |
| const btn = document.getElementById('game-over-restart'); | |
| if (overlay && msgEl && btn) { | |
| // Prefer server-provided message; fallback by winner | |
| let message = payload.message || ''; | |
| if (!message) { | |
| const key = payload.winner === 'player' ? 'game.winner.player' : payload.winner === 'enemy' ? 'game.winner.enemy' : 'game.draw.banner'; | |
| if (payload.winner === 'draw') { | |
| message = this.translate('game.draw.banner'); | |
| } else { | |
| const winnerName = this.translate(key); | |
| message = this.translate('game.win.banner', { winner: winnerName }); | |
| } | |
| } | |
| msgEl.textContent = message; | |
| // Localize button | |
| btn.textContent = this.translate('menu.actions.restart') || 'Restart'; | |
| btn.onclick = () => window.location.reload(); | |
| overlay.classList.remove('hidden'); | |
| } | |
| } | |
| sendCommand(command) { | |
| if (this.ws && this.ws.readyState === WebSocket.OPEN) { | |
| this.ws.send(JSON.stringify(command)); | |
| } | |
| } | |
| setupEventListeners() { | |
| // Canvas mouse events | |
| this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e)); | |
| this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e)); | |
| this.canvas.addEventListener('mouseup', (e) => this.onMouseUp(e)); | |
| this.canvas.addEventListener('contextmenu', (e) => { | |
| e.preventDefault(); | |
| this.onRightClick(e); | |
| }); | |
| // Keyboard shortcuts | |
| document.addEventListener('keydown', (e) => this.onKeyDown(e)); | |
| // Camera controls | |
| const zoomInBtn = document.getElementById('zoom-in'); | |
| zoomInBtn.addEventListener('click', () => { | |
| this.camera.zoom = Math.min(2, this.camera.zoom + 0.1); | |
| }); | |
| const zoomOutBtn = document.getElementById('zoom-out'); | |
| zoomOutBtn.addEventListener('click', () => { | |
| this.camera.zoom = Math.max(0.5, this.camera.zoom - 0.1); | |
| }); | |
| const resetViewBtn = document.getElementById('reset-view'); | |
| resetViewBtn.addEventListener('click', () => { | |
| this.camera.zoom = 1; | |
| this.camera.x = 200; | |
| this.camera.y = 200; | |
| }); | |
| // Build buttons | |
| document.querySelectorAll('.build-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| const type = btn.dataset.type; | |
| this.startBuildingMode(type); | |
| }); | |
| }); | |
| // Unit training buttons | |
| document.querySelectorAll('.unit-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| const type = btn.dataset.type; | |
| this.trainUnit(type); | |
| }); | |
| }); | |
| // Production source clear button | |
| const clearBtn = document.getElementById('production-source-clear'); | |
| if (clearBtn) { | |
| clearBtn.addEventListener('click', () => { | |
| this.selectedProductionBuilding = null; | |
| this.updateProductionSourceLabel(); | |
| }); | |
| } | |
| // Quick action buttons | |
| document.getElementById('select-all').addEventListener('click', () => this.selectAllUnits()); | |
| document.getElementById('stop-units').addEventListener('click', () => this.stopSelectedUnits()); | |
| // Control group buttons (click to select, Ctrl+click to assign) | |
| document.querySelectorAll('.control-group').forEach(groupDiv => { | |
| groupDiv.addEventListener('click', (e) => { | |
| const groupNum = parseInt(groupDiv.dataset.group); | |
| if (e.ctrlKey) { | |
| this.assignControlGroup(groupNum); | |
| } else { | |
| this.selectControlGroup(groupNum); | |
| } | |
| }); | |
| }); | |
| // Minimap click (left click - move camera) | |
| this.minimap.addEventListener('click', (e) => this.onMinimapClick(e)); | |
| // Minimap right click (right click - move units) | |
| this.minimap.addEventListener('contextmenu', (e) => this.onMinimapRightClick(e)); | |
| // Language selector | |
| document.getElementById('language-select').addEventListener('change', (e) => { | |
| this.changeLanguage(e.target.value); | |
| }); | |
| // Intel refresh button | |
| const refreshIntelBtn = document.getElementById('refresh-intel'); | |
| refreshIntelBtn.addEventListener('click', () => { | |
| this.requestIntelAnalysis(); | |
| }); | |
| // Window resize | |
| window.addEventListener('resize', () => this.initializeCanvas()); | |
| } | |
| requestIntelAnalysis() { | |
| this.sendCommand({ | |
| type: 'request_ai_analysis' | |
| }); | |
| this.showNotification(this.translate('hud.intel.requesting'), 'info'); | |
| } | |
| async changeLanguage(language) { | |
| await this.loadTranslations(language); | |
| // Update all UI texts | |
| this.updateUITexts(); | |
| // Show language changed hint | |
| if (this.hints) { | |
| this.hints.onLanguageChanged(); | |
| } | |
| // Send command to server | |
| this.sendCommand({ | |
| type: 'change_language', | |
| player_id: 0, | |
| language: language | |
| }); | |
| // Notification sent by server (localized) | |
| // Hint shown by HintManager | |
| } | |
| updateUITexts() { | |
| // Update page title | |
| document.title = this.translate('game.window.title'); | |
| // Update header | |
| const headerTitle = document.querySelector('#topbar h1'); | |
| if (headerTitle) { | |
| headerTitle.textContent = this.translate('game.header.title'); | |
| } | |
| // Update topbar info badges (Tick and Units labels) - robust by IDs | |
| const tickSpan = document.getElementById('tick'); | |
| if (tickSpan && tickSpan.parentNode) { | |
| const parent = tickSpan.parentNode; | |
| if (parent.firstChild && parent.firstChild.nodeType === Node.TEXT_NODE) { | |
| parent.firstChild.textContent = this.translate('hud.topbar.tick') + ' '; | |
| } else { | |
| parent.insertBefore(document.createTextNode(this.translate('hud.topbar.tick') + ' '), parent.firstChild); | |
| } | |
| } | |
| const unitsSpan = document.getElementById('unit-count'); | |
| if (unitsSpan && unitsSpan.parentNode) { | |
| const parent = unitsSpan.parentNode; | |
| if (parent.firstChild && parent.firstChild.nodeType === Node.TEXT_NODE) { | |
| parent.firstChild.textContent = this.translate('hud.topbar.units') + ' '; | |
| } else { | |
| parent.insertBefore(document.createTextNode(this.translate('hud.topbar.units') + ' '), parent.firstChild); | |
| } | |
| } | |
| // Update ALL left sidebar sections with more specific selectors | |
| const leftSections = document.querySelectorAll('#left-sidebar .sidebar-section'); | |
| // [0] Build Menu | |
| if (leftSections[0]) { | |
| const buildTitle = leftSections[0].querySelector('h3'); | |
| if (buildTitle) buildTitle.textContent = this.translate('menu.build.title'); | |
| } | |
| // [1] Train Units | |
| if (leftSections[1]) { | |
| const unitsTitle = leftSections[1].querySelector('h3'); | |
| if (unitsTitle) unitsTitle.textContent = this.translate('menu.units.title'); | |
| const sourceLabel = document.querySelector('#production-source .label'); | |
| const sourceName = document.getElementById('production-source-name'); | |
| const sourceClear = document.getElementById('production-source-clear'); | |
| if (sourceLabel) sourceLabel.textContent = `๐ญ ${this.translate('hud.production.source.label')}`; | |
| if (sourceName) sourceName.textContent = this.translate('hud.production.source.auto'); | |
| if (sourceClear) sourceClear.title = this.translate('hud.production.source.clear'); | |
| } | |
| // [2] Selection Info | |
| if (leftSections[2]) { | |
| const selectionTitle = leftSections[2].querySelector('h3'); | |
| if (selectionTitle) selectionTitle.textContent = this.translate('menu.selection.title'); | |
| } | |
| // [3] Control Groups | |
| if (leftSections[3]) { | |
| const controlTitle = leftSections[3].querySelector('h3'); | |
| if (controlTitle) controlTitle.textContent = this.translate('menu.control_groups.title'); | |
| const hint = leftSections[3].querySelector('.control-groups-hint'); | |
| if (hint) hint.textContent = this.translate('control_groups.hint'); | |
| } | |
| // Update building buttons | |
| const buildButtons = { | |
| 'barracks': { name: 'building.barracks', cost: 500 }, | |
| 'war_factory': { name: 'building.war_factory', cost: 800 }, | |
| 'power_plant': { name: 'building.power_plant', cost: 300 }, | |
| 'refinery': { name: 'building.refinery', cost: 600 }, | |
| 'defense_turret': { name: 'building.turret', cost: 400 } | |
| }; | |
| document.querySelectorAll('.build-btn').forEach(btn => { | |
| const type = btn.dataset.type; | |
| if (buildButtons[type]) { | |
| const nameSpan = btn.querySelector('.build-name'); | |
| if (nameSpan) { | |
| nameSpan.textContent = this.translate(buildButtons[type].name); | |
| } | |
| btn.title = `${this.translate(buildButtons[type].name)} (${buildButtons[type].cost}๐ฐ)`; | |
| } | |
| }); | |
| // Update unit buttons | |
| const unitButtons = { | |
| 'infantry': { name: 'unit.infantry', cost: 100 }, | |
| 'tank': { name: 'unit.tank', cost: 500 }, | |
| 'harvester': { name: 'unit.harvester', cost: 200 }, | |
| 'helicopter': { name: 'unit.helicopter', cost: 800 }, | |
| 'artillery': { name: 'unit.artillery', cost: 600 } | |
| }; | |
| document.querySelectorAll('.unit-btn').forEach(btn => { | |
| const type = btn.dataset.type; | |
| if (unitButtons[type]) { | |
| const nameSpan = btn.querySelector('.unit-name'); | |
| if (nameSpan) { | |
| nameSpan.textContent = this.translate(unitButtons[type].name); | |
| } | |
| btn.title = `${this.translate(unitButtons[type].name)} (${unitButtons[type].cost}๐ฐ)`; | |
| } | |
| }); | |
| // Update Production Queue section (right sidebar) | |
| const productionQueueDiv = document.getElementById('production-queue'); | |
| if (productionQueueDiv) { | |
| const queueSection = productionQueueDiv.closest('.sidebar-section'); | |
| if (queueSection) { | |
| const queueTitle = queueSection.querySelector('h3'); | |
| if (queueTitle) queueTitle.textContent = this.translate('menu.production_queue.title'); | |
| const emptyQueueText = queueSection.querySelector('.empty-queue'); | |
| if (emptyQueueText) emptyQueueText.textContent = this.translate('menu.production_queue.empty'); | |
| } | |
| } | |
| // Update quick action buttons | |
| const selectAllBtn = document.getElementById('select-all'); | |
| if (selectAllBtn) { | |
| selectAllBtn.textContent = this.translate('menu.actions.select_all'); | |
| selectAllBtn.title = this.translate('menu.actions.select_all.tooltip'); | |
| } | |
| const stopBtn = document.getElementById('stop-units'); | |
| if (stopBtn) { | |
| stopBtn.textContent = this.translate('menu.actions.stop'); | |
| stopBtn.title = this.translate('menu.actions.stop.tooltip'); | |
| } | |
| const attackMoveBtn = document.getElementById('attack-move'); | |
| if (attackMoveBtn) { | |
| attackMoveBtn.textContent = this.translate('menu.actions.attack_move'); | |
| attackMoveBtn.title = this.translate('menu.actions.attack_move.tooltip'); | |
| } | |
| // Update quick actions section title by locating its buttons | |
| const qaAnchor = document.getElementById('select-all') || document.getElementById('attack-move'); | |
| if (qaAnchor) { | |
| const quickActionsSection = qaAnchor.closest('.sidebar-section'); | |
| if (quickActionsSection) { | |
| const h3 = quickActionsSection.querySelector('h3'); | |
| if (h3) h3.textContent = this.translate('menu.quick_actions.title'); | |
| } | |
| } | |
| // Update control groups title (right sidebar) if present | |
| const rightSections = document.querySelectorAll('#right-sidebar .sidebar-section'); | |
| if (rightSections && rightSections.length) { | |
| // Best-effort: keep existing translations if indices differ | |
| rightSections.forEach(sec => { | |
| const hasGroupsButtons = sec.querySelector('.control-group'); | |
| if (hasGroupsButtons) { | |
| const h3 = sec.querySelector('h3'); | |
| if (h3) h3.textContent = this.translate('menu.control_groups.title'); | |
| } | |
| }); | |
| } | |
| // Update Game Stats section by targeting by value IDs | |
| const playerUnitsVal = document.getElementById('player-units'); | |
| if (playerUnitsVal) { | |
| const statsSection = playerUnitsVal.closest('.sidebar-section'); | |
| if (statsSection) { | |
| const title = statsSection.querySelector('h3'); | |
| if (title) title.textContent = this.translate('menu.stats.title'); | |
| } | |
| const playerRow = playerUnitsVal.closest('.stat-row'); | |
| if (playerRow) { | |
| const label = playerRow.querySelector('span:first-child'); | |
| if (label) label.textContent = this.translate('menu.stats.player_units'); | |
| } | |
| } | |
| const enemyUnitsVal = document.getElementById('enemy-units'); | |
| if (enemyUnitsVal) { | |
| const enemyRow = enemyUnitsVal.closest('.stat-row'); | |
| if (enemyRow) { | |
| const label = enemyRow.querySelector('span:first-child'); | |
| if (label) label.textContent = this.translate('menu.stats.enemy_units'); | |
| } | |
| } | |
| const buildingsVal = document.getElementById('player-buildings'); | |
| if (buildingsVal) { | |
| const bRow = buildingsVal.closest('.stat-row'); | |
| if (bRow) { | |
| const label = bRow.querySelector('span:first-child'); | |
| if (label) label.textContent = this.translate('menu.stats.buildings'); | |
| } | |
| } | |
| // Update tooltips for camera buttons | |
| const zoomInBtn = document.getElementById('zoom-in'); | |
| if (zoomInBtn) zoomInBtn.title = this.translate('menu.camera.zoom_in'); | |
| const zoomOutBtn = document.getElementById('zoom-out'); | |
| if (zoomOutBtn) zoomOutBtn.title = this.translate('menu.camera.zoom_out'); | |
| const resetViewBtn = document.getElementById('reset-view'); | |
| if (resetViewBtn) resetViewBtn.title = this.translate('menu.camera.reset'); | |
| const refreshIntelBtn = document.getElementById('refresh-intel'); | |
| if (refreshIntelBtn) refreshIntelBtn.title = this.translate('hud.intel.refresh.tooltip'); | |
| // Update sound button | |
| const soundToggle = document.getElementById('sound-toggle'); | |
| if (soundToggle) { | |
| const enabled = this.sounds && this.sounds.enabled; | |
| soundToggle.title = this.translate(enabled ? 'menu.sound.disable' : 'menu.sound.enable'); | |
| } | |
| // Update connection status text to current language | |
| const statusText = document.getElementById('status-text'); | |
| const statusDot = document.querySelector('.status-dot'); | |
| if (statusText && statusDot) { | |
| const connected = statusDot.classList.contains('connected'); | |
| statusText.textContent = this.translate(connected ? 'status.connected' : 'status.disconnected'); | |
| } | |
| // Initialize Intel header/status if panel exists and no analysis yet | |
| const intelHeader = document.getElementById('intel-header'); | |
| const intelStatus = document.getElementById('intel-status'); | |
| const intelSource = document.getElementById('intel-source'); | |
| if (intelHeader && intelStatus) { | |
| // Preserve the source badge inside the header: update only the first text node | |
| if (intelHeader.firstChild && intelHeader.firstChild.nodeType === Node.TEXT_NODE) { | |
| intelHeader.firstChild.textContent = this.translate('hud.intel.header.offline'); | |
| } else { | |
| intelHeader.textContent = this.translate('hud.intel.header.offline'); | |
| } | |
| intelStatus.textContent = this.translate('hud.intel.status.waiting'); | |
| if (intelSource) { | |
| intelSource.title = this.translate('hud.intel.source.heuristic'); | |
| } | |
| } | |
| // Update selection panel (translate "No units selected") | |
| this.updateSelectionInfo(); | |
| console.log(`[i18n] UI updated to ${this.currentLanguage}`); | |
| } | |
| onMouseDown(e) { | |
| const rect = this.canvas.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| if (this.buildingMode) { | |
| this.placeBuilding(x, y); | |
| return; | |
| } | |
| this.dragStart = { x, y }; | |
| } | |
| onMouseMove(e) { | |
| if (!this.dragStart) return; | |
| const rect = this.canvas.getBoundingClientRect(); | |
| this.dragCurrent = { | |
| x: e.clientX - rect.left, | |
| y: e.clientY - rect.top | |
| }; | |
| } | |
| onMouseUp(e) { | |
| if (!this.dragStart) return; | |
| const rect = this.canvas.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| if (Math.abs(x - this.dragStart.x) < 5 && Math.abs(y - this.dragStart.y) < 5) { | |
| // Single click - select unit or set production building if clicking one | |
| const worldX = x / this.camera.zoom + this.camera.x; | |
| const worldY = y / this.camera.zoom + this.camera.y; | |
| const clickedBuilding = this.getBuildingAtPosition(worldX, worldY); | |
| if (clickedBuilding && clickedBuilding.player_id === 0) { | |
| // Set preferred production building | |
| this.selectedProductionBuilding = clickedBuilding.id; | |
| // Visual feedback via notification | |
| const bname = this.translate(`building.${clickedBuilding.type}`); | |
| this.showNotification(this.translate('notification.production_building_selected', { building: bname }), 'info'); | |
| this.updateProductionSourceLabel(); | |
| } else { | |
| this.selectUnitAt(x, y, !e.shiftKey); | |
| } | |
| } else { | |
| // Drag - box select | |
| this.boxSelect(this.dragStart.x, this.dragStart.y, x, y, e.shiftKey); | |
| } | |
| this.dragStart = null; | |
| this.dragCurrent = null; | |
| } | |
| onRightClick(e) { | |
| e.preventDefault(); | |
| const rect = this.canvas.getBoundingClientRect(); | |
| const worldX = (e.clientX - rect.left) / this.camera.zoom + this.camera.x; | |
| const worldY = (e.clientY - rect.top) / this.camera.zoom + this.camera.y; | |
| // Check if preparing nuke | |
| if (this.nukePreparing) { | |
| // Launch nuke at target | |
| this.sendCommand({ | |
| type: 'launch_nuke', | |
| player_id: 0, | |
| target: { x: worldX, y: worldY } | |
| }); | |
| this.nukePreparing = false; | |
| this.showNotification(this.translate('notification.nuke_launched'), 'error'); | |
| return; | |
| } | |
| if (this.buildingMode) { | |
| this.buildingMode = null; | |
| this.showNotification(this.translate('notification.building_cancelled'), 'warning'); | |
| return; | |
| } | |
| if (this.selectedUnits.size === 0) return; | |
| // Check if clicking on an enemy unit | |
| const clickedUnit = this.getUnitAtPosition(worldX, worldY); | |
| if (clickedUnit && clickedUnit.player_id !== 0) { | |
| // ATTACK ENEMY UNIT | |
| this.attackUnit(clickedUnit.id); | |
| const targetName = this.translate(`unit.${clickedUnit.type}`); | |
| const msg = this.translate('notification.units_attacking', { target: targetName }); | |
| this.showNotification(msg, 'warning'); | |
| return; | |
| } | |
| // Check if clicking on an enemy building | |
| const clickedBuilding = this.getBuildingAtPosition(worldX, worldY); | |
| if (clickedBuilding && clickedBuilding.player_id !== 0) { | |
| // Optional debug: console.log('Attacking building', clickedBuilding.id, clickedBuilding.type); | |
| // ATTACK ENEMY BUILDING | |
| this.attackBuilding(clickedBuilding.id); | |
| const targetName = this.translate(`building.${clickedBuilding.type}`); | |
| const msg = this.translate('notification.units_attacking', { target: targetName }); | |
| this.showNotification(msg, 'warning'); | |
| return; | |
| } | |
| // Otherwise: MOVE TO POSITION | |
| this.sendCommand({ | |
| type: 'move_unit', | |
| unit_ids: Array.from(this.selectedUnits), | |
| target: { x: worldX, y: worldY } | |
| }); | |
| const moveMessage = this.translate('notification.moving_units', { count: this.selectedUnits.size }); | |
| this.showNotification(moveMessage, 'success'); | |
| } | |
| onKeyDown(e) { | |
| // Escape - cancel actions | |
| if (e.key === 'Escape') { | |
| this.buildingMode = null; | |
| this.selectedUnits.clear(); | |
| // Cancel nuke preparation | |
| if (this.nukePreparing) { | |
| this.nukePreparing = false; | |
| this.sendCommand({ type: 'cancel_nuke', player_id: 0 }); | |
| this.showNotification(this.translate('notification.nuke_cancelled'), 'info'); | |
| } | |
| } | |
| // N key - prepare nuke (if ready) | |
| if (e.key === 'n' || e.key === 'N') { | |
| if (this.gameState && this.gameState.players[0]) { | |
| const player = this.gameState.players[0]; | |
| if (player.superweapon_ready && !this.nukePreparing) { | |
| this.nukePreparing = true; | |
| this.sendCommand({ type: 'prepare_nuke', player_id: 0 }); | |
| this.showNotification(this.translate('hud.nuke.select_target'), 'warning'); | |
| } | |
| } | |
| } | |
| // WASD or Arrow keys - camera movement | |
| const moveSpeed = 20; | |
| switch(e.key) { | |
| case 'w': case 'ArrowUp': this.camera.y -= moveSpeed; break; | |
| case 's': case 'ArrowDown': this.camera.y += moveSpeed; break; | |
| case 'a': case 'ArrowLeft': this.camera.x -= moveSpeed; break; | |
| case 'd': case 'ArrowRight': this.camera.x += moveSpeed; break; | |
| } | |
| // Control Groups (1-9) | |
| const num = parseInt(e.key); | |
| if (num >= 1 && num <= 9) { | |
| if (e.ctrlKey) { | |
| // Ctrl+[1-9]: Assign selected units to group | |
| this.assignControlGroup(num); | |
| } else { | |
| // [1-9]: Select group | |
| this.selectControlGroup(num); | |
| } | |
| return; | |
| } | |
| // Ctrl+A - select all units | |
| if (e.ctrlKey && e.key === 'a') { | |
| e.preventDefault(); | |
| this.selectAllUnits(); | |
| } | |
| } | |
| assignControlGroup(groupNum) { | |
| if (this.selectedUnits.size === 0) { | |
| const msg = this.translate('notification.group.empty', { group: groupNum }); | |
| this.showNotification(msg, 'warning'); | |
| return; | |
| } | |
| // Save selected units to control group | |
| this.controlGroups[groupNum] = Array.from(this.selectedUnits); | |
| const count = this.selectedUnits.size; | |
| const msg = this.translate('notification.group.assigned', { group: groupNum, count }); | |
| this.showNotification(msg, 'success'); | |
| // Show hint for control groups | |
| if (this.hints) { | |
| this.hints.onControlGroupAssigned(); | |
| } | |
| console.log(`[ControlGroup] Group ${groupNum} assigned:`, this.controlGroups[groupNum]); | |
| } | |
| selectControlGroup(groupNum) { | |
| const groupUnits = this.controlGroups[groupNum]; | |
| if (!groupUnits || groupUnits.length === 0) { | |
| const msg = this.translate('notification.group.empty', { group: groupNum }); | |
| this.showNotification(msg, 'info'); | |
| return; | |
| } | |
| // Filter out dead units | |
| const aliveUnits = groupUnits.filter(id => | |
| this.gameState && this.gameState.units && this.gameState.units[id] | |
| ); | |
| if (aliveUnits.length === 0) { | |
| const msg = this.translate('notification.group.destroyed', { group: groupNum }); | |
| this.showNotification(msg, 'warning'); | |
| this.controlGroups[groupNum] = []; | |
| return; | |
| } | |
| // Select the group | |
| this.selectedUnits = new Set(aliveUnits); | |
| // Update control group (remove dead units) | |
| if (aliveUnits.length !== groupUnits.length) { | |
| this.controlGroups[groupNum] = aliveUnits; | |
| } | |
| const msg = this.translate('notification.group.selected', { group: groupNum, count: aliveUnits.length }); | |
| this.showNotification(msg, 'info'); | |
| console.log(`[ControlGroup] Group ${groupNum} selected:`, aliveUnits); | |
| } | |
| selectUnitAt(canvasX, canvasY, clearSelection) { | |
| if (!this.gameState) return; | |
| const worldX = canvasX / this.camera.zoom + this.camera.x; | |
| const worldY = canvasY / this.camera.zoom + this.camera.y; | |
| if (clearSelection) { | |
| this.selectedUnits.clear(); | |
| } | |
| for (const [id, unit] of Object.entries(this.gameState.units)) { | |
| if (unit.player_id !== 0) continue; // Only select player units | |
| const dx = worldX - unit.position.x; | |
| const dy = worldY - unit.position.y; | |
| const distance = Math.sqrt(dx*dx + dy*dy); | |
| if (distance < CONFIG.TILE_SIZE) { | |
| this.selectedUnits.add(id); | |
| break; | |
| } | |
| } | |
| this.updateSelectionInfo(); | |
| } | |
| boxSelect(x1, y1, x2, y2, addToSelection) { | |
| if (!this.gameState) return; | |
| if (!addToSelection) { | |
| this.selectedUnits.clear(); | |
| } | |
| const minX = Math.min(x1, x2) / this.camera.zoom + this.camera.x; | |
| const maxX = Math.max(x1, x2) / this.camera.zoom + this.camera.x; | |
| const minY = Math.min(y1, y2) / this.camera.zoom + this.camera.y; | |
| const maxY = Math.max(y1, y2) / this.camera.zoom + this.camera.y; | |
| for (const [id, unit] of Object.entries(this.gameState.units)) { | |
| if (unit.player_id !== 0) continue; | |
| if (unit.position.x >= minX && unit.position.x <= maxX && | |
| unit.position.y >= minY && unit.position.y <= maxY) { | |
| this.selectedUnits.add(id); | |
| } | |
| } | |
| this.updateSelectionInfo(); | |
| } | |
| selectAllUnits() { | |
| if (!this.gameState) return; | |
| this.selectedUnits.clear(); | |
| for (const [id, unit] of Object.entries(this.gameState.units)) { | |
| if (unit.player_id === 0) { | |
| this.selectedUnits.add(id); | |
| } | |
| } | |
| const msg = this.translate('notification.units_selected', { count: this.selectedUnits.size }); | |
| this.showNotification(msg, 'success'); | |
| this.updateSelectionInfo(); | |
| } | |
| stopSelectedUnits() { | |
| this.sendCommand({ | |
| type: 'stop_units', | |
| unit_ids: Array.from(this.selectedUnits) | |
| }); | |
| } | |
| startBuildingMode(buildingType) { | |
| this.buildingMode = buildingType; | |
| // Translate building name using building.{type} key | |
| const buildingName = this.translate(`building.${buildingType}`); | |
| const placeMessage = this.translate('notification.click_to_place', { building: buildingName }); | |
| this.showNotification(placeMessage, 'info'); | |
| } | |
| placeBuilding(canvasX, canvasY) { | |
| const worldX = canvasX / this.camera.zoom + this.camera.x; | |
| const worldY = canvasY / this.camera.zoom + this.camera.y; | |
| // Snap to grid | |
| const gridX = Math.floor(worldX / CONFIG.TILE_SIZE) * CONFIG.TILE_SIZE; | |
| const gridY = Math.floor(worldY / CONFIG.TILE_SIZE) * CONFIG.TILE_SIZE; | |
| this.sendCommand({ | |
| type: 'build_building', | |
| building_type: this.buildingMode, | |
| position: { x: gridX, y: gridY }, | |
| player_id: 0 | |
| }); | |
| // Notification sent by server (localized) | |
| this.buildingMode = null; | |
| } | |
| trainUnit(unitType) { | |
| // Check requirements based on Red Alert logic | |
| const requirements = { | |
| 'infantry': 'barracks', | |
| 'tank': 'war_factory', | |
| 'artillery': 'war_factory', | |
| 'helicopter': 'war_factory', | |
| 'harvester': 'hq' // CRITICAL: Harvester needs HQ! | |
| }; | |
| const requiredBuilding = requirements[unitType]; | |
| // Note: Requirement check done server-side | |
| // Server will send localized error notification if needed | |
| if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; | |
| this.sendCommand({ | |
| type: 'build_unit', | |
| unit_type: unitType, | |
| player_id: 0, | |
| building_id: this.selectedProductionBuilding || null | |
| }); | |
| // Notification sent by server (localized) | |
| } | |
| updateProductionSourceLabel() { | |
| const label = document.getElementById('production-source-name'); | |
| if (!label) return; | |
| if (!this.selectedProductionBuilding) { | |
| label.textContent = 'Auto'; | |
| return; | |
| } | |
| const b = this.gameState && this.gameState.buildings ? this.gameState.buildings[this.selectedProductionBuilding] : null; | |
| if (!b) { | |
| label.textContent = 'Auto'; | |
| return; | |
| } | |
| label.textContent = this.translate(`building.${b.type}`); | |
| } | |
| onMinimapClick(e) { | |
| const rect = this.minimap.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| // Convert minimap coordinates to world coordinates | |
| const worldX = (x / this.minimap.width) * (CONFIG.MAP_WIDTH * CONFIG.TILE_SIZE); | |
| const worldY = (y / this.minimap.height) * (CONFIG.MAP_HEIGHT * CONFIG.TILE_SIZE); | |
| this.camera.x = worldX - (this.canvas.width / this.camera.zoom) / 2; | |
| this.camera.y = worldY - (this.canvas.height / this.camera.zoom) / 2; | |
| } | |
| onMinimapRightClick(e) { | |
| e.preventDefault(); | |
| if (this.selectedUnits.size === 0) return; | |
| const rect = this.minimap.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| // Convert minimap coordinates to world coordinates | |
| const worldX = (x / this.minimap.width) * (CONFIG.MAP_WIDTH * CONFIG.TILE_SIZE); | |
| const worldY = (y / this.minimap.height) * (CONFIG.MAP_HEIGHT * CONFIG.TILE_SIZE); | |
| // MOVE UNITS TO POSITION | |
| this.sendCommand({ | |
| type: 'move_unit', | |
| unit_ids: Array.from(this.selectedUnits), | |
| target: { x: worldX, y: worldY } | |
| }); | |
| const moveDistantMessage = this.translate('notification.moving_units_distant', { count: this.selectedUnits.size }); | |
| this.showNotification(moveDistantMessage, 'success'); | |
| } | |
| startGameLoop() { | |
| const loop = () => { | |
| this.render(); | |
| requestAnimationFrame(loop); | |
| }; | |
| loop(); | |
| } | |
| render() { | |
| if (!this.gameState) return; | |
| // Clear canvas | |
| this.ctx.fillStyle = '#0a0a0a'; | |
| this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); | |
| this.ctx.save(); | |
| this.ctx.scale(this.camera.zoom, this.camera.zoom); | |
| this.ctx.translate(-this.camera.x, -this.camera.y); | |
| // Draw terrain | |
| this.drawTerrain(); | |
| // Draw buildings | |
| this.drawBuildings(); | |
| // Draw units | |
| this.drawUnits(); | |
| // Draw combat animations (RED ALERT style) | |
| this.updateAndDrawProjectiles(); | |
| this.updateAndDrawExplosions(); | |
| // Draw selections | |
| this.drawSelections(); | |
| // Draw drag box | |
| if (this.dragStart && this.dragCurrent) { | |
| this.ctx.strokeStyle = CONFIG.COLORS.SELECTION; | |
| this.ctx.lineWidth = 2 / this.camera.zoom; | |
| this.ctx.strokeRect( | |
| this.dragStart.x / this.camera.zoom + this.camera.x, | |
| this.dragStart.y / this.camera.zoom + this.camera.y, | |
| (this.dragCurrent.x - this.dragStart.x) / this.camera.zoom, | |
| (this.dragCurrent.y - this.dragStart.y) / this.camera.zoom | |
| ); | |
| } | |
| this.ctx.restore(); | |
| // Nuke flash effect | |
| if (this.nukeFlash > 0) { | |
| const alpha = this.nukeFlash / 60; // Fade out over 60 frames | |
| this.ctx.fillStyle = `rgba(255, 255, 255, ${alpha * 0.8})`; | |
| this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); | |
| this.nukeFlash--; | |
| } | |
| // Draw minimap | |
| this.drawMinimap(); | |
| } | |
| drawTerrain() { | |
| const terrain = this.gameState.terrain; | |
| for (let y = 0; y < terrain.length; y++) { | |
| for (let x = 0; x < terrain[y].length; x++) { | |
| const tile = terrain[y][x]; | |
| let color = CONFIG.COLORS.GRASS; | |
| switch(tile) { | |
| case 'ore': color = CONFIG.COLORS.ORE; break; | |
| case 'gem': color = CONFIG.COLORS.GEM; break; | |
| case 'water': color = CONFIG.COLORS.WATER; break; | |
| } | |
| this.ctx.fillStyle = color; | |
| this.ctx.fillRect( | |
| x * CONFIG.TILE_SIZE, | |
| y * CONFIG.TILE_SIZE, | |
| CONFIG.TILE_SIZE, | |
| CONFIG.TILE_SIZE | |
| ); | |
| // Grid lines | |
| this.ctx.strokeStyle = 'rgba(0,0,0,0.1)'; | |
| this.ctx.lineWidth = 1 / this.camera.zoom; | |
| this.ctx.strokeRect( | |
| x * CONFIG.TILE_SIZE, | |
| y * CONFIG.TILE_SIZE, | |
| CONFIG.TILE_SIZE, | |
| CONFIG.TILE_SIZE | |
| ); | |
| } | |
| } | |
| } | |
| drawBuildings() { | |
| for (const building of Object.values(this.gameState.buildings)) { | |
| const color = building.player_id === 0 ? CONFIG.COLORS.PLAYER : CONFIG.COLORS.ENEMY; | |
| this.ctx.fillStyle = color; | |
| this.ctx.fillRect( | |
| building.position.x, | |
| building.position.y, | |
| CONFIG.TILE_SIZE * 2, | |
| CONFIG.TILE_SIZE * 2 | |
| ); | |
| this.ctx.strokeStyle = '#000'; | |
| this.ctx.lineWidth = 2 / this.camera.zoom; | |
| this.ctx.strokeRect( | |
| building.position.x, | |
| building.position.y, | |
| CONFIG.TILE_SIZE * 2, | |
| CONFIG.TILE_SIZE * 2 | |
| ); | |
| // Health bar | |
| this.drawHealthBar( | |
| building.position.x, | |
| building.position.y - 10, | |
| CONFIG.TILE_SIZE * 2, | |
| building.health / building.max_health | |
| ); | |
| } | |
| } | |
| drawUnits() { | |
| for (const [id, unit] of Object.entries(this.gameState.units)) { | |
| const color = unit.player_id === 0 ? CONFIG.COLORS.PLAYER : CONFIG.COLORS.ENEMY; | |
| const size = CONFIG.TILE_SIZE / 2; | |
| // RED ALERT: Muzzle flash when attacking | |
| const isAttacking = unit.attack_animation > 0; | |
| const flashIntensity = unit.attack_animation / 10.0; // Fade out over 10 frames | |
| this.ctx.fillStyle = color; | |
| this.ctx.strokeStyle = color; | |
| this.ctx.lineWidth = 2; | |
| // Draw unit with unique shape per type | |
| switch(unit.type) { | |
| case 'infantry': | |
| // Infantry: Small square (soldier) | |
| this.ctx.fillRect( | |
| unit.position.x - size * 0.4, | |
| unit.position.y - size * 0.4, | |
| size * 0.8, | |
| size * 0.8 | |
| ); | |
| break; | |
| case 'tank': | |
| // Tank: Circle (turret top view) | |
| this.ctx.beginPath(); | |
| this.ctx.arc(unit.position.x, unit.position.y, size * 0.8, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| // Tank cannon | |
| this.ctx.fillRect( | |
| unit.position.x - size * 0.15, | |
| unit.position.y - size * 1.2, | |
| size * 0.3, | |
| size * 1.2 | |
| ); | |
| break; | |
| case 'harvester': | |
| // Harvester: Triangle pointing up (like a mining vehicle) | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(unit.position.x, unit.position.y - size); | |
| this.ctx.lineTo(unit.position.x - size * 0.8, unit.position.y + size * 0.6); | |
| this.ctx.lineTo(unit.position.x + size * 0.8, unit.position.y + size * 0.6); | |
| this.ctx.closePath(); | |
| this.ctx.fill(); | |
| // Harvester cargo indicator if carrying resources | |
| if (unit.cargo && unit.cargo > 0) { | |
| this.ctx.fillStyle = unit.cargo >= 180 ? '#FFD700' : '#FFA500'; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(unit.position.x, unit.position.y, size * 0.3, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| this.ctx.fillStyle = color; | |
| } | |
| break; | |
| case 'helicopter': | |
| // Helicopter: Diamond (rotors view from above) | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(unit.position.x, unit.position.y - size); | |
| this.ctx.lineTo(unit.position.x + size, unit.position.y); | |
| this.ctx.lineTo(unit.position.x, unit.position.y + size); | |
| this.ctx.lineTo(unit.position.x - size, unit.position.y); | |
| this.ctx.closePath(); | |
| this.ctx.fill(); | |
| // Rotor blades | |
| this.ctx.strokeStyle = color; | |
| this.ctx.lineWidth = 3; | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(unit.position.x - size * 1.2, unit.position.y); | |
| this.ctx.lineTo(unit.position.x + size * 1.2, unit.position.y); | |
| this.ctx.stroke(); | |
| break; | |
| case 'artillery': | |
| // Artillery: Pentagon (artillery platform) | |
| this.ctx.beginPath(); | |
| for (let i = 0; i < 5; i++) { | |
| const angle = (i * 2 * Math.PI / 5) - Math.PI / 2; | |
| const x = unit.position.x + size * Math.cos(angle); | |
| const y = unit.position.y + size * Math.sin(angle); | |
| if (i === 0) { | |
| this.ctx.moveTo(x, y); | |
| } else { | |
| this.ctx.lineTo(x, y); | |
| } | |
| } | |
| this.ctx.closePath(); | |
| this.ctx.fill(); | |
| // Artillery barrel | |
| this.ctx.fillRect( | |
| unit.position.x - size * 0.1, | |
| unit.position.y - size * 1.5, | |
| size * 0.2, | |
| size * 1.5 | |
| ); | |
| break; | |
| default: | |
| // Fallback: Circle | |
| this.ctx.beginPath(); | |
| this.ctx.arc(unit.position.x, unit.position.y, size, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| break; | |
| } | |
| // Health bar | |
| this.drawHealthBar( | |
| unit.position.x - size, | |
| unit.position.y - size - 10, | |
| size * 2, | |
| unit.health / unit.max_health | |
| ); | |
| // RED ALERT: Muzzle flash effect when attacking | |
| if (isAttacking && flashIntensity > 0) { | |
| this.ctx.save(); | |
| this.ctx.globalAlpha = flashIntensity * 0.8; | |
| // Flash color: bright yellow-white | |
| this.ctx.fillStyle = '#FFFF00'; | |
| this.ctx.shadowColor = '#FFAA00'; | |
| this.ctx.shadowBlur = 15; | |
| // Draw flash (circle at unit position) | |
| this.ctx.beginPath(); | |
| this.ctx.arc(unit.position.x, unit.position.y, size * 0.7, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| // Draw flash rays (for tanks/artillery) | |
| if (unit.type === 'tank' || unit.type === 'artillery') { | |
| this.ctx.strokeStyle = '#FFFF00'; | |
| this.ctx.lineWidth = 3; | |
| for (let i = 0; i < 8; i++) { | |
| const angle = (i * Math.PI / 4) + (flashIntensity * Math.PI / 8); | |
| const rayLength = size * 1.5 * flashIntensity; | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(unit.position.x, unit.position.y); | |
| this.ctx.lineTo( | |
| unit.position.x + Math.cos(angle) * rayLength, | |
| unit.position.y + Math.sin(angle) * rayLength | |
| ); | |
| this.ctx.stroke(); | |
| } | |
| } | |
| this.ctx.restore(); | |
| } | |
| } | |
| } | |
| drawHealthBar(x, y, width, healthPercent) { | |
| const barHeight = 5 / this.camera.zoom; | |
| this.ctx.fillStyle = '#000'; | |
| this.ctx.fillRect(x, y, width, barHeight); | |
| const color = healthPercent > 0.5 ? '#2ECC71' : (healthPercent > 0.25 ? '#F39C12' : '#E74C3C'); | |
| this.ctx.fillStyle = color; | |
| this.ctx.fillRect(x, y, width * healthPercent, barHeight); | |
| } | |
| drawSelections() { | |
| for (const unitId of this.selectedUnits) { | |
| const unit = this.gameState.units[unitId]; | |
| if (!unit) continue; | |
| this.ctx.strokeStyle = CONFIG.COLORS.SELECTION; | |
| this.ctx.lineWidth = 3 / this.camera.zoom; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(unit.position.x, unit.position.y, CONFIG.TILE_SIZE, 0, Math.PI * 2); | |
| this.ctx.stroke(); | |
| } | |
| } | |
| drawMinimap() { | |
| // Clear minimap | |
| this.minimapCtx.fillStyle = '#000'; | |
| this.minimapCtx.fillRect(0, 0, this.minimap.width, this.minimap.height); | |
| const scaleX = this.minimap.width / (CONFIG.MAP_WIDTH * CONFIG.TILE_SIZE); | |
| const scaleY = this.minimap.height / (CONFIG.MAP_HEIGHT * CONFIG.TILE_SIZE); | |
| // Draw terrain | |
| for (let y = 0; y < this.gameState.terrain.length; y++) { | |
| for (let x = 0; x < this.gameState.terrain[y].length; x++) { | |
| const tile = this.gameState.terrain[y][x]; | |
| if (tile === 'ore' || tile === 'gem' || tile === 'water') { | |
| this.minimapCtx.fillStyle = tile === 'ore' ? '#aa8800' : tile === 'gem' ? '#663399' : '#004466'; | |
| this.minimapCtx.fillRect( | |
| x * CONFIG.TILE_SIZE * scaleX, | |
| y * CONFIG.TILE_SIZE * scaleY, | |
| CONFIG.TILE_SIZE * scaleX, | |
| CONFIG.TILE_SIZE * scaleY | |
| ); | |
| } | |
| } | |
| } | |
| // Draw buildings | |
| for (const building of Object.values(this.gameState.buildings)) { | |
| this.minimapCtx.fillStyle = building.player_id === 0 ? '#4A90E2' : '#E74C3C'; | |
| this.minimapCtx.fillRect( | |
| building.position.x * scaleX, | |
| building.position.y * scaleY, | |
| 4, | |
| 4 | |
| ); | |
| } | |
| // Draw units | |
| for (const unit of Object.values(this.gameState.units)) { | |
| this.minimapCtx.fillStyle = unit.player_id === 0 ? '#4A90E2' : '#E74C3C'; | |
| this.minimapCtx.fillRect( | |
| unit.position.x * scaleX - 1, | |
| unit.position.y * scaleY - 1, | |
| 2, | |
| 2 | |
| ); | |
| } | |
| // Draw viewport | |
| const vpX = this.camera.x * scaleX; | |
| const vpY = this.camera.y * scaleY; | |
| const vpW = (this.canvas.width / this.camera.zoom) * scaleX; | |
| const vpH = (this.canvas.height / this.camera.zoom) * scaleY; | |
| this.minimapCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; | |
| this.minimapCtx.lineWidth = 2; | |
| this.minimapCtx.strokeRect(vpX, vpY, vpW, vpH); | |
| } | |
| updateUI() { | |
| if (!this.gameState) return; | |
| // Update top bar | |
| document.getElementById('tick').textContent = this.gameState.tick; | |
| document.getElementById('unit-count').textContent = Object.keys(this.gameState.units).length; | |
| // Update resources | |
| const player = this.gameState.players[0]; | |
| if (player) { | |
| document.getElementById('credits').textContent = player.credits; | |
| // RED ALERT: Power display with color-coded status | |
| const powerElem = document.getElementById('power'); | |
| const powerMaxElem = document.getElementById('power-max'); | |
| powerElem.textContent = player.power; | |
| powerMaxElem.textContent = player.power_consumption || player.power; | |
| // Color code based on power status | |
| if (player.power_consumption === 0) { | |
| powerElem.style.color = '#2ecc71'; // Green - no consumption | |
| } else if (player.power >= player.power_consumption) { | |
| powerElem.style.color = '#2ecc71'; // Green - enough power | |
| } else if (player.power >= player.power_consumption * 0.8) { | |
| powerElem.style.color = '#f39c12'; // Orange - low power | |
| } else { | |
| powerElem.style.color = '#e74c3c'; // Red - critical power shortage | |
| } | |
| // Superweapon indicator | |
| const nukeIndicator = document.getElementById('nuke-indicator'); | |
| if (nukeIndicator) { | |
| const chargePercent = Math.min(100, (player.superweapon_charge / 1800) * 100); | |
| const chargeBar = nukeIndicator.querySelector('.nuke-charge-bar'); | |
| const nukeStatus = nukeIndicator.querySelector('.nuke-status'); | |
| if (chargeBar) { | |
| chargeBar.style.width = chargePercent + '%'; | |
| } | |
| if (player.superweapon_ready) { | |
| nukeIndicator.classList.add('ready'); | |
| if (nukeStatus) nukeStatus.textContent = this.translate('hud.nuke.ready'); | |
| } else { | |
| nukeIndicator.classList.remove('ready'); | |
| if (nukeStatus) nukeStatus.textContent = `${this.translate('hud.nuke.charging')} ${Math.floor(chargePercent)}%`; | |
| } | |
| } | |
| } | |
| // Update stats | |
| const playerUnits = Object.values(this.gameState.units).filter(u => u.player_id === 0); | |
| const enemyUnits = Object.values(this.gameState.units).filter(u => u.player_id !== 0); | |
| const playerBuildings = Object.values(this.gameState.buildings).filter(b => b.player_id === 0); | |
| document.getElementById('player-units').textContent = playerUnits.length; | |
| document.getElementById('enemy-units').textContent = enemyUnits.length; | |
| document.getElementById('player-buildings').textContent = playerBuildings.length; | |
| // Update control groups display | |
| this.updateControlGroupsDisplay(); | |
| } | |
| updateControlGroupsDisplay() { | |
| // Update each control group display (1-9) | |
| for (let i = 1; i <= 9; i++) { | |
| const groupDiv = document.querySelector(`.control-group[data-group="${i}"]`); | |
| if (!groupDiv) continue; | |
| const groupUnits = this.controlGroups[i] || []; | |
| const aliveUnits = groupUnits.filter(id => | |
| this.gameState && this.gameState.units && this.gameState.units[id] | |
| ); | |
| const countSpan = groupDiv.querySelector('.group-count'); | |
| if (aliveUnits.length > 0) { | |
| // Localized unit count label | |
| const countLabel = this.currentLanguage === 'fr' | |
| ? `${aliveUnits.length} unitรฉ${aliveUnits.length > 1 ? 's' : ''}` | |
| : this.currentLanguage === 'zh-TW' | |
| ? `${aliveUnits.length} ๅๅฎไฝ` | |
| : `${aliveUnits.length} unit${aliveUnits.length > 1 ? 's' : ''}`; | |
| countSpan.textContent = countLabel; | |
| groupDiv.classList.add('active'); | |
| // Highlight if currently selected | |
| const isSelected = aliveUnits.some(id => this.selectedUnits.has(id)); | |
| groupDiv.classList.toggle('selected', isSelected); | |
| } else { | |
| countSpan.textContent = '-'; | |
| groupDiv.classList.remove('active', 'selected'); | |
| } | |
| } | |
| } | |
| updateIntelPanel() { | |
| if (!this.gameState) return; | |
| const dl = this.gameState.model_download; | |
| // If a download is in progress or pending, show its status in the Intel status line | |
| if (dl && dl.status && dl.status !== 'idle') { | |
| const status = document.getElementById('intel-status'); | |
| if (status) { | |
| let text = ''; | |
| if (dl.status === 'starting') text = this.translate('hud.model.download.starting'); | |
| else if (dl.status === 'downloading') text = this.translate('hud.model.download.progress', { percent: dl.percent || 0, note: dl.note || '' }); | |
| else if (dl.status === 'retrying') text = this.translate('hud.model.download.retry'); | |
| else if (dl.status === 'done') text = this.translate('hud.model.download.done'); | |
| else if (dl.status === 'error') text = this.translate('hud.model.download.error'); | |
| if (text) status.textContent = text; | |
| } | |
| } | |
| if (!this.gameState.ai_analysis) return; | |
| const analysis = this.gameState.ai_analysis; | |
| const header = document.getElementById('intel-header'); | |
| const status = document.getElementById('intel-status'); | |
| const summary = document.getElementById('intel-summary'); | |
| const tips = document.getElementById('intel-tips'); | |
| const sourceBadge = document.getElementById('intel-source'); | |
| // Update header | |
| if (!analysis.summary || analysis.summary.includes('not available') || analysis.summary.includes('indisponible')) { | |
| header.childNodes[0].textContent = this.translate('hud.intel.header.offline'); | |
| header.className = 'offline'; | |
| status.textContent = this.translate('hud.intel.status.model_missing'); | |
| summary.textContent = ''; | |
| tips.innerHTML = ''; | |
| if (sourceBadge) sourceBadge.textContent = 'โ'; | |
| } else if (analysis.summary.includes('failed') || analysis.summary.includes('รฉchec')) { | |
| header.childNodes[0].textContent = this.translate('hud.intel.header.error'); | |
| header.className = 'offline'; | |
| status.textContent = this.translate('hud.intel.status.failed'); | |
| summary.textContent = analysis.summary || ''; | |
| tips.innerHTML = ''; | |
| if (sourceBadge) sourceBadge.textContent = 'โ'; | |
| } else { | |
| header.childNodes[0].textContent = this.translate('hud.intel.header.active'); | |
| header.className = 'active'; | |
| status.textContent = this.translate('hud.intel.status.updated', {seconds: 0}); | |
| // Display summary | |
| summary.textContent = analysis.summary || ''; | |
| // Display tips | |
| if (analysis.tips && analysis.tips.length > 0) { | |
| const tipsHtml = analysis.tips.map(tip => `<div>${tip}</div>`).join(''); | |
| tips.innerHTML = tipsHtml; | |
| } else { | |
| tips.innerHTML = ''; | |
| } | |
| // Source badge | |
| if (sourceBadge) { | |
| const src = analysis.source === 'llm' ? 'LLM' : 'Heuristic'; | |
| sourceBadge.textContent = src; | |
| sourceBadge.title = src === 'LLM' ? this.translate('hud.intel.source.llm') : this.translate('hud.intel.source.heuristic'); | |
| } | |
| } | |
| } | |
| updateSelectionInfo() { | |
| const infoDiv = document.getElementById('selection-info'); | |
| if (this.selectedUnits.size === 0) { | |
| infoDiv.innerHTML = `<p class="no-selection">${this.translate('menu.selection.none')}</p>`; | |
| return; | |
| } | |
| // Hint triggers based on selection | |
| if (this.hints) { | |
| if (this.selectedUnits.size === 1) { | |
| this.hints.onFirstUnitSelected(); | |
| } else if (this.selectedUnits.size >= 3) { | |
| this.hints.onMultipleUnitsSelected(this.selectedUnits.size); | |
| } | |
| } | |
| const selectionText = this.translate('notification.units_selected', { count: this.selectedUnits.size }); | |
| let html = `<p><strong>${selectionText}</strong></p>`; | |
| const unitTypes = {}; | |
| for (const unitId of this.selectedUnits) { | |
| const unit = this.gameState.units[unitId]; | |
| if (unit) { | |
| unitTypes[unit.type] = (unitTypes[unit.type] || 0) + 1; | |
| } | |
| } | |
| for (const [type, count] of Object.entries(unitTypes)) { | |
| const typeName = this.translate(`unit.${type}`); | |
| html += `<div class="selection-item">${count}x ${typeName}</div>`; | |
| } | |
| infoDiv.innerHTML = html; | |
| } | |
| updateConnectionStatus(connected) { | |
| const statusDot = document.querySelector('.status-dot'); | |
| const statusText = document.getElementById('status-text'); | |
| if (connected) { | |
| statusDot.classList.remove('disconnected'); | |
| statusDot.classList.add('connected'); | |
| statusText.textContent = this.translate('status.connected'); | |
| } else { | |
| statusDot.classList.remove('connected'); | |
| statusDot.classList.add('disconnected'); | |
| statusText.textContent = this.translate('status.disconnected'); | |
| } | |
| } | |
| hideLoadingScreen() { | |
| const loadingScreen = document.getElementById('loading-screen'); | |
| setTimeout(() => { | |
| loadingScreen.classList.add('hidden'); | |
| }, 500); | |
| } | |
| showNotification(message, type = 'info') { | |
| const container = document.getElementById('notifications'); | |
| const notification = document.createElement('div'); | |
| notification.className = `notification ${type}`; | |
| notification.textContent = message; | |
| container.appendChild(notification); | |
| setTimeout(() => { | |
| notification.remove(); | |
| }, 3000); | |
| } | |
| // ===== COMBAT ANIMATIONS (RED ALERT STYLE) ===== | |
| detectAttacks(oldUnits, newUnits) { | |
| // Detect when units start attacking (attack_animation > 0) | |
| for (const [id, newUnit] of Object.entries(newUnits)) { | |
| const oldUnit = oldUnits[id]; | |
| if (!oldUnit) continue; | |
| // Attack just started (animation went from 0 to positive) | |
| if (newUnit.attack_animation > 0 && oldUnit.attack_animation === 0) { | |
| const target = newUnit.target_unit_id ? newUnits[newUnit.target_unit_id] : null; | |
| if (target) { | |
| this.createProjectile(newUnit, target); | |
| } | |
| } | |
| } | |
| } | |
| detectProductionComplete(oldBuildings, newBuildings) { | |
| // Detect when a building finishes construction | |
| for (const [id, newBuilding] of Object.entries(newBuildings)) { | |
| const oldBuilding = oldBuildings[id]; | |
| if (!oldBuilding) continue; | |
| // Building just completed construction | |
| if (newBuilding.under_construction === false && oldBuilding.under_construction === true) { | |
| if (this.sounds && newBuilding.player === 0) { // Only for player buildings | |
| this.sounds.play('build_complete', 0.7); | |
| } | |
| } | |
| } | |
| } | |
| detectNewUnits(oldUnits, newUnits) { | |
| // Detect when a new unit is created (production complete) | |
| for (const [id, newUnit] of Object.entries(newUnits)) { | |
| if (!oldUnits[id] && newUnit.player === 0) { // New unit for player | |
| if (this.sounds) { | |
| this.sounds.play('unit_ready', 0.6); | |
| } | |
| } | |
| } | |
| } | |
| createProjectile(attacker, target) { | |
| // RED ALERT: Different projectiles for different units | |
| const projectileType = { | |
| 'infantry': 'bullet', // Small bullet | |
| 'tank': 'shell', // Tank shell | |
| 'artillery': 'shell', // Artillery shell (larger) | |
| 'helicopter': 'missile' // Air-to-ground missile | |
| }[attacker.type] || 'bullet'; | |
| // Play unit fire sound | |
| if (this.sounds) { | |
| this.sounds.play('unit_fire', 0.5); | |
| } | |
| const projectile = { | |
| type: projectileType, | |
| startX: attacker.position.x, | |
| startY: attacker.position.y, | |
| endX: target.position.x, | |
| endY: target.position.y, | |
| x: attacker.position.x, | |
| y: attacker.position.y, | |
| progress: 0, | |
| speed: projectileType === 'missile' ? 0.15 : (projectileType === 'shell' ? 0.12 : 0.25), | |
| color: attacker.player_id === 0 ? '#4A90E2' : '#E74C3C', | |
| targetId: target.id | |
| }; | |
| this.projectiles.push(projectile); | |
| } | |
| updateAndDrawProjectiles() { | |
| // Update and draw all active projectiles | |
| for (let i = this.projectiles.length - 1; i >= 0; i--) { | |
| const proj = this.projectiles[i]; | |
| // Update position (lerp from start to end) | |
| proj.progress += proj.speed; | |
| proj.x = proj.startX + (proj.endX - proj.startX) * proj.progress; | |
| proj.y = proj.startY + (proj.endY - proj.startY) * proj.progress; | |
| // Draw projectile | |
| this.ctx.save(); | |
| this.ctx.fillStyle = proj.color; | |
| this.ctx.strokeStyle = proj.color; | |
| this.ctx.lineWidth = 2; | |
| switch (proj.type) { | |
| case 'bullet': | |
| // Small dot (infantry bullet) | |
| this.ctx.beginPath(); | |
| this.ctx.arc(proj.x, proj.y, 3, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| break; | |
| case 'shell': | |
| // Larger circle (tank/artillery shell) | |
| this.ctx.beginPath(); | |
| this.ctx.arc(proj.x, proj.y, 5, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| // Trailing smoke | |
| this.ctx.fillStyle = 'rgba(100, 100, 100, 0.3)'; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(proj.x - (proj.endX - proj.startX) * 0.1, | |
| proj.y - (proj.endY - proj.startY) * 0.1, 4, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| break; | |
| case 'missile': | |
| // Elongated missile shape | |
| const angle = Math.atan2(proj.endY - proj.startY, proj.endX - proj.startX); | |
| this.ctx.translate(proj.x, proj.y); | |
| this.ctx.rotate(angle); | |
| this.ctx.fillRect(-8, -2, 16, 4); | |
| // Missile exhaust | |
| this.ctx.fillStyle = '#FF6600'; | |
| this.ctx.fillRect(-10, -1, 3, 2); | |
| break; | |
| } | |
| this.ctx.restore(); | |
| // Check if projectile reached target | |
| if (proj.progress >= 1.0) { | |
| // Create explosion effect | |
| this.createExplosion(proj.endX, proj.endY, proj.type); | |
| this.projectiles.splice(i, 1); | |
| } | |
| } | |
| } | |
| createExplosion(x, y, type) { | |
| const explosionSize = { | |
| 'bullet': 15, | |
| 'shell': 30, | |
| 'missile': 35 | |
| }[type] || 20; | |
| // Play explosion sound | |
| if (this.sounds) { | |
| const volume = type === 'bullet' ? 0.3 : 0.6; | |
| this.sounds.play('explosion', volume); | |
| } | |
| const explosion = { | |
| x: x, | |
| y: y, | |
| size: explosionSize, | |
| maxSize: explosionSize, | |
| frame: 0, | |
| maxFrames: 15 | |
| }; | |
| this.explosions.push(explosion); | |
| } | |
| createNukeExplosion(target) { | |
| // Massive nuclear explosion | |
| const explosion = { | |
| x: target.x, | |
| y: target.y, | |
| size: 200, // Radius: 200 pixels = 5 tiles | |
| maxSize: 200, | |
| frame: 0, | |
| maxFrames: 60, // Longer animation (1 second) | |
| isNuke: true // Special rendering | |
| }; | |
| this.explosions.push(explosion); | |
| } | |
| updateAndDrawExplosions() { | |
| // Update and draw all active explosions | |
| for (let i = this.explosions.length - 1; i >= 0; i--) { | |
| const exp = this.explosions[i]; | |
| exp.frame++; | |
| // Explosion grows then shrinks | |
| const progress = exp.frame / exp.maxFrames; | |
| if (progress < 0.5) { | |
| exp.size = exp.maxSize * (progress * 2); | |
| } else { | |
| exp.size = exp.maxSize * (2 - progress * 2); | |
| } | |
| // Draw explosion (expanding circle with fading colors) | |
| this.ctx.save(); | |
| const alpha = 1 - progress; | |
| if (exp.isNuke) { | |
| // NUCLEAR EXPLOSION - massive multi-layered effect | |
| // Outer blast wave (red/orange) | |
| this.ctx.fillStyle = `rgba(255, 100, 0, ${alpha * 0.6})`; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(exp.x, exp.y, exp.size, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| // Middle ring (yellow) | |
| this.ctx.fillStyle = `rgba(255, 200, 0, ${alpha * 0.8})`; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(exp.x, exp.y, exp.size * 0.7, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| // Inner fireball (white hot) | |
| this.ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(exp.x, exp.y, exp.size * 0.4, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| } else { | |
| // Normal explosion | |
| // Outer ring (yellow/orange) | |
| this.ctx.fillStyle = `rgba(255, 200, 0, ${alpha * 0.8})`; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(exp.x, exp.y, exp.size, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| // Inner core (white hot) | |
| this.ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(exp.x, exp.y, exp.size * 0.6, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| } | |
| this.ctx.restore(); | |
| // Remove finished explosions | |
| if (exp.frame >= exp.maxFrames) { | |
| this.explosions.splice(i, 1); | |
| } | |
| } | |
| } | |
| // Helper: Get unit at world position | |
| getUnitAtPosition(worldX, worldY) { | |
| if (!this.gameState || !this.gameState.units) return null; | |
| const CLICK_TOLERANCE = CONFIG.TILE_SIZE; | |
| for (const [id, unit] of Object.entries(this.gameState.units)) { | |
| const dx = worldX - (unit.position.x + CONFIG.TILE_SIZE / 2); | |
| const dy = worldY - (unit.position.y + CONFIG.TILE_SIZE / 2); | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| if (distance < CLICK_TOLERANCE) { | |
| return { id, ...unit }; | |
| } | |
| } | |
| return null; | |
| } | |
| // Helper: Get building at world position (with tolerance) | |
| getBuildingAtPosition(worldX, worldY) { | |
| if (!this.gameState || !this.gameState.buildings) return null; | |
| for (const [id, building] of Object.entries(this.gameState.buildings)) { | |
| const bx = building.position.x; | |
| const by = building.position.y; | |
| const bw = CONFIG.TILE_SIZE * 2; | |
| const bh = CONFIG.TILE_SIZE * 2; | |
| // Add tolerance to make clicking easier (including the health bar area above) | |
| const tol = Math.max(8, Math.floor(CONFIG.TILE_SIZE * 0.2)); | |
| const left = bx - tol; | |
| const right = bx + bw + tol; | |
| const top = by - (tol + 12); // include health bar zone (~10px above) | |
| const bottom = by + bh + tol; | |
| if (worldX >= left && worldX <= right && worldY >= top && worldY <= bottom) { | |
| return { id, ...building }; | |
| } | |
| } | |
| return null; | |
| } | |
| // Helper: Send attack command | |
| attackUnit(targetId) { | |
| if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; | |
| this.sendCommand({ | |
| type: 'attack_unit', | |
| attacker_ids: Array.from(this.selectedUnits), | |
| target_id: targetId | |
| }); | |
| } | |
| // Helper: Send attack building command | |
| attackBuilding(targetBuildingId) { | |
| if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; | |
| this.sendCommand({ | |
| type: 'attack_building', | |
| attacker_ids: Array.from(this.selectedUnits), | |
| target_id: targetBuildingId | |
| }); | |
| } | |
| // Helper: Check if player has building type | |
| hasBuilding(buildingType) { | |
| if (!this.gameState || !this.gameState.buildings) return false; | |
| for (const building of Object.values(this.gameState.buildings)) { | |
| if (building.player_id === 0 && building.type === buildingType) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| } | |
| // Initialize game when DOM is ready | |
| document.addEventListener('DOMContentLoaded', () => { | |
| const game = new GameClient(); | |
| window.gameClient = game; // For debugging | |
| console.log('๐ฎ RTS Game initialized!'); | |
| }); | |