// GrechkaCraft — auto-bundled from ES modules (function() { "use strict"; // === core/constants.js === // ==================== КОНСТАНТЫ ИГРЫ ==================== const TILE = 40; // Мир const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки // Физика const GRAV = 2200; const GRAV_WATER = 550; const MOVE = 320; const JUMP = 760; // День/ночь const DAY_LEN = 360; // замедлил смену дня/ночи в 3 раза // Сохранение const SAVE_KEY = 'minegrechka_save'; // Сеть const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду // Погода const MAX_RAINDROPS = 200; // Чат const MAX_CHAT_MESSAGES = 50; // Голосовой чат const VOICE_SERVER = 'wss://voicegrech.mkn8n.ru'; // === core/state.js === // Все мутабельные переменные игры в одном объекте состояния const state = { // Камера camX: 0, camY: 0, // День/ночь worldTime: 0, isNightTime: false, // Мультиплеер isMultiplayer: false, mySocketId: null, socket: null, // Инвентарь/UI selected: 0, showFullInventory: false, craftOpen: false, inventoryOpen: false, chatOpen: false, modeIdx: 0, // Мир worldSeed: Math.floor(Math.random() * 1000000), // Погода isRaining: false, rainIntensity: 0, weatherTimer: 0, weatherChangeInterval: 60 + Math.random() * 120, // Мобы/спавн spawnT: 0, // Цикл last: 0, prevJump: false, // Сеть — throttle отправки позиции lastMoveSendTime: 0, lastSentX: 0, lastSentY: 0, // Игрок player: { x: 6 * TILE, y: 0 * TILE, w: 34, h: 34, vx: 0, vy: 0, grounded: false, inWater: false, headInWater: false, hp: 100, hunger: 100, o2: 100, invuln: 0, fallStartY: 0, lastStepTime: 0, sleeping: false, inBoat: false, armor: 0, equippedArmor: null }, // Точка спавна spawnPoint: { x: 6 * TILE, y: 0 * TILE }, // Инвентарь inv: { dirt: 6, stone: 0, sand: 0, gravel: 0, clay: 0, wood: 0, planks: 0, ladder: 0, leaves: 0, coal: 0, copper_ore: 0, iron_ore: 0, gold_ore: 0, diamond_ore: 0, brick: 0, glass: 0, tnt: 1, campfire: 0, torch: 0, meat: 0, cooked: 0, arrow: 0, wood_pickaxe: 0, stone_pickaxe: 0, iron_pickaxe: 0, wood_sword: 0, stone_sword: 0, iron_sword: 0, iron_armor: 0, bow: 0, furnace: 0, bed: 0, boat: 0, iron_ingot: 0, gold_ingot: 0, copper_ingot: 0 }, // Лодка boat: { x: 0, y: 0, w: 34, h: 34, vx: 0, vy: 0, active: false, inWater: false }, // Ввод inp: { up: false, down: false, left: false, right: false, jump: false, mine: false, build: false, bow: false }, // Мышь mouse: { x: 0, y: 0 }, // Другие игроки (MP) otherPlayers: new Map(), // Серверные мобы (MP) serverMobs: new Map(), // Мобы mobs: [], // Снаряды projectiles: [], // Отслеживание изменений мира placedBlocks: [], removedBlocks: [], // Серверные изменения serverOverrides: new Map(), // Чат chatMessages: [], // Погода — капли raindrops: [], // Облака clouds: Array.from({ length: 10 }, () => ({ x: Math.random() * 2000, y: -200 - Math.random() * 260, w: 80 + Math.random() * 120, s: 12 + Math.random() * 20 })), // Частицы parts: [], // Активный TNT activeTNT: new Set(), // Прочность инструментов toolDurability: new Map(), // Последние выбранные предметы recentItems: [], // Активные печи activeFurnaces: new Map(), // Сгенерированные колонны generated: new Set(), // Изображение героя heroImg: null }; // === config.js === // ==================== КОНФИГУРАЦИЯ СЕРВЕРА ==================== // Возможность переопределить сервер через query string const urlParams = new URLSearchParams(window.location.search); const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru'; // Защита от mixed content if (location.protocol === 'https:' && SERVER_URL.startsWith('http://')) { console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP'); alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.'); } // ==================== WORLD ID И ИГРОКА ==================== let worldId = null; let playerName = localStorage.getItem('minegrechka_playerName') || null; // Запрашиваем имя игрока, если его нет if (!playerName) { playerName = prompt('Введите ваше имя для игры:') || 'Игрок'; localStorage.setItem('minegrechka_playerName', playerName); console.log('Player name set:', playerName); } // Берём worldId из URL или генерируем новый console.log('Current URL:', window.location.href); const worldParam = urlParams.get('world'); console.log('world param:', worldParam); // Проверяем на null, undefined или пустую строку worldId = (worldParam && worldParam.trim() !== '') ? worldParam : null; console.log('worldId after params:', worldId, 'type:', typeof worldId); // Если worldId отсутствует - генерируем новый и записываем в URL if (!worldId) { worldId = Math.random().toString(36).substring(2, 10); console.log('Generated worldId:', worldId); try { const newUrl = new URL(window.location.href); newUrl.searchParams.set('world', worldId); const newUrlString = newUrl.toString(); console.log('New URL to set:', newUrlString); // Проверяем, поддерживается ли history API if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') { window.history.replaceState(null, '', newUrlString); console.log('URL after replaceState:', window.location.href); console.log('URL after replaceState (direct check):', window.location.search); } else { console.error('History API not supported!'); } } catch (e) { console.error('Error updating URL:', e); } console.log('Generated new worldId for browser:', worldId); } console.log('Final worldId:', worldId, 'Player name:', playerName); console.log(`Server URL: ${SERVER_URL}, World ID: ${worldId}`); // Setters для изменения worldId/playerName из других модулей function setWorldId(id) { worldId = id; } function setPlayerName(name) { playerName = name; } // === ui/start-menu.js === // ==================== START MENU ==================== // Detect Telegram WebApp const isTgWebApp = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData); let tgUser = null; let tgSaveLoaded = false; if (isTgWebApp) { try { Telegram.WebApp.ready(); Telegram.WebApp.expand(); tgUser = Telegram.WebApp.initDataUnsafe?.user || null; console.log('[TG] Mini App detected, user:', tgUser?.id, tgUser?.username); // Set player name from TG if (tgUser && tgUser.first_name) { const tgName = tgUser.username || tgUser.first_name; localStorage.setItem('minegrechka_playerName', tgName); } } catch (e) { console.warn('[TG] WebApp init error:', e); } } // Export TG info for other modules // ==================== HELPERS ==================== function generateShortWorldCode() { // Generate readable 6-char code: consonants + digits (easy to copy/share) const c = 'bcdfghjkmnpqrstvwxyz'; const d = '23456789'; let code = ''; for (let i = 0; i < 3; i++) code += c[Math.floor(Math.random() * c.length)]; for (let i = 0; i < 3; i++) code += d[Math.floor(Math.random() * d.length)]; return code; } function showToast(msg, duration) { duration = duration || 1800; const t = document.createElement('div'); t.textContent = msg; t.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#2ecc71;padding:10px 24px;border-radius:10px;font:bold 16px Arial,sans-serif;z-index:99999;pointer-events:none;transition:opacity 0.4s;'; document.body.appendChild(t); setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, duration); } function customAlert(msg) { const overlay = document.createElement('div'); overlay.className = 'custom-modal-overlay'; overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100000;'; const box = document.createElement('div'); box.style.cssText = 'background:#C6C6C6;border:4px solid #000;padding:24px;max-width:360px;width:90%;box-shadow:4px 4px 0 #000;font-family:Courier New,monospace;'; const text = document.createElement('div'); text.textContent = msg; text.style.marginBottom = '16px'; text.style.fontWeight = 'bold'; text.style.fontSize = '16px'; const btn = document.createElement('button'); btn.textContent = 'OK'; btn.style.cssText = 'padding:8px 24px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#5A8F3C;color:#fff;box-shadow:2px 2px 0 #000;'; btn.onclick = () => overlay.remove(); box.appendChild(text); box.appendChild(btn); overlay.appendChild(box); document.body.appendChild(overlay); } // ==================== MAIN START MENU ==================== // Minecraft-style CSS for the start menu const mcCSS = ` .mc-overlay { position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999; display:flex;align-items:center;justify-content:center;font-family:'Courier New',monospace; image-rendering:pixelated; } .mc-bg { position:absolute;inset:0;background:#000;opacity:0.75; } .mc-dirt { position:absolute;bottom:0;left:0;right:0;height:40%; background:repeating-linear-gradient(0deg,#8B6914 0px,#8B6914 2px,#7A5B12 2px,#7A5B12 4px);opacity:0.3; } .mc-box { position:relative;z-index:1;background:#C6C6C6;border:4px solid #000; padding:0;max-width:420px;width:92%;box-shadow:4px 4px 0 #000; } .mc-titlebar { background:#1a1a2e;padding:12px 16px;text-align:center; border-bottom:4px solid #000; } .mc-title { font-size:24px;font-weight:bold;color:#FFAA00;text-shadow:2px 2px 0 #000; letter-spacing:2px; } .mc-subtitle { font-size:13px;color:#AAA;margin-top:4px; } .mc-body { padding:16px;background:#C6C6C6; } .mc-btn { display:block;width:100%;padding:10px 16px;margin-bottom:8px; font-family:'Courier New',monospace;font-size:16px;font-weight:bold; cursor:pointer;text-align:center;border:3px solid #000; image-rendering:pixelated;box-shadow:3px 3px 0 #000; transition:transform 0.05s,box-shadow 0.05s; } .mc-btn:active { transform:translate(2px,2px);box-shadow:1px 1px 0 #000; } .mc-btn-green { background:#5A8F3C;color:#E0E0E0;border-color:#3A5F1C;text-shadow:1px 1px 0 #222; } .mc-btn-green:hover { background:#6DAF4E; } .mc-btn-blue { background:#3C5F9E;color:#E0E0E0;border-color:#2A4070;text-shadow:1px 1px 0 #222; } .mc-btn-blue:hover { background:#4B73BE; } .mc-btn-purple { background:#7B3FA0;color:#E0E0E0;border-color:#5A2C78;text-shadow:1px 1px 0 #222; } .mc-btn-purple:hover { background:#9555BE; } .mc-btn-orange { background:#B86E00;color:#E0E0E0;border-color:#8A5200;text-shadow:1px 1px 0 #222; } .mc-btn-orange:hover { background:#DA8500; } .mc-btn-red { background:#9E2B2B;color:#E0E0E0;border-color:#6E1B1B;text-shadow:1px 1px 0 #222; } .mc-btn-red:hover { background:#BE3B3B; } .mc-btn-gray { background:#555;color:#AAA;border-color:#333;text-shadow:1px 1px 0 #111; } .mc-btn-gray:hover { background:#666;color:#DDD; } .mc-btn-outline { background:transparent;color:#555;border-color:#555; text-shadow:none;box-shadow:2px 2px 0 #333; } .mc-btn-outline:hover { background:#DDD;color:#222; } .mc-input { width:100%;padding:8px 12px;font-family:'Courier New',monospace; font-size:16px;background:#FFF;border:3px solid #000;color:#222; box-shadow:inset 2px 2px 0 #999;margin:4px 0;box-sizing:border-box; } .mc-input:focus { outline:none;border-color:#FFAA00; } .mc-label { font-size:13px;color:#444;font-weight:bold;margin:8px 0 4px; } .mc-divider { border:none;border-top:2px solid #999;margin:12px 0; } .mc-friend-row { display:flex;justify-content:space-between;align-items:center; padding:6px 8px;margin-bottom:4px;background:#E8E8E8;border:2px solid #999; box-shadow:2px 2px 0 #999; } .mc-friend-btn { padding:4px 10px;font-family:'Courier New',monospace; font-size:13px;font-weight:bold;cursor:pointer;border:2px solid #000; box-shadow:1px 1px 0 #000; } .mc-friend-btn:active { transform:translate(1px,1px);box-shadow:none; } .mc-friend-btn-green { background:#5A8F3C;color:#E0E0E0; } .mc-friend-btn-red { background:#9E2B2B;color:#E0E0E0; } .mc-checkbox-row { display:flex;align-items:center;gap:8px;font-size:13px;color:#444;cursor:pointer; } .mc-checkbox { width:18px;height:18px;accent-color:#5A8F3C; } `; function showStartMenu() { return new Promise(async (resolve) => { const urlParams = new URLSearchParams(window.location.search); const worldParam = urlParams.get('world'); const tgStartParam = (isTgWebApp && Telegram.WebApp.initDataUnsafe?.start_param) ? Telegram.WebApp.initDataUnsafe.start_param.trim() : null; // If world is in URL or TG start param — skip menu, auto-join if ((worldParam && worldParam.trim() !== '') || tgStartParam) { const worldId = (worldParam && worldParam.trim()) || tgStartParam; console.log('[Menu] Joining world from URL/startParam:', worldId); // Set player name from TG or localStorage or default if (!state.playerName) { if (isTgWebApp && tgUser) { state.playerName = tgUser.username || tgUser.first_name || 'Игрок'; } else { state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок'; } localStorage.setItem('minegrechka_playerName', state.playerName); } resolve(worldId); return; } // Inject CSS const styleEl = document.createElement('style'); styleEl.textContent = mcCSS; document.head.appendChild(styleEl); // Build overlay const overlay = document.createElement('div'); overlay.className = 'mc-overlay'; overlay.id = 'startMenu'; overlay.innerHTML = '
'; const box = document.createElement('div'); box.className = 'mc-box'; // Title bar const titleBar = document.createElement('div'); titleBar.className = 'mc-titlebar'; titleBar.innerHTML = '
⛏️ GrechkaCraft
' + '
' + (isTgWebApp ? ('Привет, ' + (tgUser?.first_name || 'Игрок') + '!') : 'Voxel-мир в браузере') + '
'; box.appendChild(titleBar); const body = document.createElement('div'); body.className = 'mc-body'; // Mutable worldId holder let selectedWorldId = null; // === Buttons === // 🌍 New World const btnNew = document.createElement('button'); btnNew.className = 'mc-btn mc-btn-green'; btnNew.textContent = '🌍 Новый мир'; btnNew.onclick = () => { selectedWorldId = generateShortWorldCode(); overlay.remove(); styleEl.remove(); resolve(selectedWorldId); }; body.appendChild(btnNew); // 🔗 Join by code const btnJoin = document.createElement('button'); btnJoin.className = 'mc-btn mc-btn-blue'; btnJoin.textContent = '🔗 Вступить по коду'; btnJoin.onclick = () => { body.innerHTML = ''; const lbl = document.createElement('div'); lbl.className = 'mc-label'; lbl.textContent = 'Код мира:'; body.appendChild(lbl); const codeInput = document.createElement('input'); codeInput.className = 'mc-input'; codeInput.type = 'text'; codeInput.placeholder = 'bkh237'; codeInput.maxLength = 12; body.appendChild(codeInput); const btnGo = document.createElement('button'); btnGo.className = 'mc-btn mc-btn-blue'; btnGo.textContent = 'Войти →'; btnGo.onclick = () => { const code = codeInput.value.trim().toLowerCase(); if (code.length >= 3) { selectedWorldId = code; overlay.remove(); styleEl.remove(); resolve(selectedWorldId); } }; body.appendChild(btnGo); const btnBack = document.createElement('button'); btnBack.className = 'mc-btn mc-btn-gray'; btnBack.textContent = '← Назад'; btnBack.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); }; body.appendChild(btnBack); codeInput.focus(); codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') btnGo.click(); }); }; body.appendChild(btnJoin); // 💾 Continue saved game const hasLocalSave = !!localStorage.getItem(state.SAVE_KEY || 'minegrechka_save'); if (isTgWebApp || hasLocalSave) { const btnHistory = document.createElement('button'); btnHistory.className = 'mc-btn mc-btn-purple'; btnHistory.textContent = '💾 Продолжить игру'; btnHistory.onclick = async () => { if (isTgWebApp && tgUser) { try { const resp = await fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id); const data = await resp.json(); if (data.ok && data.world_id) { selectedWorldId = data.world_id; tgSaveLoaded = true; overlay.remove(); styleEl.remove(); resolve(selectedWorldId); return; } } catch (e) { console.warn('[TG] Load save failed:', e); } } if (hasLocalSave) { // Continue with a new world code (the save will be loaded separately by initDB/loadGame) selectedWorldId = generateShortWorldCode(); overlay.remove(); styleEl.remove(); resolve(selectedWorldId); return; } customAlert('Сохранений не найдено'); }; body.appendChild(btnHistory); } // === TG-only buttons === if (isTgWebApp && window.Telegram && Telegram.WebApp.switchInlineQuery) { // 📤 Invite friends const btnInvite = document.createElement('button'); btnInvite.className = 'mc-btn mc-btn-outline'; btnInvite.style.marginTop = '16px'; btnInvite.textContent = '📤 Пригласить друзей'; btnInvite.onclick = () => { const code = selectedWorldId || generateShortWorldCode(); const url = 'https://t.me/' + state.TELEGRAM_BOT_USERNAME + '/app?startapp=' + encodeURIComponent(code); if (Telegram.WebApp && Telegram.WebApp.openTelegramLink) { Telegram.WebApp.openTelegramLink('https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent('🏗️ Заходи в GrechkaCraft! Мир: ' + code)); } else { navigator.clipboard.writeText(url).then(() => showToast('📋 Скопировано!')).catch(() => showToast(url)); } }; body.appendChild(btnInvite); // 👥 Friends const btnFriends = document.createElement('button'); btnFriends.className = 'mc-btn mc-btn-orange'; btnFriends.style.marginTop = '8px'; btnFriends.textContent = '👥 Друзья'; btnFriends.onclick = async () => { try { const resp = await fetch(state.SERVER_URL + '/api/tg/friends?tg_id=' + tgUser.id); const data = await resp.json(); body.innerHTML = ''; // Back button at top const btnBackTop = document.createElement('button'); btnBackTop.className = 'mc-btn mc-btn-gray'; btnBackTop.textContent = '← Назад'; btnBackTop.style.marginBottom = '12px'; btnBackTop.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); }; body.appendChild(btnBackTop); // Nickname section if (data.nickname) { const nickDiv = document.createElement('div'); nickDiv.className = 'mc-friend-row'; nickDiv.innerHTML = '🔗 Твой ник: ' + data.nickname + ''; nickDiv.style.background = '#D5E8C5'; body.appendChild(nickDiv); } else { const nickLabel = document.createElement('div'); nickLabel.className = 'mc-label'; nickLabel.style.color = '#9E2B2B'; nickLabel.textContent = '⚡ Задай ник, чтобы друзья могли тебя найти:'; body.appendChild(nickLabel); const nickRow = document.createElement('div'); nickRow.style.display = 'flex'; nickRow.style.gap = '4px'; nickRow.style.marginBottom = '8px'; const nickInput = document.createElement('input'); nickInput.className = 'mc-input'; nickInput.style.flex = '1'; nickInput.type = 'text'; nickInput.maxLength = 16; nickInput.placeholder = 'Твой ник'; const nickBtn = document.createElement('button'); nickBtn.className = 'mc-btn mc-btn-green'; nickBtn.style.width = 'auto'; nickBtn.style.marginBottom = '0'; nickBtn.style.padding = '8px 16px'; nickBtn.textContent = '✓'; nickBtn.onclick = async () => { const n = nickInput.value.trim().toLowerCase(); if (!n || n.length < 2) { showToast('❌ Минимум 2 символа'); return; } const check = await fetch(state.SERVER_URL + '/api/nick/check?nick=' + encodeURIComponent(n)); const cd = await check.json(); if (!cd.available) { showToast('❌ Ник занят'); return; } const setResp = await fetch(state.SERVER_URL + '/api/nick/set', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tg_id: tgUser.id, nickname: n }) }); const sd = await setResp.json(); if (sd.ok) { showToast('✅ Ник: ' + sd.nickname); btnFriends.onclick(); } else { showToast('❌ Ошибка'); } }; nickRow.appendChild(nickInput); nickRow.appendChild(nickBtn); body.appendChild(nickRow); } // Friends list if (data.friends && data.friends.length) { const listLabel = document.createElement('div'); listLabel.className = 'mc-label'; listLabel.textContent = '👥 Друзья:'; body.appendChild(listLabel); for (const f of data.friends) { const fDiv = document.createElement('div'); fDiv.className = 'mc-friend-row'; const statusDot = f.online ? '🟢' : '⚫'; let info = statusDot + ' ' + f.nickname + ''; if (f.online && f.world_id) info += ' — мир ' + f.world_id; const fInfo = document.createElement('div'); fInfo.innerHTML = info; fDiv.appendChild(fInfo); if (f.online && f.world_id) { const joinBtn = document.createElement('button'); joinBtn.className = 'mc-friend-btn mc-friend-btn-green'; joinBtn.textContent = '🎮 Вступить'; joinBtn.onclick = () => { selectedWorldId = f.world_id; overlay.remove(); styleEl.remove(); resolve(selectedWorldId); }; fDiv.appendChild(joinBtn); } body.appendChild(fDiv); } } else if (data.friends && !data.friends.length) { const noFriends = document.createElement('div'); noFriends.className = 'mc-label'; noFriends.style.color = '#888'; noFriends.textContent = 'Пока нет друзей'; body.appendChild(noFriends); } // Pending friend requests if (data.pending && data.pending.length) { const pLabel = document.createElement('div'); pLabel.className = 'mc-label'; pLabel.style.color = '#B86E00'; pLabel.textContent = '📨 Запросы в друзья:'; body.appendChild(pLabel); for (const p of data.pending) { const pItem = document.createElement('div'); pItem.className = 'mc-friend-row'; const span = document.createElement('span'); span.textContent = p.from; pItem.appendChild(span); const btns = document.createElement('div'); btns.style.display = 'flex'; btns.style.gap = '4px'; const acceptBtn = document.createElement('button'); acceptBtn.className = 'mc-friend-btn mc-friend-btn-green'; acceptBtn.textContent = '✓'; acceptBtn.onclick = async () => { const r = await fetch(state.SERVER_URL + '/api/friends/accept', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id }) }); const d = await r.json(); if (d.ok) { showToast('✅ Друг добавлен!'); btnFriends.onclick(); } else { showToast('❌ Ошибка'); } }; const declineBtn = document.createElement('button'); declineBtn.className = 'mc-friend-btn mc-friend-btn-red'; declineBtn.textContent = '✕'; declineBtn.onclick = async () => { await fetch(state.SERVER_URL + '/api/friends/decline', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id }) }); showToast('Отклонено'); btnFriends.onclick(); }; btns.appendChild(acceptBtn); btns.appendChild(declineBtn); pItem.appendChild(btns); body.appendChild(pItem); } } // Add friend body.appendChild(document.createElement('hr')); const addLabel = document.createElement('div'); addLabel.className = 'mc-label'; addLabel.textContent = '➕ Добавить друга по нику:'; body.appendChild(addLabel); const addRow = document.createElement('div'); addRow.style.display = 'flex'; addRow.style.gap = '4px'; const addInput = document.createElement('input'); addInput.className = 'mc-input'; addInput.style.flex = '1'; addInput.type = 'text'; addInput.maxLength = 16; addInput.placeholder = 'Ник друга'; const addBtn = document.createElement('button'); addBtn.className = 'mc-btn mc-btn-blue'; addBtn.style.width = 'auto'; addBtn.style.marginBottom = '0'; addBtn.style.padding = '8px 16px'; addBtn.textContent = '+'; addBtn.onclick = async () => { const nick = addInput.value.trim().toLowerCase(); if (!nick || nick.length < 2) { showToast('❌ Минимум 2 символа'); return; } const r = await fetch(state.SERVER_URL + '/api/friends/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tg_id: tgUser.id, nickname: nick }) }); const d = await r.json(); if (d.ok) { showToast(d.message === 'Already friends' ? '✅ Уже друзья!' : '📨 Запрос отправлен!'); } else { showToast('❌ ' + (d.error || 'Ошибка')); } }; addRow.appendChild(addInput); addRow.appendChild(addBtn); body.appendChild(addRow); // Privacy checkboxes const settings = data.settings || { allow_requests: 1, notify_online: 1 }; const settingsDiv = document.createElement('div'); settingsDiv.style.marginTop = '12px'; const reqLabel = document.createElement('label'); reqLabel.className = 'mc-checkbox-row'; const reqCb = document.createElement('input'); reqCb.className = 'mc-checkbox'; reqCb.type = 'checkbox'; reqCb.checked = settings.allow_requests; reqCb.onchange = async () => { await fetch(state.SERVER_URL + '/api/friends/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tg_id: tgUser.id, allow_requests: reqCb.checked ? 1 : 0, notify_online: settings.notify_online }) }); }; reqLabel.appendChild(reqCb); reqLabel.appendChild(document.createTextNode(' Запросы в друзья')); settingsDiv.appendChild(reqLabel); body.appendChild(settingsDiv); } catch (e) { showToast('❌ Ошибка загрузки друзей'); console.error(e); } }; body.appendChild(btnFriends); } // Player name input (only if not already set) if (!state.playerName) { const nameDivider = document.createElement('hr'); nameDivider.className = 'mc-divider'; body.appendChild(nameDivider); const nameLabel = document.createElement('div'); nameLabel.className = 'mc-label'; nameLabel.textContent = 'Твоё имя:'; body.appendChild(nameLabel); const nameInput = document.createElement('input'); nameInput.className = 'mc-input'; nameInput.type = 'text'; nameInput.placeholder = 'Ваше имя'; nameInput.value = tgUser ? (tgUser.username || tgUser.first_name || '') : ''; nameInput.maxLength = 20; nameInput.addEventListener('input', () => { if (nameInput.value.trim()) { state.playerName = nameInput.value.trim(); localStorage.setItem('minegrechka_playerName', state.playerName); } }); body.appendChild(nameInput); } box.appendChild(body); overlay.appendChild(box); document.body.appendChild(overlay); // Pre-fill player name from TG if available if (isTgWebApp && tgUser && !state.playerName) { state.playerName = tgUser.username || tgUser.first_name || 'Игрок'; localStorage.setItem('minegrechka_playerName', state.playerName); } // Auto-load TG save info (background fetch) if (isTgWebApp && tgUser && !worldParam) { fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id) .then(r => r.json()) .then(data => { if (data.ok && data.world_id) { // could update UI but menu is already shown } }) .catch(() => {}); } }); } // === core/canvas.js === // ==================== ХОЛСТ И РАЗМЕРЫ ==================== 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; 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); } function setW(v) { W = v; } function setH(v) { H = v; } // === data/blocks.js === 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:190 }, torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:140 }, 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 } }; // === world/world-storage.js === // Мир-хранилище 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) { state.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 = state.placedBlocks.some(pb => pb.gx === gx && pb.gy === gy); if (wasPlayerPlaced) { // Удаляем из placedBlocks state.placedBlocks = state.placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy)); } else { // Это природный блок - добавляем в removedBlocks state.removedBlocks.push({ gx, gy }); } return b; } // === world/generation.js === // Генерация (по X, на всю глубину до bedrock) const generated = state.generated; // Set of gx already generated // ==================== БИОМЫ ==================== 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 + state.worldSeed*0.01)*0.5 + Math.sin(gx*0.007 + state.worldSeed*0.02)*0.3 + 0.5; const humid = Math.sin(gx*0.004 + state.worldSeed*0.015 + 1000)*0.5 + Math.cos(gx*0.006 + state.worldSeed*0.02 + 2000)*0.3 + 0.5; const mtVal = Math.sin(gx*0.001 + state.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 if (biomeCache[chunk] === undefined) biomeCache[chunk] = getBiome(chunk * 8); return biomeCache[chunk]; } // ==================== СТРУКТУРЫ ==================== 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'] ]; // ==================== ПОВЕРХНОСТЬ (BIOME-AWARE) ==================== function surfaceGyAt(gx) { const biome = getCachedBiome(gx); // Базовый шум (одинаков для всех биомов) const n1 = Math.sin(gx * 0.025 + state.worldSeed * 0.001) * 8; const n2 = Math.sin(gx * 0.012 + state.worldSeed * 0.002) * 12; const n3 = Math.sin(gx * 0.006 + state.worldSeed * 0.003) * 6; const n4 = Math.sin(gx * 0.045 + state.worldSeed * 0.004) * 4; const n5 = Math.cos(gx * 0.018 + state.worldSeed * 0.005) * 5; let h; switch(biome) { case 'desert': h = Math.floor(SEA_GY - 4 + n3*0.3 + n4*0.5); // плоско, чуть выше break; case 'tundra': h = Math.floor(SEA_GY - 6 + n2*0.5 + n3*0.4 + n5*0.3); // пологие холмы break; case 'swamp': h = Math.floor(SEA_GY - 2 + n3*0.2 + n4*0.3); // очень плоско, у воды h = Math.max(h, SEA_GY - 3); // не слишком глубоко break; case 'mountains': h = Math.floor(SEA_GY - 15 + n1*1.5 + n2*1.2 + n3); // высокие пики break; default: // plains h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5); } return h; } // ==================== КОЛОННА ==================== function genColumn(gx) { if (generated.has(gx)) return; 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); // цветы (только plains) 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') { 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 { 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 state.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(state.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(state.camX / TILE); for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) { genColumn(gx); } } function seededRandom(gx, gy) { const n = Math.sin(gx * 12.9898 + gy * 78.233 + state.worldSeed * 0.1) * 43758.5453; return n - Math.floor(n); } // === audio/sound-engine.js === // Звуковой движок 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)); } } // === ui/chat.js === // ==================== ЧАТ ==================== const chatMessages = []; 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 (state.isMultiplayer && state.socket && state.socket.connected) { state.socket.emit('chat_message', { message: message.trim() }); } else { addChatMessage('Вы', message.trim()); } } function initChat() { document.getElementById('chatToggle').onclick = () => { playSound('click'); state.chatOpen = !state.chatOpen; document.getElementById('chatPanel').style.display = state.chatOpen ? 'block' : 'none'; if (state.chatOpen) { document.getElementById('chatInput').focus(); } }; document.getElementById('chatClose').onclick = () => { playSound('click'); state.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 = ''; } }); } // === entities/player.js === // Функция для расчёта урона с учётом брони function calculateDamage(baseDamage) { // Броня снижает урон пропорционально // armor: 0 = без брони (100% урона) // armor: 0.5 = железная броня (50% урона) const reduction = state.player.armor; const actualDamage = baseDamage * (1 - reduction); console.log('[DAMAGE] Base:', baseDamage, '- Armor:', reduction, '- Actual:', actualDamage.toFixed(1)); return actualDamage; } // Инициализация игрока function initPlayer() { state.player = { x: 6 * TILE, y: 0 * TILE, w: 34, h: 34, vx: 0, vy: 0, grounded: false, inWater: false, headInWater: false, hp: 100, hunger: 100, o2: 100, invuln: 0, fallStartY: 0, lastStepTime: 0, sleeping: false, inBoat: false, armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня) equippedArmor: null // Тип надетой брони }; // Сохраняем начальную позицию для возрождения state.spawnPoint = { x: 6 * TILE, y: 0 * TILE }; } // === data/items.js === const ITEMS = { meat: { n:'Сырое мясо', icon:'🥩', food:15 }, cooked: { n:'Жареное мясо', icon:'🍖', food:45 }, arrow: { n:'Стрела', icon:'➡️', stack:64 }, }; // Runtime extensions — предметы от обжига ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' }; ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' }; ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' }; // === data/tools.js === const TOOLS = { wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', durability: 60, miningPower: 1, craft: { wood: 3, planks: 2 } }, stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 } }, iron_pickaxe: { n:'Железная кирка', icon:'⛏️', durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 } }, wood_sword: { n:'Деревянный меч', icon:'⚔️', durability: 40, damage: 5, craft: { wood: 2, planks: 1 } }, stone_sword: { n:'Каменный меч', icon:'⚔️', durability: 100, damage: 8, craft: { wood: 1, stone: 2 } }, iron_sword: { n:'Железный меч', icon:'⚔️', durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 } }, bow: { n:'Лук', icon:'🏹', durability: 150, craft: { wood: 3, string: 0 } } }; function addTool(type) { const maxDur = TOOLS[type]?.durability || 60; const key = `${type}_${Date.now()}_${Math.random().toString(36).slice(2,6)}`; state.toolDurability.set(key, { type, current: maxDur, max: maxDur }); } function useTool(type) { for (const [tid, dur] of state.toolDurability) { if (dur.type === type && dur.current > 0) { dur.current--; if (dur.current <= 0) { state.toolDurability.delete(tid); state.inv[type] = Math.max(0, (state.inv[type] || 0) - 1); return true; // broke } return false; // used but not broke } } return false; // no durability entry found } // === render/textures.js === // Текстуры блоков (простые) const tex = {}; function makeTex(type) { const t = BLOCKS[type]; const c = document.createElement('canvas'); c.width = 32; c.height = 32; const g = c.getContext('2d'); if (type === 'tnt') { g.fillStyle = '#c0392b'; g.fillRect(0, 0, 32, 32); g.fillStyle = '#fff'; g.fillRect(0, 12, 32, 8); g.fillStyle = '#000'; g.font = 'bold 10px sans-serif'; g.fillText('TNT', 6, 20); return c; } if (type === 'campfire') { g.fillStyle = '#5d4037'; g.fillRect(4, 26, 24, 6); g.fillStyle = '#3e2723'; g.fillRect(7, 23, 18, 4); return c; } if (type === 'torch') { g.fillStyle = '#6d4c41'; g.fillRect(14, 10, 4, 18); g.fillStyle = '#f39c12'; g.fillRect(12, 6, 8, 8); return c; } if (type === 'glass') { g.fillStyle = 'rgba(200,240,255,0.25)'; g.fillRect(0, 0, 32, 32); g.strokeStyle = 'rgba(255,255,255,0.65)'; g.strokeRect(2, 2, 28, 28); g.beginPath(); g.moveTo(5, 27); g.lineTo(27, 5); g.stroke(); return c; } if (type === 'water') { g.fillStyle = t.c; g.fillRect(0, 0, 32, 32); g.fillStyle = 'rgba(255,255,255,0.08)'; g.fillRect(0, 6, 32, 2); return c; } if (type === 'bed') { // Основание кровати g.fillStyle = '#e91e63'; g.fillRect(0, 0, 32, 32); // Подушка g.fillStyle = '#f8bbd0'; g.fillRect(2, 2, 14, 14); // Одеяло g.fillStyle = '#c2185b'; g.fillRect(16, 4, 14, 24); // Детали одеяла g.fillStyle = '#e91e63'; g.fillRect(18, 6, 10, 20); return c; } if (type === 'flower') { g.fillStyle = '#2ecc71'; g.fillRect(14, 14, 4, 18); g.fillStyle = t.c; g.beginPath(); g.arc(16, 12, 6, 0, 6.28); g.fill(); return c; } if (type === 'boat') { // Корпус лодки g.fillStyle = '#8B4513'; g.fillRect(2, 12, 28, 8); // Борта g.fillStyle = '#A0522D'; g.fillRect(0, 10, 32, 12); // Внутренность g.fillStyle = '#DEB887'; g.fillRect(4, 14, 24, 4); // Дно g.fillStyle = '#654321'; g.fillRect(2, 20, 28, 4); return c; } if (type === 'ladder') { // Боковые стойки лестницы g.fillStyle = '#8B4513'; g.fillRect(4, 0, 4, 32); g.fillRect(24, 0, 4, 32); // Ступени g.fillStyle = '#A0522D'; g.fillRect(4, 4, 24, 3); g.fillRect(4, 12, 24, 3); g.fillRect(4, 20, 24, 3); g.fillRect(4, 28, 24, 3); return c; } g.fillStyle = t.c || '#000'; g.fillRect(0, 0, 32, 32); g.fillStyle = 'rgba(0,0,0,0.10)'; for (let i = 0; i < 6; i++) g.fillRect((Math.random() * 28) | 0, (Math.random() * 28) | 0, 4, 4); if (type.endsWith('_ore') || type === 'coal') { g.fillStyle = 'rgba(0,0,0,0.35)'; for (let i = 0; i < 4; i++) g.fillRect((Math.random() * 24) | 0, (Math.random() * 24) | 0, 6, 6); } return c; } function initTextures() { Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k)); } // === ui/hotbar.js === // Hotbar UI function rebuildHotbar() { const hotbarEl = state.hotbarEl; const inv = state.inv; const selected = state.selected; const recentItems = state.recentItems; const toolDurability = state.toolDurability; 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'); // Звук клика по инвентарю state.selected = id; // Обновляем список последних предметов state.recentItems = state.recentItems.filter(item => item !== id); // Удаляем если уже есть state.recentItems.unshift(id); // Добавляем в начало state.recentItems = state.recentItems.slice(0, 5); // Оставляем только 5 rebuildHotbar(); }; // Показываем индикатор надетой брони if (id === 'iron_armor' && state.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); } } // === render/particles.js === // Частицы (взрыв) const parts = []; function spawnExplosion(x, y, power) { const n = Math.floor(16 + power * 10); for (let i = 0; i < n; i++) { parts.push({ x, y, vx: (Math.random() - 0.5) * (300 + power * 200), vy: (Math.random() - 0.5) * (300 + power * 200), t: 0.7, c: '#ffa500' }); } } // === world/tnt.js === // TNT логика: цепь + усиление function activateTNT(b, fuse = 3.2) { if (b.dead) return; if (b.active) return; b.active = true; b.fuse = fuse; state.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); state.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 (state.inv[b.t] !== undefined && Math.random() < 0.20) state.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 === state.player) { const actualDamage = calculateDamage(dmg); state.player.hp -= actualDamage; } else { e.hp -= dmg; } e.vx += (dx / dist || 0) * 600; e.vy -= 320; } }; hurt(state.player); state.mobs.forEach(hurt); } // === game/save.js === // ==================== СИСТЕМА СОХРЕНЕНИЯ ИГРЫ ==================== let db = null; // Оставляем для совместимости, но не используем // Инициализация (localStorage + in-memory fallback) function initDB() { return new Promise((resolve) => { console.log('Используем localStorage для сохранений (sandbox режим)'); resolve(null); }); } function saveGame() { // В MP не сохраняем мир — сервер хранит блоки и seed if (state.isMultiplayer) { console.log('[MP] Skipping world save — server is authoritative'); return; } const saveData = { version: 2, worldSeed: state.worldSeed, player: { x: state.player.x, y: state.player.y, hp: state.player.hp, hunger: state.player.hunger, o2: state.player.o2 }, inventory: state.inv, time: state.worldTime, isNight: state.isNightTime, dayNightCycle: state.dayNightCycle, // Сохраняем только изменения placedBlocks: state.placedBlocks.slice(), removedBlocks: state.removedBlocks.slice() }; const saveSize = JSON.stringify(saveData).length; console.log('Сохранение: player HP:', state.player.hp, 'hunger:', state.player.hunger, 'o2:', state.player.o2); // Пробуем сохранить в localStorage (основной метод для персистентности) try { localStorage.setItem(SAVE_KEY, JSON.stringify(saveData)); console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`); } catch (e) { console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e); // Если localStorage недоступен, используем in-memory fallback state.inMemorySave = saveData; console.log(`Игра сохранена в памяти (размер: ${saveSize} байт)`); } } function loadGame() { return new Promise((resolve, reject) => { // Пробуем localStorage try { const localSave = localStorage.getItem(SAVE_KEY); if (localSave) { const parsed = JSON.parse(localSave); console.log('Загружено из localStorage, player HP:', parsed.player?.hp); resolve(parsed); return; } } catch (e) { console.warn('Ошибка доступа к localStorage:', e); } // Если localStorage недоступен, используем in-memory сохранение if (state.inMemorySave) { console.log('Загружено из in-memory сохранения, player HP:', state.inMemorySave.player?.hp); resolve(state.inMemorySave); return; } console.log('Сохранение не найдено'); resolve(null); }); } // Миграция с версии 1 на версию 2 function migrateV1toV2(saveData) { console.log('Миграция сохранения с версии 1 на версию 2...'); // Сохраняем seed из текущей игры (так как v1 его не хранил) saveData.worldSeed = state.worldSeed; // Инициализируем массивы изменений saveData.placedBlocks = []; saveData.removedBlocks = []; // Для v1 нам нужно сравнить сгенерированные блоки с тем, что должно быть по seed // Это сложно сделать без полной перегенерации, поэтому для v1 сохраняем только seed // и при загрузке просто перегенерируем мир // Удаляем старые данные delete saveData.generatedBlocks; saveData.version = 2; console.log('Миграция завершена'); } export async function applySave(saveData) { if (!saveData) return; console.log('=== applySave START ==='); console.log('player HP before applySave:', state.player.hp); console.log('saveData.player.hp:', saveData.player?.hp); console.log('isMultiplayer:', state.isMultiplayer); // Миграция версий if (saveData.version === 1) { migrateV1toV2(saveData); } // В MP режиме сервер — истина, не даём локальному сейву перетирать мир if (state.isMultiplayer) { console.log('[MP] Skipping world seed/block restore from local save — server is authoritative'); // Восстанавливаем только игрока и инвентарь, мир не трогаем if (saveData.player) { state.player.hunger = saveData.player.hunger; state.player.o2 = saveData.player.o2; state.player.hp = (saveData.player.hp > 0) ? saveData.player.hp : 100; console.log('Restored player state from save (hp, hunger, o2)'); } if (saveData.inventory) { for (const key in saveData.inventory) { state.inv[key] = saveData.inventory[key]; } console.log('Restored inventory from save'); } // Fill recentItems from inventory if empty if (state.recentItems.length === 0) { state.recentItems = Object.keys(state.inv).filter(id => state.inv[id] > 0).slice(0, 5); } rebuildHotbar(); console.log('=== applySave END (MP mode, world skipped) ==='); return; } // Восстанавливаем seed (только одиночная игра) if (saveData.worldSeed !== undefined) { state.worldSeed = saveData.worldSeed; } // Восстанавливаем игрока if (saveData.player) { state.player.x = saveData.player.x; state.player.y = saveData.player.y; state.player.hunger = saveData.player.hunger; state.player.o2 = saveData.player.o2; // Обновляем spawnPoint на позицию из сохранения state.spawnPoint.x = state.player.x; state.spawnPoint.y = state.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!'); state.player.hp = 100; } else { state.player.hp = savedHP; } console.log('player HP after restore:', state.player.hp); console.log('spawnPoint обновлён из сохранения: x=', state.spawnPoint.x, 'y=', state.spawnPoint.y); } else { console.log('No player data in save, setting default HP: 100'); state.player.hp = 100; } console.log('=== applySave END ==='); // Восстанавливаем инвентарь if (saveData.inventory) { for (const key in saveData.inventory) { state.inv[key] = saveData.inventory[key]; } } // Восстанавливаем время if (saveData.time !== undefined) { state.worldTime = saveData.time; } // Восстанавливаем день/ночь if (saveData.isNight !== undefined) { state.isNightTime = saveData.isNight; } if (saveData.dayNightCycle !== undefined) { state.dayNightCycle = saveData.dayNightCycle; } // Перегенерируем мир по 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); } // Восстанавливаем массивы изменений state.placedBlocks = saveData.placedBlocks || []; state.removedBlocks = saveData.removedBlocks || []; } // Fill recentItems from inventory if empty (hotbar won't render otherwise) if (state.recentItems.length === 0) { state.recentItems = Object.keys(state.inv).filter(id => state.inv[id] > 0).slice(0, 5); } rebuildHotbar(); console.log('Игра загружена'); } // === ui/save-controls.js === // ==================== МЕНЮ ⋯ + СОХРАНЕНИЕ / СБРОС ==================== let dropdownOpen = false; function initSaveControls() { const menuBtn = document.getElementById('menuBtn'); const menuDropdown = document.getElementById('menuDropdown'); const saveItem = document.getElementById('saveItem'); const resetItem = document.getElementById('resetItem'); const marketItem = document.getElementById('marketItem'); const settingsItem = document.getElementById('settingsItem'); // Убить возможный старый onclick (предотвращает race condition) menuBtn.onclick = null; // Тогл меню menuBtn.addEventListener('click', (e) => { e.stopPropagation(); dropdownOpen = !dropdownOpen; menuDropdown.classList.toggle('open', dropdownOpen); }); // Закрытие по клику вне document.addEventListener('click', () => { if (dropdownOpen) { dropdownOpen = false; menuDropdown.classList.remove('open'); } }); // Предотвращаем закрытие при клике внутри дропдауна menuDropdown.addEventListener('click', (e) => { e.stopPropagation(); }); // 💾 Сохранить saveItem.addEventListener('click', () => { playSound('click'); saveGame(); dropdownOpen = false; menuDropdown.classList.remove('open'); alert('Игра сохранена!'); }); // 🔄 Новый мир resetItem.addEventListener('click', () => { if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) { playSound('click'); try { localStorage.removeItem(state.SAVE_KEY); console.log('Сохранение удалено из localStorage'); } catch (e) { console.warn('Ошибка удаления сохранения:', e); } state.inMemorySave = null; state.worldId = Math.random().toString(36).substring(2, 10); console.log('Новый worldId после сброса:', state.worldId); try { const newUrl = new URL(window.location.href); newUrl.searchParams.set('world', state.worldId); if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') { window.history.replaceState(null, '', newUrl.toString()); console.log('URL обновлён:', newUrl.toString()); } } catch (e) { console.error('Ошибка обновления URL:', e); } location.reload(); } else { dropdownOpen = false; menuDropdown.classList.remove('open'); } }); // 🏪 Рынок marketItem.addEventListener('click', () => { dropdownOpen = false; menuDropdown.classList.remove('open'); // toggleMarket импортируется в main.js, вызовем через кастомное событие document.dispatchEvent(new CustomEvent('toggleMarket')); }); // ⚙️ Настройки if (settingsItem) { settingsItem.addEventListener('click', () => { dropdownOpen = false; menuDropdown.classList.remove('open'); document.dispatchEvent(new CustomEvent('toggleSettings')); }); } } // Показываем/скрываем пункт сохранения в зависимости от режима function updateSaveButtonVisibility() { const saveItem = document.getElementById('saveItem'); if (!saveItem) return; if (state.isMultiplayer && state.otherPlayers.size > 0) { saveItem.style.display = 'none'; } else { saveItem.style.display = 'block'; } } // === multiplayer/socket-helpers.js === // Socket helpers: цвет, отправка позиции/блоков // Генерация случайного цвета для игрока на основе 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 для отправки позиции (10-20 раз в секунду) let lastMoveSendTime = 0; const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду let lastSentX = 0, lastSentY = 0; // Отправка позиции игрока (с throttle) function sendPlayerPosition() { if (!state.isMultiplayer || !state.socket || !state.socket.connected) return; const now = performance.now() / 1000; if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return; // Отправляем только если позиция изменилась const dx = Math.abs(state.player.x - lastSentX); const dy = Math.abs(state.player.y - lastSentY); if (dx < 1 && dy < 1) return; lastMoveSendTime = now; lastSentX = state.player.x; lastSentY = state.player.y; state.socket.emit('player_move', { x: state.player.x, y: state.player.y, player_name: state.playerName }); } // Отправка изменения блока function sendBlockChange(gx, gy, t, op) { if (!state.isMultiplayer || !state.socket || !state.socket.connected) return; state.socket.emit('block_change', { gx, gy, t, op }); } // === multiplayer/socket.js === // Socket.IO клиент — инициализация и все обработчики let socket = null; 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}`); state.mySocketId = socket.id; state.isMultiplayer = true; // Присоединяемся к миру socket.emit('join_world', { world_id: worldId, player_name: playerName }); // Показываем в UI state.worldIdEl.textContent = worldId; state.multiplayerStatus.style.display = 'block'; }); socket.on('connect_error', (error) => { console.error('Socket connection error:', error); state.isMultiplayer = false; }); socket.on('disconnect', () => { console.log('Disconnected from server'); state.isMultiplayer = false; state.otherPlayers.clear(); state.multiplayerStatus.style.display = 'none'; }); // Обработка world_state socket.on('world_state', (data) => { console.log('Received world_state:', data); // Устанавливаем seed и перегенерируем мир если он изменился if (data.seed !== undefined && data.seed !== state.worldSeed) { const oldSeed = state.worldSeed; state.worldSeed = data.seed; console.log('World seed changed from', oldSeed, 'to', state.worldSeed); // Очищаем и перегенерируем мир с новым seed state.generated.clear(); grid.clear(); blocks.length = 0; state.placedBlocks = []; state.removedBlocks = []; console.log('World regenerated with new seed:', state.worldSeed); } // Применяем блоки — сохраняем в state.serverOverrides для применения после genColumn if (data.blocks && Array.isArray(data.blocks)) { for (const block of data.blocks) { const key = k(block.gx, block.gy); state.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) { state.worldTime = data.time; state.isNightTime = state.worldTime > 0.5; } // Погода с сервера (мультиплеер) if (data.weatherType) { state.weatherType = data.weatherType; state.weatherChangeInterval = Infinity; state.isRaining = (data.weatherType === 'rain' || data.weatherType === 'storm' || data.weatherType === 'thunder'); } // Всегда считаем 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; } } state.spawnPoint.x = startGX * TILE; state.spawnPoint.y = safeGY * TILE; console.log('Client-side spawn point:', state.spawnPoint, 'surfaceY:', surfaceY, 'safeGY:', safeGY); } // Устанавливаем игрока в точку спавна state.player.x = state.spawnPoint.x; state.player.y = state.spawnPoint.y; state.player.vx = 0; state.player.vy = 0; state.player.fallStartY = state.player.y; console.log('Player moved to spawn point:', state.player.x, state.player.y); // Устанавливаем HP на 100% при каждом подключении к миру state.player.hp = 100; state.player.hunger = 100; state.player.o2 = 100; state.player.invuln = 0; console.log('[MULTIPLAYER CONNECT] Player HP set to 100% on connect'); // Обновляем список игроков if (data.players && Array.isArray(data.players)) { state.otherPlayers.clear(); for (const p of data.players) { if (p.socket_id !== state.mySocketId) { state.otherPlayers.set(p.socket_id, { x: p.x, y: p.y, color: getRandomPlayerColor(p.socket_id), name: p.player_name || 'Игрок' }); } } // Обновляем счётчик игроков state.playerCountEl.textContent = data.players.length; } // Server mobs if (data.mobs && Array.isArray(data.mobs)) { state.serverMobs.clear(); for (const m of data.mobs) { const sm = { ...m, maxHp: m.maxHp || m.hp, vx: m.vx || 0, vy: m.vy || 0, grounded: false, inWater: false, aiT: 0, dir: m.dir || 1, dead: false, fuse: m.fuse || 0, shootCooldown: 2, speed: m.speed || 80 }; state.serverMobs.set(m.id, sm); } } }); // Игрок присоединился socket.on('player_joined', (data) => { console.log('Player joined:', data.socket_id); if (data.socket_id !== state.mySocketId) { // Генерируем безопасную позицию для нового игрока const spawnGX = 6; genColumn(spawnGX); const surfaceY = surfaceGyAt(spawnGX); const safeSpawnX = spawnGX * TILE; const safeSpawnY = (surfaceY - 1) * TILE; state.otherPlayers.set(data.socket_id, { x: safeSpawnX, y: safeSpawnY, color: getRandomPlayerColor(data.socket_id), name: data.player_name || 'Игрок' }); addChatMessage('Система', `Игрок присоединился`); // Обновляем видимость кнопки сохранения updateSaveButtonVisibility(); } }); // Игрок переместился socket.on('player_moved', (data) => { if (data.socket_id !== state.mySocketId && state.otherPlayers.has(data.socket_id)) { const p = state.otherPlayers.get(data.socket_id); p.x = data.x; p.y = data.y; // Обновляем имя, если оно пришло if (data.player_name) { p.name = data.player_name; } } }); // Игрок покинул socket.on('player_left', (data) => { console.log('Player left:', data.socket_id); state.otherPlayers.delete(data.socket_id); addChatMessage('Система', `Игрок покинул игру`); // Обновляем видимость кнопки сохранения updateSaveButtonVisibility(); }); // === MOB SYNC (multiplayer) === socket.on('mob_spawned', (data) => { const sm = { ...data, maxHp: data.maxHp || data.hp, vx: data.vx || 0, vy: data.vy || 0, grounded: false, inWater: false, aiT: 0, dir: data.dir || 1, dead: false, fuse: data.fuse || 0, shootCooldown: 2, speed: data.speed || 80 }; state.serverMobs.set(data.id, sm); }); socket.on('mob_positions', (arr) => { for (const u of arr) { const sm = state.serverMobs.get(u.id); if (sm) { sm.x = u.x; sm.y = u.y; sm.vx = u.vx; sm.vy = u.vy; sm.dir = u.dir; sm.hp = u.hp; sm.fuse = u.fuse || 0; } } }); socket.on('mob_despawned', (data) => { state.serverMobs.delete(data.id); }); socket.on('mob_died', (data) => { const sm = state.serverMobs.get(data.id); if (sm && data.killer === state.mySocketId) { // Give loot to the killer if (sm.kind === 'chicken') playSound('hurt_chicken'); state.inv.meat += (sm.kind === 'chicken' ? 1 : 2); if (sm.kind === 'skeleton') { state.inv.arrow += 2 + Math.floor(Math.random() * 3); if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1; } rebuildHotbar(); } state.serverMobs.delete(data.id); }); socket.on('mob_hurt_ack', (data) => { const sm = state.serverMobs.get(data.id); if (sm) sm.hp = data.hp; }); socket.on('mob_explode', (data) => { explodeAt(data.gx, data.gy); state.serverMobs.delete(data.id); }); socket.on('mob_shoot', (data) => { state.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); state.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 === state.mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`; addChatMessage(senderName, data.message); }); // Обновление времени с сервера socket.on('time_sync', (data) => { if (data.time !== undefined) { state.worldTime = data.time; state.isNightTime = state.worldTime > 0.5; } }); // Погода с сервера (мультиплеер) socket.on('weather_update', (data) => { if (!state.isMultiplayer) return; if (data.type) { state.weatherType = data.type; state.weatherChangeInterval = Infinity; state.isRaining = (data.type === 'rain' || data.type === 'storm' || data.type === 'thunder'); } }); // ==================== РЫНОК ==================== socket.on('market_update', (data) => { // Update local market cache if needed // The market UI will refresh automatically when opened }); socket.on('market_error', (data) => { alert(data.message || 'Ошибка рынка'); }); socket.on('market_accepted', (data) => { // Notify user about successful trade if (data.gotItem && data.gaveItem) { const gotName = BLOCKS[data.gotItem]?.n || ITEMS[data.gotItem]?.n || TOOLS[data.gotItem]?.n || data.gotItem; const gaveName = BLOCKS[data.gaveItem]?.n || ITEMS[data.gaveItem]?.n || TOOLS[data.gaveItem]?.n || data.gaveItem; if (data.gotItem === 'iron_armor') gotName = 'Железная броня'; if (data.gaveItem === 'iron_armor') gaveName = 'Железная броня'; alert(`Обмен успешен! Вы получили: ${gotName} ×${data.gotQty}\nВы отдали: ${gaveName} ×${data.gaveQty}`); } else { alert('Обмен успешен!'); } // Refresh market if open if (state.marketPanel && state.marketPanel.style.display === 'block') { renderMarket(); } }); } catch (e) { console.error('Error initializing socket:', e); state.isMultiplayer = false; } } } // === world/water.js === // Физика жидкости 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(state.camX / TILE) - 10; const maxGX = Math.floor((state.camX + W) / TILE) + 10; const minGY = Math.floor(state.camY / TILE) - 10; const maxGY = Math.floor((state.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); } } } // === physics/water-detect.js === function isWaterAt(px, py) { const gx = Math.floor(px / TILE); const gy = Math.floor(py / TILE); const b = getBlock(gx, gy); return !!(b && b.t === 'water'); } function updateWaterFlag(e) { const cx = e.x + e.w / 2; const wasInWater = e.inWater; // В воде, если в воде хотя бы центр/ноги (чтобы корректно работать у поверхности) const mid = isWaterAt(cx, e.y + e.h / 2); const feet = isWaterAt(cx, e.y + e.h - 2); e.inWater = mid || feet; // Голова под водой — для кислорода/урона e.headInWater = isWaterAt(cx, e.y + 4); // Звук при падении в воду if (e === state.player && !wasInWater && e.inWater && e.vy > 100) { playSound('splash'); } } // === physics/collision.js === 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 (state.inp.jump) { e.vy = -200; } // Если нажимаем вниз - спускаемся else if (state.inp.down) { 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) && state.inp.jump && 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 === state.player && !state.player.inWater) { const fallTiles = (e.y - e.fallStartY) / TILE; if (fallTiles > 6) { const damage = calculateDamage((fallTiles - 6) * 10); state.player.hp -= damage; } } if (e === state.player) e.fallStartY = e.y; } } // 2) Проверка на возможность запрыгнуть на блок, если игрок рядом с ним if (e.vy < 0 && e === state.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 === state.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; } } } // === entities/mob-ai.js === // Моб 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((state.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) - (state.player.x + state.player.w / 2)) < 28 && Math.abs((m.y + m.h / 2) - (state.player.y + state.player.h / 2)) < 40 && state.player.invuln <= 0) { const damage = calculateDamage(15); state.player.hp -= damage; state.player.invuln = 0.8; state.player.vx += dir * 420; state.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((state.player.x) - m.x); const dist = Math.hypot((state.player.x + state.player.w / 2) - (m.x + m.w / 2), (state.player.y + state.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((state.player.x) - m.x); const dist = Math.hypot((state.player.x + state.player.w / 2) - (m.x + m.w / 2), (state.player.y + state.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 = (state.player.x + state.player.w / 2) - (m.x + m.w / 2); const dy = (state.player.y + state.player.h / 2) - (m.y + m.h / 2); const angle = Math.atan2(dy, dx); const speed = 450; state.projectiles.push({ x: m.x + m.w / 2, y: m.y + m.h / 3, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, dmg: 6, owner: 'mob', life: 3 }); } } else { // животные m.aiT -= dt; if (m.aiT <= 0) { m.aiT = 1.8 + Math.random() * 2.5; m.dir = Math.random() < 0.5 ? -1 : 1; if (Math.random() < 0.25) m.dir = 0; } m.vx = m.dir * (m.kind === 'chicken' ? 55 : 40); if (m.inWater) m.vy = -120; } // физика моба const g = m.inWater ? GRAV_WATER : GRAV; m.vy += g * dt; m.y += m.vy * dt; m.grounded = false; resolveY(m); m.x += m.vx * dt; resolveX(m); } function isNight() { // Автоматический цикл: ночь когда worldTime > 0.5 return state.worldTime > 0.5; } // === data/recipes.js === const RECIPES = [ { out:'planks', qty:4, cost:{ wood:1 } }, { out:'ladder', qty:3, cost:{ planks:7 } }, { out:'torch', qty:2, cost:{ coal:1, planks:1 } }, { out:'glass', qty:1, cost:{ sand:3 } }, { out:'brick', qty:1, cost:{ stone:2, clay:1 } }, { out:'campfire', qty:1, cost:{ wood:1, coal:1 } }, { out:'tnt', qty:1, cost:{ sand:2, coal:1 } }, { out:'bed', qty:1, cost:{ wood: 3, planks: 3 } }, { out:'boat', qty:1, cost:{ wood: 5 } }, { out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 } }, { out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 } }, { out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 } }, { out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 } }, { out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 } }, { out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 } }, { out:'iron_armor', qty:1, cost:{ iron_ore: 5 } }, { out:'furnace', qty:1, cost:{ stone: 8 } }, { out:'bow', qty:1, cost:{ wood: 3, planks: 2 } }, { out:'arrow', qty:4, cost:{ stone: 1, wood: 1 } } ]; // Рецепты печи (обжиг) const SMELTING_RECIPES = [ { in:'sand', qty:1, out:'glass', outQty:1, time:3 }, // песок → стекло { in:'clay', qty:1, out:'brick', outQty:1, time:3 }, // глина → кирпич { in:'iron_ore', qty:1, out:'iron_ingot', outQty:1, time:5 }, // железо → слиток { in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 }, // золото → слиток { in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 }, // медь → слиток { in:'meat', qty:1, out:'cooked', outQty:1, time:2 }, // сырое мясо → жареное { in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 } // булыжник → камень ]; // === ui/furnace.js === // ==================== ПЕЧЬ (ОБЖИГ) ==================== const furnacePanel = document.getElementById('furnacePanel'); const furnaceContent = document.getElementById('furnaceContent'); function initFurnace() { document.getElementById('furnaceClose').onclick = () => { furnacePanel.style.display = 'none'; state.currentFurnaceKey = null; }; } function openFurnaceUI(gx, gy) { state.currentFurnaceKey = `${gx},${gy}`; furnacePanel.style.display = 'block'; renderFurnaceUI(); } function renderFurnaceUI() { if (!state.currentFurnaceKey) return; // Проверяем что печь всё ещё существует const [fgx, fgy] = state.currentFurnaceKey.split(',').map(Number); const fb = getBlock(fgx, fgy); if (!fb || fb.t !== 'furnace') { furnacePanel.style.display = 'none'; state.currentFurnaceKey = null; return; } // Текущий процесс обжига const active = state.activeFurnaces.get(state.currentFurnaceKey); let html = '
'; // Доступные рецепты — показываем только те, для которых есть ресурсы for (let i = 0; i < SMELTING_RECIPES.length; i++) { const recipe = SMELTING_RECIPES[i]; const haveCount = state.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 (!state.currentFurnaceKey) return; const recipe = SMELTING_RECIPES[recipeIdx]; if ((state.inv[recipe.in] || 0) < recipe.qty) return; // Уже обжигаем в этой печи? if (state.activeFurnaces.has(state.currentFurnaceKey)) return; // Забираем ресурсы state.inv[recipe.in] -= recipe.qty; // Запускаем обжиг state.activeFurnaces.set(state.currentFurnaceKey, { recipe: recipe, progress: 0 }); playSound('fire'); rebuildHotbar(); renderFurnaceUI(); }; // Тик печей — вызывается в главном цикле function tickFurnaces(dt) { for (const [key, furnace] of state.activeFurnaces) { furnace.progress += dt; if (furnace.progress >= furnace.recipe.time) { // Обжиг завершён — выдаём результат const outItem = furnace.recipe.out; if (ITEMS[outItem]) { state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty; } else if (BLOCKS[outItem]) { state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty; } playSound('stone_build'); state.activeFurnaces.delete(key); // Если эта печь открыта — обновляем UI if (key === state.currentFurnaceKey) { renderFurnaceUI(); } } } } // === world/weather.js === // ==================== ПОГОДА ==================== function updateWeather(dt) { state.weatherTimer += dt; if (state.weatherTimer >= state.weatherChangeInterval) { state.weatherTimer = 0; state.weatherChangeInterval = 60 + Math.random() * 120; // Выбираем новую погоду: 40% дождь днём, 25% дождь ночью, остальное ясно const nightChance = isNight() ? 0.25 : 0.40; state.isRaining = Math.random() < nightChance; } // Плавная интерполяция интенсивности const target = state.isRaining ? (0.4 + Math.random() * 0.01) : 0; state.rainIntensity += (target - state.rainIntensity) * dt * 0.5; if (state.rainIntensity < 0.01) state.rainIntensity = 0; } function updateRain(dt) { if (!state.isRaining || state.rainIntensity < 0.01) { state.raindrops.length = 0; return; } // Спавн капель const spawnRate = Math.floor(state.rainIntensity * 60 * dt); // ~60 капель/сек при интенсити=1 for (let i = 0; i < spawnRate && state.raindrops.length < state.MAX_RAINDROPS; i++) { state.raindrops.push({ x: state.camX + Math.random() * state.W, y: state.camY - 20, vy: 400 + Math.random() * 200, len: 8 + Math.random() * 12 }); } // Обновление for (let i = state.raindrops.length - 1; i >= 0; i--) { const d = state.raindrops[i]; d.y += d.vy * dt; d.x -= 30 * dt; // лёгкий ветер if (d.y > state.camY + state.H + 20) { state.raindrops.splice(i, 1); } } } function drawRain() { if (state.raindrops.length === 0) return; const ctx = state.ctx; ctx.save(); ctx.strokeStyle = 'rgba(174,194,224,0.5)'; ctx.lineWidth = 1.5; ctx.beginPath(); for (const d of state.raindrops) { ctx.moveTo(d.x, d.y); ctx.lineTo(d.x - 3, d.y + d.len); } ctx.stroke(); ctx.restore(); } // === entities/mobs.js === // Сущности: животные + зомби class Entity { constructor(x, y, w, h) { this.x = x; this.y = y; this.w = w; this.h = h; this.vx = 0; this.vy = 0; this.hp = 3; this.grounded = false; this.inWater = false; this.aiT = 0; this.dir = 1; } } class Pig extends Entity { constructor(x, y) { super(x, y, 34, 24); this.kind = 'pig'; this.hp = 2; } } class Chicken extends Entity { constructor(x, y) { super(x, y, 26, 22); this.kind = 'chicken'; this.hp = 1; } } class Zombie extends Entity { constructor(x, y) { super(x, y, 34, 50); this.kind = 'zombie'; this.hp = 4; this.speed = 80 + Math.random() * 40; } } class Creeper extends Entity { constructor(x, y) { super(x, y, 34, 50); this.kind = 'creeper'; this.hp = 4; this.speed = 60 + Math.random() * 30; this.fuse = 3.2; } } class Skeleton extends Entity { constructor(x, y) { super(x, y, 34, 50); this.kind = 'skeleton'; this.hp = 4; this.speed = 70 + Math.random() * 30; this.shootCooldown = 0; } } // === ui/minimap.js === // ==================== МИНИКАРТА ==================== const minimapWrap = document.getElementById('minimapWrap'); const minimapCanvas = document.getElementById('minimap'); const minimapCtx = minimapCanvas.getContext('2d'); // Цвета блоков для миникарты (по 1 пикселю на блок) const MINIMAP_COLORS = { grass: '#4a8c2a', dirt: '#6b3410', stone: '#6a6a6a', sand: '#d4b84a', gravel: '#888', clay: '#5a9ad4', wood: '#a63d00', planks: '#c46a10', leaves: '#2a8a3a', glass: '#aaddee', water: '#2980b9', coal: '#1a1a2a', copper_ore: '#a05535', iron_ore: '#b0b0b0', gold_ore: '#d4a017', diamond_ore: '#0090d0', brick: '#8a2010', tnt: '#c02020', campfire: '#d08020', torch: '#d0a020', bedrock: '#1a1a1a', flower: '#d03050', bed: '#c03060', ladder: '#a04000', boat: '#6b3410' }; function initMinimap() { document.getElementById('mapToggle').onclick = () => { playSound('click'); state.minimapOpen = !state.minimapOpen; minimapWrap.style.display = state.minimapOpen ? 'block' : 'none'; }; } function renderMinimap() { if (!state.minimapOpen) return; const mW = minimapCanvas.width; const mH = minimapCanvas.height; const scale = 2; // пикселей на блок const TILE = state.TILE; const player = state.player; // Область карты — центрирована на игроке const pGX = Math.floor(player.x / TILE); const pGY = Math.floor(player.y / TILE); const viewW = Math.floor(mW / scale); const viewH = Math.floor(mH / scale); const startGX = pGX - Math.floor(viewW / 2); const startGY = pGY - Math.floor(viewH / 2); // Очищаем minimapCtx.fillStyle = isNight() ? '#070816' : '#87CEEB'; minimapCtx.fillRect(0, 0, mW, mH); // Рисуем блоки const imgData = minimapCtx.createImageData(mW, mH); const data = imgData.data; for (let dx = 0; dx < viewW; dx++) { for (let dy = 0; dy < viewH; dy++) { const gx = startGX + dx; const gy = startGY + dy; const b = getBlock(gx, gy); if (!b || b.dead || b.t === 'air') continue; const color = MINIMAP_COLORS[b.t]; if (!color) continue; // Парсим hex цвет const r = parseInt(color.slice(1, 3), 16); const g = parseInt(color.slice(3, 5), 16); const bl = parseInt(color.slice(5, 7), 16); // Заполняем scale x scale пикселей for (let sx = 0; sx < scale; sx++) { for (let sy = 0; sy < scale; sy++) { const px = dx * scale + sx; const py = dy * scale + sy; if (px >= mW || py >= mH) continue; const idx = (py * mW + px) * 4; data[idx] = r; data[idx + 1] = g; data[idx + 2] = bl; data[idx + 3] = 255; } } } } minimapCtx.putImageData(imgData, 0, 0); // Игрок — белый пиксель по центру minimapCtx.fillStyle = '#fff'; minimapCtx.fillRect(Math.floor(mW / 2) - 2, Math.floor(mH / 2) - 2, 4, 4); // Другие игроки — жёлтые точки for (const [sid, p] of state.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 = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs; 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); } } } // === game/modes.js === // ==================== РЕЖИМЫ ==================== const MODES = [{ id: 'mine', icon: '⛏️' }, { id: 'build', icon: '🧱' }]; function mode() { return MODES[state.modeIdx].id; } function initModes() { const modeBtn = document.getElementById('modeBtn'); modeBtn.onclick = () => { playSound('click'); // Звук клика по кнопке режима state.modeIdx = (state.modeIdx + 1) % MODES.length; modeBtn.textContent = MODES[state.modeIdx].icon; }; } // === input/mouse-handler.js === // ==================== ВЗАИМОДЕЙСТВИЕ МЫШЬ/ТАП ==================== const mouse = { x: null, y: null }; function initMouseHandlers() { const canvas = state.canvas; 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 (state.craftOpen) return; if (state.player.hp <= 0) return; const r = canvas.getBoundingClientRect(); const sx = e.clientX - r.left; const sy = e.clientY - r.top; const wx = sx + state.camX; const wy = sy + state.camY; const gx = Math.floor(wx / state.TILE); const gy = Math.floor(wy / state.TILE); // Пробуждение: клик по любой кровати когда спишь const b = getBlock(gx, gy); if (state.player.sleeping && b && b.t === 'bed') { state.player.sleeping = false; return; } if (state.player.sleeping) return; // Нельзя взаимодействовать во время сна // Клик по печи — открываем панель обжига if (b && b.t === 'furnace' && mode() === 'mine') { openFurnaceUI(gx, gy); return; } // клик по мобу (в режиме mine) if (mode() === 'mine') { // Check server mobs first (multiplayer) if (state.isMultiplayer) { for (const [id, sm] of state.serverMobs) { if (sm.dead) continue; if (wx >= sm.x && wx <= sm.x + sm.w && wy >= sm.y && wy <= sm.y + sm.h) { let dmg = 1; const swordTypes = ['iron_sword', 'stone_sword', 'wood_sword']; for (const st of swordTypes) { if (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; } } state.socket.emit('mob_hurt', { id: sm.id, dmg }); playSound('attack'); return; } } } // Local mobs (singleplayer or if not hit server mob) for (let i = state.mobs.length - 1; i >= 0; i--) { const m = state.mobs[i]; 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 (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; } } m.hp -= dmg; m.vx += (m.x - state.player.x) * 2; m.vy -= 200; playSound('attack'); if (m.hp <= 0) { if (m.kind === 'chicken') playSound('hurt_chicken'); state.inv.meat += (m.kind === 'chicken' ? 1 : 2); if (m.kind === 'skeleton') { state.inv.arrow += 2 + Math.floor(Math.random() * 3); if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1; } state.mobs.splice(i, 1); rebuildHotbar(); } return; } } } // Лук — стреляем стрелой if (state.selected === 'bow' && state.inv.bow > 0 && state.inv.arrow > 0) { const aimX = wx - state.player.x - state.player.w / 2; const aimY = wy - state.player.y - state.player.h / 2; const angle = Math.atan2(aimY, aimX); state.projectiles.push({ x: state.player.x + state.player.w / 2, y: state.player.y + state.player.h / 3, vx: Math.cos(angle) * 550, vy: Math.sin(angle) * 550, dmg: 10, owner: 'player', life: 4 }); state.inv.arrow--; useTool('bow'); playSound('hit1'); rebuildHotbar(); return; } // еда (предмет) if (ITEMS[state.selected] && state.inv[state.selected] > 0) { const it = ITEMS[state.selected]; if (state.player.hp < 100 || state.player.hunger < 100) { playSound('eat1'); // Звук употребления еды state.player.hunger = Math.min(100, state.player.hunger + it.food); state.player.hp = Math.min(100, state.player.hp + 15); state.inv[state.selected]--; rebuildHotbar(); } return; } // жарка на костре: выбран meat + клик по campfire if (b && b.t === 'campfire' && state.selected === 'meat' && state.inv.meat > 0) { playSound('fire'); // Звук при жарке на костре state.inv.meat--; state.inv.cooked++; rebuildHotbar(); return; } // Сон на кровати: клик по bed if (b && b.t === 'bed' && isNight()) { state.player.sleeping = true; saveGame(); // Сохраняем при отходе ко сну return; } if (mode() === 'mine') { if (!b) return; if (BLOCKS[b.t].fluid || BLOCKS[b.t].decor) return; if (b.t === 'tnt') { activateTNT(b, 3.2); return; } // не взрывается сразу const removed = removeBlock(gx, gy); if (removed) { state.inv[removed.t] = (state.inv[removed.t] || 0) + 1; // Тратим прочность кирки (если есть в инвентаре) const pickTypes = ['iron_pickaxe', 'stone_pickaxe', 'wood_pickaxe']; for (const pt of pickTypes) { if (state.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 (state.inv[state.selected] <= 0) return; if (!BLOCKS[state.selected]) return; if (b) return; // занято // Проверяем, ставим ли лодку if (state.selected === 'boat') { // Лодку можно ставить только на воду const waterBelow = getBlock(gx, gy + 1); if (!waterBelow || waterBelow.t !== 'water') { return; } // Создаём лодку state.boat.x = gx * state.TILE; state.boat.y = gy * state.TILE; state.boat.vx = 0; state.boat.vy = 0; state.boat.active = true; state.boat.inWater = true; // Сажаем игрока в лодку state.player.inBoat = true; state.player.x = state.boat.x; state.player.y = state.boat.y; state.player.vx = 0; state.player.vy = 0; playSound('splash'); state.inv[state.selected]--; rebuildHotbar(); return; } // запрет ставить в игрока const TILE = state.TILE; const bx = gx * TILE, by = gy * TILE; const overlap = !(bx >= state.player.x + state.player.w || bx + TILE <= state.player.x || by >= state.player.y + state.player.h || by + TILE <= state.player.y); if (overlap) return; setBlock(gx, gy, state.selected, true); // true = блок установлен игроком state.inv[state.selected]--; // Отправляем изменение блока на сервер sendBlockChange(gx, gy, state.selected, 'set'); // Звук при строительстве if (state.selected === 'stone' || state.selected === 'brick') playSound('stone_build'); else if (state.selected === 'wood' || state.selected === 'planks') playSound('wood_build'); else if (state.selected === 'glass') playSound('glass1'); else if (state.selected === 'sand') playSound('sand1'); else if (state.selected === 'snow') playSound('snow1'); else if (state.selected === 'dirt' || state.selected === 'grass') playSound('cloth1'); rebuildHotbar(); return; } }); } // === render/draw-fire.js === // Рисование костра: огонь поверх текстуры function drawFire(ctx, wx, wy, now) { const baseX = wx; const baseY = wy; const flick = 6 + (Math.sin(now / 90) + 1) * 4; ctx.fillStyle = 'rgba(255,140,0,0.85)'; ctx.beginPath(); ctx.moveTo(baseX + 10, baseY + 30); ctx.lineTo(baseX + 20, baseY + 30 - flick); ctx.lineTo(baseX + 30, baseY + 30); ctx.fill(); ctx.fillStyle = 'rgba(255,230,150,0.75)'; ctx.beginPath(); ctx.moveTo(baseX + 14, baseY + 30); ctx.lineTo(baseX + 20, baseY + 30 - (flick * 0.7)); ctx.lineTo(baseX + 26, baseY + 30); ctx.fill(); } // === game/loop.js === // ==================== ГЛАВНЫЙ ИГРОВОЙ ЦИКЛ ==================== // ==================== 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 player = state.player; const inv = state.inv; const inp = state.inp; const clouds = state.clouds; const mobs = state.mobs; const projectiles = state.projectiles; const parts = state.parts; const boat = state.boat; const jumpPressed = inp.j && !prevJump; prevJump = inp.j; // В MP время синхронизируется с сервера if (state.isMultiplayer) { // Сон всё ещё работает локально (только лечение, без ускорения времени) if (player.sleeping && isNight()) { player.hp = Math.min(100, player.hp + dt * 20); if (!isNight()) { player.sleeping = false; } } } else { // Ускорение времени во время сна if (player.sleeping && isNight()) { state.worldTime += dt * 8 / state.DAY_LEN; // В 8 раз быстрее // Восстанавливаем здоровье во время сна player.hp = Math.min(100, player.hp + dt * 20); // Автоматическое пробуждение когда наступает день if (!isNight()) { player.sleeping = false; } } else { state.worldTime += dt / state.DAY_LEN; } if (state.worldTime >= 1) state.worldTime -= 1; } // камера следует за игроком по X/Y state.camX = Math.floor((player.x + player.w / 2) - state.W / 2); state.camY = Math.floor((player.y + player.h / 2) - state.H / 2); ensureGenAroundCamera(); // clouds parallax for (const c of clouds) { c.x -= c.s * dt; if (c.x + c.w < state.camX - 400) c.x = state.camX + state.W + Math.random() * 700; } // player updateWaterFlag(player); // кислород/утопление: тратим O2 только если голова под водой, иначе восстанавливаем if (player.headInWater) { player.o2 = Math.max(0, player.o2 - 6 * dt); // замедлил в 3.7 раза if (player.o2 === 0) { const damage = calculateDamage(4 * dt); player.hp -= damage; } } else { player.o2 = Math.min(100, player.o2 + 10 * dt); // замедлил восстановление в 4 раза } // голод убывает, но HP не отнимает (как просили) player.hunger = Math.max(0, player.hunger - dt * 0.2); // замедлил в 4 раза // Игрок не может двигаться во время сна if (player.sleeping) { player.vx = 0; player.vy = 0; } else { const dir = (inp.r ? 1 : 0) - (inp.l ? 1 : 0); if (dir) player.vx = dir * state.MOVE; else player.vx *= 0.82; } // Звук шагов при движении по земле if (player.grounded && !player.inWater && Math.abs(player.vx) > 50) { const stepInterval = 0.35; // Интервал между шагами в секундах if (now / 1000 - player.lastStepTime > stepInterval) { playSound('step'); player.lastStepTime = now / 1000; } } // прыжок/плавание (новая логика) if (player.inBoat) { // Игрок в лодке - лодка следует за игроком const dir = (inp.r ? 1 : 0) - (inp.l ? 1 : 0); if (dir) boat.vx = dir * state.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 += state.TILE; // Прыгаем из лодки player.vy = -state.JUMP * 0.5; playSound('splash'); } } else if (player.inWater) { // сопротивление в воде player.vx *= 0.90; player.vy *= 0.92; // Если не нажимаем прыжок - тонем (гравитация в воде) if (!jumpPressed && !inp.j) { // Применяем гравитацию в воде - игрок тонет player.vy += state.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 = -state.JUMP; player.grounded = false; player.fallStartY = player.y; } } // Гравитация применяется только вне воды и вне лодки if (!player.inWater && !player.inBoat) { player.vy += state.GRAV * dt; } // Обновляем позицию лодки if (boat.active) { boat.x += boat.vx * dt; boat.y += boat.vy * dt; // Лодка не выходит за пределы воды const boatGX = Math.floor(boat.x / state.TILE); const boatGY = Math.floor(boat.y / state.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 += state.TILE; player.vy = -200; playSound('splash'); } } // Проверяем, не доплыл ли игрок из лодки if (player.inBoat && !boat.active) { inv.boat = (inv.boat || 0) + 1; player.inBoat = false; player.y += state.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); // Погода и дождь (в MP погода с сервера) if (!state.isMultiplayer) { updateWeather(dt); } updateRain(dt); player.invuln = Math.max(0, player.invuln - dt); // Voice position update state.voicePosT += dt; if (state.voicePosT > 0.5 && state.voiceSocket && state.voiceSocket.connected) { state.voicePosT = 0; state.voiceSocket.emit('voice_pos', { x: player.x, y: player.y }); } // Furnace tick tickFurnaces(dt); // Обновляем UI печи если открыта if (state.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 / state.TILE); const gy = Math.floor(p.y / state.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 { // Попал в моба — server mobs first in multiplayer let hitMob = false; if (state.isMultiplayer) { for (const [id, sm] of state.serverMobs) { if (sm.dead) continue; if (p.x > sm.x && p.x < sm.x + sm.w && p.y > sm.y && p.y < sm.y + sm.h) { state.socket.emit('mob_arrow_hit', { id: sm.id, dmg: p.dmg, vx: p.vx }); projectiles.splice(i, 1); hitMob = true; break; } } } if (!hitMob) { // Local mobs for (let j = mobs.length - 1; j >= 0; j--) { const m = mobs[j]; 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; if (m.hp <= 0) { inv.meat += (m.kind === 'chicken' ? 1 : 2); if (m.kind === 'skeleton') { inv.arrow += 2 + Math.floor(Math.random() * 3); if (Math.random() < 0.15) inv.bow = (inv.bow || 0) + 1; } mobs.splice(j, 1); rebuildHotbar(); } projectiles.splice(i, 1); break; } } } } // Таймаут if (p.life <= 0) projectiles.splice(i, 1); } // TNT tick for (const key of Array.from(state.activeTNT)) { const b = state.grid.get(key); if (!b || b.dead) { state.activeTNT.delete(key); continue; } b.fuse -= dt; if (b.fuse <= 0) { explodeAt(b.gx, b.gy); } } // mobs spawn (с обеих сторон камеры) — только в одиночном режиме state.spawnT += dt; if (!state.isMultiplayer && state.spawnT > 1.8 && mobs.length < 30) { state.spawnT = 0; // Выбираем сторону спавна (левая или правая) const spawnLeft = Math.random() < 0.5; const gx = spawnLeft ? Math.floor((state.camX - 200) / state.TILE) : Math.floor((state.camX + state.W + 200) / state.TILE); genColumn(gx); const sgy = surfaceGyAt(gx); const wx = gx * state.TILE + 4; const wy = (sgy - 2) * state.TILE; // не спавнить в воде const top = getBlock(gx, sgy); if (top && top.t === 'water') { // skip } else { const night = isNight(); if (night) { // Ночью спавним враждебных мобов (максимум 12 хостайл) const hostileCount = mobs.filter(m => m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton').length; if (hostileCount < 12) { const rand = Math.random(); if (rand < 0.35) { mobs.push(new Zombie(wx, wy)); } else if (rand < 0.55) { mobs.push(new Creeper(wx, wy)); } else { mobs.push(new Skeleton(wx, wy)); } } } // Животные спавнятся и днём и ночью (с лимитом) const animalCount = mobs.filter(m => m.kind === 'pig' || m.kind === 'chicken').length; if (animalCount < 8) { mobs.push(Math.random() < 0.5 ? new Pig(wx, wy) : new Chicken(wx, wy)); } } } // mobs update — только локальные (singleplayer) if (!state.isMultiplayer) { for (let i = mobs.length - 1; i >= 0; i--) { const m = mobs[i]; mobAI(m, dt); if (m.hp <= 0) mobs.splice(i, 1); } } // 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 += state.GRAV * dt; if (p.t <= 0) parts.splice(i, 1); } // death if (player.hp <= 0) { state.deathEl.style.display = 'flex'; } else if (state.deathEl.style.display === 'flex') { // Если HP > 0 но экран смерти всё ещё показан - скрываем его state.deathEl.style.display = 'none'; } // ==================== RENDER ==================== const ctx = state.ctx; const W = state.W; const H = state.H; const camX = state.camX; const camY = state.camY; const TILE = state.TILE; const tex = state.tex; const night = isNight(); // sky ctx.fillStyle = night ? '#070816' : (state.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; const blocks = state.blocks; // 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(ctx, b.gx * TILE, b.gy * TILE, now); } // Печь — огонь когда обжигает if (b.t === 'furnace' && state.activeFurnaces.has(`${b.gx},${b.gy}`)) { drawFire(ctx, b.gx * TILE + 8, b.gy * TILE + 5, now); } } // mobs const allMobsRender = state.isMultiplayer ? Array.from(state.serverMobs.values()) : mobs; for (const m of allMobsRender) { if (m.kind === 'zombie') { ctx.fillStyle = '#2ecc71'; ctx.fillRect(m.x, m.y, m.w, m.h); ctx.fillStyle = '#c0392b'; ctx.fillRect(m.x + 6, m.y + 12, 6, 6); ctx.fillRect(m.x + 22, m.y + 12, 6, 6); } else if (m.kind === 'pig') { ctx.fillStyle = '#ffb6c1'; ctx.fillRect(m.x, m.y, m.w, m.h); ctx.fillStyle = '#000'; ctx.fillRect(m.x + 22, m.y + 5, 3, 3); ctx.fillStyle = '#ff69b4'; ctx.fillRect(m.x + 28, m.y + 12, 6, 6); } else if (m.kind === 'chicken') { // chicken ctx.fillStyle = '#ecf0f1'; ctx.fillRect(m.x, m.y, m.w, m.h); ctx.fillStyle = '#f39c12'; ctx.fillRect(m.x + 18, m.y + 10, 6, 4); ctx.fillStyle = '#000'; ctx.fillRect(m.x + 8, m.y + 6, 3, 3); } else if (m.kind === 'creeper') { // creeper ctx.fillStyle = '#4CAF50'; ctx.fillRect(m.x, m.y, m.w, m.h); // Глаза ctx.fillStyle = '#000'; ctx.fillRect(m.x + 8, m.y + 8, 4, 4); ctx.fillRect(m.x + 22, m.y + 8, 4, 4); // Рот ctx.fillStyle = '#000'; ctx.fillRect(m.x + 12, m.y + 20, 10, 4); // Ноги ctx.fillStyle = '#4CAF50'; ctx.fillRect(m.x + 4, m.y + 30, 6, 20); ctx.fillRect(m.x + 24, m.y + 30, 6, 20); } else if (m.kind === 'skeleton') { // skeleton - детализированный ctx.fillStyle = '#ECEFF1'; ctx.fillRect(m.x + 10, m.y + 20, 14, 12); ctx.fillRect(m.x + 8, m.y + 0, 18, 18); ctx.fillStyle = '#000'; ctx.fillRect(m.x + 10, m.y + 6, 4, 4); ctx.fillRect(m.x + 20, m.y + 6, 4, 4); ctx.fillRect(m.x + 15, m.y + 12, 4, 2); ctx.fillStyle = '#ECEFF1'; ctx.fillRect(m.x + 2, m.y + 20, 6, 14); ctx.fillRect(m.x + 26, m.y + 20, 6, 14); ctx.fillRect(m.x + 10, m.y + 32, 6, 18); ctx.fillRect(m.x + 18, m.y + 32, 6, 18); // Лук в руке ctx.save(); ctx.translate(m.x + 30, m.y + 22); ctx.strokeStyle = '#8B4513'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, 0, 8, -Math.PI * 0.7, Math.PI * 0.7); ctx.stroke(); ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(8 * Math.cos(-Math.PI * 0.7), 8 * Math.sin(-Math.PI * 0.7)); ctx.lineTo(8 * Math.cos(Math.PI * 0.7), 8 * Math.sin(Math.PI * 0.7)); ctx.stroke(); ctx.restore(); } } // boat (рисуем первой, чтобы игрок был внутри неё) if (boat.active) { ctx.drawImage(tex['boat'], boat.x - (TILE - boat.w) / 2, boat.y - (TILE - boat.h) / 2, TILE, TILE); } // other players (multiplayer) for (const [socketId, p] of state.otherPlayers) { if (state.heroImg.complete) { ctx.drawImage(state.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 (state.heroImg.complete) { ctx.drawImage(state.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 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 && !state.craftOpen && player.hp > 0) { const wx = mouse.x + camX; const wy = mouse.y + camY; const ggx = Math.floor(wx / TILE); const ggy = Math.floor(wy / TILE); ctx.strokeStyle = 'rgba(255,255,255,0.9)'; ctx.lineWidth = 2; ctx.strokeRect(ggx * TILE, ggy * TILE, TILE, TILE); } ctx.restore(); // lighting overlay at night (Minecraft-style: offscreen lightmap, wall occlusion, warm flicker) if (night) { const lightC = state.lightC; const lightCtx = state.lightCtx; lightC.width = W * state.dpr; lightC.height = H * state.dpr; lightCtx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); lightCtx.fillStyle = 'rgba(0,0,12,0.82)'; lightCtx.fillRect(0, 0, W, H); lightCtx.globalCompositeOperation = 'destination-out'; function castLight(sx, sy, radius) { const flick = 0.88 + Math.sin(now / 80 + sx * 0.01) * 0.06 + Math.sin(now / 130 + sy * 0.02) * 0.06; const r = radius * flick; const steps2 = 12; const dists = new Float32Array(steps2); for (let i = 0; i < steps2; i++) { const ang = (i / steps2) * Math.PI * 2; const ddx = Math.cos(ang); const ddy = Math.sin(ang); let maxDist = r; for (let step = TILE * 0.5; step < r; step += TILE * 0.6) { const lgx = Math.floor((sx + ddx * step) / TILE); const lgy = Math.floor((sy + ddy * step) / TILE); const blk = getBlock(lgx, lgy); if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid && step > TILE * 0.3) { maxDist = step; break; } } dists[i] = maxDist; } 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.5, 'rgba(255,255,255,0.65)'); grad.addColorStop(1, 'rgba(255,255,255,0)'); lightCtx.fillStyle = grad; lightCtx.beginPath(); for (let i = 0; i <= steps2; i++) { const idx = i % steps2; const ang = (idx / steps2) * Math.PI * 2; const px = cx + Math.cos(ang) * dists[idx]; const py = cy + Math.sin(ang) * 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); } } lightCtx.globalCompositeOperation = 'source-over'; ctx.drawImage(lightC, 0, 0, W, H); ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (const b of blocks) { if (b.dead) continue; if (b.gx < minGX - 3 || b.gx > maxGX + 3 || b.gy < minGY - 3 || b.gy > maxGY + 3) continue; const def = BLOCKS[b.t]; if (def.lightRadius) { const flick = 0.7 + Math.sin(now / 90 + b.gx * 3.7) * 0.15 + Math.sin(now / 140 + b.gy * 2.3) * 0.15; const wx = b.gx * TILE + TILE / 2 - camX; const wy = b.gy * TILE + TILE / 2 - camY; const r = def.lightRadius * 0.6 * flick; const grad = ctx.createRadialGradient(wx, wy, 0, wx, wy, r); grad.addColorStop(0, `rgba(255,180,80,${0.12 * flick})`); grad.addColorStop(0.5, `rgba(255,140,40,${0.06 * flick})`); grad.addColorStop(1, 'rgba(255,100,20,0)'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(wx, wy, r, 0, Math.PI * 2); ctx.fill(); } } ctx.restore(); } // Дождь (после ночного оверлея) drawRain(); if (Math.random() < 0.25) { state.hpEl.textContent = Math.max(0, Math.ceil(player.hp)); state.foodEl.textContent = Math.ceil(player.hunger); document.getElementById('o2').textContent = Math.ceil(player.o2); state.sxEl.textContent = Math.floor(player.x / TILE); state.syEl.textContent = Math.floor(player.y / TILE); state.todEl.textContent = night ? 'Ночь' : 'День'; state.worldIdEl.textContent = state.worldId; if (state.isMultiplayer) { document.getElementById('multiplayerStatus').style.display = 'flex'; state.playerCountEl.textContent = state.otherPlayers.size + 1; } else { document.getElementById('multiplayerStatus').style.display = 'none'; } } // Индикатор сна if (player.sleeping) { ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillRect(0, 0, W, H); ctx.fillStyle = '#fff'; ctx.font = 'bold 32px system-ui'; ctx.textAlign = 'center'; ctx.fillText('💤 Спим...', W / 2, H / 2); ctx.font = '18px system-ui'; ctx.fillText('Нажмите на кровать чтобы проснуться', W / 2, H / 2 + 40); } // Миникарта (обновляем раз в ~4 кадра для оптимизации) if (state.minimapOpen && Math.random() < 0.25) { renderMinimap(); } requestAnimationFrame(loop); } // === ui/market.js === // ==================== РЫНОК ПРЕДЛОЖЕНИЙ ==================== const marketPanel = document.getElementById('marketPanel'); const marketClose = document.getElementById('marketClose'); const marketContent = document.getElementById('marketContent'); function initMarket() { // Кнопка закрытия marketClose.addEventListener('click', () => closeMarket()); // Слушаем событие от save-controls.js document.addEventListener('toggleMarket', () => toggleMarket()); } function toggleMarket() { if (marketPanel.style.display === 'none' || !marketPanel.style.display) { openMarket(); } else { closeMarket(); } } function closeMarket() { marketPanel.style.display = 'none'; } function openMarket() { marketPanel.style.display = 'block'; renderMarket(); } function renderMarket() { if (!state.socket) { marketContent.innerHTML = '

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

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

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

