diff --git a/game.js b/game.js index 3822c0b..2a13fb7 100644 --- a/game.js +++ b/game.js @@ -2395,63 +2395,228 @@ registerProcessor('voice-playback', VoicePlaybackProcessor); inventoryPanel.style.display = 'none'; }; - // Кнопка сохранения игры (только для одиночного режима) - const saveBtn = document.getElementById('saveBtn'); - saveBtn.onclick = () => { - playSound('click'); - saveGame(); - customAlert('Игра сохранена!'); - }; - - // Кнопка сброса игры (удаление сохранения и создание нового мира) - const resetBtn = document.getElementById('resetBtn'); - resetBtn.onclick = () => { - customConfirm('Вы уверены, что хотите удалить сохранение и начать новую игру?', () => { - playSound('click'); - - // Удаляем сохранение из localStorage - try { - localStorage.removeItem(SAVE_KEY); - console.log('Сохранение удалено из localStorage'); - } catch (e) { - console.warn('Ошибка удаления сохранения:', e); - } - - // Сбрасываем in-memory сохранение - inMemorySave = null; - - // Генерируем новый worldId - worldId = Math.random().toString(36).substring(2, 10); - console.log('Новый worldId после сброса:', worldId); - - // Обновляем URL - try { - const newUrl = new URL(window.location.href); - newUrl.searchParams.set('world', worldId); - const newUrlString = newUrl.toString(); - - if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') { - window.history.replaceState(null, '', newUrlString); - console.log('URL обновлён:', newUrlString); - } - } catch (e) { - console.error('Ошибка обновления URL:', e); - } - - // Перезагружаем страницу - location.reload(); + // Меню ⋯ (dropdown) + let dropdownOpen = false; + const menuBtn = document.getElementById('menuBtn'); + const menuDropdown = document.getElementById('menuDropdown'); + const saveItem = document.getElementById('saveItem'); + const resetItem = document.getElementById('resetItem'); + const marketItem = document.getElementById('marketItem'); + const settingsItem = document.getElementById('settingsItem'); + + if (menuBtn) { + menuBtn.onclick = null; + menuBtn.addEventListener('click', (e) => { + e.stopPropagation(); + dropdownOpen = !dropdownOpen; + menuDropdown.classList.toggle('open', dropdownOpen); }); - }; - + } + document.addEventListener('click', () => { + if (dropdownOpen) { + dropdownOpen = false; + menuDropdown.classList.remove('open'); + } + }); + menuDropdown && menuDropdown.addEventListener('click', (e) => e.stopPropagation()); + + // 💾 Сохранить + if (saveItem) { + saveItem.addEventListener('click', () => { + playSound('click'); + saveGame(); + dropdownOpen = false; + menuDropdown.classList.remove('open'); + customAlert('Игра сохранена!'); + }); + } + + // 🔄 Новый мир + if (resetItem) { + resetItem.addEventListener('click', () => { + customConfirm('Вы уверены, что хотите удалить сохранение и начать новую игру?', () => { + playSound('click'); + try { + localStorage.removeItem(SAVE_KEY); + console.log('Сохранение удалено из localStorage'); + } catch (e) { + console.warn('Ошибка удаления сохранения:', e); + } + inMemorySave = null; + worldId = Math.random().toString(36).substring(2, 10); + console.log('Новый worldId после сброса:', worldId); + try { + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('world', worldId); + if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') { + window.history.replaceState(null, '', newUrl.toString()); + } + } catch (e) { + console.error('Ошибка обновления URL:', e); + } + location.reload(); + }); + }); + } + + // 🏪 Рынок + if (marketItem) { + marketItem.addEventListener('click', () => { + dropdownOpen = false; + menuDropdown.classList.remove('open'); + toggleMarket(); + }); + } + + // ⚙ Настройки + if (settingsItem) { + settingsItem.addEventListener('click', () => { + dropdownOpen = false; + menuDropdown.classList.remove('open'); + toggleSettingsPanel(); + }); + } + // Показываем кнопку сохранения только если играем одни function updateSaveButtonVisibility() { - if (isMultiplayer && otherPlayers.size > 0) { - saveBtn.style.display = 'none'; - } else { - saveBtn.style.display = 'flex'; + if (saveItem) { + saveItem.style.display = (isMultiplayer && otherPlayers.size > 0) ? 'none' : 'block'; } } + // ==================== РЫНОК ==================== + const marketPanel = document.getElementById('marketPanel'); + const marketClose = document.getElementById('marketClose'); + const marketContent = document.getElementById('marketContent'); + + if (marketClose) { + marketClose.addEventListener('click', () => { marketPanel.style.display = 'none'; }); + } + + function toggleMarket() { + if (marketPanel.style.display === 'none' || !marketPanel.style.display) { + openMarket(); + } else { + marketPanel.style.display = 'none'; + } + } + + function openMarket() { + marketPanel.style.display = 'block'; + renderMarket(); + } + + let marketOrders = []; // локальный кэш ордеров + + function renderMarket() { + if (!socket) { + marketContent.innerHTML = '

Подключение к серверу...

'; + return; + } + socket.emit('market_list', {}, (orders) => { + if (!orders || !orders.length) { + marketOrders = []; + marketContent.innerHTML = + '

Предложений пока нет

' + + '
' + + '
'; + const btn = document.getElementById('createOrderBtn'); + if (btn) btn.addEventListener('click', showCreateOrder); + return; + } + marketOrders = orders; + let html = '
'; + html += orders.map(o => + '
' + + '
' + o.offer_item + ' ×' + o.offer_qty + + ' ' + + '' + o.want_item + ' ×' + o.want_qty + + '
' + (o.seller || 'Аноним') + '
' + ).join(''); + marketContent.innerHTML = html; + const btn = document.getElementById('createOrderBtn'); + if (btn) btn.addEventListener('click', showCreateOrder); + }); + } + + function showCreateOrder() { + const invKeys = Object.keys(inv).filter(id => inv[id] > 0); + marketContent.innerHTML = + '
' + + '

Выставить предмет

' + + '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
'; + document.getElementById('cancelOrder').addEventListener('click', () => renderMarket()); + document.getElementById('submitOrder').addEventListener('click', () => { + const offerItem = document.getElementById('offerItem').value; + const wantItem = document.getElementById('wantItem').value.trim(); + if (!offerItem || !wantItem) { + customAlert('Выберите предмет и укажите что хотите получить'); + return; + } + if (socket) { + socket.emit('market_create', { + offer_item: offerItem, + offer_qty: 1, + want_item: wantItem, + want_qty: 1 + }); + playSound('click'); + setTimeout(renderMarket, 500); + } + }); + } + + // ==================== НАСТРОЙКИ ==================== + let dayNightCycleEnabled = true; + const settingsPanel = document.getElementById('settingsPanel'); + + function toggleSettingsPanel() { + if (!settingsPanel) return; + if (settingsPanel.style.display === 'none' || !settingsPanel.style.display) { + settingsPanel.style.display = 'block'; + renderSettings(); + } else { + settingsPanel.style.display = 'none'; + } + } + + function renderSettings() { + if (!settingsPanel) return; + const contentEl = document.getElementById('settingsContent'); + if (!contentEl) return; + contentEl.innerHTML = + '
' + + '' + + '

Отключите, чтобы заморозить время суток

