(() => { // ==================== КОНФИГУРАЦИЯ СЕРВЕРА ==================== // === 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. Это может вызвать проблемы.'); } // ==================== TELEGRAM MINI APP ==================== const isTgWebApp = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData); let tgUser = null; let tgSaveLoaded = false; if (isTgWebApp) { try { Telegram.WebApp.ready(); Telegram.WebApp.expand(); tgUser = Telegram.WebApp.initDataUnsafe?.user || null; console.log('[TG] Mini App detected, user:', tgUser?.id, tgUser?.username); // Set player name from TG if (tgUser && tgUser.first_name) { const tgName = tgUser.username || tgUser.first_name; localStorage.setItem('minegrechka_playerName', tgName); } } catch(e) { console.warn('[TG] WebApp init error:', e); } } // ==================== WORLD ID И ИГРОКА ==================== let worldId = null; let playerName = localStorage.getItem('minegrechka_playerName') || null; // Priority: URL param > TG saved world > new world const worldParam = urlParams.get('world'); // ==================== START MENU ==================== let startMenuResolved = false; function generateShortWorldCode() { // Generate readable 6-char code: consonants + digits (easy to copy/share) const c = 'bcdfghjkmnpqrstvwxyz'; const d = '23456789'; let code = ''; for (let i = 0; i < 3; i++) code += c[Math.floor(Math.random() * c.length)]; for (let i = 0; i < 3; i++) code += d[Math.floor(Math.random() * d.length)]; return code; } function showStartMenu() { return new Promise((resolve) => { // If world is in URL — skip menu, auto-join if (worldParam && worldParam.trim() !== '') { worldId = worldParam.trim(); console.log('[Menu] Joining world from URL:', worldId); resolve(worldId); return; } const overlay = document.createElement('div'); overlay.id = 'startMenu'; overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:99999;font-family:Arial,sans-serif;'; const box = document.createElement('div'); box.style.cssText = 'background:#1a1a2e;border:2px solid #e94560;border-radius:16px;padding:32px;max-width:400px;width:90%;text-align:center;color:#eee;'; // Title const title = document.createElement('div'); title.style.cssText = 'font-size:28px;font-weight:bold;margin-bottom:8px;color:#e94560;'; title.textContent = '🏗️ GrechkaCraft'; box.appendChild(title); const subtitle = document.createElement('div'); subtitle.style.cssText = 'font-size:14px;color:#888;margin-bottom:24px;'; subtitle.textContent = isTgWebApp ? ('Привет, ' + (tgUser?.first_name || 'Игрок') + '!') : 'Voxel-мир в браузере'; box.appendChild(subtitle); // New World button const btnNew = document.createElement('button'); btnNew.style.cssText = 'width:100%;padding:14px;margin-bottom:12px;background:#27ae60;color:#fff;border:none;border-radius:10px;font-size:18px;cursor:pointer;font-weight:bold;'; btnNew.textContent = '🌍 Новый мир'; btnNew.onmouseover = () => btnNew.style.background = '#2ecc71'; btnNew.onmouseout = () => btnNew.style.background = '#27ae60'; btnNew.onclick = () => { worldId = generateShortWorldCode(); overlay.remove(); resolve(worldId); }; box.appendChild(btnNew); // Join World button const btnJoin = document.createElement('button'); btnJoin.style.cssText = 'width:100%;padding:14px;margin-bottom:12px;background:#2980b9;color:#fff;border:none;border-radius:10px;font-size:18px;cursor:pointer;font-weight:bold;'; btnJoin.textContent = '🔗 Вступить по коду'; btnJoin.onmouseover = () => btnJoin.style.background = '#3498db'; btnJoin.onmouseout = () => btnJoin.style.background = '#2980b9'; btnJoin.onclick = () => { const codeInput = document.createElement('input'); codeInput.type = 'text'; codeInput.placeholder = 'Введите код мира (например: bkh237)'; codeInput.style.cssText = 'width:90%;padding:12px;margin:12px 0;border:2px solid #3498db;border-radius:8px;background:#16213e;color:#fff;font-size:16px;text-align:center;text-transform:lowercase;'; codeInput.maxLength = 12; box.innerHTML = ''; const backTitle = document.createElement('div'); backTitle.style.cssText = 'font-size:20px;font-weight:bold;margin-bottom:16px;color:#3498db;'; backTitle.textContent = '🔗 Вступить в мир'; box.appendChild(backTitle); box.appendChild(codeInput); const btnGo = document.createElement('button'); btnGo.style.cssText = 'width:100%;padding:14px;margin-top:12px;background:#2980b9;color:#fff;border:none;border-radius:10px;font-size:18px;cursor:pointer;font-weight:bold;'; btnGo.textContent = 'Войти'; btnGo.onclick = () => { const code = codeInput.value.trim().toLowerCase(); if (code.length >= 3) { worldId = code; overlay.remove(); resolve(worldId); } }; box.appendChild(btnGo); const btnBack = document.createElement('button'); btnBack.style.cssText = 'width:100%;padding:10px;margin-top:8px;background:transparent;color:#888;border:1px solid #444;border-radius:8px;cursor:pointer;font-size:14px;'; btnBack.textContent = '← Назад'; btnBack.onclick = () => { overlay.remove(); showStartMenu().then(resolve); }; box.appendChild(btnBack); codeInput.focus(); codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') btnGo.click(); }); }; box.appendChild(btnJoin); // History / Continue button (only if TG user with saved game OR localStorage save) const hasLocalSave = !!localStorage.getItem('minegrechka_save'); if (isTgWebApp || hasLocalSave) { const btnHistory = document.createElement('button'); btnHistory.style.cssText = 'width:100%;padding:14px;margin-bottom:12px;background:#8e44ad;color:#fff;border:none;border-radius:10px;font-size:18px;cursor:pointer;font-weight:bold;'; btnHistory.textContent = '💾 Продолжить игру'; btnHistory.onmouseover = () => btnHistory.style.background = '#9b59b6'; btnHistory.onmouseout = () => btnHistory.style.background = '#8e44ad'; btnHistory.onclick = async () => { // TG user: load save from server if (isTgWebApp && tgUser) { try { const resp = await fetch(SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id); const data = await resp.json(); if (data.ok && data.world_id) { worldId = data.world_id; tgSaveLoaded = true; overlay.remove(); resolve(worldId); return; } } catch(e) { console.warn('[TG] Load save failed:', e); } } // Fallback: load from latest localStorage save if (hasLocalSave) { // Generate world from save (worldSeed based) worldId = generateShortWorldCode(); overlay.remove(); resolve(worldId); return; } customAlert('Сохранений не найдено'); }; box.appendChild(btnHistory); } // TG Share button (only in TG context) if (isTgWebApp && window.Telegram && Telegram.WebApp.switchInlineQuery) { const btnInvite = document.createElement('button'); btnInvite.style.cssText = 'width:100%;padding:10px;margin-top:8px;background:transparent;color:#3498db;border:1px solid #3498db;border-radius:8px;cursor:pointer;font-size:14px;'; btnInvite.textContent = '📤 Пригласить друзей'; btnInvite.onclick = () => { const code = worldId || generateShortWorldCode(); Telegram.WebApp.switchInlineQuery('play ' + code); }; box.appendChild(btnInvite); // Friends button (TG only) const btnFriends = document.createElement('button'); btnFriends.style.cssText = 'width:100%;padding:14px;margin-bottom:12px;background:#f39c12;color:#fff;border:none;border-radius:10px;font-size:18px;cursor:pointer;font-weight:bold;'; btnFriends.textContent = '👥 Друзья'; btnFriends.onmouseover = () => btnFriends.style.background = '#e67e22'; btnFriends.onmouseout = () => btnFriends.style.background = '#f39c12'; btnFriends.onclick = async () => { try { const resp = await fetch(SERVER_URL + '/api/tg/friends?tg_id=' + tgUser.id); const data = await resp.json(); if (!data.ok || !data.friends || !data.friends.length) { showToast('👥 Нет друзей. Поделись своим TG ID: ' + tgUser.id); return; } let html = '
'; for (const f of data.friends) { if (f.online) { html += '
🟢 ' + f.name + ' — мир ' + f.world_id + '
'; } else { html += '
⚫ ' + f.name + '
'; } } html += '
'; box.innerHTML = ''; const ft = document.createElement('div'); ft.style.cssText = 'font-size:20px;font-weight:bold;margin-bottom:8px;color:#f39c12;'; ft.textContent = '👥 Друзья'; box.appendChild(ft); const list = document.createElement('div'); list.innerHTML = html; box.appendChild(list); // Add friend by TG ID const addDiv = document.createElement('div'); addDiv.style.cssText = 'margin-top:16px;'; addDiv.innerHTML = '
Добавить друга по TG ID:
'; const addInput = document.createElement('input'); addInput.type = 'number'; addInput.placeholder = 'TG ID друга'; addInput.style.cssText = 'width:70%;padding:10px;border:2px solid #f39c12;border-radius:8px;background:#16213e;color:#fff;font-size:16px;'; const addBtn = document.createElement('button'); addBtn.style.cssText = 'width:25%;padding:10px;background:#f39c12;color:#fff;border:none;border-radius:8px;font-size:16px;cursor:pointer;margin-left:4px;'; addBtn.textContent = '+'; addBtn.onclick = async () => { const fid = parseInt(addInput.value); if (!fid) return; const r = await fetch(SERVER_URL + '/api/tg/friends/add', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,friend_id:fid})}); const d = await r.json(); if (d.ok) { showToast('✅ Друг добавлен!'); btnFriends.click(); } else { showToast('❌ Ошибка'); } }; addDiv.appendChild(addInput); addDiv.appendChild(addBtn); box.appendChild(addDiv); // My TG ID const myId = document.createElement('div'); myId.style.cssText = 'margin-top:12px;color:#888;font-size:13px;'; myId.textContent = '🆔 Твой ID: ' + tgUser.id + ' — отправь другу, чтобы он добавил тебя'; box.appendChild(myId); // Join online friend buttons const onlineFriends = (data.friends||[]).filter(f => f.online); if (onlineFriends.length) { for (const of2 of onlineFriends) { const joinBtn = document.createElement('button'); joinBtn.style.cssText = 'width:100%;padding:12px;margin-top:8px;background:#27ae60;color:#fff;border:none;border-radius:10px;font-size:16px;cursor:pointer;'; joinBtn.textContent = '🎮 Вступить к ' + of2.name + ' (мир ' + of2.world_id + ')'; joinBtn.onclick = () => { worldId = of2.world_id; overlay.remove(); resolve(worldId); }; box.appendChild(joinBtn); } } // Back button const btnBackF = document.createElement('button'); btnBackF.style.cssText = 'width:100%;padding:10px;margin-top:12px;background:transparent;color:#888;border:1px solid #444;border-radius:8px;cursor:pointer;font-size:14px;'; btnBackF.textContent = '← Назад'; btnBackF.onclick = () => { overlay.remove(); showStartMenu().then(resolve); }; box.appendChild(btnBackF); } catch(e) { showToast('❌ Ошибка загрузки друзей'); console.error(e); } }; box.appendChild(btnFriends); } // Player name input (if not set) if (!playerName) { const nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.placeholder = 'Ваше имя'; nameInput.style.cssText = 'width:90%;padding:10px;margin:16px 0 8px;border:2px solid #e94560;border-radius:8px;background:#16213e;color:#fff;font-size:16px;text-align:center;'; nameInput.value = tgUser ? (tgUser.username || tgUser.first_name || '') : ''; nameInput.maxLength = 20; box.appendChild(nameInput); // Update playerName on input nameInput.addEventListener('input', () => { if (nameInput.value.trim()) { playerName = nameInput.value.trim(); localStorage.setItem('minegrechka_playerName', playerName); } }); box.appendChild(document.createElement('br')); } overlay.appendChild(box); document.body.appendChild(overlay); // If TG user without world in URL, also try to auto-load their save if (isTgWebApp && tgUser && !worldParam) { fetch(SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id) .then(r => r.json()) .then(data => { if (data.ok && data.world_id) { // Update the "Continue" button to show the world code const historyBtn = box.querySelector('button:nth-child(5)'); // rough } }) .catch(() => {}); } }); } // ==================== GAME LAUNCH ==================== // Override the old auto-join logic let gameLaunched = false; async function launchGame() { if (gameLaunched) return; gameLaunched = true; if (!playerName) { playerName = tgUser ? (tgUser.username || tgUser.first_name || 'Игрок') : 'Игрок'; localStorage.setItem('minegrechka_playerName', playerName); } // Validate TG initData if in TG context if (isTgWebApp && Telegram.WebApp.initData) { try { const resp = await fetch(SERVER_URL + '/api/tg/auth', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ initData: Telegram.WebApp.initData }) }); const data = await resp.json(); if (data.ok && data.tg_id) { console.log('[TG] Authenticated, tg_id:', data.tg_id); tgUser = tgUser || {}; tgUser.id = data.tg_id; } } catch(e) { console.warn('[TG] Auth failed:', e); } } // Update URL with world code try { const newUrl = new URL(window.location.href); newUrl.searchParams.set('world', worldId); if (typeof history !== 'undefined' && history.replaceState) { history.replaceState(null, '', newUrl.toString()); } } catch(e) {} console.log('[Launch] worldId:', worldId, 'player:', playerName, 'tg:', !!tgUser); // The rest of the game init will pick up worldId and playerName // Signal that menu is done startMenuResolved = true; window.dispatchEvent(new Event('startMenuDone')); } // ==================== 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 }, scorpion: { w: 26, h: 26, hp: 3, speed: 90, hostile: true, fuse: 0, shootCooldown: 0, biome:'desert' }, polar_bear:{ w: 40, h: 34, hp: 8, speed: 50, hostile: false, fuse: 0, shootCooldown: 2, biome:'tundra' }, slime: { w: 24, h: 24, hp: 2, speed: 30, hostile: true, fuse: 0, shootCooldown: 2, biome:'swamp' }, eagle: { w: 30, h: 22, hp: 3, speed: 120, hostile: true, fuse: 0, shootCooldown: 0, biome:'mountains' } }; 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, tg_id: (tgUser && tgUser.id) ? tgUser.id : null }); // TG: отправляем tg_auth + онлайн статус для уведомления друзей if (tgUser && tgUser.id) { socket.emit('tg_auth', { tg_id: tgUser.id }); try { fetch(SERVER_URL + '/api/tg/online', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,world_id:worldId,player_name:playerName||'Игрок'})}); } catch(e) { console.warn('[TG] online notify error:', e); } } // Показываем в UI worldIdEl.textContent = worldId; // XP/Level display const lvXpNext = xpForLevel(player.level + 1); const lvXpCur = xpForLevel(player.level); const xpInLevel = player.xp - lvXpCur; const xpNeeded = lvXpNext - lvXpCur; document.getElementById('xplevel').textContent = player.level; document.getElementById('xpbar').textContent = xpInLevel + '/' + xpNeeded; 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'; // TG: убираем онлайн статус if (tgUser && tgUser.id) { try { fetch(SERVER_URL + '/api/tg/offline', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id})}); } catch(e) {} } }); // Обработка 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 set from', oldSeed, 'to', worldSeed); // Full regeneration with correct seed grid.clear(); blocks.length = 0; placedBlocks = []; removedBlocks = []; generated.clear(); for (let gx = spawnPoint.gx - 25; gx <= spawnPoint.gx + 25; gx++) { genColumn(gx); decorations(gx); } console.log('World regenerated with 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); // Пересчитываем Y по клиентской surfaceGyAt const spawnGX = Math.floor(sm.x / TILE); genColumn(spawnGX - 1); genColumn(spawnGX); genColumn(spawnGX + 1); const clientSurfaceY = surfaceGyAt(spawnGX); sm.y = (clientSurfaceY - 1) * TILE; sm.vy = 0; 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, tx: safeSpawnX, ty: 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); // Lerp: запоминаем целевую позицию, рисуем в рендере с интерполяцией p.tx = data.x; p.ty = 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); // Пересчитываем Y по клиентской surfaceGyAt — сервер может ошибаться const spawnGX = Math.floor(sm.x / TILE); // Убеждаемся что чанк сгенерирован (иначе getBlock=null → моб падает сквозь землю) genColumn(spawnGX - 1); genColumn(spawnGX); genColumn(spawnGX + 1); const clientSurfaceY = surfaceGyAt(spawnGX); sm.y = (clientSurfaceY - 1) * TILE; // 1 блок выше поверхности (под ногами) sm.vy = 0; sm.grounded = false; // resolveY в mobAI поставит grounded=true serverMobs.set(data.id, sm); }); socket.on('mob_positions', (arr) => { for (const u of arr) { let sm = serverMobs.get(u.id); if (!sm) { sm = createMobFromServer({ id: u.id, kind: u.kind || 'zombie', x: u.x, y: u.y, hp: u.hp || 1, maxHp: u.hp || 1, dir: u.dir || 1 }); serverMobs.set(u.id, sm); } sm.x += (u.x - sm.x) * 0.35; sm.y += (u.y - sm.y) * 0.35; sm.dir = u.dir != null ? u.dir : sm.dir; sm.hp = u.hp != null ? u.hp : sm.hp; sm.fuse = u.fuse != null ? u.fuse : sm.fuse; sm.grounded = !!u.grounded; } }); 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'); spawnDrops(sm.x, sm.y, sm.kind); 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 = ''; } }); // ==================== ИНИЦИАЛИЗАЦИЯ СОКЕТА ==================== // Show start menu first, then init socket showStartMenu().then((wid) => { worldId = wid; // Send tg_id if TG user initSocket(); launchGame(); }); // ==================== ЗВУКОВОЙ ДВИЖОК ==================== 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 }, // === BIOME BLOCKS === snow: { n:'Снег', c:'#ecf0f1', solid:true }, ice: { n:'Лёд', c:'#74b9ff', solid:true, slip:true }, cactus: { n:'Кактус', c:'#27ae60', solid:true, hurt:true }, mushroom: { n:'Гриб', c:'#e74c3c', solid:false, decor:true }, moss: { n:'Мох', c:'#0a6640', solid:true }, swamp_water:{ n:'Болотная вода', c:'rgba(100,120,40,0.6)', solid:false, fluid:true, poison:true }, farmland: { n:'Грядка', c:'#8B6914', solid:true, farmable:true }, dead_bush: { n:'Сухой куст', c:'#b2bec3', solid:false, decor:true }, spruce_leaves:{ n:'Ель', c:'#0a6640', solid:true }, // === CROP STAGES === wheat_stage0:{ n:'Росток пшеницы', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'wheat_stage1' }, wheat_stage1:{ n:'Пшеница', c:'#7dcea0', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'wheat_stage2' }, wheat_stage2:{ n:'Пшеница', c:'#b8d730', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'wheat_stage3' }, wheat_stage3:{ n:'Пшеница', c:'#f1c40f', solid:false, decor:true, harvestable:true, harvestItem:'wheat', harvestQty:2 }, carrot_stage0:{ n:'Росток моркови', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'carrot_stage1' }, carrot_stage1:{ n:'Морковь', c:'#f0c27a', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'carrot_stage2' }, carrot_stage2:{ n:'Морковь', c:'#e8a040', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'carrot_stage3' }, carrot_stage3:{ n:'Морковь', c:'#e67e22', solid:false, decor:true, harvestable:true, harvestItem:'carrot', harvestQty:3 }, potato_stage0:{ n:'Росток картофеля', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'potato_stage1' }, potato_stage1:{ n:'Картофель', c:'#c8b888', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'potato_stage2' }, potato_stage2:{ n:'Картофель', c:'#bfaa78', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'potato_stage3' }, potato_stage3:{ n:'Картофель', c:'#dfe6e9', solid:false, decor:true, harvestable:true, harvestItem:'potato', harvestQty:2 } }; const ITEMS = { meat: { n:'Сырое мясо', icon:'🥩', food:15 }, cooked: { n:'Жареное мясо', icon:'🍖', food:45 }, arrow: { n:'Стрела', icon:'➡️', stack:64 }, chicken_meat: { n:'Курица', icon:'🍗', food:12, stack:64 }, feather: { n:'Перо', icon:'🪶', stack:64 }, bone: { n:'Кость', icon:'🦴', stack:64 }, gunpowder: { n:'Порох', icon:'💥', stack:64 }, // === FARMING ITEMS === wheat: { n:'Пшеница', icon:'🌾', stack:64 }, bread: { n:'Хлеб', icon:'🍞', food:30 }, carrot: { n:'Морковь', icon:'🥕', food:8, stack:64 }, potato: { n:'Картофель', icon:'🥔', stack:64 }, baked_potato:{ n:'Печёная картошка', icon:'🥔', food:25 }, mushroom_stew:{ n:'Грибной суп', icon:'🍲', food:40 }, // === MOB DROP ITEMS === scorpion_stinger:{ n:'Жало скорпиона', icon:'🔺', stack:64 }, polar_fur: { n:'Шкура медведя', icon:'🧥', stack:64 }, slime_ball: { n:'Слизь', icon:'🟢', stack:64 }, eagle_feather:{ n:'Перо орла', icon:'🪶', stack:64 }, // === NEW ARMOR & TOOLS === gold_armor: { n:'Золотая броня', icon:'🛡️', stack:1, armor:0.65 } }; // Seed мира для детерминированной генерации // Seed: null in MP until server sends it; SP uses random seed let worldSeed = isMultiplayer ? null : 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 }, requiredLevel: 1 }, stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 }, requiredLevel: 2 }, iron_pickaxe: { n:'Железная кирка', icon:'⛏️', durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 }, requiredLevel: 3 }, wood_sword: { n:'Деревянный меч', icon:'⚔️', durability: 40, damage: 5, craft: { wood: 2, planks: 1 }, requiredLevel: 1 }, stone_sword: { n:'Каменный меч', icon:'⚔️', durability: 100, damage: 8, craft: { wood: 1, stone: 2 }, requiredLevel: 2 }, iron_sword: { n:'Железный меч', icon:'⚔️', durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 }, requiredLevel: 3 }, bow: { n:'Лук', icon:'🏹', durability: 150, craft: { wood: 3, planks: 2 }, requiredLevel: 4 }, diamond_pickaxe: { n:'Алмазная кирка', icon:'⛏️', durability: 500, miningPower: 5, craft: { wood: 1, diamond_ore: 2 }, requiredLevel: 7 }, diamond_sword: { n:'Алмазный меч', icon:'⚔️', durability: 400, damage: 18, craft: { wood: 1, diamond_ore: 2 }, requiredLevel: 7 }, hoe: { n:'Мотыга', icon:'🔨', durability: 80, tillTo: 'farmland', craft: { wood: 2, planks: 1 }, requiredLevel: 1 } }; // Текстуры блоков (простые) 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; } // === BIOME BLOCK TEXTURES === if (type === 'snow') { g.fillStyle = '#ecf0f1'; g.fillRect(0, 0, 32, 32); g.fillStyle = '#dfe6e9'; for (let i = 0; i < 4; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 5, 3); return c; } if (type === 'ice') { g.fillStyle = '#74b9ff'; g.fillRect(0, 0, 32, 32); g.strokeStyle = 'rgba(255,255,255,0.4)'; g.beginPath(); g.moveTo(4,10); g.lineTo(20,16); g.lineTo(10,28); g.stroke(); g.beginPath(); g.moveTo(18,4); g.lineTo(28,12); g.stroke(); return c; } if (type === 'cactus') { g.fillStyle = '#27ae60'; g.fillRect(6, 2, 20, 28); g.fillStyle = '#2ecc71'; g.fillRect(2, 8, 6, 4); g.fillRect(24, 14, 6, 4); g.fillStyle = '#1e8449'; g.fillRect(12, 0, 2, 30); g.fillRect(18, 0, 2, 30); return c; } if (type === 'mushroom') { g.fillStyle = '#f5e6cc'; g.fillRect(14, 20, 4, 12); g.fillStyle = '#e74c3c'; g.beginPath(); g.arc(16, 14, 10, Math.PI, 0); g.fill(); g.fillStyle = '#fff'; g.fillRect(10, 10, 3, 3); g.fillRect(17, 8, 4, 3); return c; } if (type === 'moss') { g.fillStyle = '#0a6640'; g.fillRect(0, 0, 32, 32); g.fillStyle = '#1e8449'; for (let i = 0; i < 6; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 4, 3); return c; } if (type === 'swamp_water') { g.fillStyle = 'rgba(100,120,40,0.6)'; g.fillRect(0, 0, 32, 32); g.fillStyle = 'rgba(80,100,20,0.3)'; g.fillRect(0, 10, 32, 2); g.fillRect(0, 22, 32, 2); return c; } if (type === 'farmland') { g.fillStyle = '#8B6914'; g.fillRect(0, 0, 32, 32); g.fillStyle = '#7a5c10'; for (let i = 0; i < 4; i++) g.fillRect(0, 6+i*8, 32, 2); g.fillStyle = '#6B4E0A'; g.fillRect(8, 2, 2, 28); g.fillRect(18, 2, 2, 28); return c; } if (type === 'dead_bush') { g.fillStyle = '#b2bec3'; g.fillRect(12, 16, 2, 16); g.fillRect(8, 14, 8, 2); g.fillRect(16, 12, 8, 2); g.fillRect(6, 18, 4, 2); return c; } if (type === 'spruce_leaves') { g.fillStyle = '#0a6640'; g.fillRect(0, 0, 32, 32); g.fillStyle = '#0d7a4d'; for (let i = 0; i < 5; i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6, 4); return c; } // CROP STAGES if (type.startsWith('wheat_stage') || type.startsWith('carrot_stage') || type.startsWith('potato_stage')) { const st = parseInt(type.charAt(type.length-1)); const colors = type.startsWith('wheat') ? ['#a8e6a0','#7dcea0','#b8d730','#f1c40f'] : type.startsWith('carrot') ? ['#a8e6a0','#f0c27a','#e8a040','#e67e22'] : ['#a8e6a0','#c8b888','#bfaa78','#dfe6e9']; g.fillStyle = '#5d4037'; g.fillRect(15, 18, 2, 14); if (st >= 1) { g.fillStyle = colors[st]; g.fillRect(10, 8 + (3-st)*3, 12, 10); } else { g.fillStyle = '#a8e6a0'; g.fillRect(14, 22, 4, 4); } 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, gold_armor:0, bow:0, furnace:0, bed:0, boat:0, iron_ingot:0, gold_ingot:0, copper_ingot:0, diamond_pickaxe:0, diamond_sword:0, hoe:0, wheat:0, bread:0, carrot:0, potato:0, baked_potato:0, scorpion_stinger:0, polar_fur:0, slime_ball:0, eagle_feather:0, snow:0, ice:0, cactus:0, mushroom:0, moss:0, farmland:0, spruce_leaves:0, dead_bush:0, wheat_stage0:0, carrot_stage0:0, potato_stage0: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 }, requiredLevel:1 }, { out:'ladder', qty:3, cost:{ planks:7 }, requiredLevel:1 }, { out:'torch', qty:2, cost:{ coal:1, planks:1 }, requiredLevel:1 }, { out:'glass', qty:1, cost:{ sand:3 }, requiredLevel:1 }, { out:'brick', qty:1, cost:{ stone:2, clay:1 }, requiredLevel:1 }, { out:'campfire', qty:1, cost:{ wood:1, coal:1 }, requiredLevel:1 }, { out:'tnt', qty:1, cost:{ sand:2, coal:1 }, requiredLevel:8 }, { out:'bed', qty:1, cost:{ wood: 3, planks: 3 }, requiredLevel:1 }, { out:'boat', qty:1, cost:{ wood: 5 }, requiredLevel:2 }, { out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 }, requiredLevel:1 }, { out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 }, requiredLevel:2 }, { out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 }, requiredLevel:3 }, { out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 }, requiredLevel:1 }, { out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 }, requiredLevel:2 }, { out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 }, requiredLevel:3 }, { out:'iron_armor', qty:1, cost:{ iron_ore: 5 }, requiredLevel:5 }, { out:'gold_armor', qty:1, cost:{ gold_ore: 8 }, requiredLevel:6 }, { out:'furnace', qty:1, cost:{ stone: 8 }, requiredLevel:3 }, { out:'bow', qty:1, cost:{ wood: 3, planks: 2 }, requiredLevel:4 }, { out:'arrow', qty:4, cost:{ stone: 1, wood: 1 }, requiredLevel:1 }, // === NEW RECIPES === { out:'bread', qty:1, cost:{ wheat: 3 }, requiredLevel:1 }, { out:'diamond_pickaxe', qty:1, cost:{ wood: 1, diamond_ore: 2 }, requiredLevel:7 }, { out:'diamond_sword', qty:1, cost:{ wood: 1, diamond_ore: 2 }, requiredLevel:7 }, { out:'hoe', qty:1, cost:{ wood: 2, planks: 1 }, requiredLevel: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 }, // булыжник → камень { in:'potato', qty:1, out:'baked_potato', outQty:1, time:2 } // картофель → печёная картошка ]; // Новые предметы от обжига ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' }; ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' }; ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' }; ITEMS.diamond_pickaxe = { n:'Алмазная кирка', icon:'⛏️', durability:500, miningPower:5 }; ITEMS.diamond_sword = { n:'Алмазный меч', icon:'⚔️', durability:400, damage:18 }; ITEMS.hoe = { n:'Мотыга', icon:'🔨', durability:80, tillTo:'farmland' }; // Активные печи: Map ключа блока → { recipe, progress, totalTime } const activeFurnaces = new Map(); // UI const hpEl = document.getElementById('hp'); const foodEl = document.getElementById('food'); const tempEl = document.getElementById("temp"); const sxEl = document.getElementById('sx'); const syEl = document.getElementById('sy'); const todEl = document.getElementById('tod'); const worldIdEl = document.getElementById('worldId'); // Toast уведомление function showToast(msg, duration) { duration = duration || 1800; const t = document.createElement('div'); t.textContent = msg; t.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#2ecc71;padding:10px 24px;border-radius:10px;font:bold 16px Arial,sans-serif;z-index:99999;pointer-events:none;transition:opacity 0.4s;'; document.body.appendChild(t); setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, duration); } // Клик на worldId → копировать код мира + toast worldIdEl.addEventListener('click', () => { const code = worldId || ''; if (!code) return; const text = code; // копируем только код, не URL if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(() => showToast('📋 Скопировано: ' + code)).catch(() => showToast(code)); } else { // fallback const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); showToast('📋 Скопировано: ' + code); } catch(e) { showToast(code); } ta.remove(); } }); 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', snow: '#ecf0f1', ice: '#74b9ff', cactus: '#27ae60', mushroom: '#e74c3c', moss: '#0a6640', swamp_water: '#687828', farmland: '#8B6914', dead_bush: '#b2bec3', spruce_leaves: '#0a6640', wheat_stage0: '#a8e6a0', wheat_stage1: '#7dcea0', wheat_stage2: '#b8d730', wheat_stage3: '#f1c40f', carrot_stage0: '#a8e6a0', carrot_stage1: '#f0c27a', carrot_stage2: '#e8a040', carrot_stage3: '#e67e22', potato_stage0: '#a8e6a0', potato_stage1: '#c8b888', potato_stage2: '#bfaa78', potato_stage3: '#dfe6e9' }; function renderMinimap() { if (!minimapOpen) return; const mW = minimapCanvas.width; const mH = minimapCanvas.height; const scale = 2; // пикселей на блок // Область карты — центрирована на игроке const pGX = Math.floor(player.x / TILE); const biome = getCachedBiome(pGX); 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(); } } } } // ==================== ГОЛОСОВОЙ ЧАТ v3 ==================== // ==================== ГОЛОСОВОЙ ЧАТ v3 ==================== // Per-speaker architecture, AudioWorklet, VAD, Opus/PCM, spatial audio // Replaces lines 1449-1665 in game.js let voiceSocket = null; let voiceStream = null; let audioCtx = null; let voiceActive = false; let voiceMode = 'near'; const VOICE_SERVER = 'https://voicegrech.mkn8n.ru'; // Voice config const VC = { sampleRate: 16000, // 16kHz — sufficient for voice, saves 33% bandwidth frameMs: 20, // 20ms frames = 320 samples @ 16kHz samplesPerFrame: 320, // 16000 * 0.02 vadThreshold: 0.0005, // RMS threshold for voice detection vadHangover: 5, // 100ms hangover after speech ends jbufTargetMs: 80, // Target jitter: 80ms (was 200ms) jbufMinMs: 40, jbufMaxMs: 200, maxSpeakers: 6, voiceRadius: 600, opusBitrate: 16000, posUpdateMs: 200, // Update position every 200ms (was 500ms) }; // Codec state let voiceCodec = 'pcm'; // 'opus' or 'pcm' let voiceEncoder = null; // WebCodecs AudioEncoder let voiceSeq = 0; let voiceTimestamp = 0; let wasSpeaking = false; let silenceFrames = 0; let captureNode = null; let playbackNode = null; // Per-speaker map const remoteSpeakers = new Map(); // socketId → { jitterBuf, lastFrame, gain, panner, lowpass, decoder, codec, x, y, name, mode, speaking, lastActive } // ==================== WORKLET CODE (inline strings) ==================== const voiceCaptureWorkletCode = ` class VoiceCaptureProcessor extends AudioWorkletProcessor { constructor() { super(); this._buf = new Float32Array(320); // 20ms @ 16kHz this._pos = 0; this._speaking = false; this._silenceFrames = 0; this._hangover = 5; // 100ms this._vadThreshold = 0.0005; this._lastRms = 0; } process(inputs) { const input = inputs[0]; if (!input || !input[0]) return true; const ch = input[0]; let i = 0; while (i < ch.length) { const rem = this._buf.length - this._pos; const n = Math.min(rem, ch.length - i); this._buf.set(ch.subarray(i, i + n), this._pos); this._pos += n; i += n; if (this._pos >= this._buf.length) { this._processFrame(); this._pos = 0; } } return true; } _processFrame() { let sum = 0; for (let i = 0; i < this._buf.length; i++) sum += this._buf[i] * this._buf[i]; const rms = Math.sqrt(sum / this._buf.length); this._lastRms = rms; if (rms > this._vadThreshold) { this._speaking = true; this._silenceFrames = 0; } else { this._silenceFrames++; if (this._silenceFrames > this._hangover) this._speaking = false; } this.port.postMessage({ type: 'frame', samples: this._buf.slice(), speaking: this._speaking, rms: rms }); } } registerProcessor('voice-capture', VoiceCaptureProcessor); `; const voicePlaybackWorkletCode = ` class VoicePlaybackProcessor extends AudioWorkletProcessor { constructor() { super(); this.speakers = new Map(); // id → { ringBuf, readPos, writePos, ready, gain, pan, lastSample, active } this.RING_SIZE = 48000; // 3 seconds @ 16kHz this.port.onmessage = (e) => { const d = e.data; if (d.type === 'addSpeaker') { this.speakers.set(d.id, { ringBuf: new Float32Array(this.RING_SIZE), readPos: 0, writePos: 0, ready: 0, gain: d.gain || 1, pan: d.pan || 0, lastSample: 0, active: false, jbufTarget: 320 * 4, // ~80ms in samples fadeOut: 0 }); } else if (d.type === 'removeSpeaker') { this.speakers.delete(d.id); } else if (d.type === 'pushFrames') { const sp = this.speakers.get(d.id); if (!sp) return; const frames = d.samples; // Float32Array for (let i = 0; i < frames.length; i++) { sp.ringBuf[sp.writePos] = frames[i]; sp.writePos = (sp.writePos + 1) % this.RING_SIZE; if (sp.ready < this.RING_SIZE) sp.ready++; } sp.active = true; sp.fadeOut = 0; } else if (d.type === 'updateSpatial') { const sp = this.speakers.get(d.id); if (sp) { sp.gain = d.gain; sp.pan = d.pan; } } }; } process(inputs, outputs) { const output = outputs[0]; if (!output || !output[0]) return true; const left = output[0]; const right = output[1] || output[0]; // mono fallback // Clear output for (let i = 0; i < left.length; i++) { left[i] = 0; right[i] = 0; } for (const [id, sp] of this.speakers) { if (sp.ready < 1 && sp.active) { // Fade out over ~20ms (128 samples) sp.fadeOut++; if (sp.fadeOut > 128) { sp.active = false; continue; } const fade = 1 - (sp.fadeOut / 128); const g = sp.gain * fade; const lg = g * Math.cos((sp.pan + 1) * Math.PI / 4); const rg = g * Math.sin((sp.pan + 1) * Math.PI / 4); // Repeat last sample with fade for (let i = 0; i < left.length; i++) { left[i] += sp.lastSample * lg; right[i] += sp.lastSample * rg; } continue; } if (!sp.active && sp.ready < sp.jbufTarget) continue; // Wait for jitter buffer to fill if (!sp.active && sp.ready >= sp.jbufTarget) sp.active = true; if (!sp.active) continue; sp.fadeOut = 0; const g = sp.gain; // Constant-power pan law const lg = g * Math.cos((sp.pan + 1) * Math.PI / 4); const rg = g * Math.sin((sp.pan + 1) * Math.PI / 4); for (let i = 0; i < left.length; i++) { if (sp.ready > 0) { const s = sp.ringBuf[sp.readPos]; sp.lastSample = s; left[i] += s * lg; right[i] += s * rg; sp.readPos = (sp.readPos + 1) % this.RING_SIZE; sp.ready--; } else { // Underrun — fade from last sample sp.lastSample *= 0.85; left[i] += sp.lastSample * lg; right[i] += sp.lastSample * rg; } } } return true; } } registerProcessor('voice-playback', VoicePlaybackProcessor); `; // ==================== Opus ENCODE/DECODE ==================== async function initVoiceEncoder() { if (false && 'AudioEncoder' in window) { try { // Check if Opus is supported const support = await AudioEncoder.isConfigSupported({ codec: 'opus', sampleRate: VC.sampleRate, numberOfChannels: 1, bitrate: VC.opusBitrate }); if (!support.supported) throw new Error('Opus not supported'); voiceEncoder = new AudioEncoder({ output: (chunk) => { // chunk: EncodedAudioChunk with Opus data const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data); // Send as binary frame via Socket.IO console.log('[voice] TX Opus chunk, seq:', voiceSeq, 'size:', data.buffer.byteLength); voiceSocket.emit('voice_data', { codec: 'opus', data: data.buffer, seq: voiceSeq++, ts: chunk.timestamp, speaking: true }); }, error: (e) => { console.error('[voice] encoder error:', e); voiceCodec = 'pcm'; voiceEncoder = null; } }); await voiceEncoder.configure({ codec: 'opus', sampleRate: VC.sampleRate, numberOfChannels: 1, bitrate: VC.opusBitrate }); voiceCodec = 'opus'; console.log('[voice] WebCodecs Opus encoder initialized @', VC.opusBitrate, 'bps'); return; } catch (e) { console.warn('[voice] WebCodecs Opus not available, PCM fallback:', e.message); } } voiceCodec = 'pcm'; console.log('[voice] Using PCM @', VC.sampleRate, 'Hz'); } async function initDecoderForSpeaker(speakerId, codec) { const sp = remoteSpeakers.get(speakerId); if (!sp) return; if (codec === 'opus' && 'AudioDecoder' in window) { try { const decoder = new AudioDecoder({ output: (audioData) => { const samples = new Float32Array(audioData.numberOfFrames); audioData.copyTo(samples, { planeIndex: 0 }); audioData.close(); // Push decoded PCM to worklet if (playbackNode) { playbackNode.port.postMessage({ type: 'pushFrames', id: speakerId, samples: samples }); } }, error: (e) => { console.error('[voice] decoder error for', speakerId, e); sp.codec = 'pcm'; // Fallback to PCM } }); await decoder.configure({ codec: 'opus', sampleRate: VC.sampleRate, numberOfChannels: 1 }); sp.decoder = decoder; sp.codec = 'opus'; console.log('[voice] Opus decoder initialized for', speakerId); } catch (e) { console.warn('[voice] Opus decoder failed, PCM fallback:', e.message); sp.codec = 'pcm'; } } else { sp.codec = 'pcm'; } } // ==================== RemoteSpeaker helper ==================== function createRemoteSpeaker(socketId, name, codec) { const sp = { id: socketId, name: name || '???', codec: codec || 'pcm', decoder: null, x: 0, y: 0, mode: 'near', speaking: false, lastActive: Date.now(), // Audio nodes per speaker (managed on main thread for smooth ramps) gainNode: audioCtx.createGain(), pannerNode: audioCtx.createStereoPanner(), lowpassNode: audioCtx.createBiquadFilter(), }; // lowpass for distance muffling sp.lowpassNode.type = 'lowpass'; sp.lowpassNode.frequency.value = 4000; sp.lowpassNode.Q.value = 0.7; // Connect: lowpass → panner → gain → (connected to mixer later) sp.lowpassNode.connect(sp.pannerNode); sp.pannerNode.connect(sp.gainNode); sp.gainNode.gain.value = 1.0; // Start with volume (worklet needs non-zero gain) remoteSpeakers.set(socketId, sp); // Register speaker in playback worklet if (playbackNode) { playbackNode.port.postMessage({ type: 'addSpeaker', id: socketId, gain: 1.0, pan: 0 }); console.log('[voice] addSpeaker posted to worklet, id:', socketId?.substring(0,8), 'gain:', sp.gainNode.gain.value); } initDecoderForSpeaker(socketId, codec); return sp; } function removeRemoteSpeaker(socketId) { const sp = remoteSpeakers.get(socketId); if (!sp) return; try { sp.gainNode.disconnect(); } catch(e) {} try { sp.pannerNode.disconnect(); } catch(e) {} try { sp.lowpassNode.disconnect(); } catch(e) {} if (sp.decoder) { try { sp.decoder.close(); } catch(e) {} } remoteSpeakers.delete(socketId); if (playbackNode) { playbackNode.port.postMessage({ type: 'removeSpeaker', id: socketId }); } } function updateSpeakerSpatial(sp) { if (!audioCtx) return; // Calculate distance-based audio const dx = sp.x - player.x; const dy = sp.y - player.y; const dist = Math.sqrt(dx * dx + dy * dy); const now = audioCtx.currentTime; const rampTime = 0.05; // 50ms smooth ramp if (sp.mode === 'world' && voiceMode === 'world') { // World mode: full volume, no panning, no distance filter sp.gainNode.gain.cancelScheduledValues(now); sp.gainNode.gain.linearRampToValueAtTime(1.0, now + rampTime); sp.pannerNode.pan.cancelScheduledValues(now); sp.pannerNode.pan.linearRampToValueAtTime(0, now + rampTime); sp.lowpassNode.frequency.cancelScheduledValues(now); sp.lowpassNode.frequency.linearRampToValueAtTime(4000, now + rampTime); } else if (dist > VC.voiceRadius) { // Out of range sp.gainNode.gain.cancelScheduledValues(now); sp.gainNode.gain.linearRampToValueAtTime(0, now + rampTime); } else { // Near mode with spatial audio const volume = 1 / (1 + dist / 150); // Perceptual falloff const pan = Math.max(-1, Math.min(1, dx / 300)); // Stereo pan based on X const cutoff = 4000 - (3800 * (dist / VC.voiceRadius)); // Muffle at distance sp.gainNode.gain.cancelScheduledValues(now); sp.gainNode.gain.linearRampToValueAtTime(volume, now + rampTime); sp.pannerNode.pan.cancelScheduledValues(now); sp.pannerNode.pan.linearRampToValueAtTime(pan, now + rampTime); sp.lowpassNode.frequency.cancelScheduledValues(now); sp.lowpassNode.frequency.linearRampToValueAtTime(Math.max(200, cutoff), now + rampTime); } // Update worklet spatial params if (playbackNode) { const currentGain = sp.gainNode.gain.value; const currentPan = sp.pannerNode.pan.value; if (currentGain > 0.01) console.log('[voice] updateSpatial, id:', sp.id?.substring(0,8), 'gain:', currentGain.toFixed(3), 'pan:', currentPan.toFixed(3)); playbackNode.port.postMessage({ type: 'updateSpatial', id: sp.id, gain: currentGain, pan: currentPan }); } } // ==================== UI ==================== 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 }); } // Update spatial for all speakers for (const [id, sp] of remoteSpeakers) updateSpeakerSpatial(sp); }; // Speaking indicators per players 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;max-width:200px;line-height:1.4;'; document.querySelector('.ui').appendChild(speakingIndicator); let speakingTimeouts = new Map(); // socketId → timeout // Codec indicator const codecIndicator = document.createElement('div'); codecIndicator.style.cssText = 'position:absolute;top:180px;right:10px;z-index:200;font-size:10px;color:rgba(255,255,255,0.5);pointer-events:none;'; codecIndicator.textContent = ''; document.querySelector('.ui').appendChild(codecIndicator); // ==================== VOICE ON/OFF ==================== voiceBtn.onclick = async () => { if (voiceActive) { // === DISABLE === voiceActive = false; voiceBtn.innerHTML = '🎤/'; voiceBtn.style.background = '#555'; codecIndicator.textContent = ''; // Clean up capture if (captureNode) { captureNode.disconnect(); captureNode = null; } if (voiceStream) { voiceStream.getTracks().forEach(t => t.stop()); voiceStream = null; } // Clean up playback if (playbackNode) { playbackNode.disconnect(); playbackNode = null; } // Clean up per-speaker nodes for (const [id, sp] of remoteSpeakers) { try { sp.gainNode.disconnect(); } catch(e) {} try { sp.pannerNode.disconnect(); } catch(e) {} try { sp.lowpassNode.disconnect(); } catch(e) {} if (sp.decoder) { try { sp.decoder.close(); } catch(e) {} } } remoteSpeakers.clear(); // Clean up encoder if (voiceEncoder) { try { voiceEncoder.close(); } catch(e) {} voiceEncoder = null; } if (audioCtx) { audioCtx.close(); audioCtx = null; } if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; } speakingIndicator.style.display = 'none'; return; } // === ENABLE === try { voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, echoCancellationType: 'browser', noiseSuppressionType: 'browser', sampleRate: { ideal: VC.sampleRate }, channelCount: { ideal: 1 } } }); audioCtx = new AudioContext({ sampleRate: VC.sampleRate }); if (audioCtx.state === 'suspended') await audioCtx.resume(); console.log('[voice] AudioContext:', audioCtx.state, 'sampleRate:', audioCtx.sampleRate); // Register worklets via Blob URLs const captureURL = URL.createObjectURL(new Blob([voiceCaptureWorkletCode], { type: 'application/javascript' })); const playbackURL = URL.createObjectURL(new Blob([voicePlaybackWorkletCode], { type: 'application/javascript' })); await audioCtx.audioWorklet.addModule(captureURL); await audioCtx.audioWorklet.addModule(playbackURL); URL.revokeObjectURL(captureURL); URL.revokeObjectURL(playbackURL); console.log('[voice] AudioWorklets registered'); // Connect mic → capture worklet const micSource = audioCtx.createMediaStreamSource(voiceStream); captureNode = new AudioWorkletNode(audioCtx, 'voice-capture'); micSource.connect(captureNode); // Do NOT connect captureNode to destination — we don't hear ourselves // Connect playback worklet → per-speaker gain nodes → destination playbackNode = new AudioWorkletNode(audioCtx, 'voice-playback', { numberOfOutputs: 1, outputChannelCount: [2] }); playbackNode.connect(audioCtx.destination); // Per-speaker nodes will connect between playback worklet output and destination // Actually: playback worklet mixes internally → stereo output → destination // Per-speaker Web Audio nodes (lowpass, pan, gain) used for spatial UPDATES only (values sent to worklet via messages) // The actual mixing happens inside the worklet // Connect to voice server FIRST so capture frames have a socket voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] }); // Handle capture frames voiceSeq = 0; voiceTimestamp = 0; wasSpeaking = false; voiceActive = true; // Enable capture BEFORE onmessage handler let _frameCount = 0; captureNode.port.onmessage = (e) => { const { type, samples, speaking, rms } = e.data; if (type !== 'frame') return; _frameCount++; if (_frameCount <= 5 || _frameCount % 100 === 0) { console.log('[voice] capture frame #', _frameCount, 'rms:', rms?.toFixed(4), 'speaking:', speaking, 'voiceActive:', voiceActive, 'socket:', !!voiceSocket, 'connected:', voiceSocket?.connected, 'codec:', voiceCodec); } if (!voiceActive || !voiceSocket || !voiceSocket.connected) { return; } voiceTimestamp += VC.samplesPerFrame; // 320 samples * (1/16000) = 20ms per frame if (!speaking && !wasSpeaking) { // Complete silence — don't transmit silenceFrames++; return; } if (voiceCodec === 'opus' && voiceEncoder) { // Encode with Opus via WebCodecs try { const audioData = new AudioData({ format: 'float32-planar', sampleRate: VC.sampleRate, numberOfFrames: samples.length, numberOfChannels: 1, timestamp: voiceTimestamp * (1000000 / VC.sampleRate), // microseconds data: samples }); voiceEncoder.encode(audioData); audioData.close(); } catch (err) { // Fallback: send as PCM sendPCMFrame(samples, speaking, wasSpeaking); } } else { sendPCMFrame(samples, speaking, wasSpeaking); } wasSpeaking = speaking; silenceFrames = 0; }; function sendPCMFrame(samples, isSpeaking, wasSp) { const int16 = new Int16Array(samples.length); for (let i = 0; i < samples.length; i++) { const s = Math.max(-1, Math.min(1, samples[i])); int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; } voiceSocket.emit('voice_data', { codec: 'pcm', data: int16.buffer, seq: voiceSeq++, ts: performance.now(), speaking: isSpeaking || wasSp }); } // Initialize encoder await initVoiceEncoder(); codecIndicator.textContent = voiceCodec === 'opus' ? '🔊 Opus' : '🔊 PCM'; // Voice socket already created above voiceSocket.on('connect', () => { console.log('[voice] Connected, id:', voiceSocket.id, 'codec:', voiceCodec); voiceSocket.emit('voice_join', { world_id: worldId, x: player.x, y: player.y, name: playerName || 'Игрок', mode: voiceMode, codec: voiceCodec }); }); voiceSocket.on('connect_error', (err) => { console.error('[voice] Connect error:', err.message); }); voiceSocket.on('voice_in', (payload) => { console.log('[voice] RX voice_in, codec:', payload.codec, 'dataSize:', payload.data?.byteLength || payload.data?.length || 'N/A', 'from:', payload.meta?.from?.substring(0,8)); const { data, meta } = payload; if (!audioCtx || audioCtx.state === 'closed') { console.warn('[voice] audioCtx missing/closed'); return; } let sp = remoteSpeakers.get(meta.from); if (!sp) { // New speaker — create per-speaker audio nodes sp = createRemoteSpeaker(meta.from, meta.name, meta.codec || 'pcm'); } // Update position sp.x = meta.x || 0; sp.y = meta.y || 0; sp.mode = meta.mode || 'near'; sp.lastActive = Date.now(); updateSpeakerSpatial(sp); // Decode and push to worklet if ((meta.codec || 'pcm') === 'opus' && sp.decoder) { // Opus decode try { const uint8 = new Uint8Array(data); const chunk = new EncodedAudioChunk({ type: 'key', // Opus frames are self-decodable timestamp: performance.now() * 1000, // microseconds data: uint8 }); sp.decoder.decode(chunk); } catch (err) { // Opus decode failed, try PCM fallback decodeAndPushPCM(sp.id, data); } } else { decodeAndPushPCM(sp.id, data); } // Update speaking indicator sp.speaking = true; speakingIndicator.style.display = 'block'; speakingIndicator.textContent = '🔊 ' + sp.name; clearTimeout(speakingTimeouts.get(meta.from)); speakingTimeouts.set(meta.from, setTimeout(() => { sp.speaking = false; // Check if anyone is still speaking let anyoneSpeaking = false; for (const [, s] of remoteSpeakers) { if (s.speaking) { anyoneSpeaking = true; break; } } if (!anyoneSpeaking) speakingIndicator.style.display = 'none'; speakingTimeouts.delete(meta.from); }, 1500)); }); voiceSocket.on('voice_leave', (data) => { removeRemoteSpeaker(data.id); }); voiceSocket.on('disconnect', () => { console.log('[voice] Disconnected'); // Clear all speakers on disconnect for (const [id] of remoteSpeakers) removeRemoteSpeaker(id); }); // voiceActive already set to true above voiceBtn.textContent = '🎤'; voiceBtn.style.background = '#2ecc71'; console.log('[voice] Voice chat ACTIVE, codec:', voiceCodec); } catch(e) { console.error('[voice] Error:', e); voiceBtn.style.background = '#e74c3c'; } }; // ==================== PCM decode helper ==================== function decodeAndPushPCM(speakerId, buffer) { console.log('[voice] decodeAndPushPCM, id:', speakerId?.substring(0,8), 'bufSize:', buffer?.byteLength || buffer?.length || 'N/A'); const int16 = new Int16Array(buffer); const float32 = new Float32Array(int16.length); for (let i = 0; i < int16.length; i++) { float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF); } if (playbackNode) { playbackNode.port.postMessage({ type: 'pushFrames', id: speakerId, samples: float32 }); } } // ==================== Voice position update ==================== 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'); // Звук клика по инвентарю if(selected === id) { // Повторный клик — снимаем выбор, возвращаем к первому блоку selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt'; } else { 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'); // Звук клика по инвентарю if(selected === id) { selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt'; } else { selected = id; recentItems = recentItems.filter(item => item !== id); recentItems.unshift(id); recentItems = recentItems.slice(0, 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(); } if(id === 'gold_armor' && inv.gold_armor > 0) { if(player.equippedArmor === 'gold_armor') { player.equippedArmor = null; player.armor = 0; } else { player.equippedArmor = 'gold_armor'; player.armor = ITEMS['gold_armor'].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 reqLv = r.requiredLevel || 1; const locked = player.level < reqLv; 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}` + (locked ? ` (Lv.${reqLv})` : ''); if(locked) { nm.style.color = '#888'; nm.style.textDecoration = 'line-through'; } 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(' '); if(locked) cs.style.color = '#666'; info.appendChild(nm); info.appendChild(cs); const btn = document.createElement('button'); btn.className='rcraft'; btn.textContent='Создать'; btn.disabled = !canCraft(r) || locked; if(locked) btn.title = `Требуется уровень ${reqLv}`; btn.onclick = () => { if(!canCraft(r) || locked) 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 = !inventoryOpen; inventoryPanel.style.display = inventoryOpen ? 'block' : 'none'; if(inventoryOpen) { 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, slowTimer: 0, // яд скорпиона — замедление fallStartY: 0, lastStepTime: 0, sleeping: false, inBoat: false, armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня) equippedArmor: null, // Тип надетой брони xp: 0, level: 1 }; // Сохраняем начальную позицию для возрождения const spawnPoint = { x: 6*TILE, y: 0*TILE }; // Система дропов с мобов const drops = []; // {x, y, vy, item, qty, age} let levelUpPopup = null; // {text, timer} function xpForLevel(lv) { if (lv <= 1) return 0; const thresholds = [0, 50, 150, 300, 500, 800, 1200, 1700, 2300, 3000]; if (lv - 1 < thresholds.length) return thresholds[lv - 1]; return Math.floor(3000 + (lv - 10) * (lv - 10) * 50 + (lv - 10) * 200); } function getMobLoot(kind) { const table = { chicken: [{item:'chicken_meat',min:1,max:2,chance:1},{item:'feather',min:0,max:1,chance:0.5}], pig: [{item:'meat',min:1,max:2,chance:1}], zombie: [{item:'meat',min:0,max:1,chance:0.5},{item:'iron_ingot',min:0,max:1,chance:0.3}], skeleton: [{item:'arrow',min:2,max:4,chance:1},{item:'bone',min:0,max:2,chance:0.6},{item:'bow',min:1,max:1,chance:0.15}], creeper: [{item:'gunpowder',min:1,max:2,chance:1}], scorpion: [{item:'scorpion_stinger',min:0,max:1,chance:0.4}], polar_bear:[{item:'polar_fur',min:1,max:2,chance:0.8},{item:'meat',min:1,max:2,chance:1}], slime: [{item:'slime_ball',min:1,max:2,chance:1}], eagle: [{item:'eagle_feather',min:1,max:2,chance:0.7}] }; return table[kind] || []; } function getMobXP(kind) { const xpTable = { chicken:3, pig:5, zombie:10, skeleton:12, creeper:15, scorpion:8, polar_bear:20, slime:5, eagle:15 }; return xpTable[kind] || 0; } function spawnDrops(mx, my, kind) { const loot = getMobLoot(kind); for (const entry of loot) { if (Math.random() > entry.chance) continue; const qty = entry.min + Math.floor(Math.random() * (entry.max - entry.min + 1)); if (qty <= 0) continue; drops.push({ x: mx + (Math.random() - 0.5) * 20, y: my + (Math.random() - 0.5) * 10, vy: -1 - Math.random() * 2, item: entry.item, qty: qty, age: 0, xpValue: getMobXP(kind) }); } } const LEVEL_UNLOCKS = { 2: 'Каменные инструменты, Лодка', 3: 'Железные инструменты, Печь', 4: 'Лук и стрелы', 5: 'Железная броня', 6: 'Золотая броня', 7: 'Алмазные инструменты', 8: 'TNT' }; function grantXP(amount) { player.xp += amount; while (player.xp >= xpForLevel(player.level + 1)) { player.level++; const unlock = LEVEL_UNLOCKS[player.level] || ''; levelUpPopup = { text: '⭐ Уровень ' + player.level + '!' + (unlock ? ' ' + unlock : ''), timer: 240 }; } } function pickupDrops() { for (let i = drops.length - 1; i >= 0; i--) { const d = drops[i]; const dx = player.x + player.w/2 - d.x; const dy = player.y + player.h/2 - d.y; if (dx*dx + dy*dy < 30*30) { if (!inv[d.item]) inv[d.item] = 0; inv[d.item] += d.qty; if (d.xpValue) grantXP(Math.ceil(d.xpValue * 0.3)); drops.splice(i, 1); rebuildHotbar(); } } } function drawDrops(ctx) { for (let i = drops.length - 1; i >= 0; i--) { const d = drops[i]; d.age++; if (d.age > 3600) { drops.splice(i, 1); continue; } // 60 sec at 60fps // Bounce animation const bounce = Math.abs(Math.sin(d.age * 0.05)) * 6; const dy = d.y - bounce; const sx = d.x - camX; const sy = dy - camY; // Skip if off screen if (sx < -40 || sx > W + 40 || sy < -40 || sy > H + 40) continue; // Glow effect ctx.save(); ctx.globalAlpha = 0.3; ctx.fillStyle = '#ffff00'; ctx.beginPath(); ctx.arc(sx, sy, 14, 0, Math.PI*2); ctx.fill(); ctx.restore(); // Item icon ctx.save(); ctx.font = '16px system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const itemDef = ITEMS[d.item]; const icon = itemDef ? itemDef.icon : '🎁'; const label = d.qty > 1 ? icon + '×' + d.qty : icon; ctx.fillText(label, sx, sy); ctx.restore(); } } function drawLevelUpPopup(ctx) { if (!levelUpPopup) return; levelUpPopup.timer--; if (levelUpPopup.timer <= 0) { levelUpPopup = null; return; } const alpha = Math.min(1, levelUpPopup.timer / 60); ctx.save(); ctx.globalAlpha = alpha; ctx.fillStyle = '#FFD700'; ctx.font = 'bold 36px system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.strokeStyle = '#000'; ctx.lineWidth = 3; ctx.strokeText(levelUpPopup.text, W/2, H/2 - 60); ctx.fillText(levelUpPopup.text, W/2, H/2 - 60); ctx.restore(); } // Система сохранения игры (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, xp: player.xp, level: player.level }, temperature: player.temperature, 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} байт)`); // TG: also save to server if (tgUser && tgUser.id) { try { fetch(SERVER_URL + '/api/tg/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tg_id: tgUser.id, save_data: JSON.stringify(saveData), world_id: worldId, player_name: playerName }) }).catch(e2 => console.warn('[TG] Save to server failed:', e2)); } catch(tgE) {} } } catch(e){ console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e); // Если localStorage недоступен, используем in-memory fallback inMemorySave = saveData; console.log(`Игра сохранена в памяти (размер: ${saveSize} байт)`); } } function loadGame(){ return new Promise(async (resolve, reject) => { // TG: try server save first if (tgUser && tgUser.id && tgSaveLoaded) { try { const resp = await fetch(SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id); const data = await resp.json(); if (data.ok && data.save_data) { const parsed = JSON.parse(data.save_data); console.log('[TG] Loaded save from server, HP:', parsed.player?.hp); tgSaveLoaded = parsed; // store for later use resolve(parsed); return; } } catch(e) { console.warn('[TG] Load from server failed:', e); } } // Пробуем 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){ player.temperature = saveData.player.temperature !== undefined ? saveData.player.temperature : 15; 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; player.xp = saveData.player.xp || 0; player.level = saveData.player.level || 1; // Обновляем 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 const snowflakes = []; // снежинки для тундры const MAX_SNOWFLAKES = 150; const raindrops = []; const MAX_RAINDROPS = 200; // ==================== РОСТ КУЛЬТУР ==================== const growthTimers = {}; // ключ: "gx,gy" → { stage:0-3, growTimer:X } // Старая функция заменена — теперь погода через биомы (см. weatherState выше) // updateWeather(dt) вызывается из основного цикла — биом-зависимая // Интеграция: определяем isRaining из weatherState для визуализации function syncWeatherVisual() { isRaining = (weatherState.type === 'rain' || weatherState.type === 'storm'); if (weatherState.type === 'clear') { rainIntensity *= 0.95; // плавное затухание if (rainIntensity < 0.01) rainIntensity = 0; } } function updateRain(dt) { syncWeatherVisual(); // Дождь if ((weatherState.type === 'rain' || weatherState.type === 'storm') && rainIntensity < weatherState.intensity * 0.6) { rainIntensity += dt * 0.3; } else if (weatherState.type === 'clear' || weatherState.type === 'snow' || weatherState.type === 'fog') { rainIntensity = Math.max(0, rainIntensity - dt * 0.5); } if (rainIntensity < 0.01) { raindrops.length = 0; } else { const spawnRate = Math.floor(rainIntensity * 80 * dt); 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); } } // Снег if (weatherState.type === 'snow') { const spawnRate = Math.floor(weatherState.intensity * 30 * dt); for (let i = 0; i < spawnRate && snowflakes.length < MAX_SNOWFLAKES; i++) { snowflakes.push({ x: camX + Math.random() * W, y: camY - 10, vy: 40 + Math.random() * 60, vx: (Math.random() - 0.5) * 30, size: 2 + Math.random() * 3 }); } } for (let i = snowflakes.length - 1; i >= 0; i--) { const s = snowflakes[i]; s.y += s.vy * dt; s.x += s.vx * dt + Math.sin(s.y * 0.02) * 10 * dt; if (s.y > camY + H + 20 || weatherState.type !== 'snow') snowflakes.splice(i, 1); } } function drawRain() { // Дождь if (raindrops.length > 0) { ctx.save(); ctx.strokeStyle = (weatherState.type === 'storm') ? 'rgba(174,194,224,0.7)' : '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(); } // Снег if (snowflakes.length > 0) { ctx.save(); ctx.fillStyle = 'rgba(255,255,255,0.8)'; for (const s of snowflakes) { ctx.beginPath(); ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } // Гроза — вспышка if (weatherState.type === 'storm' && Math.random() < 0.003) { ctx.save(); ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.fillRect(camX, camY, W, H); ctx.restore(); } // Туман — серый оверлей if (weatherState.type === 'fog') { ctx.save(); ctx.fillStyle = 'rgba(200,200,200,0.4)'; ctx.fillRect(camX, camY, W, H); 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'); spawnDrops(m.x + m.w/2, m.y + m.h/2, m.kind); // 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; } //Посадка семян на грядку if(b && b.t === 'farmland' && mode()==='build'){ const seedMap = { wheat: 'wheat_stage0', carrot: 'carrot_stage0', potato: 'potato_stage0' }; if(seedMap[selected] && inv[selected] > 0){ inv[selected]--; const cropType = seedMap[selected]; setBlock(gx, gy-1, cropType); growthTimers[gx+','+(gy-1)] = { stage:0, growTimer: 10+Math.random()*5 }; sendBlockChange(gx, gy-1, cropType, 'add'); playSound('cloth1'); rebuildHotbar(); return; } } // Мотыга — превращает grass/dirt в farmland (в любом режиме) if(selected === 'hoe' && inv.hoe > 0 && b){ if(b.t === 'grass' || b.t === 'dirt'){ setBlock(gx, gy, 'farmland'); sendBlockChange(gx, gy, b.t, 'remove'); sendBlockChange(gx, gy, 'farmland', 'add'); useTool('hoe'); playSound('cloth1'); 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) return; // Клик на урожайную культуру — сбор if(BLOCKS[b.t].harvestable){ const hInfo = BLOCKS[b.t]; inv[hInfo.harvestItem] = (inv[hInfo.harvestItem]||0) + hInfo.harvestQty; removeBlock(gx, gy); sendBlockChange(gx, gy, b.t, 'remove'); delete growthTimers[gx+','+gy]; playSound('cloth1'); rebuildHotbar(); return; } if(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; } }); // ==================== БИОМЫ ==================== const BIOMES = { plains: { name:'Равнина', surface:'grass', subsurface:'dirt', trees:true, flowers:true, treeChance:0.12 }, desert: { name:'Пустыня', surface:'sand', subsurface:'sand', trees:false, flowers:false, treeChance:0 }, tundra: { name:'Тундра', surface:'snow', subsurface:'dirt', trees:true, flowers:false, treeChance:0.06 }, swamp: { name:'Болото', surface:'moss', subsurface:'dirt', trees:true, flowers:false, treeChance:0.10 }, mountains:{ name:'Горы', surface:'stone', subsurface:'stone', trees:false, flowers:false, treeChance:0 } }; function getBiome(gx) { const temp = Math.sin(gx*0.003 + worldSeed*0.01)*0.5 + Math.sin(gx*0.007 + worldSeed*0.02)*0.3 + 0.5; const humid = Math.sin(gx*0.004 + worldSeed*0.015 + 1000)*0.5 + Math.cos(gx*0.006 + worldSeed*0.02 + 2000)*0.3 + 0.5; const mtVal = Math.sin(gx*0.001 + worldSeed*0.005)*0.5 + 0.5; if (temp > 0.7) return 'desert'; if (temp < 0.3) return 'tundra'; if (humid > 0.7 && temp >= 0.3 && temp <= 0.7) return 'swamp'; if (temp >= 0.5 && temp <= 0.7 && mtVal > 0.75) return 'mountains'; return 'plains'; } const biomeCache = {}; function getCachedBiome(gx) { const chunk = Math.floor(gx / 8); // cache per 8-tile chunk for smoother biomes if (biomeCache[chunk] === undefined) biomeCache[chunk] = getBiome(chunk * 8); return biomeCache[chunk]; } // ==================== ПОГОДА ==================== const weatherState = { type: 'clear', intensity: 0, timer: 0, duration: 180, nextChange: 120 + Math.random()*180 }; const BIOME_WEATHER = { plains: { clear:0.50, rain:0.30, storm:0.10, snow:0, fog:0.10 }, desert: { clear:0.80, rain:0.05, storm:0, snow:0, fog:0.15 }, tundra: { clear:0.20, rain:0, storm:0.10, snow:0.60, fog:0.10 }, swamp: { clear:0.20, rain:0.30, storm:0.10, snow:0, fog:0.40 }, mountains:{ clear:0.40, rain:0.30, storm:0.10, snow:0.10, fog:0.10 } }; function updateWeather(dt) { weatherState.timer += dt; if (weatherState.timer >= weatherState.nextChange) { weatherState.timer = 0; weatherState.nextChange = 60 + Math.random() * 240; const biome = getCachedBiome(Math.floor(player.x / TILE)); const probs = BIOME_WEATHER[biome] || BIOME_WEATHER.plains; const r = Math.random(); let cum = 0; if ((cum += probs.clear) > r) { weatherState.type = 'clear'; } else if ((cum += probs.rain) > r) { weatherState.type = 'rain'; } else if ((cum += probs.storm) > r) { weatherState.type = 'storm'; } else if ((cum += probs.snow) > r) { weatherState.type = 'snow'; } else { weatherState.type = 'fog'; } weatherState.duration = 60 + Math.random() * 300; } // Intensity interpolation const target = (weatherState.type === 'clear') ? 0 : 1; weatherState.intensity += (target - weatherState.intensity) * dt * 0.5; } function getWeatherSpeedMultiplier() { if (weatherState.type === 'rain') return 0.85; if (weatherState.type === 'snow') return 0.7; if (weatherState.type === 'storm') return 0.85; return 1; } function isOutdoorLight(lx, ly) { // Check if position is outdoors (no block above) const aboveGy = Math.floor(ly / TILE) - 1; const aboveGx = Math.floor(lx / TILE); const above = getBlock(aboveGx, aboveGy); return !above || !BLOCKS[above.t]?.solid; } // ==================== СТРУКТУРЫ МИРА ==================== function placeStructure(startGx, startGy, pattern) { for (let dy = 0; dy < pattern.length; dy++) { for (let dx = 0; dx < pattern[dy].length; dx++) { const bt = pattern[dy][dx]; if (bt && bt !== 'air') { setBlock(startGx + dx, startGy + dy, bt); } } } } // Пирамида в пустыне const PYRAMID_PATTERN = [ ['sand','sand','sand','sand','sand','sand','sand'], ['sand','stone','stone','stone','stone','stone','sand'], ['sand','stone','air','air','air','stone','sand'], ['sand','stone','air','air','air','stone','sand'], ['sand','stone','air','air','air','stone','sand'], ['sand','sand','stone','stone','stone','sand','sand'] ]; // Дом в равнине const HOUSE_PATTERN = [ ['air','planks','planks','planks','planks','air'], ['planks','air','air','air','air','planks'], ['planks','air','torch','air','air','planks'], ['planks','air','air','air','air','planks'], ['planks','planks','air','planks','planks','planks'] ]; // Хижина в болоте const HUT_PATTERN = [ ['air','wood','wood','wood','air'], ['wood','air','air','air','wood'], ['wood','air','torch','air','wood'], ['moss','moss','air','moss','moss'] ]; // ==================== ГЕНЕРАЦИЯ ==================== const generated = new Set(); // gx already generated function surfaceGyAt(gx) { const biome = getCachedBiome(gx); // Base noise (same for all biomes) 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; let h; switch(biome) { case 'desert': h = Math.floor(SEA_GY - 4 + n3*0.3 + n4*0.5); // flatter, slightly higher break; case 'tundra': h = Math.floor(SEA_GY - 6 + n2*0.5 + n3*0.4 + n5*0.3); // gentle rolling break; case 'swamp': h = Math.floor(SEA_GY - 2 + n3*0.2 + n4*0.3); // very flat, near sea level h = Math.max(h, SEA_GY - 3); // never too deep break; case 'mountains': h = Math.floor(SEA_GY - 15 + n1*1.5 + n2*1.2 + n3); // tall peaks break; default: // plains h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5); } return h; } function genColumn(gx) { if(generated.has(gx)) return; if(worldSeed == null) return; // wait for server seed in multiplayer generated.add(gx); const sgy = surfaceGyAt(gx); const biome = getCachedBiome(gx); // === Вода и поверхность === if(sgy > SEA_GY) { // ниже уровня моря — заливаем водой for(let gy = SEA_GY; gy < sgy; gy++) { const blockType = (biome === 'swamp' && gy >= SEA_GY - 1) ? 'swamp_water' : 'water'; setBlock(gx, gy, blockType); } setBlock(gx, sgy, 'sand'); } else { // поверхность const b = BIOMES[biome]; setBlock(gx, sgy, b.surface); // болото: случайные лужи болотной воды if(biome === 'swamp' && seededRandom(gx*3, sgy) < 0.15) { setBlock(gx, sgy-1, 'swamp_water'); } // тундра: лёд на воде рядом if(biome === 'tundra' && sgy === SEA_GY && seededRandom(gx, SEA_GY-1) < 0.3) { setBlock(gx, SEA_GY-1, 'ice'); } } // === Подповерхностные слои === for(let gy = sgy+1; gy <= BEDROCK_GY; gy++) { if(gy === BEDROCK_GY) { setBlock(gx,gy,'bedrock'); continue; } let t = BIOMES[biome].subsurface; // глубже — камень if(gy > sgy + 3) t = 'stone'; // пустыня: sand глубже if(biome === 'desert' && gy <= sgy + 6) t = 'sand'; // болото: глина ближе к поверхности if(biome === 'swamp' && gy <= sgy + 2 && seededRandom(gx, gy) < 0.3) t = 'clay'; // горы: gravel if(biome === 'mountains' && gy > sgy + 4 && seededRandom(gx, gy) < 0.12) 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 b = BIOMES[biome]; const top = getBlock(gx, sgy); // цветы (только plain) if(biome === 'plains' && top && top.t === 'grass' && seededRandom(gx, sgy-1) < 0.10) { setBlock(gx, sgy-1, 'flower'); } // деревья if(b.trees && seededRandom(gx*7, sgy-2) < b.treeChance) { if(biome === 'tundra') { // ёлки (треугольные, 3-5 высоты) const th = 3 + Math.floor(seededRandom(gx, sgy) * 3); for(let i = 0; i < th; i++) setBlock(gx, sgy-1-i, 'wood'); // крона (треугольник) for(let row = 0; row < th; row++) { const w = Math.min(row + 1, 2); for(let dx = -w; dx <= w; dx++) { const ly = sgy-1-th+row; if(ly >= 0) setBlock(gx+dx, ly, 'spruce_leaves'); } } } else if(biome === 'swamp') { // болотное дерево (короче, с мхом) setBlock(gx, sgy-1, 'wood'); setBlock(gx, sgy-2, 'moss'); setBlock(gx-1, sgy-2, 'leaves'); setBlock(gx+1, sgy-2, 'leaves'); } else { // обычное дерево (plains) 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'); } } // кактусы (пустыня) if(biome === 'desert' && seededRandom(gx*11, sgy) < 0.04) { const ch = 1 + Math.floor(seededRandom(gx, sgy+1) * 2); for(let i = 0; i < ch; i++) setBlock(gx, sgy-1-i, 'cactus'); } // грибы (болото) if(biome === 'swamp' && seededRandom(gx*13, sgy) < 0.06) { setBlock(gx, sgy-1, 'mushroom'); } // сухие кусты (пустыня) if(biome === 'desert' && seededRandom(gx*17, sgy) < 0.05) { setBlock(gx, sgy-1, 'dead_bush'); } // === Структуры мира === // Пирамида в пустыне if(biome === 'desert' && ((gx % 200 + 200) % 200) === 47 && sgy < SEA_GY && sgy > SEA_GY - 5) { placeStructure(gx, sgy - 5, PYRAMID_PATTERN); } // Дом в равнине if(biome === 'plains' && ((gx % 150 + 150) % 150) === 33 && sgy < SEA_GY) { placeStructure(gx, sgy - 4, HOUSE_PATTERN); } // Хижина в болоте if(biome === 'swamp' && ((gx % 180 + 180) % 180) === 55 && sgy < SEA_GY) { placeStructure(gx, sgy - 3, HUT_PATTERN); } // Применяем серверные оверрайды для этой колонны 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 = 1.5 + (Math.sin(now/200)+1)*1; 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 if(m.kind==='scorpion') { // Скорпион — бежит к игроку, яд (замедление) const dir = Math.sign((player.x) - m.x); m.vx = dir * m.speed; if(m.inWater && Math.random()<0.06) m.vy = -260; // Яд при касании — замедление на 3 сек 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(8); player.hp -= damage; player.invuln = 0.8; player.slowTimer = 3; // замедление player.vx += dir*300; player.vy -= 200; playSound('hit1'); } } else if(m.kind==='polar_bear') { // Белый медведь — нейтрален, атакует если ударили (hostile пока нет, атакует через proximity) m.aiT -= dt; if(m.aiT <= 0){ m.aiT = 2.0 + Math.random()*3; m.dir = Math.random()<0.5 ? -1 : 1; if(Math.random()<0.3) m.dir = 0; } m.vx = m.dir * m.speed; if(m.inWater) m.vy = -120; } else if(m.kind==='slime') { // Слизь — прыгает к игроку const dir = Math.sign((player.x+player.w/2) - (m.x+m.w/2)); m.aiT -= dt; if(m.aiT <= 0){ m.aiT = 1.5 + Math.random()*1.5; m.dir = dir; // Прыжок m.vy = -200; } m.vx = m.dir * m.speed; } else if(m.kind==='eagle') { // Орёл — летает, атакует пикированием 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 dist = Math.hypot(dx, dy); if(dist < 400) { // Пикирует на игрока m.vx = Math.sign(dx) * m.speed; m.vy = dy > 0 ? 60 : -60; // летит вниз к игроку } else { // Патрулирует m.aiT -= dt; if(m.aiT <= 0){ m.aiT = 2+Math.random()*3; m.dir = Math.random()<0.5 ? -1 : 1; } m.vx = m.dir * m.speed * 0.5; m.vy = Math.sin(performance.now()/1000) * 30; // мягкое покачивание } // Атака при касании if(dist < 30 && player.invuln <= 0){ const damage = calculateDamage(10); player.hp -= damage; player.invuln = 0.8; player.vy -= 200; playSound('hit1'); } // Орёл не падает — летающий моб m.vy *= 0.5; m.grounded = false; } else { // животные (pig, chicken) 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; } // Погода (биом-зависимая) updateWeather(dt); // 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.temperature === undefined) player.temperature = 15; const BIOME_TEMP = { tundra: -20, plains: 15, swamp: 20, desert: 45, mountains: 5 }; const UNDERGROUND_TEMP = 15; const COLD_THRESHOLD = -5; const HEAT_THRESHOLD = 35; const HEAT_RADIUS = 5; const pGX = Math.floor(player.x / TILE); const biome = getCachedBiome(pGX); const pGY = Math.floor(player.y / TILE); const sGY = surfaceGyAt(pGX); const isUndergroundTemp = (pGY - sGY) > 2; // player deeper than 2 blocks below surface let targetTemp = BIOME_TEMP[biome] || 15; if (isUndergroundTemp) targetTemp = UNDERGROUND_TEMP; if (isNight() && !isUndergroundTemp) targetTemp -= 10; if ((weatherState.type === 'rain' || weatherState.type === 'storm') && !isUndergroundTemp) targetTemp -= 5; player.temperature += (targetTemp - player.temperature) * dt * 0.3; let nearHeat = false, nearCool = false; for (let dx = -HEAT_RADIUS; dx <= HEAT_RADIUS; dx++) { for (let dy = -HEAT_RADIUS; dy <= HEAT_RADIUS; dy++) { const b = getBlock(pGX + dx, pGY + dy); if (!b || b.dead) continue; if (b.t === 'campfire' || b.t === 'torch') nearHeat = true; if (b.t === 'water' && biome === 'desert') nearCool = true; } } if (nearHeat) { player.temperature += (30 - player.temperature) * dt * 2; if (player.hp < 100) player.hp = Math.min(100, player.hp + 8 * dt); } if (nearCool) { player.temperature += (15 - player.temperature) * dt * 1.5; if (player.hp < 100 && player.temperature > HEAT_THRESHOLD) { player.hp = Math.min(100, player.hp + 8 * dt); } } if (isUndergroundTemp && !nearHeat) { if (player.hp < 100 && player.temperature >= COLD_THRESHOLD && player.temperature <= HEAT_THRESHOLD) { player.hp = Math.min(100, player.hp + 2 * dt); } } const aboveBlk = getBlock(pGX, pGY - 1); const inShade = aboveBlk && BLOCKS[aboveBlk.t] && BLOCKS[aboveBlk.t].solid && aboveBlk.t !== 'glass'; if (inShade && !isUndergroundTemp && !nearCool) { player.temperature -= 5 * dt; if (player.temperature > HEAT_THRESHOLD - 2 && player.hp < 100) { player.hp = Math.min(100, player.hp + 1 * dt); } } if (player.temperature < COLD_THRESHOLD && !isUndergroundTemp) { const severity = Math.abs(player.temperature - COLD_THRESHOLD) / 15; player.hp -= 1 * severity * dt; if (severity > 0.5) player.vx *= (1 - 0.3 * Math.min(1, severity) * dt); } if (player.temperature > HEAT_THRESHOLD && !isUndergroundTemp) { const severity = (player.temperature - HEAT_THRESHOLD) / 15; player.hp -= 1 * severity * dt; if (severity > 0.5) player.hunger -= 0.5 * severity * dt; } // Игрок не может двигаться во время сна if(player.sleeping){ player.vx = 0; player.vy = 0; } else { const dir = (inp.r?1:0) - (inp.l?1:0); const speedMult = getWeatherSpeedMultiplier() * (player.slowTimer > 0 ? 0.4 : 1.0); if(dir) player.vx = dir * MOVE * speedMult; 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); if(player.slowTimer > 0) player.slowTimer = Math.max(0, player.slowTimer - dt); // Рост культур for(const key of Object.keys(growthTimers)){ const tile = growthTimers[key]; if(tile.stage < 3){ tile.growTimer -= dt; if(tile.growTimer <= 0){ tile.stage++; tile.growTimer = 8 + Math.random()*6; // Обновляем визуальный блок const [gxStr, gyStr] = key.split(','); const gx = parseInt(gxStr), gy = parseInt(gyStr); const curBlock = getBlock(gx, gy); if(curBlock && curBlock.t !== curBlock.t.replace(/_stage\d/, '_stage'+tile.stage)){ // Вычисляем следующую стадию const baseType = curBlock.t.replace(/_stage\d/, ''); const nextType = baseType + '_stage' + tile.stage; setBlock(gx, gy, nextType); sendBlockChange(gx, gy, nextType, 'add'); // Проверяем созрел ли if(tile.stage >= 3) delete growthTimers[key]; } } } } // 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){ spawnDrops(m.x + m.w/2, m.y + m.h/2, m.kind); // 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-1)*TILE; // 1 блок выше поверхности // не спавнить в воде 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: server is authoritative for positions // mobAI does NOT run on serverMobs — server handles all physics + AI // Only remove dead mobs from local map if(isMultiplayer){ for (const [id, sm] of serverMobs) { if(sm.hp <= 0){ sm.dead = true; } } 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(); } else if(m.kind==='scorpion') { // скорпион — оранжево-коричневый ctx.fillStyle = '#d35400'; ctx.fillRect(m.x+2, m.y+4, m.w-4, m.h-8); ctx.fillStyle = '#c0392b'; ctx.fillRect(m.x+m.w-4, m.y-6, 4, 10); ctx.fillRect(m.x+m.w-2, m.y-10, 3, 5); ctx.fillStyle = '#000'; ctx.fillRect(m.x+4, m.y+6, 3, 3); ctx.fillRect(m.x+m.w-7, m.y+6, 3, 3); ctx.fillStyle = '#d35400'; ctx.fillRect(m.x-4, m.y+8, 6, 4); ctx.fillRect(m.x+m.w-2, m.y+8, 6, 4); } else if(m.kind==='polar_bear') { ctx.fillStyle = '#ecf0f1'; ctx.fillRect(m.x+2, m.y+4, m.w-4, m.h-4); ctx.fillRect(m.x+8, m.y-2, m.w-16, 10); ctx.fillStyle = '#bdc3c7'; ctx.fillRect(m.x+m.w/2-3, m.y+2, 6, 4); ctx.fillStyle = '#000'; ctx.fillRect(m.x+12, m.y+1, 3, 3); ctx.fillRect(m.x+m.w-14, m.y+1, 3, 3); ctx.fillStyle = '#ecf0f1'; ctx.fillRect(m.x+8, m.y-4, 4, 4); ctx.fillRect(m.x+m.w-12, m.y-4, 4, 4); } else if(m.kind==='slime') { const bounce = Math.sin(performance.now()/200)*3; ctx.fillStyle = 'rgba(46,204,113,0.8)'; ctx.fillRect(m.x-1, m.y-1+bounce, m.w+2, m.h+2); ctx.fillStyle = 'rgba(39,174,96,0.9)'; ctx.fillRect(m.x+1, m.y+1+bounce, m.w-2, m.h-2); ctx.fillStyle = '#fff'; ctx.fillRect(m.x+4, m.y+6+bounce, 6, 6); ctx.fillRect(m.x+m.w-10, m.y+6+bounce, 6, 6); ctx.fillStyle = '#000'; ctx.fillRect(m.x+6, m.y+8+bounce, 3, 3); ctx.fillRect(m.x+m.w-8, m.y+8+bounce, 3, 3); } else if(m.kind==='eagle') { ctx.fillStyle = '#8B4513'; ctx.fillRect(m.x+8, m.y+6, m.w-16, m.h-10); const wingY = Math.sin(performance.now()/150)*4; ctx.fillRect(m.x-6, m.y+4+wingY, 16, 6); ctx.fillRect(m.x+m.w-10, m.y+4-wingY, 16, 6); ctx.fillStyle = '#fff'; ctx.fillRect(m.x+m.w/2-3, m.y+2, 6, 6); ctx.fillStyle = '#f39c12'; ctx.fillRect(m.x+m.w/2-1, m.y+6, 4, 3); ctx.fillStyle = '#000'; ctx.fillRect(m.x+m.w/2-1, m.y+3, 2, 2); } } // boat (рисуем первой, чтобы игрок был внутри неё) if(boat.active){ ctx.drawImage(tex['boat'], boat.x - (TILE-boat.w)/2, boat.y - (TILE-boat.h)/2, TILE, TILE); } // Lerp other players towards target positions (smooth movement) for(const [socketId, p] of otherPlayers){ if (p.tx !== undefined) { p.x += (p.tx - p.x) * 0.25; p.y += (p.ty - p.y) * 0.25; } } // 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.97 + Math.sin(now/300 + sx*0.01)*0.015 + Math.sin(now/470 + sy*0.02)*0.015; 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.9 + Math.sin(now/350 + b.gx*3.7)*0.05 + Math.sin(now/530 + b.gy*2.3)*0.05; const wx = b.gx*TILE + TILE/2 - camX; const wy = b.gy*TILE + TILE/2 - camY; const r = def.lightRadius * 0.85; const grad = ctx.createRadialGradient(wx,wy, 0, wx,wy, r); grad.addColorStop(0, `rgba(255,180,80,0.28)`); grad.addColorStop(0.5, `rgba(255,140,40,0.13)`); 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 ? 'Ночь' : 'День'; document.getElementById('xplevel').textContent = player.level; const lvXpNext = xpForLevel(player.level + 1); const lvXpCur = xpForLevel(player.level); const xpInLevel = player.xp - lvXpCur; const xpNeeded = lvXpNext - lvXpCur; document.getElementById('xpbar').textContent = xpInLevel + '/' + xpNeeded; tempEl.textContent = Math.round(player.temperature) + "°C"; 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); } // Рисуем дропы // Температурный оверлей (заморозка/тепловой удар) if (typeof player.temperature !== 'undefined' && (player.temperature < -5 || player.temperature > 35)) { const severity = player.temperature < -5 ? Math.abs(player.temperature + 5) / 20 : (player.temperature - 35) / 20; const alpha = Math.min(0.35, severity * 0.15); if (player.temperature < -5) { ctx.fillStyle = 'rgba(100,150,255,' + alpha + ')'; } else { ctx.fillStyle = 'rgba(255,100,50,' + alpha + ')'; } ctx.fillRect(0, 0, W, H); } drawDrops(ctx); // Пикап дропов pickupDrops(); // Popup уровня drawLevelUpPopup(ctx); // Миникарта (обновляем раз в ~4 кадра для оптимизации) if(minimapOpen && Math.random() < 0.25){ renderMinimap(); } requestAnimationFrame(loop); } requestAnimationFrame(loop); })();