`; const btn = document.getElementById('createOrderBtn'); if (btn) btn.addEventListener('click', showCreateOrder); return; } let html = '
'; html += orders.map(o => { // Determine if current player can accept this order (not their own) const isOwnOrder = o.seller_id === state.mySocketId; const canAccept = !isOwnOrder && state.isMultiplayer; return `
${BLOCKS[o.offer_item] ? `` : ''} ${o.offer_item} ×${o.offer_qty}
⇄
${BLOCKS[o.want_item] ? `` : ''} ${o.want_item} ×${o.want_qty}
${o.seller || 'Аноним'}
${canAccept ? `` : isOwnOrder ? `` : `Ваше предложение` }
`; }).join(''); marketContent.innerHTML = html; // Add event listeners for accept/cancel buttons document.querySelectorAll('.accept-order-btn').forEach(btn => { btn.addEventListener('click', () => { const orderId = btn.getAttribute('data-order-id'); if (state.socket) { state.socket.emit('market_accept', { orderId: orderId }); } }); }); document.querySelectorAll('.cancel-order-btn').forEach(btn => { btn.addEventListener('click', () => { const orderId = btn.getAttribute('data-order-id'); if (state.socket) { state.socket.emit('market_cancel', { id: orderId }); } }); }); const createBtn = document.getElementById('createOrderBtn'); if (createBtn) createBtn.addEventListener('click', showCreateOrder); }); } function showCreateOrder() { const invItems = Object.keys(state.inv).filter(id => state.inv[id] > 0); // Create item options with images for offer const offerOptions = invItems.map(id => { const count = state.inv[id]; let itemHTML = ''; if (BLOCKS[id]) { itemHTML = ``; } else if (ITEMS[id]) { itemHTML = `${ITEMS[id].icon}`; } else if (TOOLS[id]) { itemHTML = `${TOOLS[id].icon}`; } else if (id === 'iron_armor') { itemHTML = `🛡️`; } return ``; }).join(''); marketContent.innerHTML = `

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

