From 233ff0297697e6c01b9615c6cbc0637cc45ca329 Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 13:34:12 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20jitter=20buffer=20for=20voice=20chat=20?= =?UTF-8?q?=E2=80=94=20120ms=20delay,=20continuous=20scheduling,=20gain=20?= =?UTF-8?q?ramp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game.js | 44 +- game.js.bak | 3769 --------------------------------------------------- index.html | 2 +- 3 files changed, 26 insertions(+), 3789 deletions(-) delete mode 100644 game.js.bak diff --git a/game.js b/game.js index 5ff93dd..3c035d9 100644 --- a/game.js +++ b/game.js @@ -1343,6 +1343,7 @@ function customConfirm(msg, onYes) { if (voiceActive) { // Выключить voiceActive = false; + jBufNextTime = 0; voiceBtn.innerHTML = '🎤/'; voiceBtn.style.background = '#555'; if (voiceStream) { @@ -1402,35 +1403,40 @@ function customConfirm(msg, onYes) { console.error('[voice] Socket connect error:', err.message); }); - // Очередь воспроизведения голоса — склеиваем чанки без щелчков - const voiceQueue = []; - let voicePlaying = false; - function playVoiceChunk(float32, volume) { - const FADE = 64; // сэмплов для плавного перехода - // Fade in начало - for (let i = 0; i < FADE && i < float32.length; i++) { - float32[i] *= i / FADE; + // === Jitter Buffer для голоса === + // Накапливаем чанки, затем льём непрерывно через один ScriptProcessor + const jBuf = []; // очередь { float32, volume } + const JBUF_DELAY = 0.12; // начальная задержка 120мс — буфер против джиттера + let jBufNextTime = 0; // когда следующий чанк должен стартовать + let jBufDrift = 0; // коррекция дрифта + + function scheduleVoiceChunk(float32, volume) { + const now = audioCtx.currentTime; + // Первый чанк — добавляем задержку + if (jBufNextTime === 0) { + jBufNextTime = now + JBUF_DELAY; } - // Fade out конец - for (let i = 0; i < FADE && i < float32.length; i++) { - float32[float32.length - 1 - i] *= i / FADE; + // Если чанк пришёл с опозданием (разрыв сети) — не делаем промежуток + if (jBufNextTime < now) { + jBufNextTime = now + 0.005; // минимальный зазор 5мс } const buf = audioCtx.createBuffer(1, float32.length, 24000); buf.getChannelData(0).set(float32); const src = audioCtx.createBufferSource(); src.buffer = buf; const gain = audioCtx.createGain(); - gain.gain.value = Math.min(1, volume * 1.5); // усилить тихий голос + const vol = Math.min(1.4, volume * 1.5); // усиление, макс 1.4 + gain.gain.setValueAtTime(vol, jBufNextTime); + // Лёгкий fade в конце чанка (последние 128 сэмплов) для склейки + gain.gain.linearRampToValueAtTime(vol * 0.3, jBufNextTime + float32.length / 24000 - 0.005); + gain.gain.linearRampToValueAtTime(0, jBufNextTime + float32.length / 24000); src.connect(gain).connect(audioCtx.destination); - // Склеиваем: начинаем сразу после предыдущего чанка - const when = voicePlaying ? audioCtx.currentTime + 0.02 : audioCtx.currentTime; - voicePlaying = true; - src.start(when); - src.onended = () => { voicePlaying = false; }; + src.start(jBufNextTime); + jBufNextTime += float32.length / 24000; // время звучания этого чанка } voiceSocket.on('voice_in', (payload) => { - // Воспроизводим входящий голос — raw PCM int16 + // Воспроизводим входящий голос — raw PCM int16 через jitter buffer const { data, meta, volume } = payload; if (!audioCtx || audioCtx.state === 'closed') return; @@ -1439,7 +1445,7 @@ function customConfirm(msg, onYes) { for (let i = 0; i < int16.length; i++) { float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF); } - playVoiceChunk(float32, volume || 1); + scheduleVoiceChunk(float32, volume || 1); // Индикатор speakingIndicator.style.display = 'block'; diff --git a/game.js.bak b/game.js.bak deleted file mode 100644 index 5b67ec3..0000000 --- a/game.js.bak +++ /dev/null @@ -1,3769 +0,0 @@ -(() => { - // ==================== КОНФИГУРАЦИЯ СЕРВЕРА ==================== -// === Custom modal functions === -function customAlert(msg) { - const overlay = document.createElement("div"); - overlay.className = "custom-modal-overlay"; - const box = document.createElement("div"); - box.className = "custom-modal-box"; - const text = document.createElement("div"); - text.textContent = msg; - text.style.marginBottom = "16px"; - const btn = document.createElement("button"); - btn.className = "btn-ok"; - btn.textContent = "OK"; - btn.onclick = () => overlay.remove(); - box.appendChild(text); - box.appendChild(btn); - overlay.appendChild(box); - document.querySelector("#game").appendChild(overlay); -} -function customConfirm(msg, onYes) { - const overlay = document.createElement("div"); - overlay.className = "custom-modal-overlay"; - const box = document.createElement("div"); - box.className = "custom-modal-box"; - const text = document.createElement("div"); - text.textContent = msg; - text.style.marginBottom = "16px"; - const btns = document.createElement("div"); - btns.className = "modal-btns"; - const yesBtn = document.createElement("button"); - yesBtn.className = "btn-yes"; - yesBtn.textContent = "Да"; - yesBtn.onclick = () => { overlay.remove(); onYes(); }; - const noBtn = document.createElement("button"); - noBtn.className = "btn-no"; - noBtn.textContent = "Отмена"; - noBtn.onclick = () => overlay.remove(); - btns.appendChild(yesBtn); - btns.appendChild(noBtn); - box.appendChild(text); - box.appendChild(btns); - overlay.appendChild(box); - document.querySelector("#game").appendChild(overlay); -} - // Возможность переопределить сервер через query string - const urlParams = new URLSearchParams(window.location.search); - const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru'; - const TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot'; // Имя бота для share-ссылки - const TELEGRAM_APP_SHORT_NAME = 'minegrechka'; // Короткое имя Mini App - - // Защита от mixed content - if (location.protocol === 'https:' && SERVER_URL.startsWith('http://')) { - console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP'); - alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.'); - } - - // ==================== WORLD ID И ИГРОКА ==================== - let worldId = null; - let playerName = localStorage.getItem('minegrechka_playerName') || null; - - // Запрашиваем имя игрока, если его нет - if (!playerName) { - playerName = prompt('Введите ваше имя для игры:') || 'Игрок'; - localStorage.setItem('minegrechka_playerName', playerName); - console.log('Player name set:', playerName); - } - - // Берём worldId из URL или генерируем новый - console.log('Current URL:', window.location.href); - const worldParam = urlParams.get('world'); - console.log('world param:', worldParam); - - // Проверяем на null, undefined или пустую строку - worldId = (worldParam && worldParam.trim() !== '') ? worldParam : null; - - console.log('worldId after params:', worldId, 'type:', typeof worldId); - - // Если worldId отсутствует - генерируем новый и записываем в URL - if (!worldId) { - worldId = Math.random().toString(36).substring(2, 10); - console.log('Generated worldId:', worldId); - - try { - const newUrl = new URL(window.location.href); - newUrl.searchParams.set('world', worldId); - const newUrlString = newUrl.toString(); - console.log('New URL to set:', newUrlString); - - // Проверяем, поддерживается ли history API - if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') { - window.history.replaceState(null, '', newUrlString); - console.log('URL after replaceState:', window.location.href); - console.log('URL after replaceState (direct check):', window.location.search); - } else { - console.error('History API not supported!'); - } - } catch (e) { - console.error('Error updating URL:', e); - } - - console.log('Generated new worldId for browser:', worldId); - } - - console.log('Final worldId:', worldId, 'Player name:', playerName); - - console.log(`Server URL: ${SERVER_URL}, World ID: ${worldId}`); - - // Обработчик клика на worldId для копирования ссылки - document.getElementById('worldId').onclick = () => { - const shareUrl = new URL(window.location.href); - shareUrl.searchParams.set('world', worldId); - const shareUrlString = shareUrl.toString(); - - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(shareUrlString).then(() => { - alert('Ссылка скопирована!'); - }).catch(() => { - alert('Ссылка на мир:\n' + shareUrlString); - }); - } else { - alert('Ссылка на мир:\n' + shareUrlString); - } - }; - - // ==================== SOCKET.IO КЛИЕНТ ==================== - let socket = null; - let isMultiplayer = false; // Флаг для мультиплеерного режима - const otherPlayers = new Map(); // socket_id -> {x, y, color} - const serverMobs = new Map(); // id -> mob (server-spawned, client-authoritative physics) - - // Helper to get all mobs (local + server-spawned in MP) - function getAllMobs() { - return isMultiplayer ? mobs.concat(Array.from(serverMobs.values())) : mobs; - } - - // Create a client-side mob object from server spawn data with correct properties matching client constructors - function createMobFromServer(data) { - const kindProps = { - zombie: { w: 34, h: 50, hp: 4, speed: 80 + Math.random() * 40, hostile: true, fuse: 0, shootCooldown: 2 }, - creeper: { w: 34, h: 50, hp: 4, speed: 60 + Math.random() * 30, hostile: true, fuse: 3.2, shootCooldown: 2 }, - skeleton: { w: 34, h: 50, hp: 4, speed: 70 + Math.random() * 30, hostile: true, fuse: 0, shootCooldown: 0 }, - pig: { w: 34, h: 24, hp: 2, speed: 40, hostile: false, fuse: 0, shootCooldown: 2 }, - chicken: { w: 26, h: 22, hp: 1, speed: 55, hostile: false, fuse: 0, shootCooldown: 2 } - }; - const props = kindProps[data.kind] || kindProps['pig']; // fallback - return { - id: data.id, - kind: data.kind, - x: data.x, - y: data.y, - w: props.w, - h: props.h, - hp: data.hp || props.hp, - maxHp: data.maxHp || data.hp || props.hp, - speed: props.speed, - hostile: props.hostile, - vx: 0, - vy: 0, - grounded: false, - inWater: false, - aiT: 0, - dir: data.dir || 1, - dead: false, - fuse: props.fuse, - shootCooldown: props.shootCooldown - }; - } - let mySocketId = null; - - // Throttle для отправки позиции (10-20 раз в секунду) - let lastMoveSendTime = 0; - const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду - let lastSentX = 0, lastSentY = 0; - - function initSocket() { - try { - socket = io(SERVER_URL, { - path: '/socket.io/', - transports: ['websocket', 'polling'] - }); - - socket.on('connect', () => { - console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${worldId}`); - mySocketId = socket.id; - isMultiplayer = true; - - // Присоединяемся к миру - socket.emit('join_world', { world_id: worldId, player_name: playerName }); - - // Показываем в UI - worldIdEl.textContent = worldId; - multiplayerStatus.style.display = 'block'; - }); - - socket.on('connect_error', (error) => { - console.error('Socket connection error:', error); - isMultiplayer = false; - }); - - socket.on('disconnect', () => { - console.log('Disconnected from server'); - isMultiplayer = false; - otherPlayers.clear(); - multiplayerStatus.style.display = 'none'; - }); - - // Обработка world_state - socket.on('world_state', (data) => { - console.log('Received world_state:', data); - - // Устанавливаем seed и перегенерируем мир если он изменился - if (data.seed !== undefined && data.seed !== worldSeed) { - const oldSeed = worldSeed; - worldSeed = data.seed; - console.log('World seed changed from', oldSeed, 'to', worldSeed); - - // Очищаем и перегенерируем мир с новым seed - generated.clear(); - grid.clear(); - blocks.length = 0; - placedBlocks = []; - removedBlocks = []; - console.log('World regenerated with new seed:', worldSeed); - } - - // Применяем блоки — сохраняем в serverOverrides для применения после genColumn - if (data.blocks && Array.isArray(data.blocks)) { - for (const block of data.blocks) { - const key = k(block.gx, block.gy); - serverOverrides.set(key, { op: block.op, t: block.t }); - // Также пробуем применить сразу (если колонна уже сгенерирована) - if (block.op === 'set') { - setBlock(block.gx, block.gy, block.t, false); - } else if (block.op === 'remove') { - removeBlock(block.gx, block.gy); - } - } - } - - // Устанавливаем время - if (data.time !== undefined) { - worldTime = data.time; - isNightTime = worldTime > 0.5; - } - - // Всегда считаем spawnPoint на клиенте по той же формуле что и surfaceGyAt - // Это гарантирует совпадение с terrain generation - { - 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 = 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; - } - } - spawnPoint.x = startGX * TILE; - spawnPoint.y = safeGY * TILE; - console.log('Client-side spawn point:', spawnPoint, 'surfaceY:', surfaceY, 'safeGY:', safeGY); - } - - // Устанавливаем игрока в точку спавна - player.x = spawnPoint.x; - player.y = spawnPoint.y; - player.vx = 0; - player.vy = 0; - player.fallStartY = player.y; - console.log('Player moved to spawn point:', player.x, player.y); - - // Устанавливаем HP на 100% при каждом подключении к миру - player.hp = 100; - player.hunger = 100; - player.o2 = 100; - player.invuln = 0; - console.log('[MULTIPLAYER CONNECT] Player HP set to 100% on connect'); - - // Обновляем список игроков - if (data.players && Array.isArray(data.players)) { - otherPlayers.clear(); - for (const p of data.players) { - if (p.socket_id !== mySocketId) { - otherPlayers.set(p.socket_id, { - x: p.x, - y: p.y, - color: getRandomPlayerColor(p.socket_id), - name: p.player_name || 'Игрок' - }); - } - } - // Обновляем счётчик игроков - playerCountEl.textContent = data.players.length; - } - // Server mobs — client-authoritative: create with full client-side properties - if (data.mobs && Array.isArray(data.mobs)) { - serverMobs.clear(); - for (const m of data.mobs) { - const sm = createMobFromServer(m); - serverMobs.set(m.id, sm); - } - } - }); - - // Игрок присоединился - socket.on('player_joined', (data) => { - console.log('Player joined:', data.socket_id); - if (data.socket_id !== mySocketId) { - // Генерируем безопасную позицию для нового игрока - const spawnGX = 6; - genColumn(spawnGX); - const surfaceY = surfaceGyAt(spawnGX); - const safeSpawnX = spawnGX * TILE; - const safeSpawnY = (surfaceY - 1) * TILE; - - otherPlayers.set(data.socket_id, { - x: safeSpawnX, - y: safeSpawnY, - color: getRandomPlayerColor(data.socket_id), - name: data.player_name || 'Игрок' - }); - addChatMessage('Система', `Игрок присоединился`); - // Обновляем видимость кнопки сохранения - updateSaveButtonVisibility(); - } - }); - - // Игрок переместился - socket.on('player_moved', (data) => { - if (data.socket_id !== mySocketId && otherPlayers.has(data.socket_id)) { - const p = otherPlayers.get(data.socket_id); - p.x = data.x; - p.y = data.y; - // Обновляем имя, если оно пришло - if (data.player_name) { - p.name = data.player_name; - } - } - }); - - // Игрок покинул - socket.on('player_left', (data) => { - console.log('Player left:', data.socket_id); - otherPlayers.delete(data.socket_id); - addChatMessage('Система', `Игрок покинул игру`); - // Обновляем видимость кнопки сохранения - updateSaveButtonVisibility(); - }); - - // === MOB SYNC (multiplayer) === - - socket.on('mob_spawned', (data) => { - const sm = createMobFromServer(data); - serverMobs.set(data.id, sm); - }); - - socket.on('mob_positions', (arr) => { - // Client-authoritative: ignore server positions, mobAI handles physics locally. - // Only update HP/fuse/direction for consistency (e.g. if another client hurt the mob). - for (const u of arr) { - const sm = serverMobs.get(u.id); - if (sm) { sm.hp = u.hp; sm.fuse = u.fuse != null ? u.fuse : sm.fuse; } - } - }); - - socket.on('mob_despawned', (data) => { serverMobs.delete(data.id); }); - - socket.on('mob_died', (data) => { - const sm = serverMobs.get(data.id); - if (sm && data.killer === mySocketId) { - // Give loot to the killer - if (sm.kind === 'chicken') playSound('hurt_chicken'); - inv.meat += (sm.kind==='chicken' ? 1 : 2); - if (sm.kind === 'skeleton') { - inv.arrow += 2 + Math.floor(Math.random()*3); - if (Math.random() < 0.15) inv.bow = (inv.bow||0) + 1; - } - rebuildHotbar(); - } - serverMobs.delete(data.id); - }); - - socket.on('mob_hurt_ack', (data) => { - const sm = serverMobs.get(data.id); - if (sm) sm.hp = data.hp; - }); - - socket.on('mob_explode', (data) => { - explodeAt(data.gx, data.gy); - serverMobs.delete(data.id); - }); - - socket.on('mob_shoot', (data) => { - projectiles.push({ - x: data.x, y: data.y, vx: data.vx, vy: data.vy, - dmg: data.dmg, owner: 'mob', life: data.life - }); - }); - - // Блок изменён - socket.on('block_changed', (data) => { - const key = k(data.gx, data.gy); - serverOverrides.set(key, { op: data.op, t: data.t }); - if (data.op === 'set') { - setBlock(data.gx, data.gy, data.t, false); - } else if (data.op === 'remove') { - removeBlock(data.gx, data.gy); - } - }); - - // Сообщение в чат - socket.on('chat_message', (data) => { - const senderName = data.socket_id === mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`; - addChatMessage(senderName, data.message); - }); - - // Обновление времени - socket.on('time_update', (data) => { - if (data.time !== undefined) { - worldTime = data.time; - isNightTime = worldTime > 0.5; - } - }); - - } catch (e) { - console.error('Error initializing socket:', e); - isMultiplayer = false; - } - } - - // Генерация случайного цвета для игрока на основе socket_id - function getRandomPlayerColor(socketId) { - const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4']; - let hash = 0; - for (let i = 0; i < socketId.length; i++) { - hash = ((hash << 5) - hash) + socketId.charCodeAt(i); - hash = hash & hash; - } - return colors[Math.abs(hash) % colors.length]; - } - - // Отправка позиции игрока (с throttle) - function sendPlayerPosition() { - if (!isMultiplayer || !socket || !socket.connected) return; - - const now = performance.now() / 1000; - if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return; - - // Отправляем только если позиция изменилась - const dx = Math.abs(player.x - lastSentX); - const dy = Math.abs(player.y - lastSentY); - if (dx < 1 && dy < 1) return; - - lastMoveSendTime = now; - lastSentX = player.x; - lastSentY = player.y; - - socket.emit('player_move', { x: player.x, y: player.y, player_name: playerName }); - } - - // Отправка изменения блока - function sendBlockChange(gx, gy, t, op) { - if (!isMultiplayer || !socket || !socket.connected) return; - - socket.emit('block_change', { gx, gy, t, op }); - } - - // ==================== ЧАТ ==================== - const chatMessages = []; - const MAX_CHAT_MESSAGES = 20; - - function addChatMessage(sender, message) { - const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); - chatMessages.push({ sender, message, time }); - if (chatMessages.length > MAX_CHAT_MESSAGES) { - chatMessages.shift(); - } - renderChatMessages(); - } - - function renderChatMessages() { - const chatMessagesEl = document.getElementById('chatMessages'); - if (!chatMessagesEl) return; - - chatMessagesEl.innerHTML = chatMessages.map(m => - `
${m.time} ${m.sender}: ${m.message}
` - ).join(''); - - // Прокручиваем вниз - chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; - } - - function sendChatMessage(message) { - if (!message || message.trim() === '') return; - - if (isMultiplayer && socket && socket.connected) { - socket.emit('chat_message', { message: message.trim() }); - } else { - addChatMessage('Вы', message.trim()); - } - } - - // ==================== ПОДЕЛИТЬСЯ МИРОМ ==================== - function shareWorld() { - const shareUrl = new URL(window.location.href); - shareUrl.searchParams.set('world', worldId); - const shareUrlString = shareUrl.toString(); - - // Копируем в буфер обмена - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(shareUrlString).then(() => { - alert('Ссылка скопирована!'); - }).catch(() => { - alert('Ссылка на мир:\n' + shareUrlString); - }); - } else { - alert('Ссылка на мир:\n' + shareUrlString); - } - } - - // ==================== ИНИЦИАЛИЗАЦИЯ UI ==================== - let chatOpen = false; - - document.getElementById('chatToggle').onclick = () => { - playSound('click'); - chatOpen = !chatOpen; - document.getElementById('chatPanel').style.display = chatOpen ? 'block' : 'none'; - if (chatOpen) { - document.getElementById('chatInput').focus(); - } - }; - - document.getElementById('chatClose').onclick = () => { - playSound('click'); - chatOpen = false; - document.getElementById('chatPanel').style.display = 'none'; - }; - - document.getElementById('chatSend').onclick = () => { - const input = document.getElementById('chatInput'); - sendChatMessage(input.value); - input.value = ''; - }; - - document.getElementById('chatInput').addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - sendChatMessage(e.target.value); - e.target.value = ''; - } - }); - - // ==================== ИНИЦИАЛИЗАЦИЯ СОКЕТА ==================== - // Инициализируем socket - initSocket(); - - // ==================== ЗВУКОВОЙ ДВИЖОК ==================== - const sounds = {}; - function loadSound(id, src) { - const audio = new Audio(); - audio.src = src; - audio.volume = 0.3; - sounds[id] = audio; - } - - // Загрузка звуков - 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'); - - function playSound(id) { - if(sounds[id]) { - sounds[id].currentTime = 0; - sounds[id].play().catch(e => console.error('Sound error:', e)); - } - } - - // Играем звук при прыжке - const gameEl = document.getElementById('game'); - const canvas = document.getElementById('c'); - const ctx = canvas.getContext('2d'); - - // offscreen light map (не вставляем в DOM) - const lightC = document.createElement('canvas'); - const lightCtx = lightC.getContext('2d'); - - const dpr = Math.max(1, window.devicePixelRatio || 1); - let W=0, H=0; - - const TILE = 40; - - // Мир - const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов - const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже - const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки - - const heroImg = new Image(); - heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png'; - - // Состояние инвентаря - let showFullInventory = false; - let recentItems = []; // Последние 5 выбранных предметов - - const BLOCKS = { - air: { n:'Воздух', solid:false }, - grass: { n:'Трава', c:'#7cfc00', solid:true }, - dirt: { n:'Грязь', c:'#8b4513', solid:true }, - stone: { n:'Камень', c:'#7f8c8d', solid:true }, - sand: { n:'Песок', c:'#f4d06f', solid:true }, - gravel: { n:'Гравий', c:'#95a5a6', solid:true }, - clay: { n:'Глина', c:'#74b9ff', solid:true }, - wood: { n:'Дерево', c:'#d35400', solid:true }, - planks: { n:'Доски', c:'#e67e22', solid:true }, - ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true }, - leaves: { n:'Листва', c:'#2ecc71', solid:true }, - glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55 }, - water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true }, - coal: { n:'Уголь', c:'#2c3e50', solid:true }, - copper_ore:{ n:'Медь', c:'#e17055', solid:true }, - iron_ore: { n:'Железо', c:'#dcdde1', solid:true }, - iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5 }, - gold_ore: { n:'Золото', c:'#f1c40f', solid:true }, - diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true }, - brick: { n:'Кирпич', c:'#c0392b', solid:true }, - tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true }, - campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:280 }, - torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:220 }, - bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true }, - flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true }, - bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true }, - boat: { n:'Лодка', c:'#8B4513', solid:false }, - furnace: { n:'Печь', c:'#696969', solid:true, smelting:true } - }; - - const ITEMS = { - meat: { n:'Сырое мясо', icon:'🥩', food:15 }, - cooked: { n:'Жареное мясо', icon:'🍖', food:45 }, - arrow: { n:'Стрела', icon:'➡️', stack:64 }, - }; - - // Seed мира для детерминированной генерации - // Инициализируем случайным seed, но он будет перезаписан сервером в мультиплеере - let worldSeed = Math.floor(Math.random() * 1000000); - - // Отслеживание изменений мира (для оптимизированного сохранения) - let placedBlocks = []; // [{gx, gy, t}] - блоки, установленные игроком - let removedBlocks = []; // [{gx, gy}] - блоки, удалённые игроком - - // Серверные изменения — применяются после genColumn чтобы не перезатирались - const serverOverrides = new Map(); // key "gx,gy" => {op:'set'|'remove', t?:string} - - // Инструменты - const TOOLS = { - wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', durability: 60, miningPower: 1, craft: { wood: 3, planks: 2 } }, - stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 } }, - iron_pickaxe: { n:'Железная кирка', icon:'⛏️', durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 } }, - wood_sword: { n:'Деревянный меч', icon:'⚔️', durability: 40, damage: 5, craft: { wood: 2, planks: 1 } }, - stone_sword: { n:'Каменный меч', icon:'⚔️', durability: 100, damage: 8, craft: { wood: 1, stone: 2 } }, - iron_sword: { n:'Железный меч', icon:'⚔️', durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 } }, - bow: { n:'Лук', icon:'🏹', durability: 150, craft: { wood: 3, string: 0 } } - }; - - // Текстуры блоков (простые) - const tex = {}; - function makeTex(type) { - const t = BLOCKS[type]; - const c = document.createElement('canvas'); - c.width = 32; c.height = 32; - const g = c.getContext('2d'); - - if (type === 'tnt') { - g.fillStyle='#c0392b'; g.fillRect(0,0,32,32); - g.fillStyle='#fff'; g.fillRect(0,12,32,8); - g.fillStyle='#000'; g.font='bold 10px sans-serif'; g.fillText('TNT',6,20); - return c; - } - if (type === 'campfire') { - g.fillStyle='#5d4037'; g.fillRect(4,26,24,6); - g.fillStyle='#3e2723'; g.fillRect(7,23,18,4); - return c; - } - if (type === 'torch') { - g.fillStyle='#6d4c41'; g.fillRect(14,10,4,18); - g.fillStyle='#f39c12'; g.fillRect(12,6,8,8); - return c; - } - if (type === 'glass') { - g.fillStyle='rgba(200,240,255,0.25)'; g.fillRect(0,0,32,32); - g.strokeStyle='rgba(255,255,255,0.65)'; g.strokeRect(2,2,28,28); - g.beginPath(); g.moveTo(5,27); g.lineTo(27,5); g.stroke(); - return c; - } - if (type === 'water') { - g.fillStyle = t.c; g.fillRect(0,0,32,32); - g.fillStyle = 'rgba(255,255,255,0.08)'; - g.fillRect(0,6,32,2); - return c; - } - if (type === 'bed') { - // Основание кровати - g.fillStyle = '#e91e63'; - g.fillRect(0, 0, 32, 32); - // Подушка - g.fillStyle = '#f8bbd0'; - g.fillRect(2, 2, 14, 14); - // Одеяло - g.fillStyle = '#c2185b'; - g.fillRect(16, 4, 14, 24); - // Детали одеяла - g.fillStyle = '#e91e63'; - g.fillRect(18, 6, 10, 20); - return c; - } - if (type === 'flower') { - g.fillStyle='#2ecc71'; g.fillRect(14,14,4,18); - g.fillStyle=t.c; g.beginPath(); g.arc(16,12,6,0,6.28); g.fill(); - return c; - } - if (type === 'boat') { - // Корпус лодки - g.fillStyle = '#8B4513'; - g.fillRect(2, 12, 28, 8); - // Борта - g.fillStyle = '#A0522D'; - g.fillRect(0, 10, 32, 12); - // Внутренность - g.fillStyle = '#DEB887'; - g.fillRect(4, 14, 24, 4); - // Дно - g.fillStyle = '#654321'; - g.fillRect(2, 20, 28, 4); - return c; - } - if (type === 'ladder') { - // Боковые стойки лестницы - g.fillStyle = '#8B4513'; - g.fillRect(4, 0, 4, 32); - g.fillRect(24, 0, 4, 32); - // Ступени - g.fillStyle = '#A0522D'; - g.fillRect(4, 4, 24, 3); - g.fillRect(4, 12, 24, 3); - g.fillRect(4, 20, 24, 3); - g.fillRect(4, 28, 24, 3); - return c; - } - - g.fillStyle = t.c || '#000'; - g.fillRect(0,0,32,32); - - g.fillStyle = 'rgba(0,0,0,0.10)'; - for (let i=0;i<6;i++) g.fillRect((Math.random()*28)|0, (Math.random()*28)|0, 4,4); - - if (type.endsWith('_ore') || type==='coal') { - g.fillStyle = 'rgba(0,0,0,0.35)'; - for (let i=0;i<4;i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6,6); - } - return c; - } - Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k)); - - // Мир-хранилище - const grid = new Map(); // key "gx,gy" => {gx,gy,t, ...} - const blocks = []; // для рендера/перебора видимых - function k(gx,gy){ return gx+','+gy; } - function getBlock(gx,gy){ return grid.get(k(gx,gy)); } - function hasBlock(gx,gy){ return grid.has(k(gx,gy)); } - function isSolid(gx,gy){ - const b = getBlock(gx,gy); - if(!b || b.dead) return false; - const def = BLOCKS[b.t]; - return !!def.solid && !def.fluid && !def.decor; - } - function setBlock(gx,gy,t, isPlayerPlaced = false){ - const key = k(gx,gy); - if(grid.has(key)) return false; - const b = { gx, gy, t, dead:false, active:false, fuse:0 }; - grid.set(key, b); - blocks.push(b); - - // Отслеживаем блоки, установленные игроком - if(isPlayerPlaced){ - placedBlocks.push({gx, gy, t}); - } - - return true; - } - function removeBlock(gx,gy){ - const key = k(gx,gy); - const b = grid.get(key); - if(!b) return null; - if(BLOCKS[b.t].unbreakable) return null; - grid.delete(key); - b.dead = true; - - // Отслеживаем удалённые блоки - const wasPlayerPlaced = placedBlocks.some(pb => pb.gx === gx && pb.gy === gy); - if(wasPlayerPlaced){ - // Удаляем из placedBlocks - placedBlocks = placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy)); - } else { - // Это природный блок - добавляем в removedBlocks - removedBlocks.push({gx, gy}); - } - - return b; - } - - // Физика жидкости - const waterUpdateQueue = new Set(); - let waterUpdateTimer = 0; - const WATER_UPDATE_INTERVAL = 0.05; // Обновляем воду каждые 0.05 секунды - - function updateWaterPhysics(dt){ - waterUpdateTimer += dt; - if(waterUpdateTimer < WATER_UPDATE_INTERVAL) return; - waterUpdateTimer = 0; - - // Ограничиваем количество водных блоков для обработки (оптимизация) - const MAX_WATER_BLOCKS_PER_UPDATE = 50; - let processedCount = 0; - - // Собираем только видимые водные блоки в очередь (оптимизация) - waterUpdateQueue.clear(); - const minGX = Math.floor(camX/TILE) - 10; - const maxGX = Math.floor((camX+W)/TILE) + 10; - const minGY = Math.floor(camY/TILE) - 10; - const maxGY = Math.floor((camY+H)/TILE) + 10; - - for(const b of blocks){ - if(processedCount >= MAX_WATER_BLOCKS_PER_UPDATE) break; - if(!b.dead && b.t === 'water' && - b.gx >= minGX && b.gx <= maxGX && - b.gy >= minGY && b.gy <= maxGY){ - waterUpdateQueue.add(k(b.gx, b.gy)); - processedCount++; - } - } - - // Обновляем воду с ограничением глубины распространения - const processed = new Set(); - const toAdd = []; - const MAX_WATER_DEPTH = 20; // Максимальная глубина распространения воды - - for(const key of waterUpdateQueue){ - if(processed.has(key)) continue; - const b = grid.get(key); - if(!b || b.dead) continue; - processed.add(key); - - const gx = b.gx; - const gy = b.gy; - - // Проверяем глубину - не распространяем воду слишком глубоко - if(gy > SEA_GY + MAX_WATER_DEPTH) continue; - - // Проверяем, можно ли воде упасть вниз - const belowKey = k(gx, gy + 1); - const below = grid.get(belowKey); - - // Внизу пусто - вода создаёт новый блок внизу (но не удаляется сверху) - if(!below || below.dead){ - // Ограничиваем создание новых водных блоков - if(toAdd.length < 20){ // Максимум 20 новых блоков за обновление - toAdd.push({gx, gy: gy + 1, t: 'water'}); - processed.add(belowKey); - } - continue; - } - - // Если внизу не вода и не твёрдый блок - вода может течь вниз - if(!isSolid(gx, gy + 1) && below && below.t !== 'water'){ - if(toAdd.length < 20){ - toAdd.push({gx, gy: gy + 1, t: 'water'}); - processed.add(belowKey); - } - continue; - } - - // Если внизу твёрдый блок или вода - вода растекается горизонтально - // Проверяем левую сторону - const leftKey = k(gx - 1, gy); - const left = grid.get(leftKey); - if(!left || left.dead){ - if(toAdd.length < 20){ - toAdd.push({gx: gx - 1, gy, t: 'water'}); - processed.add(leftKey); - } - continue; - } - - // Проверяем правую сторону - const rightKey = k(gx + 1, gy); - const right = grid.get(rightKey); - if(!right || right.dead){ - if(toAdd.length < 20){ - toAdd.push({gx: gx + 1, gy, t: 'water'}); - processed.add(rightKey); - } - continue; - } - } - - // Применяем изменения (только добавляем новые блоки) - for(const newData of toAdd){ - const key = k(newData.gx, newData.gy); - if(!grid.has(key)){ - const b = { - gx: newData.gx, - gy: newData.gy, - t: newData.t, - dead: false, - active: false, - fuse: 0 - }; - grid.set(key, b); - blocks.push(b); - } - } - - // Очищаем мёртвые блоки из массива - for(let i = blocks.length - 1; i >= 0; i--){ - if(blocks[i].dead){ - blocks.splice(i, 1); - } - } - } - - // Инвентарь - const inv = { - dirt:6, stone:0, sand:0, gravel:0, clay:0, - wood:0, planks:0, ladder:0, leaves:0, coal:0, - copper_ore:0, iron_ore:0, gold_ore:0, diamond_ore:0, - brick:0, glass:0, - tnt:1, campfire:0, torch:0, - meat:0, cooked:0, arrow:0, - wood_pickaxe:0, stone_pickaxe:0, iron_pickaxe:0, - wood_sword:0, stone_sword:0, iron_sword:0, - iron_armor:0, - bow:0, furnace:0, - bed:0, boat:0, - iron_ingot:0, gold_ingot:0, copper_ingot:0 - }; - let selected = 'dirt'; - - // Прочность инструментов: Map<"tooltype_id", {current, max}> - // При крафте инструмента создаём запись с max durability - const toolDurability = new Map(); - - function addTool(type) { - const def = TOOLS[type]; - if (!def) return; - const id = `${type}_${Date.now()}_${Math.random().toString(36).slice(2,6)}`; - toolDurability.set(id, { type, current: def.durability, max: def.durability }); - return id; - } - - function getToolDurability(id) { - return toolDurability.get(id); - } - - // Найти лучший инструмент данного типа в инвентаре - function findBestTool(toolType) { - if (inv[toolType] <= 0) return null; - // Возвращаем первый попавшийся — упрощённо - return toolType; - } - - // Использовать инструмент (уменьшить прочность). Возвращает true если сломался - function useTool(toolType) { - // Ищем любой инструмент этого типа с прочностью - for (const [id, dur] of toolDurability) { - if (dur.type === toolType) { - dur.current--; - if (dur.current <= 0) { - toolDurability.delete(id); - inv[toolType]--; - rebuildHotbar(); - return true; // сломался - } - return false; - } - } - return false; - } - - const RECIPES = [ - { out:'planks', qty:4, cost:{ wood:1 } }, - { out:'ladder', qty:3, cost:{ planks:7 } }, - { out:'torch', qty:2, cost:{ coal:1, planks:1 } }, - { out:'glass', qty:1, cost:{ sand:3 } }, - { out:'brick', qty:1, cost:{ stone:2, clay:1 } }, - { out:'campfire', qty:1, cost:{ wood:1, coal:1 } }, - { out:'tnt', qty:1, cost:{ sand:2, coal:1 } }, - { out:'bed', qty:1, cost:{ wood: 3, planks: 3 } }, - { out:'boat', qty:1, cost:{ wood: 5 } }, - { out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 } }, - { out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 } }, - { out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 } }, - { out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 } }, - { out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 } }, - { out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 } }, - { out:'iron_armor', qty:1, cost:{ iron_ore: 5 } }, - { out:'furnace', qty:1, cost:{ stone: 8 } }, - { out:'bow', qty:1, cost:{ wood: 3, planks: 2 } }, - { out:'arrow', qty:4, cost:{ stone: 1, wood: 1 } } - ]; - - // Рецепты печи (обжиг) - const SMELTING_RECIPES = [ - { in:'sand', qty:1, out:'glass', outQty:1, time:3 }, // песок → стекло - { in:'clay', qty:1, out:'brick', outQty:1, time:3 }, // глина → кирпич - { in:'iron_ore', qty:1, out:'iron_ingot', outQty:1, time:5 }, // железо → слиток - { in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 }, // золото → слиток - { in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 }, // медь → слиток - { in:'meat', qty:1, out:'cooked', outQty:1, time:2 }, // сырое мясо → жареное - { in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 } // булыжник → камень - ]; - - // Новые предметы от обжига - ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' }; - ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' }; - ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' }; - - // Активные печи: Map ключа блока → { recipe, progress, totalTime } - const activeFurnaces = new Map(); - - // UI - const hpEl = document.getElementById('hp'); - const foodEl = document.getElementById('food'); - const sxEl = document.getElementById('sx'); - const syEl = document.getElementById('sy'); - const todEl = document.getElementById('tod'); - const worldIdEl = document.getElementById('worldId'); - const playerCountEl = document.getElementById('playerCount'); - const hotbarEl = document.getElementById('hotbar'); - const craftPanel = document.getElementById('craftPanel'); - const recipesEl = document.getElementById('recipes'); - const deathEl = document.getElementById('death'); - const inventoryPanel = document.getElementById('inventoryPanel'); - const inventoryGrid = document.getElementById('inventoryGrid'); - - // ==================== МИНИКАРТА ==================== - const minimapWrap = document.getElementById('minimapWrap'); - const minimapCanvas = document.getElementById('minimap'); - const minimapCtx = minimapCanvas.getContext('2d'); - let minimapOpen = false; - - document.getElementById('mapToggle').onclick = () => { - playSound('click'); - minimapOpen = !minimapOpen; - minimapWrap.style.display = minimapOpen ? 'block' : 'none'; - }; - - // Цвета блоков для миникарты (по 1 пикселю на блок) - const MINIMAP_COLORS = { - grass: '#4a8c2a', dirt: '#6b3410', stone: '#6a6a6a', sand: '#d4b84a', - gravel: '#888', clay: '#5a9ad4', wood: '#a63d00', planks: '#c46a10', - leaves: '#2a8a3a', glass: '#aaddee', water: '#2980b9', coal: '#1a1a2a', - copper_ore: '#a05535', iron_ore: '#b0b0b0', gold_ore: '#d4a017', - diamond_ore: '#0090d0', brick: '#8a2010', tnt: '#c02020', - campfire: '#d08020', torch: '#d0a020', bedrock: '#1a1a1a', - flower: '#d03050', bed: '#c03060', ladder: '#a04000', boat: '#6b3410' - }; - - function renderMinimap() { - if (!minimapOpen) return; - const mW = minimapCanvas.width; - const mH = minimapCanvas.height; - const scale = 2; // пикселей на блок - - // Область карты — центрирована на игроке - const pGX = Math.floor(player.x / TILE); - const pGY = Math.floor(player.y / TILE); - const viewW = Math.floor(mW / scale); - const viewH = Math.floor(mH / scale); - const startGX = pGX - Math.floor(viewW / 2); - const startGY = pGY - Math.floor(viewH / 2); - - // Очищаем - minimapCtx.fillStyle = isNight() ? '#070816' : '#87CEEB'; - minimapCtx.fillRect(0, 0, mW, mH); - - // Рисуем блоки - const imgData = minimapCtx.createImageData(mW, mH); - const data = imgData.data; - - for (let dx = 0; dx < viewW; dx++) { - for (let dy = 0; dy < viewH; dy++) { - const gx = startGX + dx; - const gy = startGY + dy; - const b = getBlock(gx, gy); - if (!b || b.dead || b.t === 'air') continue; - - const color = MINIMAP_COLORS[b.t]; - if (!color) continue; - - // Парсим hex цвет - const r = parseInt(color.slice(1,3), 16); - const g = parseInt(color.slice(3,5), 16); - const bl = parseInt(color.slice(5,7), 16); - - // Заполняем scale x scale пикселей - for (let sx = 0; sx < scale; sx++) { - for (let sy = 0; sy < scale; sy++) { - const px = dx * scale + sx; - const py = dy * scale + sy; - if (px >= mW || py >= mH) continue; - const idx = (py * mW + px) * 4; - data[idx] = r; - data[idx+1] = g; - data[idx+2] = bl; - data[idx+3] = 255; - } - } - } - } - - minimapCtx.putImageData(imgData, 0, 0); - - // Игрок — белый пиксель по центру - minimapCtx.fillStyle = '#fff'; - minimapCtx.fillRect(Math.floor(mW/2) - 2, Math.floor(mH/2) - 2, 4, 4); - - // Другие игроки — жёлтые точки - for (const [sid, p] of otherPlayers) { - const dx = Math.floor(p.x / TILE) - startGX; - const dy = Math.floor(p.y / TILE) - startGY; - if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) { - minimapCtx.fillStyle = '#f1c40f'; - minimapCtx.fillRect(dx * scale - 1, dy * scale - 1, 3, 3); - } - } - - // Мобы — красные (враждебные) / зелёные (животные) - const allMobsForMap = getAllMobs(); - for (const m of allMobsForMap) { - const dx = Math.floor(m.x / TILE) - startGX; - const dy = Math.floor(m.y / TILE) - startGY; - if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) { - const hostile = m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton'; - minimapCtx.fillStyle = hostile ? '#e74c3c' : '#2ecc71'; - minimapCtx.fillRect(dx * scale, dy * scale, 2, 2); - } - } - } - - // ==================== ПЕЧЬ (ОБЖИГ) ==================== - const furnacePanel = document.getElementById('furnacePanel'); - const furnaceContent = document.getElementById('furnaceContent'); - let currentFurnaceKey = null; // "gx,gy" текущей открытой печи - - document.getElementById('furnaceClose').onclick = () => { - furnacePanel.style.display = 'none'; - currentFurnaceKey = null; - }; - - function openFurnaceUI(gx, gy) { - currentFurnaceKey = `${gx},${gy}`; - furnacePanel.style.display = 'block'; - renderFurnaceUI(); - } - - function renderFurnaceUI() { - if (!currentFurnaceKey) return; - - // Проверяем что печь всё ещё существует - const [fgx, fgy] = currentFurnaceKey.split(',').map(Number); - const fb = getBlock(fgx, fgy); - if (!fb || fb.t !== 'furnace') { - furnacePanel.style.display = 'none'; - currentFurnaceKey = null; - return; - } - - // Текущий процесс обжига - const active = activeFurnaces.get(currentFurnaceKey); - - let html = '
'; - - // Доступные рецепты — показываем только те, для которых есть ресурсы - for (let i = 0; i < SMELTING_RECIPES.length; i++) { - const recipe = SMELTING_RECIPES[i]; - const haveCount = inv[recipe.in] || 0; - const canSmelt = haveCount >= recipe.qty; - - // Иконка результата - const outDef = BLOCKS[recipe.out]; - const outItem = ITEMS[recipe.out]; - const iconStr = outItem ? outItem.icon : (outDef ? '🧱' : '❓'); - const nameStr = outItem ? outItem.n : (outDef ? outDef.n : recipe.out); - const inItem = ITEMS[recipe.in]; - const inDef = BLOCKS[recipe.in]; - const inName = inItem ? inItem.n : (inDef ? inDef.n : recipe.in); - - html += `
`; - html += `
${iconStr}
`; - html += `
`; - html += `
${nameStr}
`; - html += `
${inName} x${recipe.qty} (есть: ${haveCount}) • ${recipe.time}с
`; - html += `
`; - html += ``; - html += `
`; - } - - // Текущий прогресс - if (active) { - const pct = Math.min(100, Math.floor((active.progress / active.recipe.time) * 100)); - html += `
`; - html += `
🔥 Обжиг: ${pct}%
`; - html += `
`; - html += `
`; - html += `
`; - } - - html += '
'; - furnaceContent.innerHTML = html; - } - - // Глобальная функция для кнопки обжига - window._smelt = (recipeIdx) => { - if (!currentFurnaceKey) return; - const recipe = SMELTING_RECIPES[recipeIdx]; - if ((inv[recipe.in] || 0) < recipe.qty) return; - - // Уже обжигаем в этой печи? - if (activeFurnaces.has(currentFurnaceKey)) return; - - // Забираем ресурсы - inv[recipe.in] -= recipe.qty; - - // Запускаем обжиг - activeFurnaces.set(currentFurnaceKey, { - recipe: recipe, - progress: 0 - }); - - playSound('fire'); - rebuildHotbar(); - renderFurnaceUI(); - }; - - // Тик печей — вызывается в главном цикле - function tickFurnaces(dt) { - for (const [key, furnace] of activeFurnaces) { - furnace.progress += dt; - if (furnace.progress >= furnace.recipe.time) { - // Обжиг завершён — выдаём результат - const outItem = furnace.recipe.out; - if (ITEMS[outItem]) { - inv[outItem] = (inv[outItem] || 0) + furnace.recipe.outQty; - } else if (BLOCKS[outItem]) { - inv[outItem] = (inv[outItem] || 0) + furnace.recipe.outQty; - } - playSound('stone_build'); - activeFurnaces.delete(key); - - // Если эта печь открыта — обновляем UI - if (key === currentFurnaceKey) { - renderFurnaceUI(); - } - } - } - } - - // ==================== ГОЛОСОВОЙ ЧАТ ==================== - let voiceSocket = null; - let voiceStream = null; - let audioCtx = null; - let voiceProcessor = null; - let voiceActive = false; - let voiceMode = 'near'; // 'near' or 'world' - let voiceDebugCount = 0; - const VOICE_SERVER = 'https://voicegrech.mkn8n.ru'; - - // Кнопка микрофона - const voiceBtn = document.createElement('div'); - voiceBtn.innerHTML = '🎤/'; - 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;'; - speakingIndicator.textContent = '🔊'; - document.querySelector('.ui').appendChild(speakingIndicator); - let speakingTimeout = null; - - voiceBtn.onclick = async () => { - if (voiceActive) { - // Выключить - voiceActive = false; - voiceBtn.innerHTML = '🎤/'; - voiceBtn.style.background = '#555'; - if (voiceStream) { - voiceStream.getTracks().forEach(t => t.stop()); - voiceStream = null; - } - if (voiceProcessor) { voiceProcessor.disconnect(); voiceProcessor = null; } - if (audioCtx) { audioCtx.close(); audioCtx = null; } - if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; } - return; - } - - // Включить - try { - voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); - audioCtx = new AudioContext({ sampleRate: 24000 }); - if (audioCtx.state === 'suspended') await audioCtx.resume(); - console.log('[voice] AudioContext state:', audioCtx.state, 'sampleRate:', audioCtx.sampleRate); - - const source = audioCtx.createMediaStreamSource(voiceStream); - voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1); - console.log('[voice] ScriptProcessor created, bufferSize=4096'); - - voiceProcessor.onaudioprocess = (e) => { - if (!voiceActive) return; - voiceDebugCount++; - if (voiceDebugCount <= 5) { - const pcm = e.inputBuffer.getChannelData(0); - console.log('[voice] onaudioprocess #' + voiceDebugCount, 'samples:', pcm.length, 'max:', Math.max(...pcm.map(Math.abs)).toFixed(4), 'socket:', !!voiceSocket, 'connected:', voiceSocket?.connected); - } - if (!voiceSocket || !voiceSocket.connected) return; - const pcm = e.inputBuffer.getChannelData(0); - const int16 = new Int16Array(pcm.length); - for (let i = 0; i < pcm.length; i++) { - const s = Math.max(-1, Math.min(1, pcm[i])); - int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; - } - voiceSocket.emit('voice_data', int16.buffer); - }; - - // Chain: source → processor → gain(0) → destination - // ScriptProcessor MUST reach destination to fire onaudioprocess - const silentGain = audioCtx.createGain(); - silentGain.gain.value = 0; - source.connect(voiceProcessor); - voiceProcessor.connect(silentGain); - silentGain.connect(audioCtx.destination); - console.log('[voice] Audio chain: source → processor → silentGain(0) → destination'); - - // Подключаемся к голосовому серверу - voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] }); - voiceSocket.on('connect', () => { - console.log('[voice] Socket connected, id:', voiceSocket.id); - voiceSocket.emit('voice_join', { world_id: worldId, x: player.x, y: player.y, name: playerName || 'Игрок', mode: voiceMode }); - }); - voiceSocket.on('connect_error', (err) => { - console.error('[voice] Socket connect error:', err.message); - }); - - // Очередь воспроизведения голоса — склеиваем чанки без щелчков - const voiceQueue = []; - let voicePlaying = false; - function playVoiceChunk(float32, volume) { - const FADE = 64; // сэмплов для плавного перехода - // Fade in начало - for (let i = 0; i < FADE && i < float32.length; i++) { - float32[i] *= i / FADE; - } - // Fade out конец - for (let i = 0; i < FADE && i < float32.length; i++) { - float32[float32.length - 1 - i] *= i / FADE; - } - const buf = audioCtx.createBuffer(1, float32.length, 24000); - buf.getChannelData(0).set(float32); - const src = audioCtx.createBufferSource(); - src.buffer = buf; - const gain = audioCtx.createGain(); - gain.gain.value = Math.min(1, volume * 1.5); // усилить тихий голос - src.connect(gain).connect(audioCtx.destination); - // Склеиваем: начинаем сразу после предыдущего чанка - const when = voicePlaying ? audioCtx.currentTime + 0.02 : audioCtx.currentTime; - voicePlaying = true; - src.start(when); - src.onended = () => { voicePlaying = false; }; - } - - voiceSocket.on('voice_in', (payload) => { - // Воспроизводим входящий голос — raw PCM int16 - const { data, meta, volume } = payload; - if (!audioCtx || audioCtx.state === 'closed') return; - - const int16 = new Int16Array(data); - const float32 = new Float32Array(int16.length); - for (let i = 0; i < int16.length; i++) { - float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF); - } - playVoiceChunk(float32, volume || 1); - - // Индикатор - speakingIndicator.style.display = 'block'; - speakingIndicator.textContent = '🔊 ' + (meta.name || '???'); - clearTimeout(speakingTimeout); - speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500); - }); - - voiceActive = true; - voiceBtn.textContent = '🎤'; - voiceBtn.style.background = '#2ecc71'; - console.log('[voice] Voice chat ACTIVE'); - } catch(e) { - console.error('[voice] Error:', e); - voiceBtn.style.background = '#e74c3c'; - } - }; - - // Обновляем позицию для voice server - const origPlayerMove = () => {}; - // Хук в главный цикл — обновляем позицию каждые ~500ms - let voicePosT = 0; - - // Клик на часы для включения ночи - todEl.style.cursor = 'pointer'; - todEl.onclick = () => { - playSound('click'); - worldTime = 0.6; // Устанавливаем ночь - isNightTime = true; - }; - - function rebuildHotbar(){ - hotbarEl.innerHTML=''; - - // Показываем последние 5 выбранных предметов (если они есть в инвентаре) - const items = recentItems.filter(id => inv[id] > 0).slice(0, 5); - - for(const id of items){ - const s = document.createElement('div'); - s.className = 'slot'+(id===selected?' sel':''); - if(BLOCKS[id]) { - s.style.backgroundImage = `url(${tex[id].toDataURL()})`; - s.style.backgroundSize = 'cover'; - } else if(ITEMS[id]) { - s.textContent = ITEMS[id].icon; - } else if(TOOLS[id]) { - s.textContent = TOOLS[id].icon; - } else if(id === 'iron_armor') { - s.textContent = '🛡️'; - s.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)'; - } - const c = document.createElement('div'); - c.className='count'; - c.textContent = inv[id]; - s.appendChild(c); - s.onclick = () => { - playSound('click'); // Звук клика по инвентарю - selected=id; - // Обновляем список последних предметов - recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть - recentItems.unshift(id); // Добавляем в начало - recentItems = recentItems.slice(0, 5); // Оставляем только 5 - rebuildHotbar(); - }; - - // Показываем индикатор надетой брони - if(id === 'iron_armor' && player.equippedArmor === 'iron_armor') { - const equipped = document.createElement('div'); - equipped.className = 'equipped-indicator'; - equipped.textContent = '✓'; - s.appendChild(equipped); - } - - // Durability bar для инструментов - if(TOOLS[id] && inv[id] > 0) { - // Находим текущую прочность - let curDur = 0, maxDur = TOOLS[id].durability; - for (const [tid, dur] of toolDurability) { - if (dur.type === id) { - curDur = dur.current; - maxDur = dur.max; - break; - } - } - if (maxDur > 0) { - const bar = document.createElement('div'); - bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;border-radius:0 0 6px 6px;overflow:hidden;`; - const fill = document.createElement('div'); - const pct = curDur / maxDur; - const color = pct > 0.5 ? '#2ecc71' : pct > 0.25 ? '#f39c12' : '#e74c3c'; - fill.style.cssText = `width:${pct*100}%;height:100%;background:${color};`; - bar.appendChild(fill); - s.appendChild(bar); - } - } - hotbarEl.appendChild(s); - } - } - - function renderInventory() { - inventoryGrid.innerHTML = ''; - - // Создаём сетку инвентаря 7x3 - const items = Object.keys(inv).filter(id => inv[id] > 0); - - // Добавляем пустые слоты для полной сетки - for(let i = 0; i < 21; i++) { - const slot = document.createElement('div'); - slot.className = 'inv-slot' + (i < items.length && items[i] === selected ? ' sel' : ''); - - if(i < items.length) { - const id = items[i]; - if(BLOCKS[id]) { - slot.style.backgroundImage = `url(${tex[id].toDataURL()})`; - slot.style.backgroundSize = 'cover'; - } else if(ITEMS[id]) { - slot.textContent = ITEMS[id].icon; - } else if(TOOLS[id]) { - slot.textContent = TOOLS[id].icon; - } else if(id === 'iron_armor') { - slot.textContent = '🛡️'; - slot.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)'; - } - - const count = document.createElement('div'); - count.className = 'inv-count'; - count.textContent = inv[id]; - slot.appendChild(count); - - slot.onclick = () => { - playSound('click'); // Звук клика по инвентарю - selected = id; - // Обновляем список последних предметов - recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть - recentItems.unshift(id); // Добавляем в начало - recentItems = recentItems.slice(0, 5); // Оставляем только 5 - rebuildHotbar(); - renderInventory(); - }; - - // Двойной клик для надевания брони - slot.ondblclick = () => { - if(id === 'iron_armor' && inv.iron_armor > 0) { - // Если уже надета броня - снимаем её - if(player.equippedArmor === 'iron_armor') { - player.equippedArmor = null; - player.armor = 0; - console.log('[ARMOR] Iron armor unequipped'); - } else { - // Надеваем броню - player.equippedArmor = 'iron_armor'; - player.armor = BLOCKS['iron_armor'].armor; - console.log('[ARMOR] Iron armor equipped - armor:', player.armor); - } - playSound('click'); - renderInventory(); - } - }; - } - - inventoryGrid.appendChild(slot); - } - } - - function canCraft(r){ - console.log('[CRAFT] Checking recipe for:', r.out, '- cost:', r.cost); - for(const res in r.cost){ - const have = inv[res] || 0; - const need = r.cost[res]; - console.log('[CRAFT] Resource:', res, '- have:', have, '- need:', need, '- can craft:', have >= need); - if(have < need) return false; - } - return true; - } - function renderCraft(){ - recipesEl.innerHTML=''; - for(const r of RECIPES){ - const row = document.createElement('div'); - row.className='recipe'; - const icon = document.createElement('div'); - icon.className='ricon'; - // Иконка — блок, инструмент или предмет - if(tex[r.out]){ - icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`; - } else if(TOOLS[r.out]){ - icon.textContent = TOOLS[r.out].icon; - icon.style.fontSize = '24px'; - icon.style.display = 'flex'; - icon.style.alignItems = 'center'; - icon.style.justifyContent = 'center'; - } else if(ITEMS[r.out]){ - icon.textContent = ITEMS[r.out].icon; - icon.style.fontSize = '24px'; - icon.style.display = 'flex'; - icon.style.alignItems = 'center'; - icon.style.justifyContent = 'center'; - } - const info = document.createElement('div'); - info.className='rinfo'; - const nm = document.createElement('div'); - nm.className='rname'; - const itemName = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out; - nm.textContent = `${itemName} x${r.qty}`; - const cs = document.createElement('div'); - cs.className='rcost'; - cs.textContent = Object.keys(r.cost).map(x => { - const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x; - return `${cn}: ${(inv[x]||0)}/${r.cost[x]}`; - }).join(' '); - info.appendChild(nm); info.appendChild(cs); - const btn = document.createElement('button'); - btn.className='rcraft'; - btn.textContent='Создать'; - btn.disabled = !canCraft(r); - btn.onclick = () => { - if(!canCraft(r)) return; - playSound('click'); - for(const res in r.cost) inv[res]-=r.cost[res]; - inv[r.out] = (inv[r.out]||0) + r.qty; - if(TOOLS[r.out]) addTool(r.out); - rebuildHotbar(); - renderCraft(); - }; - row.appendChild(icon); row.appendChild(info); row.appendChild(btn); - recipesEl.appendChild(row); - } - } - - let craftOpen=false; - let inventoryOpen = false; - - document.getElementById('craftBtn').onclick = () => { - playSound('click'); // Звук клика по кнопке - craftOpen = !craftOpen; - craftPanel.style.display = craftOpen ? 'block' : 'none'; - if(craftOpen) { - renderCraft(); - // Закрываем инвентарь если открыт крафт - inventoryOpen = false; - inventoryPanel.style.display = 'none'; - } - }; - document.getElementById('craftClose').onclick = () => { - playSound('click'); // Звук клика по кнопке - craftOpen = false; - craftPanel.style.display = 'none'; - }; - - // Кнопка открытия инвентаря - document.getElementById('invToggle').onclick = () => { - playSound('click'); // Звук клика по кнопке - inventoryOpen = true; - inventoryPanel.style.display = 'block'; - renderInventory(); - // Закрываем крафт если открыт инвентарь - craftOpen = false; - craftPanel.style.display = 'none'; - }; - - document.getElementById('inventoryClose').onclick = () => { - playSound('click'); // Звук клика по кнопке - inventoryOpen = false; - 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(); - }); - }; - - // Показываем кнопку сохранения только если играем одни - function updateSaveButtonVisibility() { - if (isMultiplayer && otherPlayers.size > 0) { - saveBtn.style.display = 'none'; - } else { - saveBtn.style.display = 'flex'; - } - } - - // Режимы - const MODES = [{id:'mine',icon:'⛏️'},{id:'build',icon:'🧱'}]; - let modeIdx=0; - const modeBtn = document.getElementById('modeBtn'); - function mode(){ return MODES[modeIdx].id; } - modeBtn.onclick = () => { - playSound('click'); // Звук клика по кнопке режима - modeIdx=(modeIdx+1)%MODES.length; modeBtn.textContent=MODES[modeIdx].icon; - }; - - // День/ночь (автоматический цикл) - let isNightTime = false; - - // Управление - const inp = { l:false, r:false, j:false, s:false }; - function bindHold(el, key){ - const down=(e)=>{ e.preventDefault(); inp[key]=true; }; - const up=(e)=>{ e.preventDefault(); inp[key]=false; }; - el.addEventListener('pointerdown', down); - el.addEventListener('pointerup', up); - el.addEventListener('pointerleave', up); - } - const leftBtn = document.getElementById('left'); - const rightBtn = document.getElementById('right'); - const jumpBtn = document.getElementById('jump'); - const downBtn = document.getElementById('down'); - - if(leftBtn) bindHold(leftBtn,'l'); - if(rightBtn) bindHold(rightBtn,'r'); - if(jumpBtn) bindHold(jumpBtn,'j'); - if(downBtn) bindHold(downBtn,'s'); - - window.addEventListener('keydown', (e)=>{ - if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=true; - if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=true; - if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=true; - if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=true; - }); - window.addEventListener('keyup', (e)=>{ - if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=false; - if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=false; - if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=false; - if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=false; - }); - - // Лодка - const boat = { - x: 0, y: 0, - w: 34, h: 34, - vx: 0, vy: 0, - active: false, - inWater: false - }; - - // Функция для расчёта урона с учётом брони - function calculateDamage(baseDamage) { - // Броня снижает урон пропорционально - // armor: 0 = без брони (100% урона) - // armor: 0.5 = железная броня (50% урона) - const reduction = player.armor; - const actualDamage = baseDamage * (1 - reduction); - console.log('[DAMAGE] Base:', baseDamage, '- Armor:', reduction, '- Actual:', actualDamage.toFixed(1)); - return actualDamage; - } - - // Игрок - const player = { - x: 6*TILE, y: 0*TILE, - w: 34, h: 34, - vx: 0, vy: 0, - grounded: false, - inWater: false, - headInWater: false, - hp: 100, - hunger: 100, - o2: 100, - invuln: 0, - fallStartY: 0, - lastStepTime: 0, - sleeping: false, - inBoat: false, - armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня) - equippedArmor: null // Тип надетой брони - }; - - // Сохраняем начальную позицию для возрождения - const spawnPoint = { x: 6*TILE, y: 0*TILE }; - - // Система сохранения игры (localStorage + in-memory fallback) - const SAVE_KEY = 'minegrechka_save'; - let db = null; // Оставляем для совместимости, но не используем - let inMemorySave = null; // Запасное сохранение в памяти - - // Инициализация (localStorage + in-memory fallback) - function initDB(){ - return new Promise((resolve) => { - console.log('Используем localStorage для сохранений (sandbox режим)'); - resolve(null); - }); - } - - // Детерминированный генератор псевдослучайных чисел на основе seed - function seededRandom(gx, gy){ - const n = Math.sin(gx * 12.9898 + gy * 78.233 + worldSeed * 0.1) * 43758.5453; - return n - Math.floor(n); - } - - function saveGame(){ - const saveData = { - version: 2, - worldSeed: worldSeed, - player: { - x: player.x, - y: player.y, - hp: player.hp, - hunger: player.hunger, - o2: player.o2 - }, - inventory: inv, - time: worldTime, - isNight: isNightTime, - // Сохраняем только изменения - placedBlocks: placedBlocks.slice(), - removedBlocks: removedBlocks.slice() - }; - - const saveSize = JSON.stringify(saveData).length; - console.log('Сохранение: player HP:', player.hp, 'hunger:', player.hunger, 'o2:', player.o2); - - // Пробуем сохранить в localStorage (основной метод для персистентности) - try { - localStorage.setItem(SAVE_KEY, JSON.stringify(saveData)); - console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`); - } catch(e){ - console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e); - - // Если localStorage недоступен, используем in-memory fallback - inMemorySave = saveData; - console.log(`Игра сохранена в памяти (размер: ${saveSize} байт)`); - } - } - - function loadGame(){ - return new Promise((resolve, reject) => { - // Пробуем localStorage - try { - const localSave = localStorage.getItem(SAVE_KEY); - if(localSave){ - const parsed = JSON.parse(localSave); - console.log('Загружено из localStorage, player HP:', parsed.player?.hp); - resolve(parsed); - return; - } - } catch(e){ - console.warn('Ошибка доступа к localStorage:', e); - } - - // Если localStorage недоступен, используем in-memory сохранение - if(inMemorySave){ - console.log('Загружено из in-memory сохранения, player HP:', inMemorySave.player?.hp); - resolve(inMemorySave); - return; - } - - console.log('Сохранение не найдено'); - resolve(null); - }); - } - - // Миграция с версии 1 на версию 2 - function migrateV1toV2(saveData){ - console.log('Миграция сохранения с версии 1 на версию 2...'); - - // Сохраняем seed из текущей игры (так как v1 его не хранил) - saveData.worldSeed = worldSeed; - - // Инициализируем массивы изменений - saveData.placedBlocks = []; - saveData.removedBlocks = []; - - // Для v1 нам нужно сравнить сгенерированные блоки с тем, что должно быть по seed - // Это сложно сделать без полной перегенерации, поэтому для v1 сохраняем только seed - // и при загрузке просто перегенерируем мир - - // Удаляем старые данные - delete saveData.generatedBlocks; - - saveData.version = 2; - console.log('Миграция завершена'); - } - - async function applySave(saveData){ - if(!saveData) return; - - console.log('=== applySave START ==='); - console.log('player HP before applySave:', player.hp); - console.log('saveData.player.hp:', saveData.player?.hp); - - // Миграция версий - if(saveData.version === 1){ - migrateV1toV2(saveData); - } - - // Восстанавливаем seed - if(saveData.worldSeed !== undefined){ - worldSeed = saveData.worldSeed; - } - - // Восстанавливаем игрока - if(saveData.player){ - player.x = saveData.player.x; - player.y = saveData.player.y; - player.hunger = saveData.player.hunger; - player.o2 = saveData.player.o2; - - // Обновляем spawnPoint на позицию из сохранения - spawnPoint.x = player.x; - spawnPoint.y = player.y; - - // Проверяем HP из сохранения - если <= 0, устанавливаем 100 - const savedHP = saveData.player.hp; - console.log('Saved HP from file:', savedHP); - if(savedHP <= 0){ - console.log('WARNING: Saved HP is <= 0, setting to 100!'); - player.hp = 100; - } else { - player.hp = savedHP; - } - console.log('player HP after restore:', player.hp); - console.log('spawnPoint обновлён из сохранения: x=', spawnPoint.x, 'y=', spawnPoint.y); - } else { - console.log('No player data in save, setting default HP: 100'); - player.hp = 100; - } - - console.log('=== applySave END ==='); - - // Восстанавливаем инвентарь - if(saveData.inventory){ - for(const key in saveData.inventory){ - inv[key] = saveData.inventory[key]; - } - } - - // Восстанавливаем время - if(saveData.time !== undefined){ - worldTime = saveData.time; - } - - // Восстанавливаем день/ночь - if(saveData.isNight !== undefined){ - isNightTime = saveData.isNight; - } - - // Перегенерируем мир по seed - regenerateVisibleChunks(); - - // Применяем изменения (только для v2) - if(saveData.version === 2){ - // Применяем блоки, установленные игроком - for(const block of saveData.placedBlocks){ - setBlock(block.gx, block.gy, block.t, true); - } - - // Применяем удалённые блоки - for(const block of saveData.removedBlocks){ - removeBlock(block.gx, block.gy); - } - - // Восстанавливаем массивы изменений - placedBlocks = saveData.placedBlocks || []; - removedBlocks = saveData.removedBlocks || []; - } - - rebuildHotbar(); - console.log('Игра загружена'); - } - - // Камера (двухосевая) - let camX=0, camY=0; - - // День/ночь - let worldTime=0; - const DAY_LEN=360; // замедлил смену дня/ночи в 3 раза - - // Облака - const clouds = Array.from({length:10}, ()=>({ - x: Math.random()*2000, - y: -200 - Math.random()*260, // выше экрана, потому что мир по Y теперь двигается - w: 80+Math.random()*120, - s: 12+Math.random()*20 - })); - - // Дождь - let isRaining = false; - let rainIntensity = 0; // 0..1 - let weatherTimer = 0; - let weatherChangeInterval = 60 + Math.random() * 120; // смена погоды 60-180с - const raindrops = []; - const MAX_RAINDROPS = 200; - - function updateWeather(dt) { - weatherTimer += dt; - if (weatherTimer >= weatherChangeInterval) { - weatherTimer = 0; - weatherChangeInterval = 60 + Math.random() * 120; - // Выбираем новую погоду: 40% дождь днём, 25% дождь ночью, остальное ясно - const nightChance = isNight() ? 0.25 : 0.40; - isRaining = Math.random() < nightChance; - } - // Плавная интерполяция интенсивности - const target = isRaining ? (0.4 + Math.random() * 0.01) : 0; - rainIntensity += (target - rainIntensity) * dt * 0.5; - if (rainIntensity < 0.01) rainIntensity = 0; - } - - function updateRain(dt) { - if (!isRaining || rainIntensity < 0.01) { - raindrops.length = 0; - return; - } - // Спавн капель - const spawnRate = Math.floor(rainIntensity * 60 * dt); // ~60 капель/сек при интенсити=1 - for (let i = 0; i < spawnRate && raindrops.length < MAX_RAINDROPS; i++) { - raindrops.push({ - x: camX + Math.random() * W, - y: camY - 20, - vy: 400 + Math.random() * 200, - len: 8 + Math.random() * 12 - }); - } - // Обновление - for (let i = raindrops.length - 1; i >= 0; i--) { - const d = raindrops[i]; - d.y += d.vy * dt; - d.x -= 30 * dt; // лёгкий ветер - if (d.y > camY + H + 20) { - raindrops.splice(i, 1); - } - } - } - - function drawRain() { - if (raindrops.length === 0) return; - ctx.save(); - ctx.strokeStyle = 'rgba(174,194,224,0.5)'; - ctx.lineWidth = 1.5; - ctx.beginPath(); - for (const d of raindrops) { - ctx.moveTo(d.x, d.y); - ctx.lineTo(d.x - 3, d.y + d.len); - } - ctx.stroke(); - ctx.restore(); - } - - // Частицы (взрыв) - const parts = []; - function spawnExplosion(x,y, power){ - const n = Math.floor(16 + power*10); - for(let i=0;i 100){ - playSound('splash'); - } - } - - function resolveY(e){ - // Всегда пересчитываем grounded (не держим "липким") - e.grounded = false; - - const x1 = e.x + 2; - const x2 = e.x + e.w - 2; - - // Проверяем, находится ли игрок на лестнице (по центру) - const cx = e.x + e.w/2; - const cy = e.y + e.h/2; - const gx = Math.floor(cx / TILE); - const gy = Math.floor(cy / TILE); - const b = getBlock(gx, gy); - const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable; - - // Если на лестнице - можно двигаться вверх/вниз - if(onLadder){ - e.grounded = true; - - // Если нажимаем прыжок на лестнице - поднимаемся - if(inp.j){ - e.vy = -200; - } - // Если нажимаем вниз - спускаемся - else if(inp.s){ - e.vy = 100; - } - // Иначе - остаёмся на месте (нет гравитации) - else { - e.vy = 0; - } - return; - } - - // Проверяем, можно ли запрыгнуть на лестницу рядом (слева или справа) - const leftGX = Math.floor((e.x - 4) / TILE); - const rightGX = Math.floor((e.x + e.w + 4) / TILE); - const playerGY = Math.floor((e.y + e.h/2) / TILE); - - const leftBlock = getBlock(leftGX, playerGY); - const rightBlock = getBlock(rightGX, playerGY); - const leftLadder = leftBlock && BLOCKS[leftBlock.t] && BLOCKS[leftBlock.t].climbable; - const rightLadder = rightBlock && BLOCKS[rightBlock.t] && BLOCKS[rightBlock.t].climbable; - - // Если рядом есть лестница и игрок прыгает - притягиваем к ней - if((leftLadder || rightLadder) && inp.j && e.vy < 0){ - // Перемещаем игрока к лестнице - if(leftLadder && e.x > leftGX * TILE + TILE/2){ - e.x = leftGX * TILE + TILE/2 - e.w/2; - } else if(rightLadder && e.x < rightGX * TILE + TILE/2){ - e.x = rightGX * TILE + TILE/2 - e.w/2; - } - e.grounded = true; - e.vy = -150; // меньший прыжок при запрыгивании на лестницу - return; - } - - // 1) Если движемся вниз ИЛИ стоим (vy >= 0) — проверяем опору под ногами - // Берём точку на 1px ниже стопы, чтобы не зависеть от ровного попадания в границу тайла. - if(e.vy >= 0){ - const probeY = e.y + e.h + 1; - const gy = Math.floor(probeY / TILE); - const gxA = Math.floor(x1 / TILE); - const gxB = Math.floor(x2 / TILE); - - if(isSolid(gxA, gy) || isSolid(gxB, gy)){ - e.y = gy * TILE - e.h; // прижимаем к полу - e.vy = 0; - e.grounded = true; - - // урон от падения — только игроку и только не в воде - if(e === player && !player.inWater){ - const fallTiles = (e.y - e.fallStartY) / TILE; - if(fallTiles > 6) { - const damage = calculateDamage((fallTiles - 6) * 10); - player.hp -= damage; - } - } - if(e === player) e.fallStartY = e.y; - } - } - - // 2) Проверка на возможность запрыгнуть на блок, если игрок рядом с ним - if(e.vy < 0 && e === player){ - const gy = Math.floor(e.y / TILE); - const gxA = Math.floor(x1 / TILE); - const gxB = Math.floor(x2 / TILE); - - // Проверяем, есть ли блок рядом с игроком - if((isSolid(gxA, gy) || isSolid(gxB, gy)) && !isSolid(gxA, gy - 1) && !isSolid(gxB, gy - 1)){ - e.y = (gy + 1) * TILE; - e.vy = 0; - e.grounded = true; - if(e === player) e.fallStartY = e.y; - console.log("Jumped onto block!"); - } - } - - // 2) Если движемся вверх — проверяем потолок - if(e.vy < 0){ - const gy = Math.floor(e.y / TILE); - const gxA = Math.floor(x1 / TILE); - const gxB = Math.floor(x2 / TILE); - if(isSolid(gxA, gy) || isSolid(gxB, gy)){ - e.y = (gy + 1) * TILE; - e.vy = 0; - } - } - } - - function resolveX(e){ - const y1 = e.y + 2; - const y2 = e.y + e.h - 2; - - // Проверяем, находимся ли мы на лестнице - const cx = e.x + e.w/2; - const cy = e.y + e.h/2; - const gx = Math.floor(cx / TILE); - const gy = Math.floor(cy / TILE); - const b = getBlock(gx, gy); - const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable; - - if(e.vx > 0){ - const gx = Math.floor((e.x + e.w)/TILE); - const gyA = Math.floor(y1/TILE); - const gyB = Math.floor(y2/TILE); - const solidA = isSolid(gx, gyA); - const solidB = isSolid(gx, gyB); - - if(solidA || solidB){ - e.x = gx*TILE - e.w; - e.vx = 0; - } - } else if(e.vx < 0){ - const gx = Math.floor(e.x/TILE); - const gyA = Math.floor(y1/TILE); - const gyB = Math.floor(y2/TILE); - const solidA = isSolid(gx, gyA); - const solidB = isSolid(gx, gyB); - - if(solidA || solidB){ - e.x = (gx+1)*TILE; - e.vx = 0; - } - } - } - - // TNT логика: цепь + усиление - const activeTNT = new Set(); // хранит key - function activateTNT(b, fuse=3.2){ - if(b.dead) return; - if(b.active) return; - b.active=true; - b.fuse=fuse; - activeTNT.add(k(b.gx,b.gy)); - } - - function explodeAt(gx,gy){ - const center = getBlock(gx,gy); - if(!center) return; - - // усиление: считаем сколько TNT рядом (в радиусе 2) и активируем их «почти сразу» - let bonus = 0; - for(let x=gx-2; x<=gx+2; x++){ - for(let y=gy-2; y<=gy+2; y++){ - const b = getBlock(x,y); - if(b && !b.dead && b.t==='tnt' && !(x===gx && y===gy)){ - bonus += 0.8; - activateTNT(b, 0.12); // цепь - } - } - } - - const power = 1 + bonus; // условная мощность - const radius = 3.2 + bonus*0.7; // радиус разрушения в тайлах - const dmgR = 150 + bonus*60; // радиус урона в пикселях - - removeBlock(gx,gy); - activeTNT.delete(k(gx,gy)); - playSound('explode1'); // Звук взрыва - spawnExplosion(gx*TILE + TILE/2, gy*TILE + TILE/2, power); - - for(let x=Math.floor(gx-radius); x<=Math.ceil(gx+radius); x++){ - for(let y=Math.floor(gy-radius); y<=Math.ceil(gy+radius); y++){ - const d = Math.hypot(x-gx, y-gy); - if(d > radius) continue; - const b = getBlock(x,y); - if(!b || b.dead) continue; - if(BLOCKS[b.t].fluid) continue; - if(BLOCKS[b.t].unbreakable) continue; - if(b.t==='tnt') { activateTNT(b, 0.12); continue; } - removeBlock(x,y); - if(inv[b.t] !== undefined && Math.random()<0.20) inv[b.t]++; // немного дропа - } - } - rebuildHotbar(); - - // урон - const hurt = (e)=>{ - const dx = (e.x+e.w/2) - (gx*TILE+TILE/2); - const dy = (e.y+e.h/2) - (gy*TILE+TILE/2); - const dist = Math.hypot(dx,dy); - if(dist < dmgR){ - const dmg = (dmgR - dist) * 0.06 * power; - if(e === player) { - const actualDamage = calculateDamage(dmg); - player.hp -= actualDamage; - } else { - e.hp -= dmg; - } - e.vx += (dx/dist || 0) * 600; - e.vy -= 320; - } - }; - hurt(player); - mobs.forEach(hurt); - } - - // Взаимодействие мышь/тап - const mouse = { x:null, y:null }; - canvas.addEventListener('pointermove', (e)=>{ - const r = canvas.getBoundingClientRect(); - mouse.x = e.clientX - r.left; - mouse.y = e.clientY - r.top; - }); - - canvas.addEventListener('pointerdown', (e)=>{ - if(craftOpen) return; - if(player.hp<=0) return; - - const r = canvas.getBoundingClientRect(); - const sx = e.clientX - r.left; - const sy = e.clientY - r.top; - - const wx = sx + camX; - const wy = sy + camY; - - const gx = Math.floor(wx / TILE); - const gy = Math.floor(wy / TILE); - - // Пробуждение: клик по любой кровати когда спишь - const b = getBlock(gx,gy); - if(player.sleeping && b && b.t==='bed'){ - player.sleeping = false; - return; - } - - if(player.sleeping) return; // Нельзя взаимодействовать во время сна - - // Клик по печи — открываем панель обжига - if(b && b.t === 'furnace' && mode() === 'mine'){ - openFurnaceUI(gx, gy); - return; - } - - // клик по мобу (в режиме mine) - if(mode()==='mine'){ - // Check all mobs (local + server-spawned) using getAllMobs - const allClickMobs = getAllMobs(); - for(let i = allClickMobs.length - 1; i >= 0; i--){ - const m = allClickMobs[i]; - if(m.dead) continue; - if(wx>=m.x && wx<=m.x+m.w && wy>=m.y && wy<=m.y+m.h){ - let dmg = 1; - const swordTypes = ['iron_sword','stone_sword','wood_sword']; - for (const st of swordTypes) { - if (inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; } - } - m.hp -= dmg; - m.vx += (m.x - player.x) * 2; - m.vy -= 200; - playSound('attack'); - // Server-spawned mob: emit hurt to server for relay, handle death locally - if(m.id !== undefined && isMultiplayer){ - socket.emit('mob_hurt', { id: m.id, dmg }); - if(m.hp <= 0){ - socket.emit('mob_died', { id: m.id }); - } - } - if(m.hp<=0){ - if(m.kind === 'chicken') playSound('hurt_chicken'); - inv.meat += (m.kind==='chicken' ? 1 : 2); - if(m.kind === 'skeleton'){ - inv.arrow += 2 + Math.floor(Math.random()*3); - if(Math.random() < 0.15) inv.bow = (inv.bow||0) + 1; - } - // Remove from the correct array - if(m.id !== undefined){ - serverMobs.delete(m.id); - } else { - const localIdx = mobs.indexOf(m); - if(localIdx >= 0) mobs.splice(localIdx, 1); - } - rebuildHotbar(); - } - return; - } - } - } - - // Лук — стреляем стрелой - if(selected === 'bow' && inv.bow > 0 && inv.arrow > 0){ - const aimX = wx - player.x - player.w/2; - const aimY = wy - player.y - player.h/2; - const angle = Math.atan2(aimY, aimX); - projectiles.push({ - x: player.x + player.w/2, - y: player.y + player.h/3, - vx: Math.cos(angle) * 550, - vy: Math.sin(angle) * 550, - dmg: 10, - owner: 'player', - life: 4 - }); - inv.arrow--; - useTool('bow'); - playSound('hit1'); - rebuildHotbar(); - return; - } - - // еда (предмет) - if(ITEMS[selected] && inv[selected]>0){ - const it = ITEMS[selected]; - if(player.hp < 100 || player.hunger < 100){ - playSound('eat1'); // Звук употребления еды - player.hunger = Math.min(100, player.hunger + it.food); - player.hp = Math.min(100, player.hp + 15); - inv[selected]--; - rebuildHotbar(); - } - return; - } - - // жарка на костре: выбран meat + клик по campfire - if(b && b.t==='campfire' && selected==='meat' && inv.meat>0){ - playSound('fire'); // Звук при жарке на костре - inv.meat--; inv.cooked++; - rebuildHotbar(); - return; - } - - // Сон на кровати: клик по bed - if(b && b.t==='bed' && isNight()){ - player.sleeping = true; - saveGame(); // Сохраняем при отходе ко сну - return; - } - - if(mode()==='mine'){ - if(!b) return; - if(BLOCKS[b.t].fluid || BLOCKS[b.t].decor) return; - - if(b.t==='tnt'){ activateTNT(b, 3.2); return; } // не взрывается сразу - - const removed = removeBlock(gx,gy); - if(removed){ - inv[removed.t] = (inv[removed.t]||0) + 1; - - // Тратим прочность кирки (если есть в инвентаре) - const pickTypes = ['iron_pickaxe','stone_pickaxe','wood_pickaxe']; - for (const pt of pickTypes) { - if (inv[pt] > 0) { - const broke = useTool(pt); - if (broke) playSound('cloth1'); // звук поломки - break; - } - } - - // Отправляем изменение блока на сервер - sendBlockChange(gx, gy, removed.t, 'remove'); - - // Звуки при добыче блоков - if(removed.t === 'glass') playSound('glass1'); - else if(removed.t === 'sand') playSound('sand1'); - else if(removed.t === 'snow') playSound('snow1'); - else if(removed.t === 'stone' || removed.t.endsWith('_ore')) playSound('stone1'); - else if(removed.t === 'wood') playSound('wood1'); - else playSound('cloth1'); - - rebuildHotbar(); - } - return; - } - - if(mode()==='build'){ - if(inv[selected] <= 0) return; - if(!BLOCKS[selected]) return; - if(b) return; // занято - - // Проверяем, ставим ли лодку - if(selected === 'boat'){ - // Лодку можно ставить только на воду - const waterBelow = getBlock(gx, gy+1); - if(!waterBelow || waterBelow.t !== 'water'){ - return; - } - - // Создаём лодку - boat.x = gx * TILE; - boat.y = gy * TILE; - boat.vx = 0; - boat.vy = 0; - boat.active = true; - boat.inWater = true; - - // Сажаем игрока в лодку - player.inBoat = true; - player.x = boat.x; - player.y = boat.y; - player.vx = 0; - player.vy = 0; - - playSound('splash'); - inv[selected]--; - rebuildHotbar(); - return; - } - - // запрет ставить в игрока - const bx = gx*TILE, by = gy*TILE; - const overlap = !(bx >= player.x+player.w || bx+TILE <= player.x || by >= player.y+player.h || by+TILE <= player.y); - if(overlap) return; - - setBlock(gx,gy,selected, true); // true = блок установлен игроком - inv[selected]--; - - // Отправляем изменение блока на сервер - sendBlockChange(gx, gy, selected, 'set'); - - // Звук при строительстве - if(selected === 'stone' || selected === 'brick') playSound('stone_build'); - else if(selected === 'wood' || selected === 'planks') playSound('wood_build'); - else if(selected === 'glass') playSound('glass1'); - else if(selected === 'sand') playSound('sand1'); - else if(selected === 'snow') playSound('snow1'); - else if(selected === 'dirt' || selected === 'grass') playSound('cloth1'); - - rebuildHotbar(); - return; - } - }); - - // Генерация (по X, на всю глубину до bedrock) - const generated = new Set(); // gx already generated - function surfaceGyAt(gx){ - // базовая поверхность выше уровня воды с вариациями + "горы" - // Используем seed для детерминированной генерации - // Увеличили амплитуду и добавили больше частот для разнообразия - const n1 = Math.sin(gx*0.025 + worldSeed*0.001)*8; // крупные горы - const n2 = Math.sin(gx*0.012 + worldSeed*0.002)*12; // средние горы - const n3 = Math.sin(gx*0.006 + worldSeed*0.003)*6; // мелкие холмы - const n4 = Math.sin(gx*0.045 + worldSeed*0.004)*4; // детали - const n5 = Math.cos(gx*0.018 + worldSeed*0.005)*5; // дополнительные вариации - const h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5); // чем меньше gy - тем выше - return h; - } - - function genColumn(gx){ - if(generated.has(gx)) return; - generated.add(gx); - - const sgy = surfaceGyAt(gx); - - // вода (если поверхность ниже уровня моря => sgy > SEA_GY) - if(sgy > SEA_GY){ - for(let gy=SEA_GY; gy SEA_GY && gy === sgy+1 && seededRandom(gx, gy) < 0.25) t='clay'; - if(gy > sgy+6 && seededRandom(gx, gy) < 0.07) t='gravel'; - - // руды: чем глубже, тем интереснее - const depth = gy - sgy; - const r = seededRandom(gx, gy); - if(t==='stone'){ - if(r < 0.06) t='coal'; - else if(r < 0.10) t='copper_ore'; - else if(r < 0.13) t='iron_ore'; - else if(depth > 40 && r < 0.145) t='gold_ore'; - else if(depth > 70 && r < 0.152) t='diamond_ore'; - } - - setBlock(gx,gy,t); - } - - // Деревья и цветы (только на траве, и не в воде) - const top = getBlock(gx, sgy); - if(top && top.t==='grass'){ - if(seededRandom(gx, sgy-1) < 0.10){ - setBlock(gx, sgy-1,'flower'); - } - if(seededRandom(gx, sgy-2) < 0.12){ - // простое дерево - setBlock(gx, sgy-1, 'wood'); - setBlock(gx, sgy-2, 'wood'); - setBlock(gx, sgy-3, 'leaves'); - setBlock(gx-1, sgy-3,'leaves'); - setBlock(gx+1, sgy-3,'leaves'); - } - } - - // Применяем серверные оверрайды для этой колонны - const colPrefix = gx + ','; - for (const [key, ov] of serverOverrides) { - if (!key.startsWith(colPrefix)) continue; - if (ov.op === 'remove') { - const b = grid.get(key); - if (b) { grid.delete(key); b.dead = true; } - } else if (ov.op === 'set') { - if (!grid.has(key)) { - const gy = parseInt(key.split(',')[1]); - const nb = { gx, gy, t: ov.t, dead: false, active: false, fuse: 0 }; - grid.set(key, nb); - blocks.push(nb); - } - } - } - } - - // Перегенерация видимых чанков (используется при загрузке сохранения) - function regenerateVisibleChunks(){ - const gx0 = Math.floor(camX/TILE); - for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){ - // Принудительно перегенерируем колонну - generated.delete(gx); - genColumn(gx); - } - } - - function ensureGenAroundCamera(){ - const gx0 = Math.floor(camX/TILE); - for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){ - genColumn(gx); - } - } - - // Лут с дерева/листвы: дерево -> wood; листья -> leaves - // (уже в mine добавляется inv[type] автоматически) - - // Рисование костра: огонь поверх текстуры - function drawFire(wx,wy,now){ - const baseX = wx; - const baseY = wy; - const flick = 6 + (Math.sin(now/90)+1)*4; - ctx.fillStyle = 'rgba(255,140,0,0.85)'; - ctx.beginPath(); - ctx.moveTo(baseX+10, baseY+30); - ctx.lineTo(baseX+20, baseY+30-flick); - ctx.lineTo(baseX+30, baseY+30); - ctx.fill(); - - ctx.fillStyle = 'rgba(255,230,150,0.75)'; - ctx.beginPath(); - ctx.moveTo(baseX+14, baseY+30); - ctx.lineTo(baseX+20, baseY+30-(flick*0.7)); - ctx.lineTo(baseX+26, baseY+30); - ctx.fill(); - } - - // Моб AI - function mobAI(m, dt){ - updateWaterFlag(m); - - if(m.kind==='zombie'){ - // активность ночью - const night = isNight(); - if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; } - const dir = Math.sign((player.x) - m.x); - m.vx = dir * m.speed; - if(m.inWater && Math.random()<0.06) m.vy = -260; - // атака - if(Math.abs((m.x+ m.w/2) - (player.x+player.w/2)) < 28 && - Math.abs((m.y+ m.h/2) - (player.y+player.h/2)) < 40 && - player.invuln <= 0){ - const damage = calculateDamage(15); - player.hp -= damage; - player.invuln = 0.8; - player.vx += dir*420; - player.vy -= 260; - playSound('hit1'); // Звук при атаке зомби - } - } else if(m.kind==='creeper'){ - // активность ночью - const night = isNight(); - if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; } - const dir = Math.sign((player.x) - m.x); - const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2)); - - // Движение к игроку - m.vx = dir * m.speed; - if(m.inWater && Math.random()<0.06) m.vy = -260; - - // Взрыв если близко к игроку - if(dist < 60){ - m.fuse -= dt; - if(m.fuse <= 0){ - explodeAt(Math.floor((m.x+m.w/2)/TILE), Math.floor((m.y+m.h/2)/TILE)); - m.hp = 0; - } - } else { - // Поджигаем если очень близко - if(dist < 40){ - m.fuse = 0.5; // Быстрый взрыв - } - } - } else if(m.kind==='skeleton'){ - // активность ночью - const night = isNight(); - if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; } - const dir = Math.sign((player.x) - m.x); - const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2)); - - // Движение к игроку - m.vx = dir * m.speed; - if(m.inWater && Math.random()<0.06) m.vy = -260; - - // Стрельба стрелами - m.shootCooldown -= dt; - if(dist < 300 && m.shootCooldown <= 0){ - m.shootCooldown = 2.0; - const dx = (player.x+player.w/2) - (m.x+m.w/2); - const dy = (player.y+player.h/2) - (m.y+m.h/2); - const angle = Math.atan2(dy, dx); - const speed = 450; - projectiles.push({ - x: m.x + m.w/2, - y: m.y + m.h/3, - vx: Math.cos(angle) * speed, - vy: Math.sin(angle) * speed, - dmg: 6, - owner: 'mob', - life: 3 - }); - } - } else { - // животные - m.aiT -= dt; - if(m.aiT <= 0){ - m.aiT = 1.8 + Math.random()*2.5; - m.dir = Math.random()<0.5 ? -1 : 1; - if(Math.random()<0.25) m.dir = 0; - } - m.vx = m.dir * (m.kind==='chicken' ? 55 : 40); - if(m.inWater) m.vy = -120; - } - - // физика моба - const g = m.inWater ? GRAV_WATER : GRAV; - m.vy += g*dt; - - m.y += m.vy*dt; m.grounded=false; resolveY(m); - m.x += m.vx*dt; resolveX(m); - } - - function isNight(){ - // Автоматический цикл: ночь когда worldTime > 0.5 - return worldTime > 0.5; - } - - // Respawn - document.getElementById('respawnBtn').onclick = async () => { - playSound('click'); // Звук клика по кнопке - - console.log('=== RESPAWN CLICKED ==='); - console.log('isMultiplayer:', isMultiplayer); - console.log('otherPlayers.size:', otherPlayers.size); - console.log('player.hp before respawn:', player.hp); - - // В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке - if (isMultiplayer && otherPlayers.size > 0) { - console.log('Мультиплеер режим - возрождение в начальной точке'); - player.hp = 100; - player.hunger = 100; - player.o2 = 100; - player.vx = player.vy = 0; - player.invuln = 0; - player.x = spawnPoint.x; - player.y = spawnPoint.y; - player.fallStartY = player.y; - console.log('Возрождение в начальной точке, HP:', player.hp); - } else { - console.log('Одиночный режим - загружаем последнее сохранение'); - // Одиночный режим - загружаем последнее сохранение - const loadedSave = await loadGame(); - if(loadedSave){ - await applySave(loadedSave); - console.log('Загружено последнее сохранение после смерти, final HP:', player.hp); - } else { - // Если сохранения нет, возрождаемся в начальной точке - player.hp = 100; - player.hunger = 100; - player.o2 = 100; - player.vx = player.vy = 0; - player.invuln = 0; - player.x = spawnPoint.x; - player.y = spawnPoint.y; - player.fallStartY = player.y; - console.log('Возрождение в начальной точке, HP:', player.hp); - } - } - - console.log('player.hp after respawn logic:', player.hp); - console.log('Hiding death screen...'); - deathEl.style.display='none'; - console.log('=== RESPAWN END ==='); - }; - - // Resize - function resize(){ - W = gameEl.clientWidth; - H = gameEl.clientHeight; - canvas.width = W*dpr; - canvas.height = H*dpr; - lightC.width = W*dpr; - lightC.height = H*dpr; - ctx.setTransform(dpr,0,0,dpr,0,0); - } - window.addEventListener('resize', resize); - - // init - resize(); - rebuildHotbar(); - - // Инициализируем и загружаем сохранение - initDB().then(async () => { - // Пытаемся загрузить сохранённую игру - const loadedSave = await loadGame(); - if(loadedSave){ - await applySave(loadedSave); - console.log('Загружено сохранение, HP:', player.hp); - - // Проверяем HP после загрузки - если <= 0, возрождаемся - if (player.hp <= 0) { - console.log('WARNING: HP <= 0 после загрузки, возрождаемся'); - player.hp = 100; - player.hunger = 100; - player.o2 = 100; - player.x = spawnPoint.x; - player.y = spawnPoint.y; - player.vx = player.vy = 0; - player.invuln = 0; - player.fallStartY = player.y; - } - } else { - console.log('Сохранение не найдено, начинаем новую игру'); - - // Инициализируем игрока для новой игры - player.hp = 100; - player.hunger = 100; - player.o2 = 100; - player.vx = player.vy = 0; - player.invuln = 0; - - // старт — на поверхности (используем ту же логику что и в world_state) - 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 = 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; - } - } - player.y = safeGY * TILE; - player.x = startGX * TILE; - player.fallStartY = player.y; - - // Обновляем spawnPoint, чтобы возрождение было на поверхности - spawnPoint.x = player.x; - spawnPoint.y = player.y; - - console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', player.y, 'player.hp=', player.hp); - console.log('spawnPoint обновлён: x=', spawnPoint.x, 'y=', spawnPoint.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); - player.y = (surfaceGyAt(startGX)-1)*TILE; - player.fallStartY = player.y; - - for(let gx = startGX - 50; gx <= startGX + 50; gx++){ - genColumn(gx); - } - }); - - // main loop - let last = performance.now(); - let prevJump = false; - // При возврате на вкладку — сбрасываем last чтобы не было скачка dt - document.addEventListener('visibilitychange', () => { - if (!document.hidden) last = performance.now(); - }); - function loop(now){ - const rawDt = Math.min(0.05, (now-last)/1000); - last = now; - // Sub-stepping: разбиваем физику на шаги ≤16ms чтобы не проваливаться сквозь блоки - const PHYSICS_STEP = 0.016; - const steps = Math.max(1, Math.ceil(rawDt / PHYSICS_STEP)); - const dt = rawDt / steps; - - const jumpPressed = inp.j && !prevJump; - prevJump = inp.j; - - // Ускорение времени во время сна - if(player.sleeping && isNight()){ - worldTime += dt * 8 / DAY_LEN; // В 8 раз быстрее - // Восстанавливаем здоровье во время сна - player.hp = Math.min(100, player.hp + dt * 20); - // Автоматическое пробуждение когда наступает день - if(!isNight()){ - player.sleeping = false; - } - } else { - worldTime += dt / DAY_LEN; - } - if(worldTime >= 1) worldTime -= 1; - - // камера следует за игроком по X/Y - camX = Math.floor((player.x + player.w/2) - W/2); - camY = Math.floor((player.y + player.h/2) - H/2); - - ensureGenAroundCamera(); - - // clouds parallax - for(const c of clouds){ - c.x -= c.s * dt; - if(c.x + c.w < camX - 400) c.x = camX + W + Math.random()*700; - } - - // player - updateWaterFlag(player); - - // кислород/утопление: тратим O2 только если голова под водой, иначе восстанавливаем [web:223] - if(player.headInWater){ - player.o2 = Math.max(0, player.o2 - 6*dt); // замедлил в 3.7 раза - if(player.o2 === 0){ - const damage = calculateDamage(4*dt); - player.hp -= damage; - } - } else { - player.o2 = Math.min(100, player.o2 + 10*dt); // замедлил восстановление в 4 раза - } - - // голод убывает, но HP не отнимает (как просили) - player.hunger = Math.max(0, player.hunger - dt*0.2); // замедлил в 4 раза - - // Игрок не может двигаться во время сна - if(player.sleeping){ - player.vx = 0; - player.vy = 0; - } else { - const dir = (inp.r?1:0) - (inp.l?1:0); - if(dir) player.vx = dir*MOVE; - else player.vx *= 0.82; - } - - // Звук шагов при движении по земле - if(player.grounded && !player.inWater && Math.abs(player.vx) > 50){ - const stepInterval = 0.35; // Интервал между шагами в секундах - if(now/1000 - player.lastStepTime > stepInterval){ - playSound('step'); - player.lastStepTime = now/1000; - } - } - - // прыжок/плавание (новая логика) - if(player.inBoat){ - // Игрок в лодке - лодка следует за игроком - const dir = (inp.r?1:0) - (inp.l?1:0); - if(dir) boat.vx = dir * MOVE; - else boat.vx *= 0.95; - - // Лодка плавает на воде - boat.vy = 0; - - // Игрок следует за лодкой (сидит внутри неё) - player.x = boat.x + 2; // Игрок по центру лодки - player.y = boat.y - 4; // Игрок выше лодки (сидит внутри) - player.vx = boat.vx; - player.vy = boat.vy; - player.grounded = true; - player.inWater = false; // Игрок не в воде когда в лодке - - // Прыжок из лодки (высадка) - if(jumpPressed){ - // Возвращаем лодку в инвентарь - inv.boat = (inv.boat || 0) + 1; - - player.inBoat = false; - boat.active = false; - player.y += TILE; // Прыгаем из лодки - player.vy = -JUMP * 0.5; - playSound('splash'); - } - - } else if(player.inWater){ - // сопротивление в воде - player.vx *= 0.90; - player.vy *= 0.92; - - // Если не нажимаем прыжок - тонем (гравитация в воде) - if(!jumpPressed && !inp.j){ - // Применяем гравитацию в воде - игрок тонет - player.vy += GRAV_WATER * dt; - } else { - // Если нажимаем прыжок - поднимаемся на поверхность - if(jumpPressed){ - player.vy = Math.min(player.vy, -520); // рывок вверх - } else if(inp.j){ - // если держим — мягкое всплытие - player.vy = Math.min(player.vy, -260); - } - } - - } else { - // обычный прыжок (только по нажатию) - if(jumpPressed && player.grounded && !player.sleeping){ - player.vy = -JUMP; - player.grounded = false; - player.fallStartY = player.y; - } - } - - // Гравитация применяется только вне воды и вне лодки - if(!player.inWater && !player.inBoat){ - player.vy += GRAV*dt; - } - - // Обновляем позицию лодки - if(boat.active){ - boat.x += boat.vx * dt; - boat.y += boat.vy * dt; - - // Лодка не выходит за пределы воды - const boatGX = Math.floor(boat.x / TILE); - const boatGY = Math.floor(boat.y / TILE); - const below = getBlock(boatGX, boatGY + 1); - - if(!below || below.t !== 'water'){ - // Если лодка вышла из воды - выкидываем игрока - inv.boat = (inv.boat || 0) + 1; - player.inBoat = false; - boat.active = false; - player.y += TILE; - player.vy = -200; - playSound('splash'); - } - } - - // Проверяем, не доплыл ли игрок из лодки - if(player.inBoat && !boat.active){ - inv.boat = (inv.boat || 0) + 1; - player.inBoat = false; - player.y += TILE; - player.vy = -200; - playSound('splash'); - } - - // Sub-stepped physics: применяем движение мелкими шагами - for (let step = 0; step < steps; step++) { - player.y += player.vy*dt; - resolveY(player); - player.x += player.vx*dt; - resolveX(player); - } - - // Отправляем позицию на сервер (мультиплеер) - sendPlayerPosition(); - - // Обновляем физику воды - updateWaterPhysics(dt); - - // Погода и дождь - updateWeather(dt); - updateRain(dt); - - player.invuln = Math.max(0, player.invuln - dt); - - // Voice position update - voicePosT += dt; - if(voicePosT > 0.5 && voiceSocket && voiceSocket.connected){ - voicePosT = 0; - voiceSocket.emit('voice_pos', { x: player.x, y: player.y }); - } - - // Furnace tick - tickFurnaces(dt); - - // Обновляем UI печи если открыта - if(currentFurnaceKey && Math.random() < 0.1){ - renderFurnaceUI(); - } - - // Projectile tick (стрелы) - for(let i = projectiles.length-1; i>=0; i--){ - const p = projectiles[i]; - p.x += p.vx * dt; - p.y += p.vy * dt; - p.vy += 400 * dt; // гравитация - p.life -= dt; - - // Столкновение с блоком - const gx = Math.floor(p.x / TILE); - const gy = Math.floor(p.y / TILE); - const blk = getBlock(gx, gy); - if(blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid){ - // Врезался в стену - if(p.owner === 'player' && Math.random() < 0.5) inv.arrow++; // подбираем ~50% - projectiles.splice(i, 1); - continue; - } - - // Столкновение с сущностью - if(p.owner === 'mob'){ - // Попал в игрока - if(p.x > player.x && p.x < player.x+player.w && p.y > player.y && p.y < player.y+player.h){ - if(player.invuln <= 0){ - player.hp -= calculateDamage(p.dmg); - player.invuln = 0.4; - player.vx += p.vx * 0.3; - player.vy -= 150; - playSound('hit1'); - } - projectiles.splice(i, 1); - continue; - } - } else { - // Попал в моба — check all mobs (client-authoritative) - const allArrowMobs = getAllMobs(); - for(let j = allArrowMobs.length - 1; j >= 0; j--){ - const m = allArrowMobs[j]; - if(m.dead) continue; - if(p.x > m.x && p.x < m.x+m.w && p.y > m.y && p.y < m.y+m.h){ - m.hp -= p.dmg; - m.vx += p.vx * 0.2; - m.vy -= 200; - // Server-spawned mob: emit arrow hit to server for relay - if(m.id !== undefined && isMultiplayer){ - socket.emit('mob_arrow_hit', { id: m.id, dmg: p.dmg, vx: p.vx }); - if(m.hp <= 0){ - socket.emit('mob_died', { id: m.id }); - } - } - if(m.hp <= 0){ - inv.meat += (m.kind==='chicken' ? 1 : 2); - if(m.kind === 'skeleton'){ - inv.arrow += 2 + Math.floor(Math.random()*3); - if(Math.random() < 0.15) inv.bow = (inv.bow||0) + 1; - } - // Remove from the correct array - if(m.id !== undefined){ - serverMobs.delete(m.id); - } else { - const localIdx = mobs.indexOf(m); - if(localIdx >= 0) mobs.splice(localIdx, 1); - } - rebuildHotbar(); - } - projectiles.splice(i, 1); - break; - } - } - } - - // Таймаут - if(p.life <= 0) projectiles.splice(i, 1); - } - - // TNT tick - for(const key of Array.from(activeTNT)){ - const b = grid.get(key); - if(!b || b.dead){ activeTNT.delete(key); continue; } - b.fuse -= dt; - if(b.fuse <= 0){ - explodeAt(b.gx,b.gy); - } - } - - // mobs spawn (с обеих сторон камеры) — только в одиночном режиме (MP mobs come from server events) - spawnT += dt; - if(!isMultiplayer && spawnT > 1.8 && getAllMobs().length < 30){ - spawnT = 0; - - // Выбираем сторону спавна (левая или правая) - const spawnLeft = Math.random() < 0.5; - const gx = spawnLeft - ? Math.floor((camX - 200)/TILE) - : Math.floor((camX + W + 200)/TILE); - - genColumn(gx); - const sgy = surfaceGyAt(gx); - const wx = gx*TILE + 4; - const wy = (sgy-2)*TILE; - - // не спавнить в воде - const top = getBlock(gx, sgy); - if(top && top.t==='water') { - // skip - } else { - const night = isNight(); - if(night){ - // Ночью спавним враждебных мобов (максимум 12 хостайл) - const hostileCount = mobs.filter(m => m.kind==='zombie'||m.kind==='creeper'||m.kind==='skeleton').length; - if(hostileCount < 12){ - const rand = Math.random(); - if(rand < 0.35){ - mobs.push(new Zombie(wx, wy)); - } else if(rand < 0.55){ - mobs.push(new Creeper(wx, wy)); - } else { - mobs.push(new Skeleton(wx, wy)); - } - } - } - // Животные спавнятся и днём и ночью (с лимитом) - const animalCount = mobs.filter(m => m.kind==='pig'||m.kind==='chicken').length; - if(animalCount < 8){ - mobs.push(Math.random()<0.5 ? new Pig(wx, wy) : new Chicken(wx, wy)); - } - } - } - - // mobs update — mobAI runs on ALL mobs (client-authoritative for server-spawned mobs too) - { - // Local mobs - for(let i=mobs.length-1;i>=0;i--){ - const m = mobs[i]; - mobAI(m, dt); - if(m.hp<=0) mobs.splice(i,1); - } - // Server-spawned mobs (MP client-authoritative) - if(isMultiplayer){ - for (const [id, sm] of serverMobs) { - mobAI(sm, dt); - if(sm.hp <= 0){ - // Schedule removal (don't delete during iteration) - sm.dead = true; - } - } - // Remove dead server mobs - for (const [id, sm] of serverMobs) { - if(sm.dead) serverMobs.delete(id); - } - } - } - - // particles - for(let i=parts.length-1;i>=0;i--){ - const p = parts[i]; - p.t -= dt; - p.x += p.vx*dt; - p.y += p.vy*dt; - p.vy += GRAV*dt; - if(p.t <= 0) parts.splice(i,1); - } - - // death - if(player.hp <= 0){ - deathEl.style.display='flex'; - } else if(deathEl.style.display === 'flex') { - // Если HP > 0 но экран смерти всё ещё показан - скрываем его - deathEl.style.display='none'; - } - - // render - const night = isNight(); - - // sky - ctx.fillStyle = night ? '#070816' : (isRaining ? '#6B7B8D' : '#87CEEB'); - ctx.fillRect(0,0,W,H); - - // clouds (parallax x/y) - ctx.save(); - ctx.translate(-camX*0.5, -camY*0.15); - ctx.fillStyle = 'rgba(255,255,255,0.65)'; - for(const c of clouds){ - ctx.fillRect(c.x, c.y, c.w, 26); - ctx.fillRect(c.x+20, c.y-10, c.w*0.6, 22); - } - ctx.restore(); - - // world - ctx.save(); - ctx.translate(-camX, -camY); - - const minGX = Math.floor(camX/TILE)-2; - const maxGX = Math.floor((camX+W)/TILE)+2; - const minGY = Math.floor(camY/TILE)-6; - const maxGY = Math.floor((camY+H)/TILE)+6; - - // draw blocks (по массиву, но фильтруем диапазоном) - for(const b of blocks){ - if(b.dead) continue; - if(b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue; - - const def = BLOCKS[b.t]; - if(def.alpha){ - ctx.save(); - ctx.globalAlpha = def.alpha; - ctx.drawImage(tex[b.t], b.gx*TILE, b.gy*TILE, TILE, TILE); - ctx.restore(); - } else { - ctx.drawImage(tex[b.t], b.gx*TILE, b.gy*TILE, TILE, TILE); - } - - // TNT мигает, если активирован - if(b.t==='tnt' && b.active && Math.sin(now/60)>0){ - ctx.fillStyle='rgba(255,255,255,0.45)'; - ctx.fillRect(b.gx*TILE, b.gy*TILE, TILE, TILE); - } - - // огонь костра - if(b.t==='campfire'){ - drawFire(b.gx*TILE, b.gy*TILE, now); - } - // Печь — огонь когда обжигает - if(b.t==='furnace' && activeFurnaces.has(`${b.gx},${b.gy}`)){ - drawFire(b.gx*TILE + 8, b.gy*TILE + 5, now); - } - } - - // mobs - const allMobsRender = getAllMobs(); - for(const m of allMobsRender){ - if(m.kind==='zombie'){ - ctx.fillStyle = '#2ecc71'; - ctx.fillRect(m.x, m.y, m.w, m.h); - ctx.fillStyle = '#c0392b'; - ctx.fillRect(m.x+6, m.y+12, 6,6); - ctx.fillRect(m.x+22, m.y+12, 6,6); - } else if(m.kind==='pig'){ - ctx.fillStyle = '#ffb6c1'; - ctx.fillRect(m.x, m.y, m.w, m.h); - ctx.fillStyle = '#000'; - ctx.fillRect(m.x+22, m.y+5, 3,3); - ctx.fillStyle = '#ff69b4'; - ctx.fillRect(m.x+28, m.y+12, 6,6); - } else if(m.kind==='chicken'){ - // chicken - ctx.fillStyle = '#ecf0f1'; - ctx.fillRect(m.x, m.y, m.w, m.h); - ctx.fillStyle = '#f39c12'; - ctx.fillRect(m.x+18, m.y+10, 6,4); - ctx.fillStyle = '#000'; - ctx.fillRect(m.x+8, m.y+6, 3,3); - } else if(m.kind==='creeper'){ - // creeper - ctx.fillStyle = '#4CAF50'; - ctx.fillRect(m.x, m.y, m.w, m.h); - // Глаза - ctx.fillStyle = '#000'; - ctx.fillRect(m.x+8, m.y+8, 4,4); - ctx.fillRect(m.x+22, m.y+8, 4,4); - // Рот - ctx.fillStyle = '#000'; - ctx.fillRect(m.x+12, m.y+20, 10,4); - // Ноги - ctx.fillStyle = '#4CAF50'; - ctx.fillRect(m.x+4, m.y+30, 6,20); - ctx.fillRect(m.x+24, m.y+30, 6,20); - } else if(m.kind==='skeleton'){ - // skeleton - детализированный - // Тело - ctx.fillStyle = '#ECEFF1'; - ctx.fillRect(m.x+10, m.y+20, 14, 12); - // Череп - ctx.fillRect(m.x+8, m.y+0, 18, 18); - // Глазницы - ctx.fillStyle = '#000'; - ctx.fillRect(m.x+10, m.y+6, 4,4); - ctx.fillRect(m.x+20, m.y+6, 4,4); - // Нос - ctx.fillRect(m.x+15, m.y+12, 4,2); - // Руки - ctx.fillStyle = '#ECEFF1'; - ctx.fillRect(m.x+2, m.y+20, 6,14); - ctx.fillRect(m.x+26, m.y+20, 6,14); - // Ноги - ctx.fillRect(m.x+10, m.y+32, 6, 18); - ctx.fillRect(m.x+18, m.y+32, 6, 18); - // Лук в руке - ctx.save(); - ctx.translate(m.x + 30, m.y + 22); - ctx.strokeStyle = '#8B4513'; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(0, 0, 8, -Math.PI*0.7, Math.PI*0.7); - ctx.stroke(); - // Тетива - ctx.strokeStyle = '#ccc'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(8*Math.cos(-Math.PI*0.7), 8*Math.sin(-Math.PI*0.7)); - ctx.lineTo(8*Math.cos(Math.PI*0.7), 8*Math.sin(Math.PI*0.7)); - ctx.stroke(); - ctx.restore(); - } - } - - // boat (рисуем первой, чтобы игрок был внутри неё) - if(boat.active){ - ctx.drawImage(tex['boat'], boat.x - (TILE-boat.w)/2, boat.y - (TILE-boat.h)/2, TILE, TILE); - } - - // other players (multiplayer) - for(const [socketId, p] of otherPlayers){ - if(heroImg.complete){ - ctx.drawImage(heroImg, p.x - (TILE-player.w)/2, p.y - (TILE-player.h)/2, TILE, TILE); - } else { - ctx.fillStyle = p.color; - ctx.fillRect(p.x, p.y, 34, 34); - } - // Имя игрока (мелко над персонажем) - ctx.fillStyle = '#fff'; - ctx.font = '12px system-ui'; - ctx.textAlign = 'center'; - ctx.fillText(p.name, p.x + 17, p.y - 8); - } - - // player - if(heroImg.complete){ - ctx.drawImage(heroImg, player.x - (TILE-player.w)/2, player.y - (TILE-player.h)/2, TILE, TILE); - } else { - ctx.fillStyle='#fff'; - ctx.fillRect(player.x, player.y, player.w, player.h); - } - - // projectiles (стрелы) - for(const p of projectiles){ - const angle = Math.atan2(p.vy, p.vx); - ctx.save(); - ctx.translate(p.x, p.y); - ctx.rotate(angle); - ctx.fillStyle = p.owner === 'mob' ? '#c0392b' : '#f1c40f'; - ctx.fillRect(-12, -1.5, 24, 3); - // наконечник - ctx.beginPath(); - ctx.moveTo(12, -4); - ctx.lineTo(16, 0); - ctx.lineTo(12, 4); - ctx.closePath(); - ctx.fill(); - // оперение - ctx.fillStyle = '#888'; - ctx.fillRect(-12, -3, 4, 2); - ctx.fillRect(-12, 1, 4, 2); - ctx.restore(); - } - - // particles - for(const p of parts){ - ctx.fillStyle = p.c; - ctx.fillRect(p.x-2, p.y-2, 4, 4); - } - - // Стрелы скелета - for(const m of mobs){ - if(m.kind==='skeleton' && m.shootCooldown > 0.5){ - // Рисуем стрелу - const arrowX = m.x + m.w/2; - const arrowY = m.y + 15; - const targetX = player.x + player.w/2; - const targetY = player.y + player.h/2; - const angle = Math.atan2(targetY - arrowY, targetX - arrowX); - const speed = 400; - - // Проверяем, попала ли стрела - const dx = targetX - arrowX; - const dy = targetY - arrowY; - const dist = Math.hypot(dx, dy); - - // Рисуем стрелу - ctx.save(); - ctx.translate(arrowX, arrowY); - ctx.rotate(angle); - ctx.fillStyle = '#ECEFF1'; - ctx.fillRect(0, -1, 16, 2); - ctx.restore(); - - // Урон игроку если попали - if(dist < 150 && player.invuln <= 0){ - player.hp -= 8; - player.invuln = 0.5; - player.vx += Math.cos(angle) * 300; - player.vy -= 200; - playSound('hit1'); - } - } - } - - // build ghost - if(mode()==='build' && mouse.x!==null && !craftOpen && player.hp>0){ - const wx = mouse.x + camX; - const wy = mouse.y + camY; - const gx = Math.floor(wx/TILE); - const gy = Math.floor(wy/TILE); - ctx.strokeStyle = 'rgba(255,255,255,0.9)'; - ctx.lineWidth = 2; - ctx.strokeRect(gx*TILE, gy*TILE, TILE, TILE); - } - - ctx.restore(); - - // lighting overlay at night (Minecraft-style: offscreen lightmap, wall occlusion, warm flicker) - if(night){ - // 1) Рисуем тёмный оверлей на offscreen canvas - lightC.width = W*dpr; - lightC.height = H*dpr; - lightCtx.setTransform(dpr,0,0,dpr,0,0); - lightCtx.fillStyle = 'rgba(0,0,12,0.82)'; - lightCtx.fillRect(0,0,W,H); - - // 2) Вырезаем «окна света» через destination-out — свет не проходит сквозь стены - lightCtx.globalCompositeOperation = 'destination-out'; - - // Функция: рисуем мягкий луч света с затуханием за стенами - function castLight(sx, sy, radius) { - const flick = 0.92 + Math.sin(now/80 + sx*0.01)*0.04 + Math.sin(now/130 + sy*0.02)*0.04; - const r = radius * flick; - // 24 луча — мягкий круглый свет - const steps = 24; - // Собираем дистанции до стен по лучам - const dists = new Float32Array(steps); - for(let i=0; i TILE*0.3){ - maxDist = step; - break; - } - } - dists[i] = maxDist; - } - // Рисуем сглаженный полигон по dists - const cx = sx-camX, cy = sy-camY; - // Центр: яркая точка - const maxR = Math.max(...dists); - const grad = lightCtx.createRadialGradient(cx, cy, 0, cx, cy, maxR); - grad.addColorStop(0, 'rgba(255,255,255,1)'); - grad.addColorStop(0.4, 'rgba(255,255,255,0.8)'); - grad.addColorStop(1, 'rgba(255,255,255,0)'); - lightCtx.fillStyle = grad; - // Рисуем shape по dists (звездоподобный полигон) - lightCtx.beginPath(); - for(let i=0; i<=steps; i++){ - const idx = i % steps; - const nextIdx = (i+1) % steps; - const avgD = (dists[idx] + dists[nextIdx]) / 2; - const angle = (idx/steps) * Math.PI * 2; - const px = cx + Math.cos(angle) * dists[idx]; - const py = cy + Math.sin(angle) * dists[idx]; - if(i===0) lightCtx.moveTo(px, py); - else lightCtx.lineTo(px, py); - } - lightCtx.closePath(); - lightCtx.fill(); - } - - // Источники света - for(const b of blocks){ - if(b.dead) continue; - if(b.gx < minGX-5 || b.gx > maxGX+5 || b.gy < minGY-5 || b.gy > maxGY+5) continue; - const def = BLOCKS[b.t]; - if(def.lightRadius){ - castLight(b.gx*TILE + TILE/2, b.gy*TILE + TILE/2, def.lightRadius); - } - } - - // 3) Накладываем lightmap на основной canvas - lightCtx.globalCompositeOperation = 'source-over'; - ctx.drawImage(lightC, 0, 0, W, H); - - // 4) Тёплый оверлей от источников света (additive, мягкий) - ctx.save(); - ctx.globalCompositeOperation = 'lighter'; - for(const b of blocks){ - if(b.dead) continue; - if(b.gx < minGX-3 || b.gx > maxGX+3 || b.gy < minGY-3 || b.gy > maxGY+3) continue; - const def = BLOCKS[b.t]; - if(def.lightRadius){ - const flick = 0.7 + Math.sin(now/90 + b.gx*3.7)*0.15 + Math.sin(now/140 + b.gy*2.3)*0.15; - const wx = b.gx*TILE + TILE/2 - camX; - const wy = b.gy*TILE + TILE/2 - camY; - const r = def.lightRadius * 0.75 * flick; - const grad = ctx.createRadialGradient(wx,wy, 0, wx,wy, r); - grad.addColorStop(0, `rgba(255,180,80,${0.22*flick})`); - grad.addColorStop(0.5, `rgba(255,140,40,${0.10*flick})`); - grad.addColorStop(1, 'rgba(255,100,20,0)'); - ctx.fillStyle = grad; - ctx.beginPath(); - ctx.arc(wx, wy, r, 0, Math.PI*2); - ctx.fill(); - } - } - ctx.restore(); - } - - // Дождь (после ночного оверлея) - drawRain(); - if(Math.random()<0.25){ - hpEl.textContent = Math.max(0, Math.ceil(player.hp)); - foodEl.textContent = Math.ceil(player.hunger); - document.getElementById('o2').textContent = Math.ceil(player.o2); - sxEl.textContent = Math.floor(player.x/TILE); - syEl.textContent = Math.floor(player.y/TILE); - todEl.textContent = night ? 'Ночь' : 'День'; - worldIdEl.textContent = worldId; - if(isMultiplayer){ - document.getElementById('multiplayerStatus').style.display = 'flex'; - playerCountEl.textContent = otherPlayers.size + 1; // +1 = мы сами - } else { - document.getElementById('multiplayerStatus').style.display = 'none'; - } - } - - // Индикатор сна - if(player.sleeping){ - ctx.fillStyle = 'rgba(0,0,0,0.7)'; - ctx.fillRect(0, 0, W, H); - ctx.fillStyle = '#fff'; - ctx.font = 'bold 32px system-ui'; - ctx.textAlign = 'center'; - ctx.fillText('💤 Спим...', W/2, H/2); - ctx.font = '18px system-ui'; - ctx.fillText('Нажмите на кровать чтобы проснуться', W/2, H/2 + 40); - } - - // Миникарта (обновляем раз в ~4 кадра для оптимизации) - if(minimapOpen && Math.random() < 0.25){ - renderMinimap(); - } - - requestAnimationFrame(loop); - } - - requestAnimationFrame(loop); -})(); diff --git a/index.html b/index.html index 6a87a52..1905551 100644 --- a/index.html +++ b/index.html @@ -93,6 +93,6 @@ - +