Luigi's picture
feat(endgame): elimination-based game over + overlay; fix LLM chat_format and i18n draw
a510770
raw
history blame
77.2 kB
/**
* 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!');
});