' + + '
'; + const toggle = document.getElementById('dayNightToggle'); + if (toggle) { + toggle.addEventListener('change', (e) => { + dayNightCycleEnabled = e.target.checked; + console.log('dayNightCycle:', dayNightCycleEnabled); + }); + } + } + + if (settingsPanel) { + const settingsClose = document.getElementById('settingsClose'); + if (settingsClose) { + settingsClose.addEventListener('click', () => { settingsPanel.style.display = 'none'; }); + } + } + // Режимы const MODES = [{id:'mine',icon:'⛏️'},{id:'build',icon:'🧱'}]; let modeIdx=0; @@ -4212,7 +4377,7 @@ registerProcessor('voice-playback', VoicePlaybackProcessor); // Ускорение времени во время сна if(player.sleeping && isNight()){ - worldTime += dt * 8 / DAY_LEN; // В 8 раз быстрее + if(dayNightCycleEnabled) worldTime += dt * 8 / DAY_LEN; // В 8 раз быстрее // Восстанавливаем здоровье во время сна player.hp = Math.min(100, player.hp + dt * 20); // Автоматическое пробуждение когда наступает день @@ -4220,7 +4385,7 @@ registerProcessor('voice-playback', VoicePlaybackProcessor); player.sleeping = false; } } else { - worldTime += dt / DAY_LEN; + if(dayNightCycleEnabled) worldTime += dt / DAY_LEN; } if(worldTime >= 1) worldTime -= 1; diff --git a/index.html b/index.html index 1a2c0ac..e5beac8 100644 --- a/index.html +++ b/index.html @@ -25,13 +25,18 @@
⛏️
-
💾
🔨
-
🔄
💬
📦
🗺️
+ +
@@ -91,8 +96,24 @@ + + + + - + diff --git a/src/main.js b/src/main.js index a95594a..e3e4157 100644 --- a/src/main.js +++ b/src/main.js @@ -1,278 +1,228 @@ // ==================== ENTRY POINT ==================== import { state } from './core/state.js'; +import { SERVER_URL } from './config.js'; +import { showStartMenu, isTgWebApp, tgUser } from './ui/start-menu.js'; +import { initCanvas } from './core/canvas.js'; import { initSocket } from './multiplayer/socket.js'; -import { sendBlockChange, sendPlayerPosition } from './multiplayer/socket-helpers.js'; import { loadSound, playSound } from './audio/sound-engine.js'; -import { BLOCKS } from './data/blocks.js'; -import { ITEMS } from './data/items.js'; -import { TOOLS } from './data/tools.js'; -import { SMELTING_RECIPES } from './data/recipes.js'; -import { initTextures } from './render/textures.js'; +import { initTextures, tex, itemTex } from './render/textures.js'; import { getBlock, setBlock, removeBlock } from './world/world-storage.js'; -import { genColumn, surfaceGyAt, ensureGenAroundCamera, regenerateVisibleChunks } from './world/generation.js'; +import { genColumn, surfaceGyAt } from './world/generation.js'; import { initDB, loadGame, applySave, saveGame } from './game/save.js'; import { loop } from './game/loop.js'; import { initChat } from './ui/chat.js'; import { initFurnace } from './ui/furnace.js'; import { initMinimap } from './ui/minimap.js'; import { initSaveControls, updateSaveButtonVisibility } from './ui/save-controls.js'; +import { initMarket } from './ui/market.js'; import { initRespawn } from './ui/respawn.js'; import { initShare } from './ui/share.js'; import { initControls } from './input/controls.js'; import { initMouseHandlers } from './input/mouse-handler.js'; import { initModes } from './game/modes.js'; import { initVoice } from './multiplayer/voice-chat.js'; -import { resolveY, resolveX } from './physics/collision.js'; -import { calculateDamage } from './entities/player.js'; -import { updateWaterFlag } from './physics/water-detect.js'; -import { updateWaterPhysics } from './physics/water.js'; -import { explodeAt, activateTNT } from './world/tnt.js'; -import { useTool } from './data/tools.js'; import { rebuildHotbar } from './ui/hotbar.js'; +import { toggleCraft, closeCraft, toggleInventory, closeInventory } from './ui/craft.js'; -// ==================== 1) CONFIG — parse URL/getName ==================== +// ==================== 1) CONFIG — parse URL ==================== const urlParams = new URLSearchParams(window.location.search); -state.SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru'; +state.SERVER_URL = SERVER_URL; state.TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot'; state.TELEGRAM_APP_SHORT_NAME = 'minegrechka'; -// Защита от mixed content -if (location.protocol === 'https:' && state.SERVER_URL.startsWith('http://')) { - console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP'); - alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.'); +// Set TG-related state +if (isTgWebApp) { + state.myTgId = tgUser?.id || null; } -// Player name -state.playerName = localStorage.getItem('minegrechka_playerName') || null; -if (!state.playerName) { - state.playerName = prompt('Введите ваше имя для игры:') || 'Игрок'; - localStorage.setItem('minegrechka_playerName', state.playerName); - console.log('Player name set:', state.playerName); -} +state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png'; -// World ID from URL or generate new -console.log('Current URL:', window.location.href); -const worldParam = urlParams.get('world'); -console.log('world param:', worldParam); +// ==================== 2) START MENU ==================== +// Show the start menu before initializing the game. +// The menu returns a Promise that resolves with the worldId. +async function startGame() { + const worldId = await showStartMenu(); + + // Set worldId and playerName + state.worldId = worldId; + if (!state.playerName) { + state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок'; + localStorage.setItem('minegrechka_playerName', state.playerName); + } + console.log('[Launch] worldId:', state.worldId, 'player:', state.playerName, 'tg:', isTgWebApp); -state.worldId = (worldParam && worldParam.trim() !== '') ? worldParam : null; -console.log('worldId after params:', state.worldId, 'type:', typeof state.worldId); - -if (!state.worldId) { - state.worldId = Math.random().toString(36).substring(2, 10); - console.log('Generated worldId:', state.worldId); + // Validate TG initData if in TG context + if (isTgWebApp && window.Telegram && Telegram.WebApp.initData) { + try { + const resp = await fetch(state.SERVER_URL + '/api/tg/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ initData: Telegram.WebApp.initData }) + }); + const data = await resp.json(); + if (data.ok && data.tg_id) { + console.log('[TG] Authenticated, tg_id:', data.tg_id); + state.myTgId = data.tg_id; + } + } catch (e) { console.warn('[TG] Auth failed:', e); } + } + // Update URL with world code try { const newUrl = new URL(window.location.href); newUrl.searchParams.set('world', state.worldId); - const newUrlString = newUrl.toString(); - console.log('New URL to set:', newUrlString); - - if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') { - window.history.replaceState(null, '', newUrlString); - console.log('URL after replaceState:', window.location.href); - } else { - console.error('History API not supported!'); + if (typeof history !== 'undefined' && history.replaceState) { + history.replaceState(null, '', newUrl.toString()); } - } catch (e) { - console.error('Error updating URL:', e); - } + } catch (e) {} - console.log('Generated new worldId for browser:', state.worldId); -} + // ==================== 3) CANVAS — init from canvas module ==================== + initCanvas(); -console.log('Final worldId:', state.worldId, 'Player name:', state.playerName); + // ==================== 4) STATE — set DOM refs ==================== + state.hpEl = document.getElementById('hp'); + state.foodEl = document.getElementById('food'); + state.sxEl = document.getElementById('sx'); + state.syEl = document.getElementById('sy'); + state.todEl = document.getElementById('tod'); + state.worldIdEl = document.getElementById('worldId'); + state.playerCountEl = document.getElementById('playerCount'); + state.deathEl = document.getElementById('death'); + state.hotbarEl = document.getElementById('hotbar'); + state.craftPanel = document.getElementById('craftPanel'); + state.recipesEl = document.getElementById('recipes'); + state.inventoryPanel = document.getElementById('inventoryPanel'); + state.inventoryGrid = document.getElementById('inventoryGrid'); + state.marketPanel = document.getElementById('marketPanel'); + state.marketContent = document.getElementById('marketContent'); + state.tradePanel = document.getElementById('tradePanel'); + state.tradeContent = document.getElementById('tradeContent'); + state.multiplayerStatus = document.getElementById('multiplayerStatus'); -// ==================== 2) CANVAS — get elements, resize ==================== -state.gameEl = document.getElementById('game'); -state.canvas = document.getElementById('c'); -state.ctx = state.canvas.getContext('2d'); + // ==================== 5) TEXTURES — initTextures() ==================== + initTextures(); + state.tex = tex; + state.itemTex = itemTex; -// offscreen light map -state.lightC = document.createElement('canvas'); -state.lightCtx = state.lightC.getContext('2d'); + // ==================== 6) CRAFT/INVENTORY BUTTONS ==================== + const craftBtn = document.getElementById('craftBtn'); + const invToggle = document.getElementById('invToggle'); + const craftClose = document.getElementById('craftClose'); + const inventoryClose = document.getElementById('inventoryClose'); -state.dpr = Math.max(1, window.devicePixelRatio || 1); + if (craftBtn) craftBtn.onclick = () => toggleCraft(); + if (craftClose) craftClose.onclick = () => closeCraft(); + if (invToggle) invToggle.onclick = () => toggleInventory(); + if (inventoryClose) inventoryClose.onclick = () => closeInventory(); -function resize() { - state.W = state.gameEl.clientWidth; - state.H = state.gameEl.clientHeight; - state.canvas.width = state.W * state.dpr; - state.canvas.height = state.H * state.dpr; - state.lightC.width = state.W * state.dpr; - state.lightC.height = state.H * state.dpr; - state.ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); -} -window.addEventListener('resize', resize); -resize(); + // ==================== 7) CONTROLS — initControls ==================== + initControls(); -// ==================== 3) STATE — init player, inv, etc ==================== -state.otherPlayers = new Map(); -state.serverMobs = new Map(); -state.grid = new Map(); -state.blocks = []; -state.generated = new Set(); -state.serverOverrides = new Map(); -state.activeFurnaces = new Map(); -state.activeTNT = new Set(); -state.toolDurability = new Map(); -state.worldSeed = Math.floor(Math.random() * 1000000); + // ==================== 8) MOUSE — initMouseHandlers ==================== + initMouseHandlers(); -// Clouds -state.clouds = Array.from({ length: 10 }, () => ({ - x: Math.random() * 2000, - y: -200 - Math.random() * 260, - w: 80 + Math.random() * 120, - s: 12 + Math.random() * 20 -})); + // ==================== 9) UI MODULES — init all UI ==================== + initChat(); + initFurnace(); + initMinimap(); + initSaveControls(); + initMarket(); + initRespawn(); + initShare(); + initModes(); -// Weather -state.weatherTimer = 0; -state.weatherChangeInterval = 60 + Math.random() * 120; + // ==================== 10) SOCKET — initSocket ==================== + initSocket(); -// Player spawn -state.player.x = 6 * state.TILE; -state.player.y = 0; -state.spawnPoint.x = 6 * state.TILE; -state.spawnPoint.y = 0; + // ==================== 11) VOICE — initVoice ==================== + initVoice(); -// Hero image -state.heroImg = new Image(); -state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png'; + // ==================== 12) SAVE — loadGame, applySave ==================== + rebuildHotbar(); -// UI elements -state.hpEl = document.getElementById('hp'); -state.foodEl = document.getElementById('food'); -state.sxEl = document.getElementById('sx'); -state.syEl = document.getElementById('sy'); -state.todEl = document.getElementById('tod'); -state.worldIdEl = document.getElementById('worldId'); -state.playerCountEl = document.getElementById('playerCount'); -state.deathEl = document.getElementById('death'); + initDB().then(async () => { + const loadedSave = await loadGame(); + if (loadedSave) { + await applySave(loadedSave); + console.log('Загружено сохранение, HP:', state.player.hp); -// ==================== 4) TEXTURES — initTextures() ==================== -initTextures(); + if (state.player.hp <= 0) { + console.log('WARNING: HP <= 0 после загрузки, возрождаемся'); + state.player.hp = 100; + state.player.hunger = 100; + state.player.o2 = 100; + state.player.x = state.spawnPoint.x; + state.player.y = state.spawnPoint.y; + state.player.vx = state.player.vy = 0; + state.player.invuln = 0; + state.player.fallStartY = state.player.y; + } + } else { + console.log('Сохранение не найдено, начинаем новую игру'); -// ==================== 5) AUDIO — loadSound for each ==================== -loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3'); -loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3'); -loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3'); -loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3'); -loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3'); -loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3'); -loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3'); -loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3'); -loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3'); -loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3'); -loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3'); -loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3'); -loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3'); -loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3'); -loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3'); -loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3'); -loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3'); - -// ==================== 6) CONTROLS — initControls ==================== -initControls(); - -// ==================== 7) MOUSE — initMouseHandlers ==================== -initMouseHandlers(); - -// ==================== 8) UI MODULES — init all UI ==================== -initChat(); -initFurnace(); -initMinimap(); -initSaveControls(); -initRespawn(); -initShare(); -initModes(); - -// ==================== 9) SOCKET — initSocket ==================== -initSocket(); - -// ==================== 10) VOICE — initVoice ==================== -initVoice(); - -// ==================== 11) SAVE — loadGame, applySave ==================== -rebuildHotbar(); - -initDB().then(async () => { - const loadedSave = await loadGame(); - if (loadedSave) { - await applySave(loadedSave); - console.log('Загружено сохранение, HP:', state.player.hp); - - if (state.player.hp <= 0) { - console.log('WARNING: HP <= 0 после загрузки, возрождаемся'); state.player.hp = 100; state.player.hunger = 100; state.player.o2 = 100; - state.player.x = state.spawnPoint.x; - state.player.y = state.spawnPoint.y; state.player.vx = state.player.vy = 0; state.player.invuln = 0; + + // старт — на поверхности + const startGX = 6; + for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx); + const surfaceY = surfaceGyAt(startGX); + let safeGY = surfaceY - 1; + const aboveBlock = getBlock(startGX, surfaceY - 1); + if (aboveBlock && aboveBlock.t === 'water') { + for (let gy = state.SEA_GY - 1; gy >= 0; gy--) { + const b = getBlock(startGX, gy); + if (!b || b.dead || b.t === 'air' || b.t === 'water') continue; + safeGY = gy - 1; + break; + } + } + state.player.y = safeGY * state.TILE; + state.player.x = startGX * state.TILE; state.player.fallStartY = state.player.y; - } - } else { - console.log('Сохранение не найдено, начинаем новую игру'); - state.player.hp = 100; - state.player.hunger = 100; - state.player.o2 = 100; - state.player.vx = state.player.vy = 0; - state.player.invuln = 0; + state.spawnPoint.x = state.player.x; + state.spawnPoint.y = state.player.y; - // старт — на поверхности - const startGX = 6; - for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx); - const surfaceY = surfaceGyAt(startGX); - let safeGY = surfaceY - 1; - const aboveBlock = getBlock(startGX, surfaceY - 1); - if (aboveBlock && aboveBlock.t === 'water') { - for (let gy = state.SEA_GY - 1; gy >= 0; gy--) { - const b = getBlock(startGX, gy); - if (!b || b.dead || b.t === 'air' || b.t === 'water') continue; - safeGY = gy - 1; - break; + console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', state.player.y); + + // Генерируем карту вокруг стартовой позиции + for (let gx = startGX - 50; gx <= startGX + 50; gx++) { + genColumn(gx); } } - state.player.y = safeGY * state.TILE; - state.player.x = startGX * state.TILE; + + // Автосейв при скрытии страницы + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + saveGame(); + } + }); + + // Автосейв перед закрытием страницы + window.addEventListener('beforeunload', () => { + saveGame(); + }); + }).catch(err => { + console.error('Ошибка инициализации:', err); + const startGX = 6; + genColumn(startGX); + state.player.y = (surfaceGyAt(startGX) - 1) * state.TILE; state.player.fallStartY = state.player.y; - state.spawnPoint.x = state.player.x; - state.spawnPoint.y = state.player.y; - - console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', state.player.y); - - // Генерируем карту вокруг стартовой позиции for (let gx = startGX - 50; gx <= startGX + 50; gx++) { genColumn(gx); } - } - - // Автосейв при скрытии страницы - document.addEventListener('visibilitychange', () => { - if (document.hidden) { - saveGame(); - } }); - // Автосейв перед закрытием страницы - window.addEventListener('beforeunload', () => { - saveGame(); - }); -}).catch(err => { - console.error('Ошибка инициализации:', err); - const startGX = 6; - genColumn(startGX); - state.player.y = (surfaceGyAt(startGX) - 1) * state.TILE; - state.player.fallStartY = state.player.y; + // ==================== 13) LOOP — startLoop ==================== + requestAnimationFrame(loop); +} - for (let gx = startGX - 50; gx <= startGX + 50; gx++) { - genColumn(gx); - } +// Start the game +startGame().catch(err => { + console.error('Fatal error starting game:', err); }); - -// ==================== 13) LOOP — startLoop ==================== -requestAnimationFrame(loop); \ No newline at end of file diff --git a/src/multiplayer/voice-chat.js b/src/multiplayer/voice-chat.js index affc6b4..798b42b 100644 --- a/src/multiplayer/voice-chat.js +++ b/src/multiplayer/voice-chat.js @@ -7,6 +7,7 @@ let voiceStream = null; let audioCtx = null; let voiceProcessor = null; let voiceActive = false; +let voiceMode = 'near'; // 'near' — 600px radius, 'world' — global const VOICE_SERVER = 'https://voicegrech.mkn8n.ru'; // Кнопка микрофона @@ -16,6 +17,30 @@ voiceBtn.title = 'Голосовой чат (выкл)'; voiceBtn.style.cssText = 'position:absolute;top:74px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;'; document.querySelector('.ui').appendChild(voiceBtn); +// Кнопка режима (рядом / весь мир) +const voiceModeBtn = document.createElement('div'); +voiceModeBtn.innerHTML = '📢'; +voiceModeBtn.title = 'Режим: рядом (600px)'; +voiceModeBtn.style.cssText = 'position:absolute;top:74px;right:190px;width:48px;height:52px;border-radius:12px;background:#3498db;z-index:200;display:flex;align-items:center;justify-content:center;font-size:20px;cursor:pointer;border:2px solid rgba(255,255,255,0.7);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;color:#fff;font-weight:bold;'; +document.querySelector('.ui').appendChild(voiceModeBtn); + +voiceModeBtn.onclick = () => { + if (voiceMode === 'near') { + voiceMode = 'world'; + voiceModeBtn.innerHTML = '🌍'; + voiceModeBtn.title = 'Режим: весь мир'; + voiceModeBtn.style.background = '#e67e22'; + } else { + voiceMode = 'near'; + voiceModeBtn.innerHTML = '📢'; + voiceModeBtn.title = 'Режим: рядом (600px)'; + voiceModeBtn.style.background = '#3498db'; + } + if (voiceSocket && voiceSocket.connected) { + voiceSocket.emit('voice_mode', { mode: voiceMode }); + } +}; + // Индикатор говорящего const speakingIndicator = document.createElement('div'); speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;'; @@ -64,7 +89,7 @@ voiceBtn.onclick = async () => { // Подключаемся к голосовому серверу voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] }); voiceSocket.on('connect', () => { - voiceSocket.emit('voice_join', { world_id: worldId, x: state.player.x, y: state.player.y, name: state.playerName || 'Игрок' }); + voiceSocket.emit('voice_join', { world_id: worldId, x: state.player.x, y: state.player.y, name: state.playerName || 'Игрок', mode: voiceMode }); }); voiceSocket.on('voice_in', (payload) => { diff --git a/src/ui/market.js b/src/ui/market.js new file mode 100644 index 0000000..30d456e --- /dev/null +++ b/src/ui/market.js @@ -0,0 +1,114 @@ +// ==================== РЫНОК ПРЕДЛОЖЕНИЙ ==================== +import { state } from '../core/state.js'; +import { playSound } from '../audio/sound-engine.js'; + +const marketPanel = document.getElementById('marketPanel'); +const marketClose = document.getElementById('marketClose'); +const marketContent = document.getElementById('marketContent'); + +export function initMarket() { + // Кнопка закрытия + marketClose.addEventListener('click', () => closeMarket()); + + // Слушаем событие от save-controls.js + document.addEventListener('toggleMarket', () => toggleMarket()); +} + +export function toggleMarket() { + if (marketPanel.style.display === 'none' || !marketPanel.style.display) { + openMarket(); + } else { + closeMarket(); + } +} + +export function closeMarket() { + marketPanel.style.display = 'none'; +} + +function openMarket() { + marketPanel.style.display = 'block'; + renderMarket(); +} + +function renderMarket() { + if (!state.socket) { + marketContent.innerHTML = '

