(() => { // ==================== КОНФИГУРАЦИЯ СЕРВЕРА ==================== // Возможность переопределить сервер через query string const urlParams = new URLSearchParams(window.location.search); const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru'; const TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot'; // Имя бота для share-ссылки const TELEGRAM_APP_SHORT_NAME = 'minegrechka'; // Короткое имя Mini App // Защита от mixed content if (location.protocol === 'https:' && SERVER_URL.startsWith('http://')) { console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP'); alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.'); } // ==================== WORLD ID И ИГРОКА ==================== let worldId = null; let playerName = localStorage.getItem('minegrechka_playerName') || null; // Запрашиваем имя игрока, если его нет if (!playerName) { playerName = prompt('Введите ваше имя для игры:') || 'Игрок'; localStorage.setItem('minegrechka_playerName', playerName); console.log('Player name set:', playerName); } // Берём worldId из URL или генерируем новый console.log('Current URL:', window.location.href); const worldParam = urlParams.get('world'); console.log('world param:', worldParam); // Проверяем на null, undefined или пустую строку worldId = (worldParam && worldParam.trim() !== '') ? worldParam : null; console.log('worldId after params:', worldId, 'type:', typeof worldId); // Если worldId отсутствует - генерируем новый и записываем в URL if (!worldId) { worldId = Math.random().toString(36).substring(2, 10); console.log('Generated worldId:', worldId); try { const newUrl = new URL(window.location.href); newUrl.searchParams.set('world', worldId); const newUrlString = newUrl.toString(); console.log('New URL to set:', newUrlString); // Проверяем, поддерживается ли history API if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') { window.history.replaceState(null, '', newUrlString); console.log('URL after replaceState:', window.location.href); console.log('URL after replaceState (direct check):', window.location.search); } else { console.error('History API not supported!'); } } catch (e) { console.error('Error updating URL:', e); } console.log('Generated new worldId for browser:', worldId); } console.log('Final worldId:', worldId, 'Player name:', playerName); console.log(`Server URL: ${SERVER_URL}, World ID: ${worldId}`); // Обработчик клика на worldId для копирования ссылки document.getElementById('worldId').onclick = () => { const shareUrl = new URL(window.location.href); shareUrl.searchParams.set('world', worldId); const shareUrlString = shareUrl.toString(); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(shareUrlString).then(() => { alert('Ссылка скопирована!'); }).catch(() => { alert('Ссылка на мир:\n' + shareUrlString); }); } else { alert('Ссылка на мир:\n' + shareUrlString); } }; // ==================== SOCKET.IO КЛИЕНТ ==================== let socket = null; let isMultiplayer = false; // Флаг для мультиплеерного режима const otherPlayers = new Map(); // socket_id -> {x, y, color} let mySocketId = null; // Throttle для отправки позиции (10-20 раз в секунду) let lastMoveSendTime = 0; const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду let lastSentX = 0, lastSentY = 0; function initSocket() { try { socket = io(SERVER_URL, { path: '/socket.io/', transports: ['websocket', 'polling'] }); socket.on('connect', () => { console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${worldId}`); mySocketId = socket.id; isMultiplayer = true; // Присоединяемся к миру socket.emit('join_world', { world_id: worldId, player_name: playerName }); // Показываем в UI worldIdEl.textContent = worldId; multiplayerStatus.style.display = 'block'; }); socket.on('connect_error', (error) => { console.error('Socket connection error:', error); isMultiplayer = false; }); socket.on('disconnect', () => { console.log('Disconnected from server'); isMultiplayer = false; otherPlayers.clear(); multiplayerStatus.style.display = 'none'; }); // Обработка world_state socket.on('world_state', (data) => { console.log('Received world_state:', data); // Устанавливаем seed и перегенерируем мир если он изменился if (data.seed !== undefined && data.seed !== worldSeed) { const oldSeed = worldSeed; worldSeed = data.seed; console.log('World seed changed from', oldSeed, 'to', worldSeed); // Очищаем и перегенерируем мир с новым seed generated.clear(); grid.clear(); blocks.length = 0; placedBlocks = []; removedBlocks = []; console.log('World regenerated with new seed:', worldSeed); } // Применяем блоки if (data.blocks && Array.isArray(data.blocks)) { for (const block of data.blocks) { if (block.op === 'set') { setBlock(block.gx, block.gy, block.t, false); } else if (block.op === 'remove') { removeBlock(block.gx, block.gy); } } } // Устанавливаем время if (data.time !== undefined) { worldTime = data.time; isNightTime = worldTime > 0.5; } // Если есть spawnPoint от сервера - используем его и генерируем эту позицию if (data.spawnPoint) { spawnPoint.x = data.spawnPoint.x; spawnPoint.y = data.spawnPoint.y; // Генерируем колонну в точке спавна const spawnGX = Math.floor(spawnPoint.x / TILE); genColumn(spawnGX); console.log('Server spawnPoint received and column generated:', spawnPoint); } else { // Если spawnPoint не пришёл от сервера - генерируем безопасную позицию const startGX = 6; genColumn(startGX); const surfaceY = surfaceGyAt(startGX); spawnPoint.x = startGX * TILE; spawnPoint.y = (surfaceY - 1) * TILE; console.log('Generated safe spawn point:', spawnPoint, 'surfaceY:', surfaceY); } // Устанавливаем игрока в точку спавна player.x = spawnPoint.x; player.y = spawnPoint.y; player.vx = 0; player.vy = 0; player.fallStartY = player.y; console.log('Player moved to spawn point:', player.x, player.y); // Устанавливаем HP на 100% при каждом подключении к миру player.hp = 100; player.hunger = 100; player.o2 = 100; player.invuln = 0; console.log('[MULTIPLAYER CONNECT] Player HP set to 100% on connect'); // Обновляем список игроков if (data.players && Array.isArray(data.players)) { otherPlayers.clear(); for (const p of data.players) { if (p.socket_id !== mySocketId) { otherPlayers.set(p.socket_id, { x: p.x, y: p.y, color: getRandomPlayerColor(p.socket_id), name: p.player_name || 'Игрок' }); } } // Обновляем счётчик игроков playerCountEl.textContent = data.players.length; } }); // Игрок присоединился socket.on('player_joined', (data) => { console.log('Player joined:', data.socket_id); if (data.socket_id !== mySocketId) { // Генерируем безопасную позицию для нового игрока const spawnGX = 6; genColumn(spawnGX); const surfaceY = surfaceGyAt(spawnGX); const safeSpawnX = spawnGX * TILE; const safeSpawnY = (surfaceY - 1) * TILE; otherPlayers.set(data.socket_id, { x: safeSpawnX, y: safeSpawnY, color: getRandomPlayerColor(data.socket_id), name: data.player_name || 'Игрок' }); addChatMessage('Система', `Игрок присоединился`); // Обновляем видимость кнопки сохранения updateSaveButtonVisibility(); } }); // Игрок переместился socket.on('player_moved', (data) => { if (data.socket_id !== mySocketId && otherPlayers.has(data.socket_id)) { const p = otherPlayers.get(data.socket_id); p.x = data.x; p.y = data.y; // Обновляем имя, если оно пришло if (data.player_name) { p.name = data.player_name; } } }); // Игрок покинул socket.on('player_left', (data) => { console.log('Player left:', data.socket_id); otherPlayers.delete(data.socket_id); addChatMessage('Система', `Игрок покинул игру`); // Обновляем видимость кнопки сохранения updateSaveButtonVisibility(); }); // Блок изменён socket.on('block_changed', (data) => { if (data.op === 'set') { setBlock(data.gx, data.gy, data.t, false); } else if (data.op === 'remove') { removeBlock(data.gx, data.gy); } }); // Сообщение в чат socket.on('chat_message', (data) => { const senderName = data.socket_id === mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`; addChatMessage(senderName, data.message); }); // Обновление времени socket.on('time_update', (data) => { if (data.time !== undefined) { worldTime = data.time; isNightTime = worldTime > 0.5; } }); } catch (e) { console.error('Error initializing socket:', e); isMultiplayer = false; } } // Генерация случайного цвета для игрока на основе socket_id function getRandomPlayerColor(socketId) { const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4']; let hash = 0; for (let i = 0; i < socketId.length; i++) { hash = ((hash << 5) - hash) + socketId.charCodeAt(i); hash = hash & hash; } return colors[Math.abs(hash) % colors.length]; } // Отправка позиции игрока (с throttle) function sendPlayerPosition() { if (!isMultiplayer || !socket || !socket.connected) return; const now = performance.now() / 1000; if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return; // Отправляем только если позиция изменилась const dx = Math.abs(player.x - lastSentX); const dy = Math.abs(player.y - lastSentY); if (dx < 1 && dy < 1) return; lastMoveSendTime = now; lastSentX = player.x; lastSentY = player.y; socket.emit('player_move', { x: player.x, y: player.y, player_name: playerName }); } // Отправка изменения блока function sendBlockChange(gx, gy, t, op) { if (!isMultiplayer || !socket || !socket.connected) return; socket.emit('block_change', { gx, gy, t, op }); } // ==================== ЧАТ ==================== const chatMessages = []; const MAX_CHAT_MESSAGES = 20; function addChatMessage(sender, message) { const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); chatMessages.push({ sender, message, time }); if (chatMessages.length > MAX_CHAT_MESSAGES) { chatMessages.shift(); } renderChatMessages(); } function renderChatMessages() { const chatMessagesEl = document.getElementById('chatMessages'); if (!chatMessagesEl) return; chatMessagesEl.innerHTML = chatMessages.map(m => `
${m.time} ${m.sender}: ${m.message}
` ).join(''); // Прокручиваем вниз chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; } function sendChatMessage(message) { if (!message || message.trim() === '') return; if (isMultiplayer && socket && socket.connected) { socket.emit('chat_message', { message: message.trim() }); } else { addChatMessage('Вы', message.trim()); } } // ==================== ПОДЕЛИТЬСЯ МИРОМ ==================== function shareWorld() { const shareUrl = new URL(window.location.href); shareUrl.searchParams.set('world', worldId); const shareUrlString = shareUrl.toString(); // Копируем в буфер обмена if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(shareUrlString).then(() => { alert('Ссылка скопирована!'); }).catch(() => { alert('Ссылка на мир:\n' + shareUrlString); }); } else { alert('Ссылка на мир:\n' + shareUrlString); } } // ==================== ИНИЦИАЛИЗАЦИЯ UI ==================== let chatOpen = false; document.getElementById('chatToggle').onclick = () => { playSound('click'); chatOpen = !chatOpen; document.getElementById('chatPanel').style.display = chatOpen ? 'block' : 'none'; if (chatOpen) { document.getElementById('chatInput').focus(); } }; document.getElementById('chatClose').onclick = () => { playSound('click'); chatOpen = false; document.getElementById('chatPanel').style.display = 'none'; }; document.getElementById('chatSend').onclick = () => { const input = document.getElementById('chatInput'); sendChatMessage(input.value); input.value = ''; }; document.getElementById('chatInput').addEventListener('keypress', (e) => { if (e.key === 'Enter') { sendChatMessage(e.target.value); e.target.value = ''; } }); // ==================== ИНИЦИАЛИЗАЦИЯ СОКЕТА ==================== // Инициализируем socket initSocket(); // ==================== ЗВУКОВОЙ ДВИЖОК ==================== const sounds = {}; function loadSound(id, src) { const audio = new Audio(); audio.src = src; audio.volume = 0.3; sounds[id] = audio; } // Загрузка звуков loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3'); loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3'); loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3'); loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3'); loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3'); loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3'); loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3'); loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3'); loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3'); loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3'); loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3'); loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3'); loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3'); loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3'); loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3'); loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3'); loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3'); function playSound(id) { if(sounds[id]) { sounds[id].currentTime = 0; sounds[id].play().catch(e => console.error('Sound error:', e)); } } // Играем звук при прыжке const gameEl = document.getElementById('game'); const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); // offscreen light map (не вставляем в DOM) const lightC = document.createElement('canvas'); const lightCtx = lightC.getContext('2d'); const dpr = Math.max(1, window.devicePixelRatio || 1); let W=0, H=0; const TILE = 40; // Мир const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже const GEN_MARGIN_X = 26; // запас генерации по X (в тайлах) const heroImg = new Image(); heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png'; // Состояние инвентаря let showFullInventory = false; let recentItems = []; // Последние 5 выбранных предметов const BLOCKS = { air: { n:'Воздух', solid:false }, grass: { n:'Трава', c:'#7cfc00', solid:true }, dirt: { n:'Грязь', c:'#8b4513', solid:true }, stone: { n:'Камень', c:'#7f8c8d', solid:true }, sand: { n:'Песок', c:'#f4d06f', solid:true }, gravel: { n:'Гравий', c:'#95a5a6', solid:true }, clay: { n:'Глина', c:'#74b9ff', solid:true }, wood: { n:'Дерево', c:'#d35400', solid:true }, planks: { n:'Доски', c:'#e67e22', solid:true }, ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true }, leaves: { n:'Листва', c:'#2ecc71', solid:true }, glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55 }, water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true }, coal: { n:'Уголь', c:'#2c3e50', solid:true }, copper_ore:{ n:'Медь', c:'#e17055', solid:true }, iron_ore: { n:'Железо', c:'#dcdde1', solid:true }, iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5 }, gold_ore: { n:'Золото', c:'#f1c40f', solid:true }, diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true }, brick: { n:'Кирпич', c:'#c0392b', solid:true }, tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true }, campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius: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 } }; const ITEMS = { meat: { n:'Сырое мясо', icon:'🥩', food:15 }, cooked: { n:'Жареное мясо', icon:'🍖', food:45 } }; // Seed мира для детерминированной генерации // Инициализируем случайным seed, но он будет перезаписан сервером в мультиплеере let worldSeed = Math.floor(Math.random() * 1000000); // Отслеживание изменений мира (для оптимизированного сохранения) let placedBlocks = []; // [{gx, gy, t}] - блоки, установленные игроком let removedBlocks = []; // [{gx, gy}] - блоки, удалённые игроком // Инструменты 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 } } }; // Текстуры блоков (простые) const tex = {}; function makeTex(type) { const t = BLOCKS[type]; const c = document.createElement('canvas'); c.width = 32; c.height = 32; const g = c.getContext('2d'); if (type === 'tnt') { g.fillStyle='#c0392b'; g.fillRect(0,0,32,32); g.fillStyle='#fff'; g.fillRect(0,12,32,8); g.fillStyle='#000'; g.font='bold 10px sans-serif'; g.fillText('TNT',6,20); return c; } if (type === 'campfire') { g.fillStyle='#5d4037'; g.fillRect(4,26,24,6); g.fillStyle='#3e2723'; g.fillRect(7,23,18,4); return c; } if (type === 'torch') { g.fillStyle='#6d4c41'; g.fillRect(14,10,4,18); g.fillStyle='#f39c12'; g.fillRect(12,6,8,8); return c; } if (type === 'glass') { g.fillStyle='rgba(200,240,255,0.25)'; g.fillRect(0,0,32,32); g.strokeStyle='rgba(255,255,255,0.65)'; g.strokeRect(2,2,28,28); g.beginPath(); g.moveTo(5,27); g.lineTo(27,5); g.stroke(); return c; } if (type === 'water') { g.fillStyle = t.c; g.fillRect(0,0,32,32); g.fillStyle = 'rgba(255,255,255,0.08)'; g.fillRect(0,6,32,2); return c; } if (type === 'bed') { // Основание кровати g.fillStyle = '#e91e63'; g.fillRect(0, 0, 32, 32); // Подушка g.fillStyle = '#f8bbd0'; g.fillRect(2, 2, 14, 14); // Одеяло g.fillStyle = '#c2185b'; g.fillRect(16, 4, 14, 24); // Детали одеяла g.fillStyle = '#e91e63'; g.fillRect(18, 6, 10, 20); return c; } if (type === 'flower') { g.fillStyle='#2ecc71'; g.fillRect(14,14,4,18); g.fillStyle=t.c; g.beginPath(); g.arc(16,12,6,0,6.28); g.fill(); return c; } if (type === 'boat') { // Корпус лодки g.fillStyle = '#8B4513'; g.fillRect(2, 12, 28, 8); // Борта g.fillStyle = '#A0522D'; g.fillRect(0, 10, 32, 12); // Внутренность g.fillStyle = '#DEB887'; g.fillRect(4, 14, 24, 4); // Дно g.fillStyle = '#654321'; g.fillRect(2, 20, 28, 4); return c; } if (type === 'ladder') { // Боковые стойки лестницы g.fillStyle = '#8B4513'; g.fillRect(4, 0, 4, 32); g.fillRect(24, 0, 4, 32); // Ступени g.fillStyle = '#A0522D'; g.fillRect(4, 4, 24, 3); g.fillRect(4, 12, 24, 3); g.fillRect(4, 20, 24, 3); g.fillRect(4, 28, 24, 3); return c; } g.fillStyle = t.c || '#000'; g.fillRect(0,0,32,32); g.fillStyle = 'rgba(0,0,0,0.10)'; for (let i=0;i<6;i++) g.fillRect((Math.random()*28)|0, (Math.random()*28)|0, 4,4); if (type.endsWith('_ore') || type==='coal') { g.fillStyle = 'rgba(0,0,0,0.35)'; for (let i=0;i<4;i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6,6); } return c; } Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k)); // Мир-хранилище const grid = new Map(); // key "gx,gy" => {gx,gy,t, ...} const blocks = []; // для рендера/перебора видимых function k(gx,gy){ return gx+','+gy; } function getBlock(gx,gy){ return grid.get(k(gx,gy)); } function hasBlock(gx,gy){ return grid.has(k(gx,gy)); } function isSolid(gx,gy){ const b = getBlock(gx,gy); if(!b || b.dead) return false; const def = BLOCKS[b.t]; return !!def.solid && !def.fluid && !def.decor; } function setBlock(gx,gy,t, isPlayerPlaced = false){ const key = k(gx,gy); if(grid.has(key)) return false; const b = { gx, gy, t, dead:false, active:false, fuse:0 }; grid.set(key, b); blocks.push(b); // Отслеживаем блоки, установленные игроком if(isPlayerPlaced){ placedBlocks.push({gx, gy, t}); } return true; } function removeBlock(gx,gy){ const key = k(gx,gy); const b = grid.get(key); if(!b) return null; if(BLOCKS[b.t].unbreakable) return null; grid.delete(key); b.dead = true; // Отслеживаем удалённые блоки const wasPlayerPlaced = placedBlocks.some(pb => pb.gx === gx && pb.gy === gy); if(wasPlayerPlaced){ // Удаляем из placedBlocks placedBlocks = placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy)); } else { // Это природный блок - добавляем в removedBlocks removedBlocks.push({gx, gy}); } return b; } // Физика жидкости const waterUpdateQueue = new Set(); let waterUpdateTimer = 0; const WATER_UPDATE_INTERVAL = 0.05; // Обновляем воду каждые 0.05 секунды function updateWaterPhysics(dt){ waterUpdateTimer += dt; if(waterUpdateTimer < WATER_UPDATE_INTERVAL) return; waterUpdateTimer = 0; // Ограничиваем количество водных блоков для обработки (оптимизация) const MAX_WATER_BLOCKS_PER_UPDATE = 50; let processedCount = 0; // Собираем только видимые водные блоки в очередь (оптимизация) waterUpdateQueue.clear(); const minGX = Math.floor(camX/TILE) - 10; const maxGX = Math.floor((camX+W)/TILE) + 10; const minGY = Math.floor(camY/TILE) - 10; const maxGY = Math.floor((camY+H)/TILE) + 10; for(const b of blocks){ if(processedCount >= MAX_WATER_BLOCKS_PER_UPDATE) break; if(!b.dead && b.t === 'water' && b.gx >= minGX && b.gx <= maxGX && b.gy >= minGY && b.gy <= maxGY){ waterUpdateQueue.add(k(b.gx, b.gy)); processedCount++; } } // Обновляем воду с ограничением глубины распространения const processed = new Set(); const toAdd = []; const MAX_WATER_DEPTH = 20; // Максимальная глубина распространения воды for(const key of waterUpdateQueue){ if(processed.has(key)) continue; const b = grid.get(key); if(!b || b.dead) continue; processed.add(key); const gx = b.gx; const gy = b.gy; // Проверяем глубину - не распространяем воду слишком глубоко if(gy > SEA_GY + MAX_WATER_DEPTH) continue; // Проверяем, можно ли воде упасть вниз const belowKey = k(gx, gy + 1); const below = grid.get(belowKey); // Внизу пусто - вода создаёт новый блок внизу (но не удаляется сверху) if(!below || below.dead){ // Ограничиваем создание новых водных блоков if(toAdd.length < 20){ // Максимум 20 новых блоков за обновление toAdd.push({gx, gy: gy + 1, t: 'water'}); processed.add(belowKey); } continue; } // Если внизу не вода и не твёрдый блок - вода может течь вниз if(!isSolid(gx, gy + 1) && below && below.t !== 'water'){ if(toAdd.length < 20){ toAdd.push({gx, gy: gy + 1, t: 'water'}); processed.add(belowKey); } continue; } // Если внизу твёрдый блок или вода - вода растекается горизонтально // Проверяем левую сторону const leftKey = k(gx - 1, gy); const left = grid.get(leftKey); if(!left || left.dead){ if(toAdd.length < 20){ toAdd.push({gx: gx - 1, gy, t: 'water'}); processed.add(leftKey); } continue; } // Проверяем правую сторону const rightKey = k(gx + 1, gy); const right = grid.get(rightKey); if(!right || right.dead){ if(toAdd.length < 20){ toAdd.push({gx: gx + 1, gy, t: 'water'}); processed.add(rightKey); } continue; } } // Применяем изменения (только добавляем новые блоки) for(const newData of toAdd){ const key = k(newData.gx, newData.gy); if(!grid.has(key)){ const b = { gx: newData.gx, gy: newData.gy, t: newData.t, dead: false, active: false, fuse: 0 }; grid.set(key, b); blocks.push(b); } } // Очищаем мёртвые блоки из массива for(let i = blocks.length - 1; i >= 0; i--){ if(blocks[i].dead){ blocks.splice(i, 1); } } } // Инвентарь const inv = { dirt:6, stone:0, sand:0, gravel:0, clay:0, wood:0, planks:0, ladder:0, leaves:0, coal:0, copper_ore:0, iron_ore:0, gold_ore:0, diamond_ore:0, brick:0, glass:0, tnt:1, campfire:0, torch:0, meat:0, cooked:0, wood_pickaxe:0, stone_pickaxe:0, iron_pickaxe:0, wood_sword:0, stone_sword:0, iron_sword:0, iron_armor:0, bed:0, boat:0 }; let selected = 'dirt'; 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 } } ]; // UI const hpEl = document.getElementById('hp'); const foodEl = document.getElementById('food'); const sxEl = document.getElementById('sx'); const syEl = document.getElementById('sy'); const todEl = document.getElementById('tod'); const worldIdEl = document.getElementById('worldId'); const playerCountEl = document.getElementById('playerCount'); const hotbarEl = document.getElementById('hotbar'); const craftPanel = document.getElementById('craftPanel'); const recipesEl = document.getElementById('recipes'); const deathEl = document.getElementById('death'); const inventoryPanel = document.getElementById('inventoryPanel'); const inventoryGrid = document.getElementById('inventoryGrid'); // Клик на часы для включения ночи todEl.style.cursor = 'pointer'; todEl.onclick = () => { playSound('click'); worldTime = 0.6; // Устанавливаем ночь isNightTime = true; }; function rebuildHotbar(){ hotbarEl.innerHTML=''; // Показываем последние 5 выбранных предметов (если они есть в инвентаре) const items = recentItems.filter(id => inv[id] > 0).slice(0, 5); for(const id of items){ const s = document.createElement('div'); s.className = 'slot'+(id===selected?' sel':''); if(BLOCKS[id]) { s.style.backgroundImage = `url(${tex[id].toDataURL()})`; s.style.backgroundSize = 'cover'; } else if(ITEMS[id]) { s.textContent = ITEMS[id].icon; } else if(TOOLS[id]) { s.textContent = TOOLS[id].icon; } else if(id === 'iron_armor') { s.textContent = '🛡️'; s.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)'; } const c = document.createElement('div'); c.className='count'; c.textContent = inv[id]; s.appendChild(c); s.onclick = () => { playSound('click'); // Звук клика по инвентарю selected=id; // Обновляем список последних предметов recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть recentItems.unshift(id); // Добавляем в начало recentItems = recentItems.slice(0, 5); // Оставляем только 5 rebuildHotbar(); }; // Показываем индикатор надетой брони if(id === 'iron_armor' && player.equippedArmor === 'iron_armor') { const equipped = document.createElement('div'); equipped.className = 'equipped-indicator'; equipped.textContent = '✓'; s.appendChild(equipped); } hotbarEl.appendChild(s); } } function renderInventory() { inventoryGrid.innerHTML = ''; // Создаём сетку инвентаря 7x3 const items = Object.keys(inv).filter(id => inv[id] > 0); // Добавляем пустые слоты для полной сетки for(let i = 0; i < 21; i++) { const slot = document.createElement('div'); slot.className = 'inv-slot' + (i < items.length && items[i] === selected ? ' sel' : ''); if(i < items.length) { const id = items[i]; if(BLOCKS[id]) { slot.style.backgroundImage = `url(${tex[id].toDataURL()})`; slot.style.backgroundSize = 'cover'; } else if(ITEMS[id]) { slot.textContent = ITEMS[id].icon; } else if(TOOLS[id]) { slot.textContent = TOOLS[id].icon; } else if(id === 'iron_armor') { slot.textContent = '🛡️'; slot.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)'; } const count = document.createElement('div'); count.className = 'inv-count'; count.textContent = inv[id]; slot.appendChild(count); slot.onclick = () => { playSound('click'); // Звук клика по инвентарю selected = id; // Обновляем список последних предметов recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть recentItems.unshift(id); // Добавляем в начало recentItems = recentItems.slice(0, 5); // Оставляем только 5 rebuildHotbar(); renderInventory(); }; // Двойной клик для надевания брони slot.ondblclick = () => { if(id === 'iron_armor' && inv.iron_armor > 0) { // Если уже надета броня - снимаем её if(player.equippedArmor === 'iron_armor') { player.equippedArmor = null; player.armor = 0; console.log('[ARMOR] Iron armor unequipped'); } else { // Надеваем броню player.equippedArmor = 'iron_armor'; player.armor = BLOCKS['iron_armor'].armor; console.log('[ARMOR] Iron armor equipped - armor:', player.armor); } playSound('click'); renderInventory(); } }; } inventoryGrid.appendChild(slot); } } function canCraft(r){ console.log('[CRAFT] Checking recipe for:', r.out, '- cost:', r.cost); for(const res in r.cost){ const have = inv[res] || 0; const need = r.cost[res]; console.log('[CRAFT] Resource:', res, '- have:', have, '- need:', need, '- can craft:', have >= need); if(have < need) return false; } return true; } function renderCraft(){ recipesEl.innerHTML=''; for(const r of RECIPES){ const row = document.createElement('div'); row.className='recipe'; const icon = document.createElement('div'); icon.className='ricon'; icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`; const info = document.createElement('div'); info.className='rinfo'; const nm = document.createElement('div'); nm.className='rname'; nm.textContent = `${BLOCKS[r.out].n} x${r.qty}`; const cs = document.createElement('div'); cs.className='rcost'; cs.textContent = Object.keys(r.cost).map(x => `${BLOCKS[x].n}: ${(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; rebuildHotbar(); renderCraft(); }; row.appendChild(icon); row.appendChild(info); row.appendChild(btn); recipesEl.appendChild(row); } } let craftOpen=false; let inventoryOpen = false; document.getElementById('craftBtn').onclick = () => { playSound('click'); // Звук клика по кнопке craftOpen = !craftOpen; craftPanel.style.display = craftOpen ? 'block' : 'none'; if(craftOpen) { renderCraft(); // Закрываем инвентарь если открыт крафт inventoryOpen = false; inventoryPanel.style.display = 'none'; } }; document.getElementById('craftClose').onclick = () => { playSound('click'); // Звук клика по кнопке craftOpen = false; craftPanel.style.display = 'none'; }; // Кнопка открытия инвентаря document.getElementById('invToggle').onclick = () => { playSound('click'); // Звук клика по кнопке inventoryOpen = true; inventoryPanel.style.display = 'block'; renderInventory(); // Закрываем крафт если открыт инвентарь craftOpen = false; craftPanel.style.display = 'none'; }; document.getElementById('inventoryClose').onclick = () => { playSound('click'); // Звук клика по кнопке inventoryOpen = false; inventoryPanel.style.display = 'none'; }; // Кнопка сохранения игры (только для одиночного режима) const saveBtn = document.getElementById('saveBtn'); saveBtn.onclick = () => { playSound('click'); saveGame(); alert('Игра сохранена!'); }; // Кнопка сброса игры (удаление сохранения и создание нового мира) const resetBtn = document.getElementById('resetBtn'); resetBtn.onclick = () => { if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) { playSound('click'); // Удаляем сохранение из localStorage try { localStorage.removeItem(SAVE_KEY); console.log('Сохранение удалено из localStorage'); } catch (e) { console.warn('Ошибка удаления сохранения:', e); } // Сбрасываем in-memory сохранение inMemorySave = null; // Генерируем новый worldId worldId = Math.random().toString(36).substring(2, 10); console.log('Новый worldId после сброса:', worldId); // Обновляем URL try { const newUrl = new URL(window.location.href); newUrl.searchParams.set('world', worldId); const newUrlString = newUrl.toString(); if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') { window.history.replaceState(null, '', newUrlString); console.log('URL обновлён:', newUrlString); } } catch (e) { console.error('Ошибка обновления URL:', e); } // Перезагружаем страницу location.reload(); } }; // Показываем кнопку сохранения только если играем одни function updateSaveButtonVisibility() { if (isMultiplayer && otherPlayers.size > 0) { saveBtn.style.display = 'none'; } else { saveBtn.style.display = 'flex'; } } // Режимы const MODES = [{id:'move',icon:'🏃'},{id:'mine',icon:'⛏️'},{id:'build',icon:'🧱'}]; let modeIdx=0; const modeBtn = document.getElementById('modeBtn'); function mode(){ return MODES[modeIdx].id; } modeBtn.onclick = () => { playSound('click'); // Звук клика по кнопке режима modeIdx=(modeIdx+1)%MODES.length; modeBtn.textContent=MODES[modeIdx].icon; }; // День/ночь (автоматический цикл) let isNightTime = false; // Управление const inp = { l:false, r:false, j:false, s:false }; function bindHold(el, key){ const down=(e)=>{ e.preventDefault(); inp[key]=true; }; const up=(e)=>{ e.preventDefault(); inp[key]=false; }; el.addEventListener('pointerdown', down); el.addEventListener('pointerup', up); el.addEventListener('pointerleave', up); } const leftBtn = document.getElementById('left'); const rightBtn = document.getElementById('right'); const jumpBtn = document.getElementById('jump'); const downBtn = document.getElementById('down'); if(leftBtn) bindHold(leftBtn,'l'); if(rightBtn) bindHold(rightBtn,'r'); if(jumpBtn) bindHold(jumpBtn,'j'); if(downBtn) bindHold(downBtn,'s'); window.addEventListener('keydown', (e)=>{ if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=true; if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=true; if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=true; if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=true; }); window.addEventListener('keyup', (e)=>{ if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=false; if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=false; if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=false; if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=false; }); // Лодка const boat = { x: 0, y: 0, w: 34, h: 34, vx: 0, vy: 0, active: false, inWater: false }; // Функция для расчёта урона с учётом брони function calculateDamage(baseDamage) { // Броня снижает урон пропорционально // armor: 0 = без брони (100% урона) // armor: 0.5 = железная броня (50% урона) const reduction = player.armor; const actualDamage = baseDamage * (1 - reduction); console.log('[DAMAGE] Base:', baseDamage, '- Armor:', reduction, '- Actual:', actualDamage.toFixed(1)); return actualDamage; } // Игрок const player = { x: 6*TILE, y: 0*TILE, w: 34, h: 34, vx: 0, vy: 0, grounded: false, inWater: false, headInWater: false, hp: 100, hunger: 100, o2: 100, invuln: 0, fallStartY: 0, lastStepTime: 0, sleeping: false, inBoat: false, armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня) equippedArmor: null // Тип надетой брони }; // Сохраняем начальную позицию для возрождения const spawnPoint = { x: 6*TILE, y: 0*TILE }; // Система сохранения игры (localStorage + in-memory fallback) const SAVE_KEY = 'minegrechka_save'; let db = null; // Оставляем для совместимости, но не используем let inMemorySave = null; // Запасное сохранение в памяти // Инициализация (localStorage + in-memory fallback) function initDB(){ return new Promise((resolve) => { console.log('Используем localStorage для сохранений (sandbox режим)'); resolve(null); }); } // Детерминированный генератор псевдослучайных чисел на основе seed function seededRandom(gx, gy){ const n = Math.sin(gx * 12.9898 + gy * 78.233 + worldSeed * 0.1) * 43758.5453; return n - Math.floor(n); } function saveGame(){ const saveData = { version: 2, worldSeed: worldSeed, player: { x: player.x, y: player.y, hp: player.hp, hunger: player.hunger, o2: player.o2 }, inventory: inv, time: worldTime, isNight: isNightTime, // Сохраняем только изменения placedBlocks: placedBlocks.slice(), removedBlocks: removedBlocks.slice() }; const saveSize = JSON.stringify(saveData).length; console.log('Сохранение: player HP:', player.hp, 'hunger:', player.hunger, 'o2:', player.o2); // Пробуем сохранить в localStorage (основной метод для персистентности) try { localStorage.setItem(SAVE_KEY, JSON.stringify(saveData)); console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`); } catch(e){ console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e); // Если localStorage недоступен, используем in-memory fallback inMemorySave = saveData; console.log(`Игра сохранена в памяти (размер: ${saveSize} байт)`); } } function loadGame(){ return new Promise((resolve, reject) => { // Пробуем localStorage try { const localSave = localStorage.getItem(SAVE_KEY); if(localSave){ const parsed = JSON.parse(localSave); console.log('Загружено из localStorage, player HP:', parsed.player?.hp); resolve(parsed); return; } } catch(e){ console.warn('Ошибка доступа к localStorage:', e); } // Если localStorage недоступен, используем in-memory сохранение if(inMemorySave){ console.log('Загружено из in-memory сохранения, player HP:', inMemorySave.player?.hp); resolve(inMemorySave); return; } console.log('Сохранение не найдено'); resolve(null); }); } // Миграция с версии 1 на версию 2 function migrateV1toV2(saveData){ console.log('Миграция сохранения с версии 1 на версию 2...'); // Сохраняем seed из текущей игры (так как v1 его не хранил) saveData.worldSeed = worldSeed; // Инициализируем массивы изменений saveData.placedBlocks = []; saveData.removedBlocks = []; // Для v1 нам нужно сравнить сгенерированные блоки с тем, что должно быть по seed // Это сложно сделать без полной перегенерации, поэтому для v1 сохраняем только seed // и при загрузке просто перегенерируем мир // Удаляем старые данные delete saveData.generatedBlocks; saveData.version = 2; console.log('Миграция завершена'); } async function applySave(saveData){ if(!saveData) return; console.log('=== applySave START ==='); console.log('player HP before applySave:', player.hp); console.log('saveData.player.hp:', saveData.player?.hp); // Миграция версий if(saveData.version === 1){ migrateV1toV2(saveData); } // Восстанавливаем seed if(saveData.worldSeed !== undefined){ worldSeed = saveData.worldSeed; } // Восстанавливаем игрока if(saveData.player){ player.x = saveData.player.x; player.y = saveData.player.y; player.hunger = saveData.player.hunger; player.o2 = saveData.player.o2; // Обновляем spawnPoint на позицию из сохранения spawnPoint.x = player.x; spawnPoint.y = player.y; // Проверяем HP из сохранения - если <= 0, устанавливаем 100 const savedHP = saveData.player.hp; console.log('Saved HP from file:', savedHP); if(savedHP <= 0){ console.log('WARNING: Saved HP is <= 0, setting to 100!'); player.hp = 100; } else { player.hp = savedHP; } console.log('player HP after restore:', player.hp); console.log('spawnPoint обновлён из сохранения: x=', spawnPoint.x, 'y=', spawnPoint.y); } else { console.log('No player data in save, setting default HP: 100'); player.hp = 100; } console.log('=== applySave END ==='); // Восстанавливаем инвентарь if(saveData.inventory){ for(const key in saveData.inventory){ inv[key] = saveData.inventory[key]; } } // Восстанавливаем время if(saveData.time !== undefined){ worldTime = saveData.time; } // Восстанавливаем день/ночь if(saveData.isNight !== undefined){ isNightTime = saveData.isNight; } // Перегенерируем мир по seed regenerateVisibleChunks(); // Применяем изменения (только для v2) if(saveData.version === 2){ // Применяем блоки, установленные игроком for(const block of saveData.placedBlocks){ setBlock(block.gx, block.gy, block.t, true); } // Применяем удалённые блоки for(const block of saveData.removedBlocks){ removeBlock(block.gx, block.gy); } // Восстанавливаем массивы изменений placedBlocks = saveData.placedBlocks || []; removedBlocks = saveData.removedBlocks || []; } rebuildHotbar(); console.log('Игра загружена'); } // Камера (двухосевая) let camX=0, camY=0; // День/ночь let worldTime=0; const DAY_LEN=360; // замедлил смену дня/ночи в 3 раза // Облака const clouds = Array.from({length:10}, ()=>({ x: Math.random()*2000, y: -200 - Math.random()*260, // выше экрана, потому что мир по Y теперь двигается w: 80+Math.random()*120, s: 12+Math.random()*20 })); // Частицы (взрыв) const parts = []; function spawnExplosion(x,y, power){ const n = Math.floor(16 + power*10); for(let i=0;i 100){ playSound('splash'); } } function resolveY(e){ // Всегда пересчитываем grounded (не держим "липким") e.grounded = false; const x1 = e.x + 2; const x2 = e.x + e.w - 2; // Проверяем, находится ли игрок на лестнице (по центру) const cx = e.x + e.w/2; const cy = e.y + e.h/2; const gx = Math.floor(cx / TILE); const gy = Math.floor(cy / TILE); const b = getBlock(gx, gy); const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable; // Если на лестнице - можно двигаться вверх/вниз if(onLadder){ e.grounded = true; // Если нажимаем прыжок на лестнице - поднимаемся if(inp.j){ e.vy = -200; } // Если нажимаем вниз - спускаемся else if(inp.s){ e.vy = 100; } // Иначе - остаёмся на месте (нет гравитации) else { e.vy = 0; } return; } // Проверяем, можно ли запрыгнуть на лестницу рядом (слева или справа) const leftGX = Math.floor((e.x - 4) / TILE); const rightGX = Math.floor((e.x + e.w + 4) / TILE); const playerGY = Math.floor((e.y + e.h/2) / TILE); const leftBlock = getBlock(leftGX, playerGY); const rightBlock = getBlock(rightGX, playerGY); const leftLadder = leftBlock && BLOCKS[leftBlock.t] && BLOCKS[leftBlock.t].climbable; const rightLadder = rightBlock && BLOCKS[rightBlock.t] && BLOCKS[rightBlock.t].climbable; // Если рядом есть лестница и игрок прыгает - притягиваем к ней if((leftLadder || rightLadder) && inp.j && e.vy < 0){ // Перемещаем игрока к лестнице if(leftLadder && e.x > leftGX * TILE + TILE/2){ e.x = leftGX * TILE + TILE/2 - e.w/2; } else if(rightLadder && e.x < rightGX * TILE + TILE/2){ e.x = rightGX * TILE + TILE/2 - e.w/2; } e.grounded = true; e.vy = -150; // меньший прыжок при запрыгивании на лестницу return; } // 1) Если движемся вниз ИЛИ стоим (vy >= 0) — проверяем опору под ногами // Берём точку на 1px ниже стопы, чтобы не зависеть от ровного попадания в границу тайла. if(e.vy >= 0){ const probeY = e.y + e.h + 1; const gy = Math.floor(probeY / TILE); const gxA = Math.floor(x1 / TILE); const gxB = Math.floor(x2 / TILE); if(isSolid(gxA, gy) || isSolid(gxB, gy)){ e.y = gy * TILE - e.h; // прижимаем к полу e.vy = 0; e.grounded = true; // урон от падения — только игроку и только не в воде if(e === player && !player.inWater){ const fallTiles = (e.y - e.fallStartY) / TILE; if(fallTiles > 6) { const damage = calculateDamage((fallTiles - 6) * 10); player.hp -= damage; } } if(e === player) e.fallStartY = e.y; } } // 2) Проверка на возможность запрыгнуть на блок, если игрок рядом с ним if(e.vy < 0 && e === player){ const gy = Math.floor(e.y / TILE); const gxA = Math.floor(x1 / TILE); const gxB = Math.floor(x2 / TILE); // Проверяем, есть ли блок рядом с игроком if((isSolid(gxA, gy) || isSolid(gxB, gy)) && !isSolid(gxA, gy - 1) && !isSolid(gxB, gy - 1)){ e.y = (gy + 1) * TILE; e.vy = 0; e.grounded = true; if(e === player) e.fallStartY = e.y; console.log("Jumped onto block!"); } } // 2) Если движемся вверх — проверяем потолок if(e.vy < 0){ const gy = Math.floor(e.y / TILE); const gxA = Math.floor(x1 / TILE); const gxB = Math.floor(x2 / TILE); if(isSolid(gxA, gy) || isSolid(gxB, gy)){ e.y = (gy + 1) * TILE; e.vy = 0; } } } function resolveX(e){ const y1 = e.y + 2; const y2 = e.y + e.h - 2; // Проверяем, находимся ли мы на лестнице const cx = e.x + e.w/2; const cy = e.y + e.h/2; const gx = Math.floor(cx / TILE); const gy = Math.floor(cy / TILE); const b = getBlock(gx, gy); const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable; if(e.vx > 0){ const gx = Math.floor((e.x + e.w)/TILE); const gyA = Math.floor(y1/TILE); const gyB = Math.floor(y2/TILE); const solidA = isSolid(gx, gyA); const solidB = isSolid(gx, gyB); if(solidA || solidB){ e.x = gx*TILE - e.w; e.vx = 0; } } else if(e.vx < 0){ const gx = Math.floor(e.x/TILE); const gyA = Math.floor(y1/TILE); const gyB = Math.floor(y2/TILE); const solidA = isSolid(gx, gyA); const solidB = isSolid(gx, gyB); if(solidA || solidB){ e.x = (gx+1)*TILE; e.vx = 0; } } } // TNT логика: цепь + усиление const activeTNT = new Set(); // хранит key function activateTNT(b, fuse=3.2){ if(b.dead) return; if(b.active) return; b.active=true; b.fuse=fuse; activeTNT.add(k(b.gx,b.gy)); } function explodeAt(gx,gy){ const center = getBlock(gx,gy); if(!center) return; // усиление: считаем сколько TNT рядом (в радиусе 2) и активируем их «почти сразу» let bonus = 0; for(let x=gx-2; x<=gx+2; x++){ for(let y=gy-2; y<=gy+2; y++){ const b = getBlock(x,y); if(b && !b.dead && b.t==='tnt' && !(x===gx && y===gy)){ bonus += 0.8; activateTNT(b, 0.12); // цепь } } } const power = 1 + bonus; // условная мощность const radius = 3.2 + bonus*0.7; // радиус разрушения в тайлах const dmgR = 150 + bonus*60; // радиус урона в пикселях removeBlock(gx,gy); activeTNT.delete(k(gx,gy)); playSound('explode1'); // Звук взрыва spawnExplosion(gx*TILE + TILE/2, gy*TILE + TILE/2, power); for(let x=Math.floor(gx-radius); x<=Math.ceil(gx+radius); x++){ for(let y=Math.floor(gy-radius); y<=Math.ceil(gy+radius); y++){ const d = Math.hypot(x-gx, y-gy); if(d > radius) continue; const b = getBlock(x,y); if(!b || b.dead) continue; if(BLOCKS[b.t].fluid) continue; if(BLOCKS[b.t].unbreakable) continue; if(b.t==='tnt') { activateTNT(b, 0.12); continue; } removeBlock(x,y); if(inv[b.t] !== undefined && Math.random()<0.20) inv[b.t]++; // немного дропа } } rebuildHotbar(); // урон const hurt = (e)=>{ const dx = (e.x+e.w/2) - (gx*TILE+TILE/2); const dy = (e.y+e.h/2) - (gy*TILE+TILE/2); const dist = Math.hypot(dx,dy); if(dist < dmgR){ const dmg = (dmgR - dist) * 0.06 * power; if(e === player) { const actualDamage = calculateDamage(dmg); player.hp -= actualDamage; } else { e.hp -= dmg; } e.vx += (dx/dist || 0) * 600; e.vy -= 320; } }; hurt(player); mobs.forEach(hurt); } // Взаимодействие мышь/тап const mouse = { x:null, y:null }; canvas.addEventListener('pointermove', (e)=>{ const r = canvas.getBoundingClientRect(); mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top; }); canvas.addEventListener('pointerdown', (e)=>{ if(craftOpen) return; if(player.hp<=0) return; const r = canvas.getBoundingClientRect(); const sx = e.clientX - r.left; const sy = e.clientY - r.top; const wx = sx + camX; const wy = sy + camY; const gx = Math.floor(wx / TILE); const gy = Math.floor(wy / TILE); // Пробуждение: клик по любой кровати когда спишь const b = getBlock(gx,gy); if(player.sleeping && b && b.t==='bed'){ player.sleeping = false; return; } if(player.sleeping) return; // Нельзя взаимодействовать во время сна // клик по мобу (в режиме mine) if(mode()==='mine'){ for(let i=mobs.length-1;i>=0;i--){ const m = mobs[i]; if(wx>=m.x && wx<=m.x+m.w && wy>=m.y && wy<=m.y+m.h){ m.hp -= 1; m.vx += (m.x - player.x) * 2; m.vy -= 200; playSound('attack'); // Звук атаки игрока if(m.hp<=0){ // дроп еды if(m.kind === 'chicken') playSound('hurt_chicken'); // Звук при убийстве курицы inv.meat += (m.kind==='chicken' ? 1 : 2); mobs.splice(i,1); rebuildHotbar(); } return; } } } // еда (предмет) if(ITEMS[selected] && inv[selected]>0){ const it = ITEMS[selected]; if(player.hp < 100 || player.hunger < 100){ playSound('eat1'); // Звук употребления еды player.hunger = Math.min(100, player.hunger + it.food); player.hp = Math.min(100, player.hp + 15); inv[selected]--; rebuildHotbar(); } return; } // жарка на костре: выбран meat + клик по campfire if(b && b.t==='campfire' && selected==='meat' && inv.meat>0){ playSound('fire'); // Звук при жарке на костре inv.meat--; inv.cooked++; rebuildHotbar(); return; } // Сон на кровати: клик по bed if(b && b.t==='bed' && isNight()){ player.sleeping = true; saveGame(); // Сохраняем при отходе ко сну return; } if(mode()==='mine'){ if(!b) return; if(BLOCKS[b.t].fluid || BLOCKS[b.t].decor) return; if(b.t==='tnt'){ activateTNT(b, 3.2); return; } // не взрывается сразу const removed = removeBlock(gx,gy); if(removed){ inv[removed.t] = (inv[removed.t]||0) + 1; // Отправляем изменение блока на сервер sendBlockChange(gx, gy, removed.t, 'remove'); // Звуки при добыче блоков if(removed.t === 'glass') playSound('glass1'); else if(removed.t === 'sand') playSound('sand1'); else if(removed.t === 'snow') playSound('snow1'); else if(removed.t === 'stone' || removed.t.endsWith('_ore')) playSound('stone1'); else if(removed.t === 'wood') playSound('wood1'); else playSound('cloth1'); rebuildHotbar(); } return; } if(mode()==='build'){ if(inv[selected] <= 0) return; if(!BLOCKS[selected]) return; if(b) return; // занято // Проверяем, ставим ли лодку if(selected === 'boat'){ // Лодку можно ставить только на воду const waterBelow = getBlock(gx, gy+1); if(!waterBelow || waterBelow.t !== 'water'){ return; } // Создаём лодку boat.x = gx * TILE; boat.y = gy * TILE; boat.vx = 0; boat.vy = 0; boat.active = true; boat.inWater = true; // Сажаем игрока в лодку player.inBoat = true; player.x = boat.x; player.y = boat.y; player.vx = 0; player.vy = 0; playSound('splash'); inv[selected]--; rebuildHotbar(); return; } // запрет ставить в игрока const bx = gx*TILE, by = gy*TILE; const overlap = !(bx >= player.x+player.w || bx+TILE <= player.x || by >= player.y+player.h || by+TILE <= player.y); if(overlap) return; setBlock(gx,gy,selected, true); // true = блок установлен игроком inv[selected]--; // Отправляем изменение блока на сервер sendBlockChange(gx, gy, selected, 'set'); // Звук при строительстве if(selected === 'stone' || selected === 'brick') playSound('stone_build'); else if(selected === 'wood' || selected === 'planks') playSound('wood_build'); else if(selected === 'glass') playSound('glass1'); else if(selected === 'sand') playSound('sand1'); else if(selected === 'snow') playSound('snow1'); else if(selected === 'dirt' || selected === 'grass') playSound('cloth1'); rebuildHotbar(); return; } }); // Генерация (по X, на всю глубину до bedrock) const generated = new Set(); // gx already generated function surfaceGyAt(gx){ // базовая поверхность выше уровня воды с вариациями + "горы" // Используем seed для детерминированной генерации // Увеличили амплитуду и добавили больше частот для разнообразия const n1 = Math.sin(gx*0.025 + worldSeed*0.001)*8; // крупные горы const n2 = Math.sin(gx*0.012 + worldSeed*0.002)*12; // средние горы const n3 = Math.sin(gx*0.006 + worldSeed*0.003)*6; // мелкие холмы const n4 = Math.sin(gx*0.045 + worldSeed*0.004)*4; // детали const n5 = Math.cos(gx*0.018 + worldSeed*0.005)*5; // дополнительные вариации const h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5); // чем меньше gy - тем выше return h; } function genColumn(gx){ if(generated.has(gx)) return; generated.add(gx); const sgy = surfaceGyAt(gx); // вода (если поверхность ниже уровня моря => sgy > SEA_GY) if(sgy > SEA_GY){ for(let gy=SEA_GY; gy SEA_GY && gy === sgy+1 && seededRandom(gx, gy) < 0.25) t='clay'; if(gy > sgy+6 && seededRandom(gx, gy) < 0.07) t='gravel'; // руды: чем глубже, тем интереснее const depth = gy - sgy; const r = seededRandom(gx, gy); if(t==='stone'){ if(r < 0.06) t='coal'; else if(r < 0.10) t='copper_ore'; else if(r < 0.13) t='iron_ore'; else if(depth > 40 && r < 0.145) t='gold_ore'; else if(depth > 70 && r < 0.152) t='diamond_ore'; } setBlock(gx,gy,t); } // Деревья и цветы (только на траве, и не в воде) const top = getBlock(gx, sgy); if(top && top.t==='grass'){ if(seededRandom(gx, sgy-1) < 0.10){ setBlock(gx, sgy-1,'flower'); } if(seededRandom(gx, sgy-2) < 0.12){ // простое дерево setBlock(gx, sgy-1, 'wood'); setBlock(gx, sgy-2, 'wood'); setBlock(gx, sgy-3, 'leaves'); setBlock(gx-1, sgy-3,'leaves'); setBlock(gx+1, sgy-3,'leaves'); } } } // Перегенерация видимых чанков (используется при загрузке сохранения) function regenerateVisibleChunks(){ const gx0 = Math.floor(camX/TILE); for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){ // Принудительно перегенерируем колонну generated.delete(gx); genColumn(gx); } } function ensureGenAroundCamera(){ const gx0 = Math.floor(camX/TILE); for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){ genColumn(gx); } } // Лут с дерева/листвы: дерево -> wood; листья -> leaves // (уже в mine добавляется inv[type] автоматически) // Рисование костра: огонь поверх текстуры function drawFire(wx,wy,now){ const baseX = wx; const baseY = wy; const flick = 6 + (Math.sin(now/90)+1)*4; ctx.fillStyle = 'rgba(255,140,0,0.85)'; ctx.beginPath(); ctx.moveTo(baseX+10, baseY+30); ctx.lineTo(baseX+20, baseY+30-flick); ctx.lineTo(baseX+30, baseY+30); ctx.fill(); ctx.fillStyle = 'rgba(255,230,150,0.75)'; ctx.beginPath(); ctx.moveTo(baseX+14, baseY+30); ctx.lineTo(baseX+20, baseY+30-(flick*0.7)); ctx.lineTo(baseX+26, baseY+30); ctx.fill(); } // Моб AI function mobAI(m, dt){ updateWaterFlag(m); if(m.kind==='zombie'){ // активность ночью const night = isNight(); if(!night){ m.hp=0; return; } const dir = Math.sign((player.x) - m.x); m.vx = dir * m.speed; if(m.inWater && Math.random()<0.06) m.vy = -260; // атака if(Math.abs((m.x+ m.w/2) - (player.x+player.w/2)) < 28 && Math.abs((m.y+ m.h/2) - (player.y+player.h/2)) < 40 && player.invuln <= 0){ const damage = calculateDamage(15); player.hp -= damage; player.invuln = 0.8; player.vx += dir*420; player.vy -= 260; playSound('hit1'); // Звук при атаке зомби } } else if(m.kind==='creeper'){ // активность ночью const night = isNight(); if(!night){ m.hp=0; return; } const dir = Math.sign((player.x) - m.x); const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2)); // Движение к игроку m.vx = dir * m.speed; if(m.inWater && Math.random()<0.06) m.vy = -260; // Взрыв если близко к игроку if(dist < 60){ m.fuse -= dt; if(m.fuse <= 0){ explodeAt(Math.floor((m.x+m.w/2)/TILE), Math.floor((m.y+m.h/2)/TILE)); m.hp = 0; } } else { // Поджигаем если очень близко if(dist < 40){ m.fuse = 0.5; // Быстрый взрыв } } } else if(m.kind==='skeleton'){ // активность ночью const night = isNight(); if(!night){ m.hp=0; return; } const dir = Math.sign((player.x) - m.x); const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2)); // Движение к игроку m.vx = dir * m.speed; if(m.inWater && Math.random()<0.06) m.vy = -260; // Стрельба стрелами с проверкой препятствий m.shootCooldown -= dt; if(dist < 200 && m.shootCooldown <= 0){ m.shootCooldown = 1.5; // Создаём стрелу (упрощённо - просто урон) const dx = (player.x+player.w/2) - (m.x+m.w/2); const dy = (player.y+player.h/2) - (m.y+m.h/2); const angle = Math.atan2(dy, dx); // Проверяем препятствия (до 20 блоков для более точной проверки) let blocked = false; const checkSteps = 20; const stepSize = dist / checkSteps; for(let i = 1; i <= checkSteps; i++){ const checkX = m.x + m.w/2 + Math.cos(angle) * stepSize * i; const checkY = m.y + m.h/2 + Math.sin(angle) * stepSize * i; const checkGX = Math.floor(checkX / TILE); const checkGY = Math.floor(checkY / TILE); const block = getBlock(checkGX, checkGY); // Любой блок (кроме воздуха) является укрытием if(block && !block.dead && block.t !== 'air'){ blocked = true; break; } } // Урон игроку если попали и нет препятствий if(!blocked && dist < 150 && player.invuln <= 0){ const damage = calculateDamage(8); player.hp -= damage; player.invuln = 0.5; player.vx += Math.cos(angle) * 300; player.vy -= 200; playSound('hit1'); } } } else { // животные m.aiT -= dt; if(m.aiT <= 0){ m.aiT = 1.8 + Math.random()*2.5; m.dir = Math.random()<0.5 ? -1 : 1; if(Math.random()<0.25) m.dir = 0; } m.vx = m.dir * (m.kind==='chicken' ? 55 : 40); if(m.inWater) m.vy = -120; } // физика моба const g = m.inWater ? GRAV_WATER : GRAV; m.vy += g*dt; m.y += m.vy*dt; m.grounded=false; resolveY(m); m.x += m.vx*dt; resolveX(m); } function isNight(){ // Автоматический цикл: ночь когда worldTime > 0.5 return worldTime > 0.5; } // Respawn document.getElementById('respawnBtn').onclick = async () => { playSound('click'); // Звук клика по кнопке console.log('=== RESPAWN CLICKED ==='); console.log('isMultiplayer:', isMultiplayer); console.log('otherPlayers.size:', otherPlayers.size); console.log('player.hp before respawn:', player.hp); // В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке if (isMultiplayer && otherPlayers.size > 0) { console.log('Мультиплеер режим - возрождение в начальной точке'); player.hp = 100; player.hunger = 100; player.o2 = 100; player.vx = player.vy = 0; player.invuln = 0; player.x = spawnPoint.x; player.y = spawnPoint.y; player.fallStartY = player.y; console.log('Возрождение в начальной точке, HP:', player.hp); } else { console.log('Одиночный режим - загружаем последнее сохранение'); // Одиночный режим - загружаем последнее сохранение const loadedSave = await loadGame(); if(loadedSave){ await applySave(loadedSave); console.log('Загружено последнее сохранение после смерти, final HP:', player.hp); } else { // Если сохранения нет, возрождаемся в начальной точке player.hp = 100; player.hunger = 100; player.o2 = 100; player.vx = player.vy = 0; player.invuln = 0; player.x = spawnPoint.x; player.y = spawnPoint.y; player.fallStartY = player.y; console.log('Возрождение в начальной точке, HP:', player.hp); } } console.log('player.hp after respawn logic:', player.hp); console.log('Hiding death screen...'); deathEl.style.display='none'; console.log('=== RESPAWN END ==='); }; // Resize function resize(){ W = gameEl.clientWidth; H = gameEl.clientHeight; canvas.width = W*dpr; canvas.height = H*dpr; lightC.width = W*dpr; lightC.height = H*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); } window.addEventListener('resize', resize); // init resize(); rebuildHotbar(); // Инициализируем и загружаем сохранение initDB().then(async () => { // Пытаемся загрузить сохранённую игру const loadedSave = await loadGame(); if(loadedSave){ await applySave(loadedSave); console.log('Загружено сохранение, HP:', player.hp); // Проверяем HP после загрузки - если <= 0, возрождаемся if (player.hp <= 0) { console.log('WARNING: HP <= 0 после загрузки, возрождаемся'); player.hp = 100; player.hunger = 100; player.o2 = 100; player.x = spawnPoint.x; player.y = spawnPoint.y; player.vx = player.vy = 0; player.invuln = 0; player.fallStartY = player.y; } } else { console.log('Сохранение не найдено, начинаем новую игру'); // Инициализируем игрока для новой игры player.hp = 100; player.hunger = 100; player.o2 = 100; player.vx = player.vy = 0; player.invuln = 0; // старт — на поверхности (ровно на 1 тайл выше поверхности) const startGX = 6; genColumn(startGX); const surfaceY = surfaceGyAt(startGX); player.y = (surfaceY - 1) * TILE; player.x = startGX * TILE; player.fallStartY = player.y; // Обновляем spawnPoint, чтобы возрождение было на поверхности spawnPoint.x = player.x; spawnPoint.y = player.y; console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', player.y, 'player.hp=', player.hp); console.log('spawnPoint обновлён: x=', spawnPoint.x, 'y=', spawnPoint.y); // Генерируем карту вокруг стартовой позиции при инициализации for(let gx = startGX - 50; gx <= startGX + 50; gx++){ genColumn(gx); } } // Автосейв при скрытии страницы (защита от потери прогресса) document.addEventListener('visibilitychange', () => { if(document.hidden){ saveGame(); } }); // Автосейв перед закрытием страницы (защита от потери прогресса) window.addEventListener('beforeunload', () => { saveGame(); }); }).catch(err => { console.error('Ошибка инициализации:', err); // При ошибке начинаем новую игру const startGX = 6; genColumn(startGX); player.y = (surfaceGyAt(startGX)-1)*TILE; player.fallStartY = player.y; for(let gx = startGX - 50; gx <= startGX + 50; gx++){ genColumn(gx); } }); // main loop let last = performance.now(); let prevJump = false; function loop(now){ const dt = Math.min(0.05, (now-last)/1000); last = now; const jumpPressed = inp.j && !prevJump; prevJump = inp.j; // Ускорение времени во время сна if(player.sleeping && isNight()){ worldTime += dt * 8 / DAY_LEN; // В 8 раз быстрее // Восстанавливаем здоровье во время сна player.hp = Math.min(100, player.hp + dt * 20); // Автоматическое пробуждение когда наступает день if(!isNight()){ player.sleeping = false; } } else { worldTime += dt / DAY_LEN; } if(worldTime >= 1) worldTime -= 1; // камера следует за игроком по X/Y camX = Math.floor((player.x + player.w/2) - W/2); camY = Math.floor((player.y + player.h/2) - H/2); ensureGenAroundCamera(); // clouds parallax for(const c of clouds){ c.x -= c.s * dt; if(c.x + c.w < camX - 400) c.x = camX + W + Math.random()*700; } // player updateWaterFlag(player); // кислород/утопление: тратим O2 только если голова под водой, иначе восстанавливаем [web:223] if(player.headInWater){ player.o2 = Math.max(0, player.o2 - 6*dt); // замедлил в 3.7 раза if(player.o2 === 0){ const damage = calculateDamage(4*dt); player.hp -= damage; } } else { player.o2 = Math.min(100, player.o2 + 10*dt); // замедлил восстановление в 4 раза } // голод убывает, но HP не отнимает (как просили) player.hunger = Math.max(0, player.hunger - dt*0.2); // замедлил в 4 раза // Игрок не может двигаться во время сна if(player.sleeping){ player.vx = 0; player.vy = 0; } else { const dir = (inp.r?1:0) - (inp.l?1:0); if(dir) player.vx = dir*MOVE; else player.vx *= 0.82; } // Звук шагов при движении по земле if(player.grounded && !player.inWater && Math.abs(player.vx) > 50){ const stepInterval = 0.35; // Интервал между шагами в секундах if(now/1000 - player.lastStepTime > stepInterval){ playSound('step'); player.lastStepTime = now/1000; } } // прыжок/плавание (новая логика) if(player.inBoat){ // Игрок в лодке - лодка следует за игроком const dir = (inp.r?1:0) - (inp.l?1:0); if(dir) boat.vx = dir * MOVE; else boat.vx *= 0.95; // Лодка плавает на воде boat.vy = 0; // Игрок следует за лодкой (сидит внутри неё) player.x = boat.x + 2; // Игрок по центру лодки player.y = boat.y - 4; // Игрок выше лодки (сидит внутри) player.vx = boat.vx; player.vy = boat.vy; player.grounded = true; player.inWater = false; // Игрок не в воде когда в лодке // Прыжок из лодки (высадка) if(jumpPressed){ // Возвращаем лодку в инвентарь inv.boat = (inv.boat || 0) + 1; player.inBoat = false; boat.active = false; player.y += TILE; // Прыгаем из лодки player.vy = -JUMP * 0.5; playSound('splash'); } } else if(player.inWater){ // сопротивление в воде player.vx *= 0.90; player.vy *= 0.92; // Если не нажимаем прыжок - тонем (гравитация в воде) if(!jumpPressed && !inp.j){ // Применяем гравитацию в воде - игрок тонет player.vy += GRAV_WATER * dt; } else { // Если нажимаем прыжок - поднимаемся на поверхность if(jumpPressed){ player.vy = Math.min(player.vy, -520); // рывок вверх } else if(inp.j){ // если держим — мягкое всплытие player.vy = Math.min(player.vy, -260); } } } else { // обычный прыжок (только по нажатию) if(jumpPressed && player.grounded && !player.sleeping){ player.vy = -JUMP; player.grounded = false; player.fallStartY = player.y; } } // Гравитация применяется только вне воды и вне лодки if(!player.inWater && !player.inBoat){ player.vy += GRAV*dt; } // Обновляем позицию лодки if(boat.active){ boat.x += boat.vx * dt; boat.y += boat.vy * dt; // Лодка не выходит за пределы воды const boatGX = Math.floor(boat.x / TILE); const boatGY = Math.floor(boat.y / TILE); const below = getBlock(boatGX, boatGY + 1); if(!below || below.t !== 'water'){ // Если лодка вышла из воды - выкидываем игрока inv.boat = (inv.boat || 0) + 1; player.inBoat = false; boat.active = false; player.y += TILE; player.vy = -200; playSound('splash'); } } // Проверяем, не доплыл ли игрок из лодки if(player.inBoat && !boat.active){ inv.boat = (inv.boat || 0) + 1; player.inBoat = false; player.y += TILE; player.vy = -200; playSound('splash'); } player.y += player.vy*dt; resolveY(player); player.x += player.vx*dt; resolveX(player); // Отправляем позицию на сервер (мультиплеер) sendPlayerPosition(); // Обновляем физику воды updateWaterPhysics(dt); player.invuln = Math.max(0, player.invuln - dt); // TNT tick for(const key of Array.from(activeTNT)){ const b = grid.get(key); if(!b || b.dead){ activeTNT.delete(key); continue; } b.fuse -= dt; if(b.fuse <= 0){ explodeAt(b.gx,b.gy); } } // mobs spawn (с обеих сторон камеры) spawnT += dt; if(spawnT > 1.8 && mobs.length < 30){ spawnT = 0; // Выбираем сторону спавна (левая или правая) const spawnLeft = Math.random() < 0.5; const gx = spawnLeft ? Math.floor((camX - 200)/TILE) : Math.floor((camX + W + 200)/TILE); genColumn(gx); const sgy = surfaceGyAt(gx); const wx = gx*TILE + 4; const wy = (sgy-2)*TILE; // не спавнить в воде const top = getBlock(gx, sgy); if(top && top.t==='water') { // skip } else { if(isNight()){ // Ночью спавним больше враждебных мобов 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)); } } else { // Днём только животные mobs.push(Math.random()<0.5 ? new Pig(wx, wy) : new Chicken(wx, wy)); } } } // mobs update 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 += GRAV*dt; if(p.t <= 0) parts.splice(i,1); } // death if(player.hp <= 0){ deathEl.style.display='flex'; } else if(deathEl.style.display === 'flex') { // Если HP > 0 но экран смерти всё ещё показан - скрываем его deathEl.style.display='none'; } // render const night = isNight(); // sky ctx.fillStyle = night ? '#070816' : '#87CEEB'; ctx.fillRect(0,0,W,H); // clouds (parallax x/y) ctx.save(); ctx.translate(-camX*0.5, -camY*0.15); ctx.fillStyle = 'rgba(255,255,255,0.65)'; for(const c of clouds){ ctx.fillRect(c.x, c.y, c.w, 26); ctx.fillRect(c.x+20, c.y-10, c.w*0.6, 22); } ctx.restore(); // world ctx.save(); ctx.translate(-camX, -camY); const minGX = Math.floor(camX/TILE)-2; const maxGX = Math.floor((camX+W)/TILE)+2; const minGY = Math.floor(camY/TILE)-6; const maxGY = Math.floor((camY+H)/TILE)+6; // draw blocks (по массиву, но фильтруем диапазоном) for(const b of blocks){ if(b.dead) continue; if(b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue; const def = BLOCKS[b.t]; if(def.alpha){ ctx.save(); ctx.globalAlpha = def.alpha; ctx.drawImage(tex[b.t], b.gx*TILE, b.gy*TILE, TILE, TILE); ctx.restore(); } else { ctx.drawImage(tex[b.t], b.gx*TILE, b.gy*TILE, TILE, TILE); } // TNT мигает, если активирован if(b.t==='tnt' && b.active && Math.sin(now/60)>0){ ctx.fillStyle='rgba(255,255,255,0.45)'; ctx.fillRect(b.gx*TILE, b.gy*TILE, TILE, TILE); } // огонь костра if(b.t==='campfire'){ drawFire(b.gx*TILE, b.gy*TILE, now); } } // mobs for(const m of mobs){ 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); } } // boat (рисуем первой, чтобы игрок был внутри неё) if(boat.active){ ctx.drawImage(tex['boat'], boat.x - (TILE-boat.w)/2, boat.y - (TILE-boat.h)/2, TILE, TILE); } // other players (multiplayer) for(const [socketId, p] of otherPlayers){ if(heroImg.complete){ ctx.drawImage(heroImg, p.x - (TILE-player.w)/2, p.y - (TILE-player.h)/2, TILE, TILE); } else { ctx.fillStyle = p.color; ctx.fillRect(p.x, p.y, 34, 34); } // Имя игрока (мелко над персонажем) ctx.fillStyle = '#fff'; ctx.font = '12px system-ui'; ctx.textAlign = 'center'; ctx.fillText(p.name, p.x + 17, p.y - 8); } // player if(heroImg.complete){ ctx.drawImage(heroImg, player.x - (TILE-player.w)/2, player.y - (TILE-player.h)/2, TILE, TILE); } else { ctx.fillStyle='#fff'; ctx.fillRect(player.x, player.y, player.w, player.h); } // particles for(const p of parts){ ctx.fillStyle = p.c; ctx.fillRect(p.x-2, p.y-2, 4, 4); } // Стрелы скелета for(const m of mobs){ if(m.kind==='skeleton' && m.shootCooldown > 0.5){ // Рисуем стрелу const arrowX = m.x + m.w/2; const arrowY = m.y + 15; const targetX = player.x + player.w/2; const targetY = player.y + player.h/2; const angle = Math.atan2(targetY - arrowY, targetX - arrowX); const speed = 400; // Проверяем, попала ли стрела const dx = targetX - arrowX; const dy = targetY - arrowY; const dist = Math.hypot(dx, dy); // Рисуем стрелу ctx.save(); ctx.translate(arrowX, arrowY); ctx.rotate(angle); ctx.fillStyle = '#ECEFF1'; ctx.fillRect(0, -1, 16, 2); ctx.restore(); // Урон игроку если попали if(dist < 150 && player.invuln <= 0){ player.hp -= 8; player.invuln = 0.5; player.vx += Math.cos(angle) * 300; player.vy -= 200; playSound('hit1'); } } } // build ghost if(mode()==='build' && mouse.x!==null && !craftOpen && player.hp>0){ const wx = mouse.x + camX; const wy = mouse.y + camY; const gx = Math.floor(wx/TILE); const gy = Math.floor(wy/TILE); ctx.strokeStyle = 'rgba(255,255,255,0.9)'; ctx.lineWidth = 2; ctx.strokeRect(gx*TILE, gy*TILE, TILE, TILE); } ctx.restore(); // lighting overlay at night if(night){ // Рисуем полупрозрачный тёмный оверлей ctx.save(); ctx.fillStyle = 'rgba(0,0,0,0.50)'; ctx.fillRect(0, 0, W, H); ctx.restore(); // Освещение от факелов и костров ctx.save(); ctx.globalCompositeOperation = 'lighter'; // Функция для рисования света function drawLight(x, y, radius, intensity) { const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius); gradient.addColorStop(0, `rgba(255, 255, 200, ${intensity})`); gradient.addColorStop(1, `rgba(255, 255, 200, 0)`); ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); } // Освещение от факелов (1/3 яркости) 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.lightRadius){ const wx = b.gx*TILE + TILE/2 - camX; const wy = b.gy*TILE + TILE/2 - camY; drawLight(wx, wy, def.lightRadius, 0.33); } } ctx.restore(); } // UI tick if(Math.random()<0.25){ hpEl.textContent = Math.max(0, Math.ceil(player.hp)); foodEl.textContent = Math.ceil(player.hunger); document.getElementById('o2').textContent = Math.ceil(player.o2); sxEl.textContent = Math.floor(player.x/TILE); syEl.textContent = Math.floor(player.y/TILE); todEl.textContent = night ? 'Ночь' : 'День'; worldIdEl.textContent = worldId; if(isMultiplayer){ document.getElementById('multiplayerStatus').style.display = 'flex'; playerCountEl.textContent = otherPlayers.size + 1; // +1 = мы сами } else { document.getElementById('multiplayerStatus').style.display = 'none'; } } // Индикатор сна if(player.sleeping){ ctx.fillStyle = 'rgba(0,0,0,0.7)'; ctx.fillRect(0, 0, W, H); ctx.fillStyle = '#fff'; ctx.font = 'bold 32px system-ui'; ctx.textAlign = 'center'; ctx.fillText('💤 Спим...', W/2, H/2); ctx.font = '18px system-ui'; ctx.fillText('Нажмите на кровать чтобы проснуться', W/2, H/2 + 40); } requestAnimationFrame(loop); } requestAnimationFrame(loop); })();