`; // Update max count when offer item changes const offerSelect = document.getElementById('offerItem'); const offerCountInput = document.getElementById('offerCount'); const offerMaxCountSpan = document.getElementById('offerMaxCount'); function updateOfferMax() { const selectedId = offerSelect.value; const maxCount = state.inv[selectedId] || 0; offerCountInput.max = maxCount; offerCountInput.value = Math.min(parseInt(offerCountInput.value) || 1, maxCount); offerMaxCountSpan.textContent = `Максимум: ${maxCount}`; // If current value exceeds max, adjust it if (parseInt(offerCountInput.value) > maxCount) { offerCountInput.value = maxCount; } } offerSelect.addEventListener('change', updateOfferMax); offerCountInput.addEventListener('input', () => { const val = parseInt(offerCountInput.value) || 1; const max = parseInt(offerSelect.value ? (state.inv[offerSelect.value] || 0) : 0); if (val > max) offerCountInput.value = max; }); // Initialize if (offerSelect.options.length > 0) { offerSelect.selectedIndex = 0; updateOfferMax(); } document.getElementById('cancelOrder').addEventListener('click', () => renderMarket()); document.getElementById('submitOrder').addEventListener('click', () => { const offerItem = offerSelect.value; const offerQty = parseInt(offerCountInput.value) || 1; const wantItem = document.getElementById('wantItem').value; const wantQty = parseInt(document.getElementById('wantCount').value) || 1; if (!offerItem || !wantItem) { alert('Выберите оба предмета'); return; } const offerMax = state.inv[offerItem] || 0; if (offerQty > offerMax) { alert(`Недостаточно предметов. Доступно: ${offerMax}`); return; } if (state.socket) { // Lock items for trade state.socket.emit('market_lock_items', { offer_item: offerItem, offer_qty: offerQty, want_item: wantItem, want_qty: wantQty }, (result) => { if (result.success) { // Items locked, now create the offer state.socket.emit('market_create', { offer_item: offerItem, offer_qty: offerQty, want_item: wantItem, want_qty: wantQty }); playSound('click'); setTimeout(renderMarket, 500); } else { alert(result.message || 'Не удалось заблокировать предметы для обмена'); } }); } }); } // === ui/respawn.js === // ==================== RESPAWN ==================== function initRespawn() { document.getElementById('respawnBtn').onclick = async () => { playSound('click'); // Звук клика по кнопке console.log('=== RESPAWN CLICKED ==='); console.log('isMultiplayer:', state.isMultiplayer); console.log('otherPlayers.size:', state.otherPlayers.size); console.log('player.hp before respawn:', state.player.hp); const player = state.player; const deathEl = state.deathEl; // В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке if (state.isMultiplayer && state.otherPlayers.size > 0) { console.log('Мультиплеер режим - возрождение в начальной точке'); player.hp = 100; player.hunger = 100; player.o2 = 100; player.vx = player.vy = 0; player.invuln = 0; player.x = state.spawnPoint.x; player.y = state.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 = state.spawnPoint.x; player.y = state.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 ==='); }; } // === ui/share.js === // ==================== ПОДЕЛИТЬСЯ МИРОМ ==================== function initShare() { // Обработчик клика на worldId для копирования ссылки document.getElementById('worldId').onclick = () => { const shareUrl = new URL(window.location.href); shareUrl.searchParams.set('world', state.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); } }; } function shareWorld() { const shareUrl = new URL(window.location.href); shareUrl.searchParams.set('world', state.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); } } // === input/controls.js === // ==================== УПРАВЛЕНИЕ ==================== const inp = state.inp; function bindHold(el, key) { const down = (e) => { e.preventDefault(); state.inp[key] = true; }; const up = (e) => { e.preventDefault(); state.inp[key] = false; }; el.addEventListener('pointerdown', down); el.addEventListener('pointerup', up); el.addEventListener('pointerleave', up); } function initControls() { 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') state.inp.l = true; if (e.code === 'KeyD' || e.code === 'ArrowRight') state.inp.r = true; if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') state.inp.j = true; if (e.code === 'KeyS' || e.code === 'ArrowDown') state.inp.s = true; }); window.addEventListener('keyup', (e) => { if (e.code === 'KeyA' || e.code === 'ArrowLeft') state.inp.l = false; if (e.code === 'KeyD' || e.code === 'ArrowRight') state.inp.r = false; if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') state.inp.j = false; if (e.code === 'KeyS' || e.code === 'ArrowDown') state.inp.s = false; }); } // === multiplayer/voice-chat.js === // Голосовой чат let voiceSocket = null; let voiceStream = null; let audioCtx = null; let voiceProcessor = null; let voiceActive = false; let voiceMode = 'near'; // 'near' — 600px radius, 'world' — global const VOICE_SERVER = 'https://voicegrech.mkn8n.ru'; // Кнопка микрофона const voiceBtn = document.createElement('div'); voiceBtn.innerHTML = '🎤/'; voiceBtn.title = 'Голосовой чат (выкл)'; voiceBtn.style.cssText = 'position:absolute;top:74px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;'; document.querySelector('.ui').appendChild(voiceBtn); // Кнопка режима (рядом / весь мир) const voiceModeBtn = document.createElement('div'); voiceModeBtn.innerHTML = '📢'; voiceModeBtn.title = 'Режим: рядом (600px)'; voiceModeBtn.style.cssText = 'position:absolute;top:74px;right:190px;width:48px;height:52px;border-radius:12px;background:#3498db;z-index:200;display:flex;align-items:center;justify-content:center;font-size:20px;cursor:pointer;border:2px solid rgba(255,255,255,0.7);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;color:#fff;font-weight:bold;'; document.querySelector('.ui').appendChild(voiceModeBtn); voiceModeBtn.onclick = () => { if (voiceMode === 'near') { voiceMode = 'world'; voiceModeBtn.innerHTML = '🌍'; voiceModeBtn.title = 'Режим: весь мир'; voiceModeBtn.style.background = '#e67e22'; } else { voiceMode = 'near'; voiceModeBtn.innerHTML = '📢'; voiceModeBtn.title = 'Режим: рядом (600px)'; voiceModeBtn.style.background = '#3498db'; } if (voiceSocket && voiceSocket.connected) { voiceSocket.emit('voice_mode', { mode: voiceMode }); } }; // Индикатор говорящего const speakingIndicator = document.createElement('div'); speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;'; speakingIndicator.textContent = '🔊'; document.querySelector('.ui').appendChild(speakingIndicator); let speakingTimeout = null; voiceBtn.onclick = async () => { if (voiceActive) { // Выключить voiceActive = false; voiceBtn.innerHTML = '🎤/'; voiceBtn.style.background = '#555'; if (voiceStream) { voiceStream.getTracks().forEach(t => t.stop()); voiceStream = null; } if (voiceProcessor) { voiceProcessor.disconnect(); voiceProcessor = null; } if (audioCtx) { audioCtx.close(); audioCtx = null; } if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; } return; } // Включить try { voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 24000 } }); audioCtx = new AudioContext({ sampleRate: 24000 }); const source = audioCtx.createMediaStreamSource(voiceStream); voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1); voiceProcessor.onaudioprocess = (e) => { if (!voiceActive || !voiceSocket || !voiceSocket.connected) return; const pcm = e.inputBuffer.getChannelData(0); // Конвертируем float32 → int16 для экономии трафика const int16 = new Int16Array(pcm.length); for (let i = 0; i < pcm.length; i++) { const s = Math.max(-1, Math.min(1, pcm[i])); int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; } voiceSocket.emit('voice_data', int16.buffer); }; source.connect(voiceProcessor); voiceProcessor.connect(audioCtx.createGain()); // не в destination — чтобы не слышать себя // Подключаемся к голосовому серверу voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] }); voiceSocket.on('connect', () => { voiceSocket.emit('voice_join', { world_id: worldId, x: state.player.x, y: state.player.y, name: state.playerName || 'Игрок', mode: voiceMode }); }); voiceSocket.on('voice_in', (payload) => { // Воспроизводим входящий голос const { data, meta, volume } = payload; if (!audioCtx || audioCtx.state === 'closed') return; // Int16 → Float32 const int16 = new Int16Array(data); const float32 = new Float32Array(int16.length); for (let i = 0; i < int16.length; i++) { float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF) * volume; } const buf = audioCtx.createBuffer(1, float32.length, 24000); buf.getChannelData(0).set(float32); const src = audioCtx.createBufferSource(); src.buffer = buf; const gain = audioCtx.createGain(); gain.gain.value = volume; src.connect(gain).connect(audioCtx.destination); src.start(); // Индикатор speakingIndicator.style.display = 'block'; speakingIndicator.textContent = `🔊 ${meta.name}`; clearTimeout(speakingTimeout); speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500); }); voiceActive = true; voiceBtn.textContent = '🎤'; voiceBtn.style.background = '#2ecc71'; } catch (e) { console.error('Voice error:', e); voiceBtn.style.background = '#e74c3c'; } }; function initVoice() { // Voice position update hook — should be called from main loop let voicePosT = 0; return { update(dt) { voicePosT += dt; if (voicePosT > 0.5 && voiceSocket && voiceSocket.connected) { voicePosT = 0; voiceSocket.emit('voice_pos', { x: state.player.x, y: state.player.y }); } } }; } // === ui/inventory.js === // Inventory UI function renderInventory() { const inventoryGrid = state.inventoryGrid; const inv = state.inv; const selected = state.selected; 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'); // Звук клика по инвентарю state.selected = id; // Обновляем список последних предметов state.recentItems = state.recentItems.filter(item => item !== id); // Удаляем если уже есть state.recentItems.unshift(id); // Добавляем в начало state.recentItems = state.recentItems.slice(0, 5); // Оставляем только 5 rebuildHotbar(); renderInventory(); }; // Двойной клик для надевания брони slot.ondblclick = () => { if (id === 'iron_armor' && inv.iron_armor > 0) { // Если уже надета броня - снимаем её if (state.player.equippedArmor === 'iron_armor') { state.player.equippedArmor = null; state.player.armor = 0; console.log('[ARMOR] Iron armor unequipped'); } else { // Надеваем броню state.player.equippedArmor = 'iron_armor'; state.player.armor = BLOCKS['iron_armor'].armor; console.log('[ARMOR] Iron armor equipped - armor:', state.player.armor); } playSound('click'); renderInventory(); } }; } inventoryGrid.appendChild(slot); } } // === ui/craft.js === // Craft UI function canCraft(r) { const inv = state.inv; 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() { const inv = state.inv; const recipesEl = state.recipesEl; recipesEl.innerHTML = ''; for (const r of RECIPES) { const row = document.createElement('div'); row.className = 'recipe'; const icon = document.createElement('div'); icon.className = 'ricon'; // Иконка — блок, инструмент или предмет if (tex[r.out]) { icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`; } else if (TOOLS[r.out]) { icon.textContent = TOOLS[r.out].icon; icon.style.fontSize = '24px'; icon.style.display = 'flex'; icon.style.alignItems = 'center'; icon.style.justifyContent = 'center'; } else if (ITEMS[r.out]) { icon.textContent = ITEMS[r.out].icon; icon.style.fontSize = '24px'; icon.style.display = 'flex'; icon.style.alignItems = 'center'; icon.style.justifyContent = 'center'; } const info = document.createElement('div'); info.className = 'rinfo'; const nm = document.createElement('div'); nm.className = 'rname'; const itemName = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out; nm.textContent = `${itemName} x${r.qty}`; const cs = document.createElement('div'); cs.className = 'rcost'; cs.textContent = Object.keys(r.cost).map(x => { const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x; return `${cn}: ${(inv[x] || 0)}/${r.cost[x]}`; }).join(' '); info.appendChild(nm); info.appendChild(cs); const btn = document.createElement('button'); btn.className = 'rcraft'; btn.textContent = 'Создать'; btn.disabled = !canCraft(r); btn.onclick = () => { if (!canCraft(r)) return; playSound('click'); for (const res in r.cost) inv[res] -= r.cost[res]; inv[r.out] = (inv[r.out] || 0) + r.qty; if (TOOLS[r.out]) addTool(r.out); rebuildHotbar(); renderCraft(); }; row.appendChild(icon); row.appendChild(info); row.appendChild(btn); recipesEl.appendChild(row); } } let craftOpen = false; let inventoryOpen = false; function toggleCraft() { playSound('click'); // Звук клика по кнопке craftOpen = !craftOpen; state.craftPanel.style.display = craftOpen ? 'block' : 'none'; if (craftOpen) { renderCraft(); // Закрываем инвентарь если открыт крафт inventoryOpen = false; state.inventoryPanel.style.display = 'none'; } } function closeCraft() { playSound('click'); // Звук клика по кнопке craftOpen = false; state.craftPanel.style.display = 'none'; } function toggleInventory() { playSound('click'); // Звук клика по кнопке inventoryOpen = true; state.inventoryPanel.style.display = 'block'; renderInventory(); // Закрываем крафт если открыт инвентарь craftOpen = false; state.craftPanel.style.display = 'none'; } function closeInventory() { playSound('click'); // Звук клика по кнопке inventoryOpen = false; state.inventoryPanel.style.display = 'none'; } // === main.js === // ==================== ENTRY POINT ==================== // ==================== 1) CONFIG — parse URL ==================== const urlParams = new URLSearchParams(window.location.search); state.SERVER_URL = SERVER_URL; state.TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot'; state.TELEGRAM_APP_SHORT_NAME = 'minegrechka'; // Set TG-related state if (isTgWebApp) { state.myTgId = tgUser?.id || null; } state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png'; // ==================== 2) START MENU ==================== // Show the start menu before initializing the game. // The menu returns a Promise that resolves with the worldId. async function startGame() { const worldId = await showStartMenu(); // Set worldId and playerName state.worldId = worldId; if (!state.playerName) { state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок'; localStorage.setItem('minegrechka_playerName', state.playerName); } console.log('[Launch] worldId:', state.worldId, 'player:', state.playerName, 'tg:', isTgWebApp); // Validate TG initData if in TG context if (isTgWebApp && window.Telegram && Telegram.WebApp.initData) { try { const resp = await fetch(state.SERVER_URL + '/api/tg/auth', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ initData: Telegram.WebApp.initData }) }); const data = await resp.json(); if (data.ok && data.tg_id) { console.log('[TG] Authenticated, tg_id:', data.tg_id); state.myTgId = data.tg_id; } } catch (e) { console.warn('[TG] Auth failed:', e); } } // Update URL with world code try { const newUrl = new URL(window.location.href); newUrl.searchParams.set('world', state.worldId); if (typeof history !== 'undefined' && history.replaceState) { history.replaceState(null, '', newUrl.toString()); } } catch (e) {} // ==================== 3) CANVAS — init from canvas module ==================== initCanvas(); // ==================== 4) STATE — set DOM refs ==================== state.hpEl = document.getElementById('hp'); state.foodEl = document.getElementById('food'); state.sxEl = document.getElementById('sx'); state.syEl = document.getElementById('sy'); state.todEl = document.getElementById('tod'); state.worldIdEl = document.getElementById('worldId'); state.playerCountEl = document.getElementById('playerCount'); state.deathEl = document.getElementById('death'); state.hotbarEl = document.getElementById('hotbar'); state.craftPanel = document.getElementById('craftPanel'); state.recipesEl = document.getElementById('recipes'); state.inventoryPanel = document.getElementById('inventoryPanel'); state.inventoryGrid = document.getElementById('inventoryGrid'); state.marketPanel = document.getElementById('marketPanel'); state.marketContent = document.getElementById('marketContent'); state.tradePanel = document.getElementById('tradePanel'); state.tradeContent = document.getElementById('tradeContent'); state.multiplayerStatus = document.getElementById('multiplayerStatus'); // ==================== 5) TEXTURES — initTextures() ==================== initTextures(); state.tex = tex; state.itemTex = itemTex; // ==================== 6) CRAFT/INVENTORY BUTTONS ==================== const craftBtn = document.getElementById('craftBtn'); const invToggle = document.getElementById('invToggle'); const craftClose = document.getElementById('craftClose'); const inventoryClose = document.getElementById('inventoryClose'); if (craftBtn) craftBtn.onclick = () => toggleCraft(); if (craftClose) craftClose.onclick = () => closeCraft(); if (invToggle) invToggle.onclick = () => toggleInventory(); if (inventoryClose) inventoryClose.onclick = () => closeInventory(); // ==================== 7) CONTROLS — initControls ==================== initControls(); // ==================== 8) MOUSE — initMouseHandlers ==================== initMouseHandlers(); // ==================== 9) UI MODULES — init all UI ==================== initChat(); initFurnace(); initMinimap(); initSaveControls(); initMarket(); initRespawn(); initShare(); initModes(); // ==================== 10) SOCKET — initSocket ==================== initSocket(); // ==================== 11) VOICE — initVoice ==================== initVoice(); // ==================== 12) SAVE — loadGame, applySave ==================== rebuildHotbar(); initDB().then(async () => { const loadedSave = await loadGame(); if (loadedSave) { await applySave(loadedSave); console.log('Загружено сохранение, HP:', state.player.hp); if (state.player.hp <= 0) { console.log('WARNING: HP <= 0 после загрузки, возрождаемся'); state.player.hp = 100; state.player.hunger = 100; state.player.o2 = 100; state.player.x = state.spawnPoint.x; state.player.y = state.spawnPoint.y; state.player.vx = state.player.vy = 0; state.player.invuln = 0; state.player.fallStartY = state.player.y; } } else { console.log('Сохранение не найдено, начинаем новую игру'); state.player.hp = 100; state.player.hunger = 100; state.player.o2 = 100; state.player.vx = state.player.vy = 0; state.player.invuln = 0; // старт — на поверхности const startGX = 6; for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx); const surfaceY = surfaceGyAt(startGX); let safeGY = surfaceY - 1; const aboveBlock = getBlock(startGX, surfaceY - 1); if (aboveBlock && aboveBlock.t === 'water') { for (let gy = state.SEA_GY - 1; gy >= 0; gy--) { const b = getBlock(startGX, gy); if (!b || b.dead || b.t === 'air' || b.t === 'water') continue; safeGY = gy - 1; break; } } state.player.y = safeGY * state.TILE; state.player.x = startGX * state.TILE; state.player.fallStartY = state.player.y; state.spawnPoint.x = state.player.x; state.spawnPoint.y = state.player.y; console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', state.player.y); // Генерируем карту вокруг стартовой позиции for (let gx = startGX - 50; gx <= startGX + 50; gx++) { genColumn(gx); } } // Автосейв при скрытии страницы document.addEventListener('visibilitychange', () => { if (document.hidden) { saveGame(); } }); // Автосейв перед закрытием страницы window.addEventListener('beforeunload', () => { saveGame(); }); }).catch(err => { console.error('Ошибка инициализации:', err); const startGX = 6; genColumn(startGX); state.player.y = (surfaceGyAt(startGX) - 1) * state.TILE; state.player.fallStartY = state.player.y; for (let gx = startGX - 50; gx <= startGX + 50; gx++) { genColumn(gx); } }); // ==================== 13) LOOP — startLoop ==================== requestAnimationFrame(loop); } // Start the game startGame().catch(err => { console.error('Fatal error starting game:', err); }); })();