Подключение к серверу...

'; + return; + } + + // Запрашиваем список ордеров с сервера + state.socket.emit('market_list', {}, (orders) => { + if (!orders || !orders.length) { + marketContent.innerHTML = ` +

Предложений пока нет

+
+ +
+ `; + const btn = document.getElementById('createOrderBtn'); + if (btn) btn.addEventListener('click', showCreateOrder); + return; + } + + let html = '
'; + + html += orders.map(o => ` +
+
+ ${o.offer_item} ×${o.offer_qty} + + ${o.want_item} ×${o.want_qty} +
+
${o.seller || 'Аноним'}
+
+ `).join(''); + + marketContent.innerHTML = html; + const btn = document.getElementById('createOrderBtn'); + if (btn) btn.addEventListener('click', showCreateOrder); + }); +} + +function showCreateOrder() { + const invItems = Object.keys(state.inv).filter(id => state.inv[id] > 0); + + marketContent.innerHTML = ` +
+

Выставить предмет

+ + + + +
+ + +
+
+ `; + + document.getElementById('cancelOrder').addEventListener('click', () => renderMarket()); + document.getElementById('submitOrder').addEventListener('click', () => { + const offerItem = document.getElementById('offerItem').value; + const wantItem = document.getElementById('wantItem').value.trim(); + if (!offerItem || !wantItem) { + alert('Выберите предмет и укажите что хотите получить'); + return; + } + if (state.socket) { + state.socket.emit('market_create', { + offer_item: offerItem, + offer_qty: 1, + want_item: wantItem, + want_qty: 1 + }); + playSound('click'); + setTimeout(renderMarket, 500); + } + }); +} \ No newline at end of file diff --git a/src/ui/save-controls.js b/src/ui/save-controls.js index 445a7c4..0306c0e 100644 --- a/src/ui/save-controls.js +++ b/src/ui/save-controls.js @@ -1,22 +1,55 @@ -// ==================== КНОПКИ СОХРАНЕНИЯ / СБРОСА ==================== +// ==================== МЕНЮ ⋯ + СОХРАНЕНИЕ / СБРОС ==================== import { state } from '../core/state.js'; import { playSound } from '../audio/sound-engine.js'; import { saveGame } from '../game/save.js'; +let dropdownOpen = false; + export function initSaveControls() { - const saveBtn = document.getElementById('saveBtn'); - saveBtn.onclick = () => { + const menuBtn = document.getElementById('menuBtn'); + const menuDropdown = document.getElementById('menuDropdown'); + const saveItem = document.getElementById('saveItem'); + const resetItem = document.getElementById('resetItem'); + const marketItem = document.getElementById('marketItem'); + const settingsItem = document.getElementById('settingsItem'); + + // Убить возможный старый onclick (предотвращает race condition) + menuBtn.onclick = null; + + // Тогл меню + menuBtn.addEventListener('click', (e) => { + e.stopPropagation(); + dropdownOpen = !dropdownOpen; + menuDropdown.classList.toggle('open', dropdownOpen); + }); + + // Закрытие по клику вне + document.addEventListener('click', () => { + if (dropdownOpen) { + dropdownOpen = false; + menuDropdown.classList.remove('open'); + } + }); + + // Предотвращаем закрытие при клике внутри дропдауна + menuDropdown.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + // 💾 Сохранить + saveItem.addEventListener('click', () => { playSound('click'); saveGame(); + dropdownOpen = false; + menuDropdown.classList.remove('open'); alert('Игра сохранена!'); - }; + }); - const resetBtn = document.getElementById('resetBtn'); - resetBtn.onclick = () => { + // 🔄 Новый мир + resetItem.addEventListener('click', () => { if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) { playSound('click'); - // Удаляем сохранение из localStorage try { localStorage.removeItem(state.SAVE_KEY); console.log('Сохранение удалено из localStorage'); @@ -24,39 +57,53 @@ export function initSaveControls() { console.warn('Ошибка удаления сохранения:', e); } - // Сбрасываем in-memory сохранение state.inMemorySave = null; - - // Генерируем новый worldId state.worldId = Math.random().toString(36).substring(2, 10); console.log('Новый worldId после сброса:', state.worldId); - // Обновляем URL try { const newUrl = new URL(window.location.href); newUrl.searchParams.set('world', state.worldId); - const newUrlString = newUrl.toString(); - if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') { - window.history.replaceState(null, '', newUrlString); - console.log('URL обновлён:', newUrlString); + window.history.replaceState(null, '', newUrl.toString()); + console.log('URL обновлён:', newUrl.toString()); } } catch (e) { console.error('Ошибка обновления URL:', e); } - // Перезагружаем страницу location.reload(); + } else { + dropdownOpen = false; + menuDropdown.classList.remove('open'); } - }; + }); + + // 🏪 Рынок + marketItem.addEventListener('click', () => { + dropdownOpen = false; + menuDropdown.classList.remove('open'); + // toggleMarket импортируется в main.js, вызовем через кастомное событие + document.dispatchEvent(new CustomEvent('toggleMarket')); + }); + + // ⚙️ Настройки + if (settingsItem) { + settingsItem.addEventListener('click', () => { + dropdownOpen = false; + menuDropdown.classList.remove('open'); + document.dispatchEvent(new CustomEvent('toggleSettings')); + }); + } } -// Показываем кнопку сохранения только если играем одни +// Показываем/скрываем пункт сохранения в зависимости от режима export function updateSaveButtonVisibility() { - const saveBtn = document.getElementById('saveBtn'); + const saveItem = document.getElementById('saveItem'); + if (!saveItem) return; if (state.isMultiplayer && state.otherPlayers.size > 0) { - saveBtn.style.display = 'none'; + saveItem.style.display = 'none'; } else { - saveBtn.style.display = 'flex'; + saveItem.style.display = 'block'; } } \ No newline at end of file diff --git a/src/ui/start-menu.js b/src/ui/start-menu.js new file mode 100644 index 0000000..8183205 --- /dev/null +++ b/src/ui/start-menu.js @@ -0,0 +1,551 @@ +// ==================== START MENU ==================== +import { state } from '../core/state.js'; + +// Detect Telegram WebApp +const isTgWebApp = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData); +let tgUser = null; +let tgSaveLoaded = false; + +if (isTgWebApp) { + try { + Telegram.WebApp.ready(); + Telegram.WebApp.expand(); + tgUser = Telegram.WebApp.initDataUnsafe?.user || null; + console.log('[TG] Mini App detected, user:', tgUser?.id, tgUser?.username); + // Set player name from TG + if (tgUser && tgUser.first_name) { + const tgName = tgUser.username || tgUser.first_name; + localStorage.setItem('minegrechka_playerName', tgName); + } + } catch (e) { + console.warn('[TG] WebApp init error:', e); + } +} + +// Export TG info for other modules +export { isTgWebApp, tgUser }; + +// ==================== HELPERS ==================== + +function generateShortWorldCode() { + // Generate readable 6-char code: consonants + digits (easy to copy/share) + const c = 'bcdfghjkmnpqrstvwxyz'; + const d = '23456789'; + let code = ''; + for (let i = 0; i < 3; i++) code += c[Math.floor(Math.random() * c.length)]; + for (let i = 0; i < 3; i++) code += d[Math.floor(Math.random() * d.length)]; + return code; +} + +export function showToast(msg, duration) { + duration = duration || 1800; + const t = document.createElement('div'); + t.textContent = msg; + t.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#2ecc71;padding:10px 24px;border-radius:10px;font:bold 16px Arial,sans-serif;z-index:99999;pointer-events:none;transition:opacity 0.4s;'; + document.body.appendChild(t); + setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, duration); +} + +function customAlert(msg) { + const overlay = document.createElement('div'); + overlay.className = 'custom-modal-overlay'; + overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100000;'; + const box = document.createElement('div'); + box.style.cssText = 'background:#C6C6C6;border:4px solid #000;padding:24px;max-width:360px;width:90%;box-shadow:4px 4px 0 #000;font-family:Courier New,monospace;'; + const text = document.createElement('div'); + text.textContent = msg; + text.style.marginBottom = '16px'; + text.style.fontWeight = 'bold'; + text.style.fontSize = '16px'; + const btn = document.createElement('button'); + btn.textContent = 'OK'; + btn.style.cssText = 'padding:8px 24px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#5A8F3C;color:#fff;box-shadow:2px 2px 0 #000;'; + btn.onclick = () => overlay.remove(); + box.appendChild(text); + box.appendChild(btn); + overlay.appendChild(box); + document.body.appendChild(overlay); +} + +// ==================== MAIN START MENU ==================== + +// Minecraft-style CSS for the start menu +const mcCSS = ` + .mc-overlay { position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999; + display:flex;align-items:center;justify-content:center;font-family:'Courier New',monospace; + image-rendering:pixelated; } + .mc-bg { position:absolute;inset:0;background:#000;opacity:0.75; } + .mc-dirt { position:absolute;bottom:0;left:0;right:0;height:40%; + background:repeating-linear-gradient(0deg,#8B6914 0px,#8B6914 2px,#7A5B12 2px,#7A5B12 4px);opacity:0.3; } + .mc-box { position:relative;z-index:1;background:#C6C6C6;border:4px solid #000; + padding:0;max-width:420px;width:92%;box-shadow:4px 4px 0 #000; } + .mc-titlebar { background:#1a1a2e;padding:12px 16px;text-align:center; + border-bottom:4px solid #000; } + .mc-title { font-size:24px;font-weight:bold;color:#FFAA00;text-shadow:2px 2px 0 #000; + letter-spacing:2px; } + .mc-subtitle { font-size:13px;color:#AAA;margin-top:4px; } + .mc-body { padding:16px;background:#C6C6C6; } + .mc-btn { display:block;width:100%;padding:10px 16px;margin-bottom:8px; + font-family:'Courier New',monospace;font-size:16px;font-weight:bold; + cursor:pointer;text-align:center;border:3px solid #000; + image-rendering:pixelated;box-shadow:3px 3px 0 #000; + transition:transform 0.05s,box-shadow 0.05s; } + .mc-btn:active { transform:translate(2px,2px);box-shadow:1px 1px 0 #000; } + .mc-btn-green { background:#5A8F3C;color:#E0E0E0;border-color:#3A5F1C;text-shadow:1px 1px 0 #222; } + .mc-btn-green:hover { background:#6DAF4E; } + .mc-btn-blue { background:#3C5F9E;color:#E0E0E0;border-color:#2A4070;text-shadow:1px 1px 0 #222; } + .mc-btn-blue:hover { background:#4B73BE; } + .mc-btn-purple { background:#7B3FA0;color:#E0E0E0;border-color:#5A2C78;text-shadow:1px 1px 0 #222; } + .mc-btn-purple:hover { background:#9555BE; } + .mc-btn-orange { background:#B86E00;color:#E0E0E0;border-color:#8A5200;text-shadow:1px 1px 0 #222; } + .mc-btn-orange:hover { background:#DA8500; } + .mc-btn-red { background:#9E2B2B;color:#E0E0E0;border-color:#6E1B1B;text-shadow:1px 1px 0 #222; } + .mc-btn-red:hover { background:#BE3B3B; } + .mc-btn-gray { background:#555;color:#AAA;border-color:#333;text-shadow:1px 1px 0 #111; } + .mc-btn-gray:hover { background:#666;color:#DDD; } + .mc-btn-outline { background:transparent;color:#555;border-color:#555; + text-shadow:none;box-shadow:2px 2px 0 #333; } + .mc-btn-outline:hover { background:#DDD;color:#222; } + .mc-input { width:100%;padding:8px 12px;font-family:'Courier New',monospace; + font-size:16px;background:#FFF;border:3px solid #000;color:#222; + box-shadow:inset 2px 2px 0 #999;margin:4px 0;box-sizing:border-box; } + .mc-input:focus { outline:none;border-color:#FFAA00; } + .mc-label { font-size:13px;color:#444;font-weight:bold;margin:8px 0 4px; } + .mc-divider { border:none;border-top:2px solid #999;margin:12px 0; } + .mc-friend-row { display:flex;justify-content:space-between;align-items:center; + padding:6px 8px;margin-bottom:4px;background:#E8E8E8;border:2px solid #999; + box-shadow:2px 2px 0 #999; } + .mc-friend-btn { padding:4px 10px;font-family:'Courier New',monospace; + font-size:13px;font-weight:bold;cursor:pointer;border:2px solid #000; + box-shadow:1px 1px 0 #000; } + .mc-friend-btn:active { transform:translate(1px,1px);box-shadow:none; } + .mc-friend-btn-green { background:#5A8F3C;color:#E0E0E0; } + .mc-friend-btn-red { background:#9E2B2B;color:#E0E0E0; } + .mc-checkbox-row { display:flex;align-items:center;gap:8px;font-size:13px;color:#444;cursor:pointer; } + .mc-checkbox { width:18px;height:18px;accent-color:#5A8F3C; } +`; + +export function showStartMenu() { + return new Promise(async (resolve) => { + const urlParams = new URLSearchParams(window.location.search); + const worldParam = urlParams.get('world'); + const tgStartParam = (isTgWebApp && Telegram.WebApp.initDataUnsafe?.start_param) + ? Telegram.WebApp.initDataUnsafe.start_param.trim() + : null; + + // If world is in URL or TG start param — skip menu, auto-join + if ((worldParam && worldParam.trim() !== '') || tgStartParam) { + const worldId = (worldParam && worldParam.trim()) || tgStartParam; + console.log('[Menu] Joining world from URL/startParam:', worldId); + + // Set player name from TG or localStorage or default + if (!state.playerName) { + if (isTgWebApp && tgUser) { + state.playerName = tgUser.username || tgUser.first_name || 'Игрок'; + } else { + state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок'; + } + localStorage.setItem('minegrechka_playerName', state.playerName); + } + + resolve(worldId); + return; + } + + // Inject CSS + const styleEl = document.createElement('style'); + styleEl.textContent = mcCSS; + document.head.appendChild(styleEl); + + // Build overlay + const overlay = document.createElement('div'); + overlay.className = 'mc-overlay'; + overlay.id = 'startMenu'; + overlay.innerHTML = '
'; + + const box = document.createElement('div'); + box.className = 'mc-box'; + + // Title bar + const titleBar = document.createElement('div'); + titleBar.className = 'mc-titlebar'; + titleBar.innerHTML = '
⛏️ GrechkaCraft
' + + '
' + (isTgWebApp ? ('Привет, ' + (tgUser?.first_name || 'Игрок') + '!') : 'Voxel-мир в браузере') + '
'; + box.appendChild(titleBar); + + const body = document.createElement('div'); + body.className = 'mc-body'; + + // Mutable worldId holder + let selectedWorldId = null; + + // === Buttons === + + // 🌍 New World + const btnNew = document.createElement('button'); + btnNew.className = 'mc-btn mc-btn-green'; + btnNew.textContent = '🌍 Новый мир'; + btnNew.onclick = () => { + selectedWorldId = generateShortWorldCode(); + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId); + }; + body.appendChild(btnNew); + + // 🔗 Join by code + const btnJoin = document.createElement('button'); + btnJoin.className = 'mc-btn mc-btn-blue'; + btnJoin.textContent = '🔗 Вступить по коду'; + btnJoin.onclick = () => { + body.innerHTML = ''; + const lbl = document.createElement('div'); + lbl.className = 'mc-label'; + lbl.textContent = 'Код мира:'; + body.appendChild(lbl); + const codeInput = document.createElement('input'); + codeInput.className = 'mc-input'; + codeInput.type = 'text'; + codeInput.placeholder = 'bkh237'; + codeInput.maxLength = 12; + body.appendChild(codeInput); + const btnGo = document.createElement('button'); + btnGo.className = 'mc-btn mc-btn-blue'; + btnGo.textContent = 'Войти →'; + btnGo.onclick = () => { + const code = codeInput.value.trim().toLowerCase(); + if (code.length >= 3) { + selectedWorldId = code; + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId); + } + }; + body.appendChild(btnGo); + const btnBack = document.createElement('button'); + btnBack.className = 'mc-btn mc-btn-gray'; + btnBack.textContent = '← Назад'; + btnBack.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); }; + body.appendChild(btnBack); + codeInput.focus(); + codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') btnGo.click(); }); + }; + body.appendChild(btnJoin); + + // 💾 Continue saved game + const hasLocalSave = !!localStorage.getItem(state.SAVE_KEY || 'minegrechka_save'); + if (isTgWebApp || hasLocalSave) { + const btnHistory = document.createElement('button'); + btnHistory.className = 'mc-btn mc-btn-purple'; + btnHistory.textContent = '💾 Продолжить игру'; + btnHistory.onclick = async () => { + if (isTgWebApp && tgUser) { + try { + const resp = await fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id); + const data = await resp.json(); + if (data.ok && data.world_id) { + selectedWorldId = data.world_id; + tgSaveLoaded = true; + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId); + return; + } + } catch (e) { console.warn('[TG] Load save failed:', e); } + } + if (hasLocalSave) { + // Continue with a new world code (the save will be loaded separately by initDB/loadGame) + selectedWorldId = generateShortWorldCode(); + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId); + return; + } + customAlert('Сохранений не найдено'); + }; + body.appendChild(btnHistory); + } + + // === TG-only buttons === + if (isTgWebApp && window.Telegram && Telegram.WebApp.switchInlineQuery) { + // 📤 Invite friends + const btnInvite = document.createElement('button'); + btnInvite.className = 'mc-btn mc-btn-outline'; + btnInvite.style.marginTop = '16px'; + btnInvite.textContent = '📤 Пригласить друзей'; + btnInvite.onclick = () => { + const code = selectedWorldId || generateShortWorldCode(); + const url = 'https://t.me/' + state.TELEGRAM_BOT_USERNAME + '/app?startapp=' + encodeURIComponent(code); + if (Telegram.WebApp && Telegram.WebApp.openTelegramLink) { + Telegram.WebApp.openTelegramLink('https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent('🏗️ Заходи в GrechkaCraft! Мир: ' + code)); + } else { + navigator.clipboard.writeText(url).then(() => showToast('📋 Скопировано!')).catch(() => showToast(url)); + } + }; + body.appendChild(btnInvite); + + // 👥 Friends + const btnFriends = document.createElement('button'); + btnFriends.className = 'mc-btn mc-btn-orange'; + btnFriends.style.marginTop = '8px'; + btnFriends.textContent = '👥 Друзья'; + btnFriends.onclick = async () => { + try { + const resp = await fetch(state.SERVER_URL + '/api/tg/friends?tg_id=' + tgUser.id); + const data = await resp.json(); + body.innerHTML = ''; + + // Back button at top + const btnBackTop = document.createElement('button'); + btnBackTop.className = 'mc-btn mc-btn-gray'; + btnBackTop.textContent = '← Назад'; + btnBackTop.style.marginBottom = '12px'; + btnBackTop.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); }; + body.appendChild(btnBackTop); + + // Nickname section + if (data.nickname) { + const nickDiv = document.createElement('div'); + nickDiv.className = 'mc-friend-row'; + nickDiv.innerHTML = '🔗 Твой ник: ' + data.nickname + ''; + nickDiv.style.background = '#D5E8C5'; + body.appendChild(nickDiv); + } else { + const nickLabel = document.createElement('div'); + nickLabel.className = 'mc-label'; + nickLabel.style.color = '#9E2B2B'; + nickLabel.textContent = '⚡ Задай ник, чтобы друзья могли тебя найти:'; + body.appendChild(nickLabel); + const nickRow = document.createElement('div'); + nickRow.style.display = 'flex'; + nickRow.style.gap = '4px'; + nickRow.style.marginBottom = '8px'; + const nickInput = document.createElement('input'); + nickInput.className = 'mc-input'; + nickInput.style.flex = '1'; + nickInput.type = 'text'; + nickInput.maxLength = 16; + nickInput.placeholder = 'Твой ник'; + const nickBtn = document.createElement('button'); + nickBtn.className = 'mc-btn mc-btn-green'; + nickBtn.style.width = 'auto'; + nickBtn.style.marginBottom = '0'; + nickBtn.style.padding = '8px 16px'; + nickBtn.textContent = '✓'; + nickBtn.onclick = async () => { + const n = nickInput.value.trim().toLowerCase(); + if (!n || n.length < 2) { showToast('❌ Минимум 2 символа'); return; } + const check = await fetch(state.SERVER_URL + '/api/nick/check?nick=' + encodeURIComponent(n)); + const cd = await check.json(); + if (!cd.available) { showToast('❌ Ник занят'); return; } + const setResp = await fetch(state.SERVER_URL + '/api/nick/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tg_id: tgUser.id, nickname: n }) + }); + const sd = await setResp.json(); + if (sd.ok) { showToast('✅ Ник: ' + sd.nickname); btnFriends.onclick(); } else { showToast('❌ Ошибка'); } + }; + nickRow.appendChild(nickInput); + nickRow.appendChild(nickBtn); + body.appendChild(nickRow); + } + + // Friends list + if (data.friends && data.friends.length) { + const listLabel = document.createElement('div'); + listLabel.className = 'mc-label'; + listLabel.textContent = '👥 Друзья:'; + body.appendChild(listLabel); + for (const f of data.friends) { + const fDiv = document.createElement('div'); + fDiv.className = 'mc-friend-row'; + const statusDot = f.online ? '🟢' : '⚫'; + let info = statusDot + ' ' + f.nickname + ''; + if (f.online && f.world_id) info += ' — мир ' + f.world_id; + const fInfo = document.createElement('div'); + fInfo.innerHTML = info; + fDiv.appendChild(fInfo); + if (f.online && f.world_id) { + const joinBtn = document.createElement('button'); + joinBtn.className = 'mc-friend-btn mc-friend-btn-green'; + joinBtn.textContent = '🎮 Вступить'; + joinBtn.onclick = () => { + selectedWorldId = f.world_id; + overlay.remove(); + styleEl.remove(); + resolve(selectedWorldId); + }; + fDiv.appendChild(joinBtn); + } + body.appendChild(fDiv); + } + } else if (data.friends && !data.friends.length) { + const noFriends = document.createElement('div'); + noFriends.className = 'mc-label'; + noFriends.style.color = '#888'; + noFriends.textContent = 'Пока нет друзей'; + body.appendChild(noFriends); + } + + // Pending friend requests + if (data.pending && data.pending.length) { + const pLabel = document.createElement('div'); + pLabel.className = 'mc-label'; + pLabel.style.color = '#B86E00'; + pLabel.textContent = '📨 Запросы в друзья:'; + body.appendChild(pLabel); + for (const p of data.pending) { + const pItem = document.createElement('div'); + pItem.className = 'mc-friend-row'; + const span = document.createElement('span'); + span.textContent = p.from; + pItem.appendChild(span); + const btns = document.createElement('div'); + btns.style.display = 'flex'; + btns.style.gap = '4px'; + const acceptBtn = document.createElement('button'); + acceptBtn.className = 'mc-friend-btn mc-friend-btn-green'; + acceptBtn.textContent = '✓'; + acceptBtn.onclick = async () => { + const r = await fetch(state.SERVER_URL + '/api/friends/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id }) + }); + const d = await r.json(); + if (d.ok) { showToast('✅ Друг добавлен!'); btnFriends.onclick(); } else { showToast('❌ Ошибка'); } + }; + const declineBtn = document.createElement('button'); + declineBtn.className = 'mc-friend-btn mc-friend-btn-red'; + declineBtn.textContent = '✕'; + declineBtn.onclick = async () => { + await fetch(state.SERVER_URL + '/api/friends/decline', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id }) + }); + showToast('Отклонено'); + btnFriends.onclick(); + }; + btns.appendChild(acceptBtn); + btns.appendChild(declineBtn); + pItem.appendChild(btns); + body.appendChild(pItem); + } + } + + // Add friend + body.appendChild(document.createElement('hr')); + const addLabel = document.createElement('div'); + addLabel.className = 'mc-label'; + addLabel.textContent = '➕ Добавить друга по нику:'; + body.appendChild(addLabel); + const addRow = document.createElement('div'); + addRow.style.display = 'flex'; + addRow.style.gap = '4px'; + const addInput = document.createElement('input'); + addInput.className = 'mc-input'; + addInput.style.flex = '1'; + addInput.type = 'text'; + addInput.maxLength = 16; + addInput.placeholder = 'Ник друга'; + const addBtn = document.createElement('button'); + addBtn.className = 'mc-btn mc-btn-blue'; + addBtn.style.width = 'auto'; + addBtn.style.marginBottom = '0'; + addBtn.style.padding = '8px 16px'; + addBtn.textContent = '+'; + addBtn.onclick = async () => { + const nick = addInput.value.trim().toLowerCase(); + if (!nick || nick.length < 2) { showToast('❌ Минимум 2 символа'); return; } + const r = await fetch(state.SERVER_URL + '/api/friends/request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tg_id: tgUser.id, nickname: nick }) + }); + const d = await r.json(); + if (d.ok) { showToast(d.message === 'Already friends' ? '✅ Уже друзья!' : '📨 Запрос отправлен!'); } + else { showToast('❌ ' + (d.error || 'Ошибка')); } + }; + addRow.appendChild(addInput); + addRow.appendChild(addBtn); + body.appendChild(addRow); + + // Privacy checkboxes + const settings = data.settings || { allow_requests: 1, notify_online: 1 }; + const settingsDiv = document.createElement('div'); + settingsDiv.style.marginTop = '12px'; + const reqLabel = document.createElement('label'); + reqLabel.className = 'mc-checkbox-row'; + const reqCb = document.createElement('input'); + reqCb.className = 'mc-checkbox'; + reqCb.type = 'checkbox'; + reqCb.checked = settings.allow_requests; + reqCb.onchange = async () => { + await fetch(state.SERVER_URL + '/api/friends/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tg_id: tgUser.id, + allow_requests: reqCb.checked ? 1 : 0, + notify_online: settings.notify_online + }) + }); + }; + reqLabel.appendChild(reqCb); + reqLabel.appendChild(document.createTextNode(' Запросы в друзья')); + settingsDiv.appendChild(reqLabel); + body.appendChild(settingsDiv); + } catch (e) { showToast('❌ Ошибка загрузки друзей'); console.error(e); } + }; + body.appendChild(btnFriends); + } + + // Player name input (only if not already set) + if (!state.playerName) { + const nameDivider = document.createElement('hr'); + nameDivider.className = 'mc-divider'; + body.appendChild(nameDivider); + const nameLabel = document.createElement('div'); + nameLabel.className = 'mc-label'; + nameLabel.textContent = 'Твоё имя:'; + body.appendChild(nameLabel); + const nameInput = document.createElement('input'); + nameInput.className = 'mc-input'; + nameInput.type = 'text'; + nameInput.placeholder = 'Ваше имя'; + nameInput.value = tgUser ? (tgUser.username || tgUser.first_name || '') : ''; + nameInput.maxLength = 20; + nameInput.addEventListener('input', () => { + if (nameInput.value.trim()) { + state.playerName = nameInput.value.trim(); + localStorage.setItem('minegrechka_playerName', state.playerName); + } + }); + body.appendChild(nameInput); + } + + box.appendChild(body); + overlay.appendChild(box); + document.body.appendChild(overlay); + + // Pre-fill player name from TG if available + if (isTgWebApp && tgUser && !state.playerName) { + state.playerName = tgUser.username || tgUser.first_name || 'Игрок'; + localStorage.setItem('minegrechka_playerName', state.playerName); + } + + // Auto-load TG save info (background fetch) + if (isTgWebApp && tgUser && !worldParam) { + fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id) + .then(r => r.json()) + .then(data => { + if (data.ok && data.world_id) { + // could update UI but menu is already shown + } + }) + .catch(() => {}); + } + }); +} diff --git a/style.css b/style.css index 54d8a5f..7d9a764 100644 --- a/style.css +++ b/style.css @@ -36,11 +36,10 @@ canvas { display:block; width:100%; height:100%; image-rendering:pixelated; } font-size:24px; cursor:pointer; pointer-events:auto; box-shadow:0 4px 0 rgba(0,0,0,0.5); } .rbtn:active { transform: translateY(4px); box-shadow:none; } #modeBtn { top:10px; background:#f39c12; } -#saveBtn { top:10px; right:70px !important; background:#27ae60; } -#resetBtn { top:10px; right:130px !important; background:#e74c3c; } -#mapToggle { top:10px; right:190px !important; background:#1abc9c; } +#mapToggle { top:10px; right:70px !important; background:#1abc9c; } #craftBtn { top:74px; right:10px !important; background:#9b59b6; } #invToggle { top:74px; right:70px !important; background:#3498db; } +#menuBtn { top:74px; right:130px !important; background:#555; } #chatToggle { display: none !important; } #chatPanel { display: none !important; } @@ -104,3 +103,14 @@ body.touch-device #hotbar { .custom-modal-box .btn-yes { background:#e74c3c; } .custom-modal-box .btn-no { background:#555; } .custom-modal-box .btn-ok { background:#2ecc71; } + +/* Menu dropdown ⋯ */ +.menu-dropdown { position:absolute; top:120px; right:58px; background:rgba(20,20,30,0.95); border:2px solid rgba(255,255,255,0.7); border-radius:10px; display:none; flex-direction:column; min-width:140px; z-index:300; pointer-events:auto; overflow:hidden; } +.menu-dropdown.open { display:flex; } +.menu-item { padding:10px 14px; color:#fff; font-size:14px; font-weight:700; cursor:pointer; border-bottom:1px solid rgba(255,255,255,0.1); } +.menu-item:last-child { border-bottom:none; } +.menu-item:hover { background:rgba(255,255,255,0.12); } +.menu-item:active { background:rgba(255,255,255,0.2); } + +/* Market panel */ +#marketPanel { display:none; position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:360px; max-width:90%; background:rgba(10,10,12,0.95); border:2px solid rgba(255,255,255,0.85); border-radius:14px; pointer-events:auto; padding:12px; z-index:200; }