/** * 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 => `
${this.translate('menu.selection.none')}
`; 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 = `${selectionText}
`; 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 += `