diff --git a/game.js b/game.js index 7c2825f..76440a9 100644 --- a/game.js +++ b/game.js @@ -1,1275 +1,848 @@ (() => { - // src/core/constants.js - var TILE = 40; - var SEA_GY = 14; - var BEDROCK_GY = 140; - var GEN_MARGIN_X = 40; - var GRAV = 2200; - var GRAV_WATER = 550; - - // src/core/state.js - var 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() * 1e6), - // Погода - 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: /* @__PURE__ */ new Map(), - // Серверные мобы (MP) - serverMobs: /* @__PURE__ */ new Map(), - // Мобы - mobs: [], - // Снаряды - projectiles: [], - // Отслеживание изменений мира - placedBlocks: [], - removedBlocks: [], - // Серверные изменения - serverOverrides: /* @__PURE__ */ new Map(), - // Чат - chatMessages: [], - // Погода — капли - raindrops: [], - // Облака - clouds: Array.from({ length: 10 }, () => ({ - x: Math.random() * 2e3, - y: -200 - Math.random() * 260, - w: 80 + Math.random() * 120, - s: 12 + Math.random() * 20 - })), - // Частицы - parts: [], - // Активный TNT - activeTNT: /* @__PURE__ */ new Set(), - // Прочность инструментов - toolDurability: /* @__PURE__ */ new Map(), - // Последние выбранные предметы - recentItems: [], - // Активные печи - activeFurnaces: /* @__PURE__ */ new Map(), - // Сгенерированные колонны - generated: /* @__PURE__ */ new Set(), - // Изображение героя - heroImg: null - }; - - // src/config.js - var urlParams = new URLSearchParams(window.location.search); - var SERVER_URL = urlParams.get("server") || "https://apigrech.mkn8n.ru"; - if (location.protocol === "https:" && SERVER_URL.startsWith("http://")) { - console.warn("\u26A0\uFE0F Mixed content warning: page is HTTPS but server URL is HTTP"); - alert("\u26A0\uFE0F \u041F\u0440\u0435\u0434\u0443\u043F\u0440\u0435\u0436\u0434\u0435\u043D\u0438\u0435: \u0441\u0442\u0440\u0430\u043D\u0438\u0446\u0430 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u0430 \u043F\u043E HTTPS, \u043D\u043E \u0441\u0435\u0440\u0432\u0435\u0440 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0435\u0442 HTTP. \u042D\u0442\u043E \u043C\u043E\u0436\u0435\u0442 \u0432\u044B\u0437\u0432\u0430\u0442\u044C \u043F\u0440\u043E\u0431\u043B\u0435\u043C\u044B."); + // ==================== КОНФИГУРАЦИЯ СЕРВЕРА ==================== + // Возможность переопределить сервер через 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. Это может вызвать проблемы.'); } - var worldId = null; - var playerName = localStorage.getItem("minegrechka_playerName") || null; + + // ==================== WORLD ID И ИГРОКА ==================== + let worldId = null; + let playerName = localStorage.getItem('minegrechka_playerName') || null; + + // Запрашиваем имя игрока, если его нет if (!playerName) { - playerName = prompt("\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0430\u0448\u0435 \u0438\u043C\u044F \u0434\u043B\u044F \u0438\u0433\u0440\u044B:") || "\u0418\u0433\u0440\u043E\u043A"; - localStorage.setItem("minegrechka_playerName", playerName); - console.log("Player name set:", playerName); + playerName = prompt('Введите ваше имя для игры:') || 'Игрок'; + localStorage.setItem('minegrechka_playerName', playerName); + console.log('Player name set:', playerName); } - console.log("Current URL:", window.location.href); - var worldParam = urlParams.get("world"); - console.log("world param:", worldParam); - worldId = worldParam && worldParam.trim() !== "" ? worldParam : null; - console.log("worldId after params:", worldId, "type:", typeof worldId); + + // Берём 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); + console.log('Generated worldId:', worldId); + try { const newUrl = new URL(window.location.href); - newUrl.searchParams.set("world", worldId); + newUrl.searchParams.set('world', worldId); const newUrlString = newUrl.toString(); - console.log("New URL to set:", newUrlString); - if (typeof window.history !== "undefined" && typeof window.history.replaceState === "function") { - window.history.replaceState(null, "", newUrlString); - console.log("URL after replaceState:", window.location.href); - console.log("URL after replaceState (direct check):", window.location.search); + 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!"); + console.error('History API not supported!'); } } catch (e) { - console.error("Error updating URL:", e); + console.error('Error updating URL:', e); } - console.log("Generated new worldId for browser:", worldId); + + console.log('Generated new worldId for browser:', worldId); } - console.log("Final worldId:", worldId, "Player name:", playerName); + + console.log('Final worldId:', worldId, 'Player name:', playerName); + console.log(`Server URL: ${SERVER_URL}, World ID: ${worldId}`); - - // src/data/blocks.js - var BLOCKS = { - air: { n: "\u0412\u043E\u0437\u0434\u0443\u0445", solid: false }, - grass: { n: "\u0422\u0440\u0430\u0432\u0430", c: "#7cfc00", solid: true }, - dirt: { n: "\u0413\u0440\u044F\u0437\u044C", c: "#8b4513", solid: true }, - stone: { n: "\u041A\u0430\u043C\u0435\u043D\u044C", c: "#7f8c8d", solid: true }, - sand: { n: "\u041F\u0435\u0441\u043E\u043A", c: "#f4d06f", solid: true }, - gravel: { n: "\u0413\u0440\u0430\u0432\u0438\u0439", c: "#95a5a6", solid: true }, - clay: { n: "\u0413\u043B\u0438\u043D\u0430", c: "#74b9ff", solid: true }, - wood: { n: "\u0414\u0435\u0440\u0435\u0432\u043E", c: "#d35400", solid: true }, - planks: { n: "\u0414\u043E\u0441\u043A\u0438", c: "#e67e22", solid: true }, - ladder: { n: "\u041B\u0435\u0441\u0442\u043D\u0438\u0446\u0430", c: "#d35400", solid: false, climbable: true }, - leaves: { n: "\u041B\u0438\u0441\u0442\u0432\u0430", c: "#2ecc71", solid: true }, - glass: { n: "\u0421\u0442\u0435\u043A\u043B\u043E", c: "rgba(200,240,255,0.25)", solid: true, alpha: 0.55 }, - water: { n: "\u0412\u043E\u0434\u0430", c: "rgba(52,152,219,0.55)", solid: false, fluid: true }, - coal: { n: "\u0423\u0433\u043E\u043B\u044C", c: "#2c3e50", solid: true }, - copper_ore: { n: "\u041C\u0435\u0434\u044C", c: "#e17055", solid: true }, - iron_ore: { n: "\u0416\u0435\u043B\u0435\u0437\u043E", c: "#dcdde1", solid: true }, - iron_armor: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u0430\u044F \u0431\u0440\u043E\u043D\u044F", c: "#95a5a6", solid: false, armor: 0.5 }, - gold_ore: { n: "\u0417\u043E\u043B\u043E\u0442\u043E", c: "#f1c40f", solid: true }, - diamond_ore: { n: "\u0410\u043B\u043C\u0430\u0437", c: "#00a8ff", solid: true }, - brick: { n: "\u041A\u0438\u0440\u043F\u0438\u0447", c: "#c0392b", solid: true }, - tnt: { n: "TNT", c: "#e74c3c", solid: true, explosive: true }, - campfire: { n: "\u041A\u043E\u0441\u0442\u0451\u0440", c: "#e67e22", solid: true, lightRadius: 190 }, - torch: { n: "\u0424\u0430\u043A\u0435\u043B", c: "#f9ca24", solid: true, lightRadius: 140 }, - bedrock: { n: "\u0411\u0435\u0434\u0440\u043E\u043A", c: "#2d3436", solid: true, unbreakable: true }, - flower: { n: "\u0426\u0432\u0435\u0442\u043E\u043A", c: "#ff4757", solid: false, decor: true }, - bed: { n: "\u041A\u0440\u043E\u0432\u0430\u0442\u044C", c: "#e91e63", solid: true, bed: true }, - boat: { n: "\u041B\u043E\u0434\u043A\u0430", c: "#8B4513", solid: false }, - furnace: { n: "\u041F\u0435\u0447\u044C", c: "#696969", solid: true, smelting: true } + + // Обработчик клика на 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); + } }; - - // src/world/world-storage.js - var grid = /* @__PURE__ */ new Map(); - var blocks = []; - function k(gx, gy) { - return gx + "," + gy; - } - function getBlock2(gx, gy) { - return grid.get(k(gx, gy)); - } - function isSolid(gx, gy) { - const b = getBlock2(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) { - state.placedBlocks = state.placedBlocks.filter((pb) => !(pb.gx === gx && pb.gy === gy)); - } else { - state.removedBlocks.push({ gx, gy }); - } - return b; - } - - // src/world/generation.js - var generated = state.generated; - function surfaceGyAt(gx) { - const n1 = Math.sin(gx * 0.025 + state.worldSeed * 1e-3) * 8; - const n2 = Math.sin(gx * 0.012 + state.worldSeed * 2e-3) * 12; - const n3 = Math.sin(gx * 6e-3 + state.worldSeed * 3e-3) * 6; - const n4 = Math.sin(gx * 0.045 + state.worldSeed * 4e-3) * 4; - const n5 = Math.cos(gx * 0.018 + state.worldSeed * 5e-3) * 5; - const 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); - if (sgy > SEA_GY) { - for (let gy = SEA_GY; gy < sgy; gy++) { - setBlock(gx, gy, "water"); - } - setBlock(gx, sgy, "sand"); - } else { - if (sgy < SEA_GY - 10) setBlock(gx, sgy, "stone"); - else setBlock(gx, sgy, "grass"); - } - for (let gy = sgy + 1; gy <= BEDROCK_GY; gy++) { - if (gy === BEDROCK_GY) { - setBlock(gx, gy, "bedrock"); - continue; - } - let t = "stone"; - if (gy <= sgy + 3) t = "dirt"; - if (sgy > 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.1) 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 = getBlock2(gx, sgy); - if (top && top.t === "grass") { - if (seededRandom(gx, sgy - 1) < 0.1) { - setBlock(gx, sgy - 1, "flower"); - } - if (seededRandom(gx, sgy - 2) < 0.12) { - setBlock(gx, sgy - 1, "wood"); - setBlock(gx, sgy - 2, "wood"); - setBlock(gx, sgy - 3, "leaves"); - setBlock(gx - 1, sgy - 3, "leaves"); - setBlock(gx + 1, sgy - 3, "leaves"); - } - } - const colPrefix = gx + ","; - for (const [key, ov] of state.serverOverrides) { - if (!key.startsWith(colPrefix)) continue; - if (ov.op === "remove") { - const b = grid.get(key); - if (b) { - grid.delete(key); - b.dead = true; + + // ==================== SOCKET.IO КЛИЕНТ ==================== + let socket = null; + let isMultiplayer = false; // Флаг для мультиплеерного режима + const otherPlayers = new Map(); // socket_id -> {x, y, color} + const serverMobs = new Map(); // id -> mob (server-authoritative in MP) + let mySocketId = null; + + // Throttle для отправки позиции (10-20 раз в секунду) + let lastMoveSendTime = 0; + const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду + let lastSentX = 0, lastSentY = 0; + + function initSocket() { + try { + socket = io(SERVER_URL, { + path: '/socket.io/', + transports: ['websocket', 'polling'] + }); + + socket.on('connect', () => { + console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${worldId}`); + mySocketId = socket.id; + isMultiplayer = true; + + // Присоединяемся к миру + socket.emit('join_world', { world_id: worldId, player_name: playerName }); + + // Показываем в UI + worldIdEl.textContent = worldId; + multiplayerStatus.style.display = 'block'; + }); + + socket.on('connect_error', (error) => { + console.error('Socket connection error:', error); + isMultiplayer = false; + }); + + socket.on('disconnect', () => { + console.log('Disconnected from server'); + isMultiplayer = false; + otherPlayers.clear(); + multiplayerStatus.style.display = 'none'; + }); + + // Обработка world_state + socket.on('world_state', (data) => { + console.log('Received world_state:', data); + + // Устанавливаем seed и перегенерируем мир если он изменился + if (data.seed !== undefined && data.seed !== worldSeed) { + const oldSeed = worldSeed; + worldSeed = data.seed; + console.log('World seed changed from', oldSeed, 'to', worldSeed); + + // Очищаем и перегенерируем мир с новым seed + generated.clear(); + grid.clear(); + blocks.length = 0; + placedBlocks = []; + removedBlocks = []; + console.log('World regenerated with new seed:', worldSeed); + } + + // Применяем блоки — сохраняем в serverOverrides для применения после genColumn + if (data.blocks && Array.isArray(data.blocks)) { + for (const block of data.blocks) { + const key = k(block.gx, block.gy); + serverOverrides.set(key, { op: block.op, t: block.t }); + // Также пробуем применить сразу (если колонна уже сгенерирована) + if (block.op === 'set') { + setBlock(block.gx, block.gy, block.t, false); + } else if (block.op === 'remove') { + removeBlock(block.gx, block.gy); + } + } + } + + // Устанавливаем время + if (data.time !== undefined) { + worldTime = data.time; + isNightTime = worldTime > 0.5; + } + + // Всегда считаем spawnPoint на клиенте по той же формуле что и surfaceGyAt + // Это гарантирует совпадение с terrain generation + { + const startGX = 6; + // Генерируем колонну и соседние для безопасного спавна + for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx); + const surfaceY = surfaceGyAt(startGX); + // Ищем ближайшую небудущую позицию сверху вниз от поверхности + let safeGY = surfaceY - 1; + // Проверяем что над поверхностью воздух (не в воде) + const aboveBlock = getBlock(startGX, surfaceY - 1); + if (aboveBlock && aboveBlock.t === 'water') { + // Если в воде — ищем поверхность выше уровня моря + for (let gy = SEA_GY - 1; gy >= 0; gy--) { + const b = getBlock(startGX, gy); + if (!b || b.dead || b.t === 'air' || b.t === 'water') continue; + safeGY = gy - 1; + break; + } + } + spawnPoint.x = startGX * TILE; + spawnPoint.y = safeGY * TILE; + console.log('Client-side spawn point:', spawnPoint, 'surfaceY:', surfaceY, 'safeGY:', safeGY); } - } 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); + + // Устанавливаем игрока в точку спавна + 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; } - } - } - } - 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); - } - - // src/audio/sound-engine.js - var sounds = {}; + // Server mobs + if (data.mobs && Array.isArray(data.mobs)) { + 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 }; + serverMobs.set(m.id, sm); + } + } + }); + + // Игрок присоединился + socket.on('player_joined', (data) => { + console.log('Player joined:', data.socket_id); + if (data.socket_id !== mySocketId) { + // Генерируем безопасную позицию для нового игрока + const spawnGX = 6; + genColumn(spawnGX); + const surfaceY = surfaceGyAt(spawnGX); + const safeSpawnX = spawnGX * TILE; + const safeSpawnY = (surfaceY - 1) * TILE; + + otherPlayers.set(data.socket_id, { + x: safeSpawnX, + y: safeSpawnY, + color: getRandomPlayerColor(data.socket_id), + name: data.player_name || 'Игрок' + }); + addChatMessage('Система', `Игрок присоединился`); + // Обновляем видимость кнопки сохранения + updateSaveButtonVisibility(); + } + }); + + // Игрок переместился + socket.on('player_moved', (data) => { + if (data.socket_id !== mySocketId && otherPlayers.has(data.socket_id)) { + const p = otherPlayers.get(data.socket_id); + p.x = data.x; + p.y = data.y; + // Обновляем имя, если оно пришло + if (data.player_name) { + p.name = data.player_name; + } + } + }); + + // Игрок покинул + socket.on('player_left', (data) => { + console.log('Player left:', data.socket_id); + otherPlayers.delete(data.socket_id); + addChatMessage('Система', `Игрок покинул игру`); + // Обновляем видимость кнопки сохранения + updateSaveButtonVisibility(); + }); + + // === MOB SYNC (multiplayer) === + + socket.on('mob_spawned', (data) => { + const sm = { ...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 }; + serverMobs.set(data.id, sm); + }); + + socket.on('mob_positions', (arr) => { + for (const u of arr) { + const sm = 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) => { serverMobs.delete(data.id); }); + + socket.on('mob_died', (data) => { + const sm = serverMobs.get(data.id); + if (sm && data.killer === mySocketId) { + // Give loot to the killer + if (sm.kind === 'chicken') playSound('hurt_chicken'); + inv.meat += (sm.kind==='chicken' ? 1 : 2); + if (sm.kind === 'skeleton') { + inv.arrow += 2 + Math.floor(Math.random()*3); + if (Math.random() < 0.15) inv.bow = (inv.bow||0) + 1; + } + rebuildHotbar(); + } + serverMobs.delete(data.id); + }); + + socket.on('mob_hurt_ack', (data) => { + const sm = serverMobs.get(data.id); + if (sm) sm.hp = data.hp; + }); + + socket.on('mob_explode', (data) => { + explodeAt(data.gx, data.gy); + serverMobs.delete(data.id); + }); + + socket.on('mob_shoot', (data) => { + projectiles.push({ + x: data.x, y: data.y, vx: data.vx, vy: data.vy, + dmg: data.dmg, owner: 'mob', life: data.life + }); + }); + + // Блок изменён + socket.on('block_changed', (data) => { + const key = k(data.gx, data.gy); + serverOverrides.set(key, { op: data.op, t: data.t }); + if (data.op === 'set') { + setBlock(data.gx, data.gy, data.t, false); + } else if (data.op === 'remove') { + removeBlock(data.gx, data.gy); + } + }); + + // Сообщение в чат + socket.on('chat_message', (data) => { + const senderName = data.socket_id === mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`; + addChatMessage(senderName, data.message); + }); + + // Обновление времени + socket.on('time_update', (data) => { + if (data.time !== undefined) { + worldTime = data.time; + isNightTime = worldTime > 0.5; + } + }); + + } catch (e) { + console.error('Error initializing socket:', e); + isMultiplayer = false; + } + } + + // Генерация случайного цвета для игрока на основе socket_id + function getRandomPlayerColor(socketId) { + const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4']; + let hash = 0; + for (let i = 0; i < socketId.length; i++) { + hash = ((hash << 5) - hash) + socketId.charCodeAt(i); + hash = hash & hash; + } + return colors[Math.abs(hash) % colors.length]; + } + + // Отправка позиции игрока (с throttle) + function sendPlayerPosition() { + if (!isMultiplayer || !socket || !socket.connected) return; + + const now = performance.now() / 1000; + if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return; + + // Отправляем только если позиция изменилась + const dx = Math.abs(player.x - lastSentX); + const dy = Math.abs(player.y - lastSentY); + if (dx < 1 && dy < 1) return; + + lastMoveSendTime = now; + lastSentX = player.x; + lastSentY = player.y; + + socket.emit('player_move', { x: player.x, y: player.y, player_name: playerName }); + } + + // Отправка изменения блока + function sendBlockChange(gx, gy, t, op) { + if (!isMultiplayer || !socket || !socket.connected) return; + + socket.emit('block_change', { gx, gy, t, op }); + } + + // ==================== ЧАТ ==================== + const chatMessages = []; + const MAX_CHAT_MESSAGES = 20; + + function addChatMessage(sender, message) { + const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); + chatMessages.push({ sender, message, time }); + if (chatMessages.length > MAX_CHAT_MESSAGES) { + chatMessages.shift(); + } + renderChatMessages(); + } + + function renderChatMessages() { + const chatMessagesEl = document.getElementById('chatMessages'); + if (!chatMessagesEl) return; + + chatMessagesEl.innerHTML = chatMessages.map(m => + `
${m.time} ${m.sender}: ${m.message}
` + ).join(''); + + // Прокручиваем вниз + chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; + } + + function sendChatMessage(message) { + if (!message || message.trim() === '') return; + + if (isMultiplayer && socket && socket.connected) { + socket.emit('chat_message', { message: message.trim() }); + } else { + addChatMessage('Вы', message.trim()); + } + } + + // ==================== ПОДЕЛИТЬСЯ МИРОМ ==================== + function shareWorld() { + const shareUrl = new URL(window.location.href); + shareUrl.searchParams.set('world', worldId); + const shareUrlString = shareUrl.toString(); + + // Копируем в буфер обмена + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(shareUrlString).then(() => { + alert('Ссылка скопирована!'); + }).catch(() => { + alert('Ссылка на мир:\n' + shareUrlString); + }); + } else { + alert('Ссылка на мир:\n' + shareUrlString); + } + } + + // ==================== ИНИЦИАЛИЗАЦИЯ UI ==================== + let chatOpen = false; + + document.getElementById('chatToggle').onclick = () => { + playSound('click'); + chatOpen = !chatOpen; + document.getElementById('chatPanel').style.display = chatOpen ? 'block' : 'none'; + if (chatOpen) { + document.getElementById('chatInput').focus(); + } + }; + + document.getElementById('chatClose').onclick = () => { + playSound('click'); + chatOpen = false; + document.getElementById('chatPanel').style.display = 'none'; + }; + + document.getElementById('chatSend').onclick = () => { + const input = document.getElementById('chatInput'); + sendChatMessage(input.value); + input.value = ''; + }; + + document.getElementById('chatInput').addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + sendChatMessage(e.target.value); + e.target.value = ''; + } + }); + + // ==================== ИНИЦИАЛИЗАЦИЯ СОКЕТА ==================== + // Инициализируем socket + initSocket(); + + // ==================== ЗВУКОВОЙ ДВИЖОК ==================== + const sounds = {}; function loadSound(id, src) { const audio = new Audio(); audio.src = src; audio.volume = 0.3; sounds[id] = audio; } - loadSound("splash", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3"); - loadSound("sand1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3"); - loadSound("snow1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3"); - loadSound("stone1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3"); - loadSound("wood1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3"); - loadSound("cloth1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3"); - loadSound("fire", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3"); - loadSound("hit1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3"); - loadSound("attack", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3"); - loadSound("hurt_chicken", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3"); - loadSound("stone_build", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3"); - loadSound("wood_build", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3"); - loadSound("click", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3"); - loadSound("explode1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3"); - loadSound("glass1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3"); - loadSound("eat1", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3"); - loadSound("step", "https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3"); + + // Загрузка звуков + 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]) { + if(sounds[id]) { sounds[id].currentTime = 0; - sounds[id].play().catch((e) => console.error("Sound error:", e)); + sounds[id].play().catch(e => console.error('Sound error:', e)); } } - - // src/ui/chat.js - var chatMessages = []; - var MAX_CHAT_MESSAGES = 20; - function addChatMessage(sender, message) { - const time = (/* @__PURE__ */ 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("\u0412\u044B", 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 = ""; - } - }); - } - - // src/entities/player.js - function calculateDamage(baseDamage) { - const reduction = state.player.armor; - const actualDamage = baseDamage * (1 - reduction); - console.log("[DAMAGE] Base:", baseDamage, "- Armor:", reduction, "- Actual:", actualDamage.toFixed(1)); - return actualDamage; - } - - // src/data/items.js - var ITEMS = { - meat: { n: "\u0421\u044B\u0440\u043E\u0435 \u043C\u044F\u0441\u043E", icon: "\u{1F969}", food: 15 }, - cooked: { n: "\u0416\u0430\u0440\u0435\u043D\u043E\u0435 \u043C\u044F\u0441\u043E", icon: "\u{1F356}", food: 45 }, - arrow: { n: "\u0421\u0442\u0440\u0435\u043B\u0430", icon: "\u27A1\uFE0F", stack: 64 } + + // Играем звук при прыжке + const gameEl = document.getElementById('game'); + const canvas = document.getElementById('c'); + const ctx = canvas.getContext('2d'); + + // offscreen light map (не вставляем в DOM) + const lightC = document.createElement('canvas'); + const lightCtx = lightC.getContext('2d'); + + const dpr = Math.max(1, window.devicePixelRatio || 1); + let W=0, H=0; + + const TILE = 40; + + // Мир + const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов + const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже + const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки + + const heroImg = new Image(); + heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png'; + + // Состояние инвентаря + let showFullInventory = false; + let recentItems = []; // Последние 5 выбранных предметов + + const BLOCKS = { + air: { n:'Воздух', solid:false }, + grass: { n:'Трава', c:'#7cfc00', solid:true }, + dirt: { n:'Грязь', c:'#8b4513', solid:true }, + stone: { n:'Камень', c:'#7f8c8d', solid:true }, + sand: { n:'Песок', c:'#f4d06f', solid:true }, + gravel: { n:'Гравий', c:'#95a5a6', solid:true }, + clay: { n:'Глина', c:'#74b9ff', solid:true }, + wood: { n:'Дерево', c:'#d35400', solid:true }, + planks: { n:'Доски', c:'#e67e22', solid:true }, + ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true }, + leaves: { n:'Листва', c:'#2ecc71', solid:true }, + glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55 }, + water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true }, + coal: { n:'Уголь', c:'#2c3e50', solid:true }, + copper_ore:{ n:'Медь', c:'#e17055', solid:true }, + iron_ore: { n:'Железо', c:'#dcdde1', solid:true }, + iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5 }, + gold_ore: { n:'Золото', c:'#f1c40f', solid:true }, + diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true }, + brick: { n:'Кирпич', c:'#c0392b', solid:true }, + tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true }, + campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius: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 } }; - ITEMS.iron_ingot = { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u044B\u0439 \u0441\u043B\u0438\u0442\u043E\u043A", icon: "\u{1F529}" }; - ITEMS.gold_ingot = { n: "\u0417\u043E\u043B\u043E\u0442\u043E\u0439 \u0441\u043B\u0438\u0442\u043E\u043A", icon: "\u{1FA99}" }; - ITEMS.copper_ingot = { n: "\u041C\u0435\u0434\u043D\u044B\u0439 \u0441\u043B\u0438\u0442\u043E\u043A", icon: "\u{1F7E4}" }; - - // src/data/tools.js - var TOOLS = { - wood_pickaxe: { n: "\u0414\u0435\u0440\u0435\u0432\u044F\u043D\u043D\u0430\u044F \u043A\u0438\u0440\u043A\u0430", icon: "\u26CF\uFE0F", durability: 60, miningPower: 1, craft: { wood: 3, planks: 2 } }, - stone_pickaxe: { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u0430\u044F \u043A\u0438\u0440\u043A\u0430", icon: "\u26CF\uFE0F", durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 } }, - iron_pickaxe: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u0430\u044F \u043A\u0438\u0440\u043A\u0430", icon: "\u26CF\uFE0F", durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 } }, - wood_sword: { n: "\u0414\u0435\u0440\u0435\u0432\u044F\u043D\u043D\u044B\u0439 \u043C\u0435\u0447", icon: "\u2694\uFE0F", durability: 40, damage: 5, craft: { wood: 2, planks: 1 } }, - stone_sword: { n: "\u041A\u0430\u043C\u0435\u043D\u043D\u044B\u0439 \u043C\u0435\u0447", icon: "\u2694\uFE0F", durability: 100, damage: 8, craft: { wood: 1, stone: 2 } }, - iron_sword: { n: "\u0416\u0435\u043B\u0435\u0437\u043D\u044B\u0439 \u043C\u0435\u0447", icon: "\u2694\uFE0F", durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 } }, - bow: { n: "\u041B\u0443\u043A", icon: "\u{1F3F9}", durability: 150, craft: { wood: 3, string: 0 } } + + const ITEMS = { + meat: { n:'Сырое мясо', icon:'🥩', food:15 }, + cooked: { n:'Жареное мясо', icon:'🍖', food:45 }, + arrow: { n:'Стрела', icon:'➡️', stack:64 }, }; - 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; - } - return false; - } - } - return false; - } + + // Seed мира для детерминированной генерации + // Инициализируем случайным seed, но он будет перезаписан сервером в мультиплеере + let worldSeed = Math.floor(Math.random() * 1000000); + + // Отслеживание изменений мира (для оптимизированного сохранения) + let placedBlocks = []; // [{gx, gy, t}] - блоки, установленные игроком + let removedBlocks = []; // [{gx, gy}] - блоки, удалённые игроком - // src/render/textures.js - var tex = {}; + // Серверные изменения — применяются после genColumn чтобы не перезатирались + const serverOverrides = new Map(); // key "gx,gy" => {op:'set'|'remove', t?:string} + + // Инструменты + const TOOLS = { + wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', durability: 60, miningPower: 1, craft: { wood: 3, planks: 2 } }, + stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 } }, + iron_pickaxe: { n:'Железная кирка', icon:'⛏️', durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 } }, + wood_sword: { n:'Деревянный меч', icon:'⚔️', durability: 40, damage: 5, craft: { wood: 2, planks: 1 } }, + stone_sword: { n:'Каменный меч', icon:'⚔️', durability: 100, damage: 8, craft: { wood: 1, stone: 2 } }, + iron_sword: { n:'Железный меч', icon:'⚔️', durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 } }, + bow: { n:'Лук', icon:'🏹', durability: 150, craft: { wood: 3, string: 0 } } + }; + + // Текстуры блоков (простые) + const tex = {}; function makeTex(type) { const t = BLOCKS[type]; - const c = document.createElement("canvas"); - c.width = 32; - c.height = 32; - const g = c.getContext("2d"); - if (type === "tnt") { - g.fillStyle = "#c0392b"; + 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 = "#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.fillStyle = '#f8bbd0'; g.fillRect(2, 2, 14, 14); - g.fillStyle = "#c2185b"; + // Одеяло + g.fillStyle = '#c2185b'; g.fillRect(16, 4, 14, 24); - g.fillStyle = "#e91e63"; + // Детали одеяла + 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(); + 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"; + if (type === 'boat') { + // Корпус лодки + g.fillStyle = '#8B4513'; g.fillRect(2, 12, 28, 8); - g.fillStyle = "#A0522D"; + // Борта + g.fillStyle = '#A0522D'; g.fillRect(0, 10, 32, 12); - g.fillStyle = "#DEB887"; + // Внутренность + g.fillStyle = '#DEB887'; g.fillRect(4, 14, 24, 4); - g.fillStyle = "#654321"; + // Дно + g.fillStyle = '#654321'; g.fillRect(2, 20, 28, 4); return c; } - if (type === "ladder") { - g.fillStyle = "#8B4513"; + if (type === 'ladder') { + // Боковые стойки лестницы + g.fillStyle = '#8B4513'; g.fillRect(4, 0, 4, 32); g.fillRect(24, 0, 4, 32); - g.fillStyle = "#A0522D"; + // Ступени + 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); + + 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((k2) => tex[k2] = makeTex(k2)); + 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; } - - // src/ui/hotbar.js - function rebuildHotbar() { - const hotbarEl = state.hotbarEl; - const inv = state.inv; - const selected = state.selected; - const recentItems = state.recentItems; - const toolDurability = state.toolDurability; - hotbarEl.innerHTML = ""; - 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 = "\u{1F6E1}\uFE0F"; - 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); - rebuildHotbar(); - }; - if (id === "iron_armor" && state.player.equippedArmor === "iron_armor") { - const equipped = document.createElement("div"); - equipped.className = "equipped-indicator"; - equipped.textContent = "\u2713"; - s.appendChild(equipped); - } - if (TOOLS[id] && inv[id] > 0) { - let curDur = 0, maxDur = TOOLS[id].durability; - for (const [tid, dur] of toolDurability) { - if (dur.type === id) { - curDur = dur.current; - maxDur = dur.max; - break; - } - } - if (maxDur > 0) { - const bar = document.createElement("div"); - bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;border-radius:0 0 6px 6px;overflow:hidden;`; - const fill = document.createElement("div"); - const pct = curDur / maxDur; - const color = pct > 0.5 ? "#2ecc71" : pct > 0.25 ? "#f39c12" : "#e74c3c"; - fill.style.cssText = `width:${pct * 100}%;height:100%;background:${color};`; - bar.appendChild(fill); - s.appendChild(bar); - } - } - hotbarEl.appendChild(s); + function 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; } - - // src/render/particles.js - var 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" - }); - } - } - - // src/world/tnt.js - 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 = getBlock2(gx, gy); - if (!center) return; - let bonus = 0; - for (let x = gx - 2; x <= gx + 2; x++) { - for (let y = gy - 2; y <= gy + 2; y++) { - const b = getBlock2(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 = getBlock2(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] !== void 0 && Math.random() < 0.2) 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); - } - - // src/game/save.js - var SAVE_KEY = state.SAVE_KEY; - function initDB() { - return new Promise((resolve) => { - console.log("\u0418\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0435\u043C localStorage \u0434\u043B\u044F \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0439 (sandbox \u0440\u0435\u0436\u0438\u043C)"); - resolve(null); - }); - } - function saveGame() { - 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, - // Сохраняем только изменения - placedBlocks: state.placedBlocks.slice(), - removedBlocks: state.removedBlocks.slice() - }; - const saveSize = JSON.stringify(saveData).length; - console.log("\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435: player HP:", state.player.hp, "hunger:", state.player.hunger, "o2:", state.player.o2); - try { - localStorage.setItem(SAVE_KEY, JSON.stringify(saveData)); - console.log(`\u0418\u0433\u0440\u0430 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0430 \u0432 localStorage (\u0440\u0430\u0437\u043C\u0435\u0440: ${saveSize} \u0431\u0430\u0439\u0442)`); - } catch (e) { - console.warn("\u041E\u0448\u0438\u0431\u043A\u0430 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u044F \u0432 localStorage, \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0435\u043C \u0442\u043E\u043B\u044C\u043A\u043E in-memory:", e); - state.inMemorySave = saveData; - console.log(`\u0418\u0433\u0440\u0430 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0430 \u0432 \u043F\u0430\u043C\u044F\u0442\u0438 (\u0440\u0430\u0437\u043C\u0435\u0440: ${saveSize} \u0431\u0430\u0439\u0442)`); - } - } - function loadGame() { - return new Promise((resolve, reject) => { - try { - const localSave = localStorage.getItem(SAVE_KEY); - if (localSave) { - const parsed = JSON.parse(localSave); - console.log("\u0417\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043E \u0438\u0437 localStorage, player HP:", parsed.player?.hp); - resolve(parsed); - return; - } - } catch (e) { - console.warn("\u041E\u0448\u0438\u0431\u043A\u0430 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043A localStorage:", e); - } - if (state.inMemorySave) { - console.log("\u0417\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043E \u0438\u0437 in-memory \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u044F, player HP:", state.inMemorySave.player?.hp); - resolve(state.inMemorySave); - return; - } - console.log("\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E"); - resolve(null); - }); - } - function migrateV1toV2(saveData) { - console.log("\u041C\u0438\u0433\u0440\u0430\u0446\u0438\u044F \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u044F \u0441 \u0432\u0435\u0440\u0441\u0438\u0438 1 \u043D\u0430 \u0432\u0435\u0440\u0441\u0438\u044E 2..."); - saveData.worldSeed = state.worldSeed; - saveData.placedBlocks = []; - saveData.removedBlocks = []; - delete saveData.generatedBlocks; - saveData.version = 2; - console.log("\u041C\u0438\u0433\u0440\u0430\u0446\u0438\u044F \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u0430"); - } - 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); - if (saveData.version === 1) { - migrateV1toV2(saveData); - } - if (saveData.worldSeed !== void 0) { - 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; - state.spawnPoint.x = state.player.x; - state.spawnPoint.y = state.player.y; - 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 \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D \u0438\u0437 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u044F: x=", state.spawnPoint.x, "y=", state.spawnPoint.y); + 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 { - console.log("No player data in save, setting default HP: 100"); - state.player.hp = 100; + // Это природный блок - добавляем в removedBlocks + removedBlocks.push({gx, gy}); } - console.log("=== applySave END ==="); - if (saveData.inventory) { - for (const key in saveData.inventory) { - state.inv[key] = saveData.inventory[key]; - } - } - if (saveData.time !== void 0) { - state.worldTime = saveData.time; - } - if (saveData.isNight !== void 0) { - state.isNightTime = saveData.isNight; - } - regenerateVisibleChunks(); - 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 || []; - } - rebuildHotbar(); - console.log("\u0418\u0433\u0440\u0430 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u0430"); + + return b; } - - // src/ui/save-controls.js - function initSaveControls() { - const saveBtn = document.getElementById("saveBtn"); - saveBtn.onclick = () => { - playSound("click"); - saveGame(); - alert("\u0418\u0433\u0440\u0430 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0430!"); - }; - const resetBtn = document.getElementById("resetBtn"); - resetBtn.onclick = () => { - if (confirm("\u0412\u044B \u0443\u0432\u0435\u0440\u0435\u043D\u044B, \u0447\u0442\u043E \u0445\u043E\u0442\u0438\u0442\u0435 \u0443\u0434\u0430\u043B\u0438\u0442\u044C \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u0438 \u043D\u0430\u0447\u0430\u0442\u044C \u043D\u043E\u0432\u0443\u044E \u0438\u0433\u0440\u0443?")) { - playSound("click"); - try { - localStorage.removeItem(state.SAVE_KEY); - console.log("\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u0443\u0434\u0430\u043B\u0435\u043D\u043E \u0438\u0437 localStorage"); - } catch (e) { - console.warn("\u041E\u0448\u0438\u0431\u043A\u0430 \u0443\u0434\u0430\u043B\u0435\u043D\u0438\u044F \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u044F:", e); - } - state.inMemorySave = null; - state.worldId = Math.random().toString(36).substring(2, 10); - console.log("\u041D\u043E\u0432\u044B\u0439 worldId \u043F\u043E\u0441\u043B\u0435 \u0441\u0431\u0440\u043E\u0441\u0430:", state.worldId); - try { - const newUrl = new URL(window.location.href); - newUrl.searchParams.set("world", state.worldId); - const newUrlString = newUrl.toString(); - if (typeof window.history !== "undefined" && typeof window.history.replaceState === "function") { - window.history.replaceState(null, "", newUrlString); - console.log("URL \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D:", newUrlString); - } - } catch (e) { - console.error("\u041E\u0448\u0438\u0431\u043A\u0430 \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F URL:", e); - } - location.reload(); - } - }; - } - function updateSaveButtonVisibility() { - const saveBtn = document.getElementById("saveBtn"); - if (state.isMultiplayer && state.otherPlayers.size > 0) { - saveBtn.style.display = "none"; - } else { - saveBtn.style.display = "flex"; - } - } - - // src/multiplayer/socket-helpers.js - 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]; - } - var lastMoveSendTime = 0; - var MOVE_SEND_INTERVAL = 0.05; - var lastSentX = 0; - var lastSentY = 0; - function sendPlayerPosition() { - if (!state.isMultiplayer || !state.socket || !state.socket.connected) return; - const now = performance.now() / 1e3; - 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 }); - } - - // src/multiplayer/socket.js - var 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 }); - 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"; - }); - socket.on("world_state", (data) => { - console.log("Received world_state:", data); - if (data.seed !== void 0 && data.seed !== state.worldSeed) { - const oldSeed = state.worldSeed; - state.worldSeed = data.seed; - console.log("World seed changed from", oldSeed, "to", state.worldSeed); - state.generated.clear(); - grid.clear(); - blocks.length = 0; - state.placedBlocks = []; - state.removedBlocks = []; - console.log("World regenerated with new seed:", state.worldSeed); - } - 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 !== void 0) { - state.worldTime = data.time; - state.isNightTime = state.worldTime > 0.5; - } - { - 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); - 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 || "\u0418\u0433\u0440\u043E\u043A" - }); - } - } - state.playerCountEl.textContent = data.players.length; - } - 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 || "\u0418\u0433\u0440\u043E\u043A" - }); - addChatMessage("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", `\u0418\u0433\u0440\u043E\u043A \u043F\u0440\u0438\u0441\u043E\u0435\u0434\u0438\u043D\u0438\u043B\u0441\u044F`); - 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("\u0421\u0438\u0441\u0442\u0435\u043C\u0430", `\u0418\u0433\u0440\u043E\u043A \u043F\u043E\u043A\u0438\u043D\u0443\u043B \u0438\u0433\u0440\u0443`); - updateSaveButtonVisibility(); - }); - 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) { - 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 ? "\u0412\u044B" : `\u0418\u0433\u0440\u043E\u043A ${data.socket_id.substring(0, 6)}`; - addChatMessage(senderName, data.message); - }); - socket.on("time_update", (data) => { - if (data.time !== void 0) { - state.worldTime = data.time; - state.isNightTime = state.worldTime > 0.5; - } - }); - } catch (e) { - console.error("Error initializing socket:", e); - state.isMultiplayer = false; - } - } - - // src/data/recipes.js - var 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 } - // булыжник → камень - ]; - - // src/core/canvas.js - var gameEl = document.getElementById("game"); - var canvas = document.getElementById("c"); - var ctx = canvas.getContext("2d"); - var lightC = document.createElement("canvas"); - var lightCtx = lightC.getContext("2d"); - var dpr = Math.max(1, window.devicePixelRatio || 1); - var W = 0; - var H = 0; - - // src/world/water.js - var waterUpdateQueue = /* @__PURE__ */ new Set(); - var waterUpdateTimer = 0; - var WATER_UPDATE_INTERVAL = 0.05; - function updateWaterPhysics(dt) { + + // Физика жидкости + 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; + 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) { + 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 = /* @__PURE__ */ new Set(); + + // Обновляем воду с ограничением глубины распространения + const processed = new Set(); const toAdd = []; - const MAX_WATER_DEPTH = 20; - for (const key of waterUpdateQueue) { - if (processed.has(key)) continue; + 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; + if(!b || b.dead) continue; processed.add(key); + const gx = b.gx; const gy = b.gy; - if (gy > SEA_GY + MAX_WATER_DEPTH) continue; + + // Проверяем глубину - не распространяем воду слишком глубоко + 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) { - toAdd.push({ gx, gy: gy + 1, t: "water" }); + + // Внизу пусто - вода создаёт новый блок внизу (но не удаляется сверху) + 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" }); + + // Если внизу не вода и не твёрдый блок - вода может течь вниз + 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" }); + 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" }); + 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) { + + // Применяем изменения (только добавляем новые блоки) + for(const newData of toAdd){ const key = k(newData.gx, newData.gy); - if (!grid.has(key)) { + if(!grid.has(key)){ const b = { gx: newData.gx, gy: newData.gy, @@ -1282,500 +855,190 @@ blocks.push(b); } } - for (let i = blocks.length - 1; i >= 0; i--) { - if (blocks[i].dead) { + + // Очищаем мёртвые блоки из массива + for(let i = blocks.length - 1; i >= 0; i--){ + if(blocks[i].dead){ blocks.splice(i, 1); } } } - - // src/physics/water-detect.js - function isWaterAt(px, py) { - const gx = Math.floor(px / TILE); - const gy = Math.floor(py / TILE); - const b = getBlock2(gx, gy); - return !!(b && b.t === "water"); + + // Инвентарь + const inv = { + dirt:6, stone:0, sand:0, gravel:0, clay:0, + wood:0, planks:0, ladder:0, leaves:0, coal:0, + copper_ore:0, iron_ore:0, gold_ore:0, diamond_ore:0, + brick:0, glass:0, + tnt:1, campfire:0, torch:0, + meat:0, cooked:0, arrow:0, + wood_pickaxe:0, stone_pickaxe:0, iron_pickaxe:0, + wood_sword:0, stone_sword:0, iron_sword:0, + iron_armor:0, + bow:0, furnace:0, + bed:0, boat:0, + iron_ingot:0, gold_ingot:0, copper_ingot:0 + }; + let selected = 'dirt'; + + // Прочность инструментов: Map<"tooltype_id", {current, max}> + // При крафте инструмента создаём запись с max durability + const toolDurability = new Map(); + + function addTool(type) { + const def = TOOLS[type]; + if (!def) return; + const id = `${type}_${Date.now()}_${Math.random().toString(36).slice(2,6)}`; + toolDurability.set(id, { type, current: def.durability, max: def.durability }); + return id; } - function 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"); - } + + function getToolDurability(id) { + return toolDurability.get(id); } - - // src/physics/collision.js - function resolveY(e) { - 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 = getBlock2(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 = getBlock2(leftGX, playerGY); - const rightBlock = getBlock2(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; - } - if (e.vy >= 0) { - const probeY = e.y + e.h + 1; - const gy2 = Math.floor(probeY / TILE); - const gxA = Math.floor(x1 / TILE); - const gxB = Math.floor(x2 / TILE); - if (isSolid(gxA, gy2) || isSolid(gxB, gy2)) { - e.y = gy2 * 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; - } + + // Найти лучший инструмент данного типа в инвентаре + function findBestTool(toolType) { + if (inv[toolType] <= 0) return null; + // Возвращаем первый попавшийся — упрощённо + return toolType; + } + + // Использовать инструмент (уменьшить прочность). Возвращает true если сломался + function useTool(toolType) { + // Ищем любой инструмент этого типа с прочностью + for (const [id, dur] of toolDurability) { + if (dur.type === toolType) { + dur.current--; + if (dur.current <= 0) { + toolDurability.delete(id); + inv[toolType]--; + rebuildHotbar(); + return true; // сломался } - if (e === state.player) e.fallStartY = e.y; - } - } - if (e.vy < 0 && e === state.player) { - const gy2 = Math.floor(e.y / TILE); - const gxA = Math.floor(x1 / TILE); - const gxB = Math.floor(x2 / TILE); - if ((isSolid(gxA, gy2) || isSolid(gxB, gy2)) && !isSolid(gxA, gy2 - 1) && !isSolid(gxB, gy2 - 1)) { - e.y = (gy2 + 1) * TILE; - e.vy = 0; - e.grounded = true; - if (e === state.player) e.fallStartY = e.y; - console.log("Jumped onto block!"); - } - } - if (e.vy < 0) { - const gy2 = Math.floor(e.y / TILE); - const gxA = Math.floor(x1 / TILE); - const gxB = Math.floor(x2 / TILE); - if (isSolid(gxA, gy2) || isSolid(gxB, gy2)) { - e.y = (gy2 + 1) * TILE; - e.vy = 0; + return false; } } + return false; } - 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 = getBlock2(gx, gy); - const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable; - if (e.vx > 0) { - const gx2 = Math.floor((e.x + e.w) / TILE); - const gyA = Math.floor(y1 / TILE); - const gyB = Math.floor(y2 / TILE); - const solidA = isSolid(gx2, gyA); - const solidB = isSolid(gx2, gyB); - if (solidA || solidB) { - e.x = gx2 * TILE - e.w; - e.vx = 0; - } - } else if (e.vx < 0) { - const gx2 = Math.floor(e.x / TILE); - const gyA = Math.floor(y1 / TILE); - const gyB = Math.floor(y2 / TILE); - const solidA = isSolid(gx2, gyA); - const solidB = isSolid(gx2, gyB); - if (solidA || solidB) { - e.x = (gx2 + 1) * TILE; - e.vx = 0; - } - } - } - - // src/entities/mob-ai.js - 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; - 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() { - return state.worldTime > 0.5; - } - - // src/ui/furnace.js - var furnacePanel = document.getElementById("furnacePanel"); - var 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 = getBlock2(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 ? "\u{1F9F1}" : "\u2753"; - 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} (\u0435\u0441\u0442\u044C: ${haveCount}) \u2022 ${recipe.time}\u0441
`; - html += `
`; - html += ``; - html += `
`; - } - if (active) { - const pct = Math.min(100, Math.floor(active.progress / active.recipe.time * 100)); - html += `
`; - html += `
\u{1F525} \u041E\u0431\u0436\u0438\u0433: ${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, - progress: 0 - }); - playSound("fire"); - rebuildHotbar(); - renderFurnaceUI(); + + const RECIPES = [ + { out:'planks', qty:4, cost:{ wood:1 } }, + { out:'ladder', qty:3, cost:{ planks:7 } }, + { out:'torch', qty:2, cost:{ coal:1, planks:1 } }, + { out:'glass', qty:1, cost:{ sand:3 } }, + { out:'brick', qty:1, cost:{ stone:2, clay:1 } }, + { out:'campfire', qty:1, cost:{ wood:1, coal:1 } }, + { out:'tnt', qty:1, cost:{ sand:2, coal:1 } }, + { out:'bed', qty:1, cost:{ wood: 3, planks: 3 } }, + { out:'boat', qty:1, cost:{ wood: 5 } }, + { out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 } }, + { out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 } }, + { out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 } }, + { out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 } }, + { out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 } }, + { out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 } }, + { out:'iron_armor', qty:1, cost:{ iron_ore: 5 } }, + { out:'furnace', qty:1, cost:{ stone: 8 } }, + { out:'bow', qty:1, cost:{ wood: 3, planks: 2 } }, + { out:'arrow', qty:4, cost:{ stone: 1, wood: 1 } } + ]; + + // Рецепты печи (обжиг) + const SMELTING_RECIPES = [ + { in:'sand', qty:1, out:'glass', outQty:1, time:3 }, // песок → стекло + { in:'clay', qty:1, out:'brick', outQty:1, time:3 }, // глина → кирпич + { in:'iron_ore', qty:1, out:'iron_ingot', outQty:1, time:5 }, // железо → слиток + { in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 }, // золото → слиток + { in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 }, // медь → слиток + { in:'meat', qty:1, out:'cooked', outQty:1, time:2 }, // сырое мясо → жареное + { in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 } // булыжник → камень + ]; + + // Новые предметы от обжига + ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' }; + ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' }; + ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' }; + + // Активные печи: Map ключа блока → { recipe, progress, totalTime } + const activeFurnaces = new Map(); + + // UI + const hpEl = document.getElementById('hp'); + const foodEl = document.getElementById('food'); + const sxEl = document.getElementById('sx'); + const syEl = document.getElementById('sy'); + const todEl = document.getElementById('tod'); + const worldIdEl = document.getElementById('worldId'); + const playerCountEl = document.getElementById('playerCount'); + const hotbarEl = document.getElementById('hotbar'); + const craftPanel = document.getElementById('craftPanel'); + const recipesEl = document.getElementById('recipes'); + const deathEl = document.getElementById('death'); + const inventoryPanel = document.getElementById('inventoryPanel'); + const inventoryGrid = document.getElementById('inventoryGrid'); + + // ==================== МИНИКАРТА ==================== + const minimapWrap = document.getElementById('minimapWrap'); + const minimapCanvas = document.getElementById('minimap'); + const minimapCtx = minimapCanvas.getContext('2d'); + let minimapOpen = false; + + document.getElementById('mapToggle').onclick = () => { + playSound('click'); + minimapOpen = !minimapOpen; + minimapWrap.style.display = minimapOpen ? 'block' : 'none'; }; - 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); - if (key === state.currentFurnaceKey) { - renderFurnaceUI(); - } - } - } - } - - // src/world/weather.js - function updateWeather(dt) { - state.weatherTimer += dt; - if (state.weatherTimer >= state.weatherChangeInterval) { - state.weatherTimer = 0; - state.weatherChangeInterval = 60 + Math.random() * 120; - const nightChance = isNight() ? 0.25 : 0.4; - 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); - 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 ctx2 = state.ctx; - ctx2.save(); - ctx2.strokeStyle = "rgba(174,194,224,0.5)"; - ctx2.lineWidth = 1.5; - ctx2.beginPath(); - for (const d of state.raindrops) { - ctx2.moveTo(d.x, d.y); - ctx2.lineTo(d.x - 3, d.y + d.len); - } - ctx2.stroke(); - ctx2.restore(); - } - - // src/entities/mobs.js - var Entity = class { - 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; - } + + // Цвета блоков для миникарты (по 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' }; - var Pig = class extends Entity { - constructor(x, y) { - super(x, y, 34, 24); - this.kind = "pig"; - this.hp = 2; - } - }; - var Chicken = class extends Entity { - constructor(x, y) { - super(x, y, 26, 22); - this.kind = "chicken"; - this.hp = 1; - } - }; - var Zombie = class extends Entity { - constructor(x, y) { - super(x, y, 34, 50); - this.kind = "zombie"; - this.hp = 4; - this.speed = 80 + Math.random() * 40; - } - }; - var Creeper = class 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; - } - }; - var Skeleton = class 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; - } - }; - - // src/ui/minimap.js - var minimapWrap = document.getElementById("minimapWrap"); - var minimapCanvas = document.getElementById("minimap"); - var minimapCtx = minimapCanvas.getContext("2d"); - var 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; + if (!minimapOpen) return; const mW = minimapCanvas.width; const mH = minimapCanvas.height; - const scale = 2; - const TILE2 = state.TILE; - const player = state.player; - const pGX = Math.floor(player.x / TILE2); - const pGY = Math.floor(player.y / TILE2); + const scale = 2; // пикселей на блок + + // Область карты — центрирована на игроке + const pGX = Math.floor(player.x / TILE); + const pGY = Math.floor(player.y / TILE); const viewW = Math.floor(mW / scale); const viewH = Math.floor(mH / scale); const startGX = pGX - Math.floor(viewW / 2); const startGY = pGY - Math.floor(viewH / 2); - minimapCtx.fillStyle = isNight() ? "#070816" : "#87CEEB"; + + // Очищаем + minimapCtx.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 = getBlock2(gx, gy); - if (!b || b.dead || b.t === "air") continue; + const b = getBlock(gx, gy); + if (!b || b.dead || b.t === 'air') continue; + const color = MINIMAP_COLORS[b.t]; if (!color) continue; - const r = parseInt(color.slice(1, 3), 16); - const g = parseInt(color.slice(3, 5), 16); - const bl = parseInt(color.slice(5, 7), 16); + + // Парсим 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; @@ -1783,462 +1046,2097 @@ 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; + 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 / TILE2) - startGX; - const dy = Math.floor(p.y / TILE2) - startGY; + + // Игрок — белый пиксель по центру + minimapCtx.fillStyle = '#fff'; + minimapCtx.fillRect(Math.floor(mW/2) - 2, Math.floor(mH/2) - 2, 4, 4); + + // Другие игроки — жёлтые точки + for (const [sid, p] of otherPlayers) { + const dx = Math.floor(p.x / TILE) - startGX; + const dy = Math.floor(p.y / TILE) - startGY; if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) { - minimapCtx.fillStyle = "#f1c40f"; + minimapCtx.fillStyle = '#f1c40f'; minimapCtx.fillRect(dx * scale - 1, dy * scale - 1, 3, 3); } } - const allMobsForMap = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs; + + // Мобы — красные (враждебные) / зелёные (животные) + const allMobsForMap = isMultiplayer ? Array.from(serverMobs.values()) : mobs; for (const m of allMobsForMap) { - const dx = Math.floor(m.x / TILE2) - startGX; - const dy = Math.floor(m.y / TILE2) - startGY; + 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"; + const hostile = m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton'; + minimapCtx.fillStyle = hostile ? '#e74c3c' : '#2ecc71'; minimapCtx.fillRect(dx * scale, dy * scale, 2, 2); } } } - - // src/game/modes.js - var MODES = [{ id: "mine", icon: "\u26CF\uFE0F" }, { id: "build", icon: "\u{1F9F1}" }]; - function mode() { - return MODES[state.modeIdx].id; + + // ==================== ПЕЧЬ (ОБЖИГ) ==================== + const furnacePanel = document.getElementById('furnacePanel'); + const furnaceContent = document.getElementById('furnaceContent'); + let currentFurnaceKey = null; // "gx,gy" текущей открытой печи + + document.getElementById('furnaceClose').onclick = () => { + furnacePanel.style.display = 'none'; + currentFurnaceKey = null; + }; + + function openFurnaceUI(gx, gy) { + currentFurnaceKey = `${gx},${gy}`; + furnacePanel.style.display = 'block'; + renderFurnaceUI(); } - function initModes() { - const modeBtn = document.getElementById("modeBtn"); - modeBtn.onclick = () => { - playSound("click"); - state.modeIdx = (state.modeIdx + 1) % MODES.length; - modeBtn.textContent = MODES[state.modeIdx].icon; + + function renderFurnaceUI() { + if (!currentFurnaceKey) return; + + // Проверяем что печь всё ещё существует + const [fgx, fgy] = currentFurnaceKey.split(',').map(Number); + const fb = getBlock(fgx, fgy); + if (!fb || fb.t !== 'furnace') { + furnacePanel.style.display = 'none'; + currentFurnaceKey = null; + return; + } + + // Текущий процесс обжига + const active = activeFurnaces.get(currentFurnaceKey); + + let html = '
'; + + // Доступные рецепты — показываем только те, для которых есть ресурсы + for (let i = 0; i < SMELTING_RECIPES.length; i++) { + const recipe = SMELTING_RECIPES[i]; + const haveCount = inv[recipe.in] || 0; + const canSmelt = haveCount >= recipe.qty; + + // Иконка результата + const outDef = BLOCKS[recipe.out]; + const outItem = ITEMS[recipe.out]; + const iconStr = outItem ? outItem.icon : (outDef ? '🧱' : '❓'); + const nameStr = outItem ? outItem.n : (outDef ? outDef.n : recipe.out); + const inItem = ITEMS[recipe.in]; + const inDef = BLOCKS[recipe.in]; + const inName = inItem ? inItem.n : (inDef ? inDef.n : recipe.in); + + html += `
`; + html += `
${iconStr}
`; + html += `
`; + html += `
${nameStr}
`; + html += `
${inName} x${recipe.qty} (есть: ${haveCount}) • ${recipe.time}с
`; + html += `
`; + html += ``; + html += `
`; + } + + // Текущий прогресс + if (active) { + const pct = Math.min(100, Math.floor((active.progress / active.recipe.time) * 100)); + html += `
`; + html += `
🔥 Обжиг: ${pct}%
`; + html += `
`; + html += `
`; + html += `
`; + } + + html += '
'; + furnaceContent.innerHTML = html; + } + + // Глобальная функция для кнопки обжига + window._smelt = (recipeIdx) => { + if (!currentFurnaceKey) return; + const recipe = SMELTING_RECIPES[recipeIdx]; + if ((inv[recipe.in] || 0) < recipe.qty) return; + + // Уже обжигаем в этой печи? + if (activeFurnaces.has(currentFurnaceKey)) return; + + // Забираем ресурсы + inv[recipe.in] -= recipe.qty; + + // Запускаем обжиг + activeFurnaces.set(currentFurnaceKey, { + recipe: recipe, + progress: 0 + }); + + playSound('fire'); + rebuildHotbar(); + renderFurnaceUI(); + }; + + // Тик печей — вызывается в главном цикле + function tickFurnaces(dt) { + for (const [key, furnace] of activeFurnaces) { + furnace.progress += dt; + if (furnace.progress >= furnace.recipe.time) { + // Обжиг завершён — выдаём результат + const outItem = furnace.recipe.out; + if (ITEMS[outItem]) { + inv[outItem] = (inv[outItem] || 0) + furnace.recipe.outQty; + } else if (BLOCKS[outItem]) { + inv[outItem] = (inv[outItem] || 0) + furnace.recipe.outQty; + } + playSound('stone_build'); + activeFurnaces.delete(key); + + // Если эта печь открыта — обновляем UI + if (key === currentFurnaceKey) { + renderFurnaceUI(); + } + } + } + } + + // ==================== ГОЛОСОВОЙ ЧАТ ==================== + let voiceSocket = null; + let voiceStream = null; + let audioCtx = null; + let voiceProcessor = null; + let voiceActive = false; + 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 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); + // ScriptProcessor MUST connect to destination to fire onaudioprocess events + // Use a zero-gain node to silence own playback while keeping the processor active + const silentGain = audioCtx.createGain(); + silentGain.gain.value = 0; // mute — don't hear ourselves + voiceProcessor.connect(silentGain); + silentGain.connect(audioCtx.destination); + + // Подключаемся к голосовому серверу + voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] }); + voiceSocket.on('connect', () => { + voiceSocket.emit('voice_join', { world_id: worldId, x: player.x, y: player.y, name: playerName || 'Игрок' }); + }); + + 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'; + } + }; + + // Обновляем позицию для voice server + const origPlayerMove = () => {}; + // Хук в главный цикл — обновляем позицию каждые ~500ms + let voicePosT = 0; + + // Клик на часы для включения ночи + todEl.style.cursor = 'pointer'; + todEl.onclick = () => { + playSound('click'); + worldTime = 0.6; // Устанавливаем ночь + isNightTime = true; + }; + + function rebuildHotbar(){ + hotbarEl.innerHTML=''; + + // Показываем последние 5 выбранных предметов (если они есть в инвентаре) + const items = recentItems.filter(id => inv[id] > 0).slice(0, 5); + + for(const id of items){ + const s = document.createElement('div'); + s.className = 'slot'+(id===selected?' sel':''); + if(BLOCKS[id]) { + s.style.backgroundImage = `url(${tex[id].toDataURL()})`; + s.style.backgroundSize = 'cover'; + } else if(ITEMS[id]) { + s.textContent = ITEMS[id].icon; + } else if(TOOLS[id]) { + s.textContent = TOOLS[id].icon; + } else if(id === 'iron_armor') { + s.textContent = '🛡️'; + s.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)'; + } + const c = document.createElement('div'); + c.className='count'; + c.textContent = inv[id]; + s.appendChild(c); + s.onclick = () => { + playSound('click'); // Звук клика по инвентарю + selected=id; + // Обновляем список последних предметов + recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть + recentItems.unshift(id); // Добавляем в начало + recentItems = recentItems.slice(0, 5); // Оставляем только 5 + rebuildHotbar(); + }; + + // Показываем индикатор надетой брони + if(id === 'iron_armor' && player.equippedArmor === 'iron_armor') { + const equipped = document.createElement('div'); + equipped.className = 'equipped-indicator'; + equipped.textContent = '✓'; + s.appendChild(equipped); + } + + // Durability bar для инструментов + if(TOOLS[id] && inv[id] > 0) { + // Находим текущую прочность + let curDur = 0, maxDur = TOOLS[id].durability; + for (const [tid, dur] of toolDurability) { + if (dur.type === id) { + curDur = dur.current; + maxDur = dur.max; + break; + } + } + if (maxDur > 0) { + const bar = document.createElement('div'); + bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;border-radius:0 0 6px 6px;overflow:hidden;`; + const fill = document.createElement('div'); + const pct = curDur / maxDur; + const color = pct > 0.5 ? '#2ecc71' : pct > 0.25 ? '#f39c12' : '#e74c3c'; + fill.style.cssText = `width:${pct*100}%;height:100%;background:${color};`; + bar.appendChild(fill); + s.appendChild(bar); + } + } + hotbarEl.appendChild(s); + } + } + + function renderInventory() { + inventoryGrid.innerHTML = ''; + + // Создаём сетку инвентаря 7x3 + const items = Object.keys(inv).filter(id => inv[id] > 0); + + // Добавляем пустые слоты для полной сетки + for(let i = 0; i < 21; i++) { + const slot = document.createElement('div'); + slot.className = 'inv-slot' + (i < items.length && items[i] === selected ? ' sel' : ''); + + if(i < items.length) { + const id = items[i]; + if(BLOCKS[id]) { + slot.style.backgroundImage = `url(${tex[id].toDataURL()})`; + slot.style.backgroundSize = 'cover'; + } else if(ITEMS[id]) { + slot.textContent = ITEMS[id].icon; + } else if(TOOLS[id]) { + slot.textContent = TOOLS[id].icon; + } else if(id === 'iron_armor') { + slot.textContent = '🛡️'; + slot.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)'; + } + + const count = document.createElement('div'); + count.className = 'inv-count'; + count.textContent = inv[id]; + slot.appendChild(count); + + slot.onclick = () => { + playSound('click'); // Звук клика по инвентарю + selected = id; + // Обновляем список последних предметов + recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть + recentItems.unshift(id); // Добавляем в начало + recentItems = recentItems.slice(0, 5); // Оставляем только 5 + rebuildHotbar(); + renderInventory(); + }; + + // Двойной клик для надевания брони + slot.ondblclick = () => { + if(id === 'iron_armor' && inv.iron_armor > 0) { + // Если уже надета броня - снимаем её + if(player.equippedArmor === 'iron_armor') { + player.equippedArmor = null; + player.armor = 0; + console.log('[ARMOR] Iron armor unequipped'); + } else { + // Надеваем броню + player.equippedArmor = 'iron_armor'; + player.armor = BLOCKS['iron_armor'].armor; + console.log('[ARMOR] Iron armor equipped - armor:', player.armor); + } + playSound('click'); + renderInventory(); + } + }; + } + + inventoryGrid.appendChild(slot); + } + } + + function canCraft(r){ + console.log('[CRAFT] Checking recipe for:', r.out, '- cost:', r.cost); + for(const res in r.cost){ + const have = inv[res] || 0; + const need = r.cost[res]; + console.log('[CRAFT] Resource:', res, '- have:', have, '- need:', need, '- can craft:', have >= need); + if(have < need) return false; + } + return true; + } + function renderCraft(){ + recipesEl.innerHTML=''; + for(const r of RECIPES){ + const row = document.createElement('div'); + row.className='recipe'; + const icon = document.createElement('div'); + icon.className='ricon'; + // Иконка — блок, инструмент или предмет + if(tex[r.out]){ + icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`; + } else if(TOOLS[r.out]){ + icon.textContent = TOOLS[r.out].icon; + icon.style.fontSize = '24px'; + icon.style.display = 'flex'; + icon.style.alignItems = 'center'; + icon.style.justifyContent = 'center'; + } else if(ITEMS[r.out]){ + icon.textContent = ITEMS[r.out].icon; + icon.style.fontSize = '24px'; + icon.style.display = 'flex'; + icon.style.alignItems = 'center'; + icon.style.justifyContent = 'center'; + } + const info = document.createElement('div'); + info.className='rinfo'; + const nm = document.createElement('div'); + nm.className='rname'; + const itemName = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out; + nm.textContent = `${itemName} x${r.qty}`; + const cs = document.createElement('div'); + cs.className='rcost'; + cs.textContent = Object.keys(r.cost).map(x => { + const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x; + return `${cn}: ${(inv[x]||0)}/${r.cost[x]}`; + }).join(' '); + info.appendChild(nm); info.appendChild(cs); + const btn = document.createElement('button'); + btn.className='rcraft'; + btn.textContent='Создать'; + btn.disabled = !canCraft(r); + btn.onclick = () => { + if(!canCraft(r)) return; + playSound('click'); + for(const res in r.cost) inv[res]-=r.cost[res]; + inv[r.out] = (inv[r.out]||0) + r.qty; + if(TOOLS[r.out]) addTool(r.out); + rebuildHotbar(); + renderCraft(); + }; + row.appendChild(icon); row.appendChild(info); row.appendChild(btn); + recipesEl.appendChild(row); + } + } + + let craftOpen=false; + let inventoryOpen = false; + + document.getElementById('craftBtn').onclick = () => { + playSound('click'); // Звук клика по кнопке + craftOpen = !craftOpen; + craftPanel.style.display = craftOpen ? 'block' : 'none'; + if(craftOpen) { + renderCraft(); + // Закрываем инвентарь если открыт крафт + inventoryOpen = false; + inventoryPanel.style.display = 'none'; + } + }; + document.getElementById('craftClose').onclick = () => { + playSound('click'); // Звук клика по кнопке + craftOpen = false; + craftPanel.style.display = 'none'; + }; + + // Кнопка открытия инвентаря + document.getElementById('invToggle').onclick = () => { + playSound('click'); // Звук клика по кнопке + inventoryOpen = true; + inventoryPanel.style.display = 'block'; + renderInventory(); + // Закрываем крафт если открыт инвентарь + craftOpen = false; + craftPanel.style.display = 'none'; + }; + + document.getElementById('inventoryClose').onclick = () => { + playSound('click'); // Звук клика по кнопке + inventoryOpen = false; + inventoryPanel.style.display = 'none'; + }; + + // Кнопка сохранения игры (только для одиночного режима) + const saveBtn = document.getElementById('saveBtn'); + saveBtn.onclick = () => { + playSound('click'); + saveGame(); + 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:'mine',icon:'⛏️'},{id:'build',icon:'🧱'}]; + let modeIdx=0; + const modeBtn = document.getElementById('modeBtn'); + function mode(){ return MODES[modeIdx].id; } + modeBtn.onclick = () => { + playSound('click'); // Звук клика по кнопке режима + modeIdx=(modeIdx+1)%MODES.length; modeBtn.textContent=MODES[modeIdx].icon; + }; + + // День/ночь (автоматический цикл) + let isNightTime = false; + + // Управление + const inp = { l:false, r:false, j:false, s:false }; + function bindHold(el, key){ + const down=(e)=>{ e.preventDefault(); inp[key]=true; }; + const up=(e)=>{ e.preventDefault(); inp[key]=false; }; + el.addEventListener('pointerdown', down); + el.addEventListener('pointerup', up); + el.addEventListener('pointerleave', up); + } + const leftBtn = document.getElementById('left'); + const rightBtn = document.getElementById('right'); + const jumpBtn = document.getElementById('jump'); + const downBtn = document.getElementById('down'); + + if(leftBtn) bindHold(leftBtn,'l'); + if(rightBtn) bindHold(rightBtn,'r'); + if(jumpBtn) bindHold(jumpBtn,'j'); + if(downBtn) bindHold(downBtn,'s'); + + window.addEventListener('keydown', (e)=>{ + if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=true; + if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=true; + if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=true; + if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=true; + }); + window.addEventListener('keyup', (e)=>{ + if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=false; + if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=false; + if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=false; + if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=false; + }); + + // Лодка + const boat = { + x: 0, y: 0, + w: 34, h: 34, + vx: 0, vy: 0, + active: false, + inWater: false + }; + + // Функция для расчёта урона с учётом брони + function calculateDamage(baseDamage) { + // Броня снижает урон пропорционально + // armor: 0 = без брони (100% урона) + // armor: 0.5 = железная броня (50% урона) + const reduction = player.armor; + const actualDamage = baseDamage * (1 - reduction); + console.log('[DAMAGE] Base:', baseDamage, '- Armor:', reduction, '- Actual:', actualDamage.toFixed(1)); + return actualDamage; + } + + // Игрок + const player = { + x: 6*TILE, y: 0*TILE, + w: 34, h: 34, + vx: 0, vy: 0, + grounded: false, + inWater: false, + headInWater: false, + hp: 100, + hunger: 100, + o2: 100, + invuln: 0, + fallStartY: 0, + lastStepTime: 0, + sleeping: false, + inBoat: false, + armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня) + equippedArmor: null // Тип надетой брони + }; + + // Сохраняем начальную позицию для возрождения + const spawnPoint = { x: 6*TILE, y: 0*TILE }; + + // Система сохранения игры (localStorage + in-memory fallback) + const SAVE_KEY = 'minegrechka_save'; + let db = null; // Оставляем для совместимости, но не используем + let inMemorySave = null; // Запасное сохранение в памяти + + // Инициализация (localStorage + in-memory fallback) + function initDB(){ + return new Promise((resolve) => { + console.log('Используем localStorage для сохранений (sandbox режим)'); + resolve(null); + }); + } + + // Детерминированный генератор псевдослучайных чисел на основе seed + function seededRandom(gx, gy){ + const n = Math.sin(gx * 12.9898 + gy * 78.233 + worldSeed * 0.1) * 43758.5453; + return n - Math.floor(n); + } + + function saveGame(){ + const saveData = { + version: 2, + worldSeed: worldSeed, + player: { + x: player.x, + y: player.y, + hp: player.hp, + hunger: player.hunger, + o2: player.o2 + }, + inventory: inv, + time: worldTime, + isNight: isNightTime, + // Сохраняем только изменения + placedBlocks: placedBlocks.slice(), + removedBlocks: removedBlocks.slice() }; + + const saveSize = JSON.stringify(saveData).length; + console.log('Сохранение: player HP:', player.hp, 'hunger:', player.hunger, 'o2:', player.o2); + + // Пробуем сохранить в localStorage (основной метод для персистентности) + try { + localStorage.setItem(SAVE_KEY, JSON.stringify(saveData)); + console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`); + } catch(e){ + console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e); + + // Если localStorage недоступен, используем in-memory fallback + inMemorySave = saveData; + console.log(`Игра сохранена в памяти (размер: ${saveSize} байт)`); + } + } + + function loadGame(){ + return new Promise((resolve, reject) => { + // Пробуем localStorage + try { + const localSave = localStorage.getItem(SAVE_KEY); + if(localSave){ + const parsed = JSON.parse(localSave); + console.log('Загружено из localStorage, player HP:', parsed.player?.hp); + resolve(parsed); + return; + } + } catch(e){ + console.warn('Ошибка доступа к localStorage:', e); + } + + // Если localStorage недоступен, используем in-memory сохранение + if(inMemorySave){ + console.log('Загружено из in-memory сохранения, player HP:', inMemorySave.player?.hp); + resolve(inMemorySave); + return; + } + + console.log('Сохранение не найдено'); + resolve(null); + }); + } + + // Миграция с версии 1 на версию 2 + function migrateV1toV2(saveData){ + console.log('Миграция сохранения с версии 1 на версию 2...'); + + // Сохраняем seed из текущей игры (так как v1 его не хранил) + saveData.worldSeed = worldSeed; + + // Инициализируем массивы изменений + saveData.placedBlocks = []; + saveData.removedBlocks = []; + + // Для v1 нам нужно сравнить сгенерированные блоки с тем, что должно быть по seed + // Это сложно сделать без полной перегенерации, поэтому для v1 сохраняем только seed + // и при загрузке просто перегенерируем мир + + // Удаляем старые данные + delete saveData.generatedBlocks; + + saveData.version = 2; + console.log('Миграция завершена'); + } + + async function applySave(saveData){ + if(!saveData) return; + + console.log('=== applySave START ==='); + console.log('player HP before applySave:', player.hp); + console.log('saveData.player.hp:', saveData.player?.hp); + + // Миграция версий + if(saveData.version === 1){ + migrateV1toV2(saveData); + } + + // Восстанавливаем seed + if(saveData.worldSeed !== undefined){ + worldSeed = saveData.worldSeed; + } + + // Восстанавливаем игрока + if(saveData.player){ + player.x = saveData.player.x; + player.y = saveData.player.y; + player.hunger = saveData.player.hunger; + player.o2 = saveData.player.o2; + + // Обновляем spawnPoint на позицию из сохранения + spawnPoint.x = player.x; + spawnPoint.y = player.y; + + // Проверяем HP из сохранения - если <= 0, устанавливаем 100 + const savedHP = saveData.player.hp; + console.log('Saved HP from file:', savedHP); + if(savedHP <= 0){ + console.log('WARNING: Saved HP is <= 0, setting to 100!'); + player.hp = 100; + } else { + player.hp = savedHP; + } + console.log('player HP after restore:', player.hp); + console.log('spawnPoint обновлён из сохранения: x=', spawnPoint.x, 'y=', spawnPoint.y); + } else { + console.log('No player data in save, setting default HP: 100'); + player.hp = 100; + } + + console.log('=== applySave END ==='); + + // Восстанавливаем инвентарь + if(saveData.inventory){ + for(const key in saveData.inventory){ + inv[key] = saveData.inventory[key]; + } + } + + // Восстанавливаем время + if(saveData.time !== undefined){ + worldTime = saveData.time; + } + + // Восстанавливаем день/ночь + if(saveData.isNight !== undefined){ + isNightTime = saveData.isNight; + } + + // Перегенерируем мир по seed + regenerateVisibleChunks(); + + // Применяем изменения (только для v2) + if(saveData.version === 2){ + // Применяем блоки, установленные игроком + for(const block of saveData.placedBlocks){ + setBlock(block.gx, block.gy, block.t, true); + } + + // Применяем удалённые блоки + for(const block of saveData.removedBlocks){ + removeBlock(block.gx, block.gy); + } + + // Восстанавливаем массивы изменений + placedBlocks = saveData.placedBlocks || []; + removedBlocks = saveData.removedBlocks || []; + } + + rebuildHotbar(); + console.log('Игра загружена'); + } + + // Камера (двухосевая) + let camX=0, camY=0; + + // День/ночь + let worldTime=0; + const DAY_LEN=360; // замедлил смену дня/ночи в 3 раза + + // Облака + const clouds = Array.from({length:10}, ()=>({ + x: Math.random()*2000, + y: -200 - Math.random()*260, // выше экрана, потому что мир по Y теперь двигается + w: 80+Math.random()*120, + s: 12+Math.random()*20 + })); + + // Дождь + let isRaining = false; + let rainIntensity = 0; // 0..1 + let weatherTimer = 0; + let weatherChangeInterval = 60 + Math.random() * 120; // смена погоды 60-180с + const raindrops = []; + const MAX_RAINDROPS = 200; + + function updateWeather(dt) { + weatherTimer += dt; + if (weatherTimer >= weatherChangeInterval) { + weatherTimer = 0; + weatherChangeInterval = 60 + Math.random() * 120; + // Выбираем новую погоду: 40% дождь днём, 25% дождь ночью, остальное ясно + const nightChance = isNight() ? 0.25 : 0.40; + isRaining = Math.random() < nightChance; + } + // Плавная интерполяция интенсивности + const target = isRaining ? (0.4 + Math.random() * 0.01) : 0; + rainIntensity += (target - rainIntensity) * dt * 0.5; + if (rainIntensity < 0.01) rainIntensity = 0; } - // src/input/mouse-handler.js - var mouse = { x: null, y: null }; - function initMouseHandlers() { - const canvas2 = state.canvas; - canvas2.addEventListener("pointermove", (e) => { - const r = canvas2.getBoundingClientRect(); - mouse.x = e.clientX - r.left; - mouse.y = e.clientY - r.top; - }); - canvas2.addEventListener("pointerdown", (e) => { - if (state.craftOpen) return; - if (state.player.hp <= 0) return; - const r = canvas2.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 = getBlock2(gx, gy); - if (state.player.sleeping && b && b.t === "bed") { - state.player.sleeping = false; - return; + function updateRain(dt) { + if (!isRaining || rainIntensity < 0.01) { + raindrops.length = 0; + return; + } + // Спавн капель + const spawnRate = Math.floor(rainIntensity * 60 * dt); // ~60 капель/сек при интенсити=1 + for (let i = 0; i < spawnRate && raindrops.length < MAX_RAINDROPS; i++) { + raindrops.push({ + x: camX + Math.random() * W, + y: camY - 20, + vy: 400 + Math.random() * 200, + len: 8 + Math.random() * 12 + }); + } + // Обновление + for (let i = raindrops.length - 1; i >= 0; i--) { + const d = raindrops[i]; + d.y += d.vy * dt; + d.x -= 30 * dt; // лёгкий ветер + if (d.y > camY + H + 20) { + raindrops.splice(i, 1); } - if (state.player.sleeping) return; - if (b && b.t === "furnace" && mode() === "mine") { - openFurnaceUI(gx, gy); - return; + } + } + + function drawRain() { + if (raindrops.length === 0) return; + ctx.save(); + ctx.strokeStyle = 'rgba(174,194,224,0.5)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + for (const d of raindrops) { + ctx.moveTo(d.x, d.y); + ctx.lineTo(d.x - 3, d.y + d.len); + } + ctx.stroke(); + ctx.restore(); + } + + // Частицы (взрыв) + const parts = []; + function spawnExplosion(x,y, power){ + const n = Math.floor(16 + power*10); + for(let i=0;i 100){ + playSound('splash'); + } + } + + function resolveY(e){ + // Всегда пересчитываем grounded (не держим "липким") + e.grounded = false; + + const x1 = e.x + 2; + const x2 = e.x + e.w - 2; + + // Проверяем, находится ли игрок на лестнице (по центру) + const cx = e.x + e.w/2; + const cy = e.y + e.h/2; + const gx = Math.floor(cx / TILE); + const gy = Math.floor(cy / TILE); + const b = getBlock(gx, gy); + const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable; + + // Если на лестнице - можно двигаться вверх/вниз + if(onLadder){ + e.grounded = true; + + // Если нажимаем прыжок на лестнице - поднимаемся + if(inp.j){ + e.vy = -200; } - if (mode() === "mine") { - 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; - } + // Если нажимаем вниз - спускаемся + 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; } } - 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) { + if(e === player) e.fallStartY = e.y; + } + } + + // 2) Проверка на возможность запрыгнуть на блок, если игрок рядом с ним + if(e.vy < 0 && e === player){ + const gy = Math.floor(e.y / TILE); + const gxA = Math.floor(x1 / TILE); + const gxB = Math.floor(x2 / TILE); + + // Проверяем, есть ли блок рядом с игроком + if((isSolid(gxA, gy) || isSolid(gxB, gy)) && !isSolid(gxA, gy - 1) && !isSolid(gxB, gy - 1)){ + e.y = (gy + 1) * TILE; + e.vy = 0; + e.grounded = true; + if(e === player) e.fallStartY = e.y; + console.log("Jumped onto block!"); + } + } + + // 2) Если движемся вверх — проверяем потолок + if(e.vy < 0){ + const gy = Math.floor(e.y / TILE); + const gxA = Math.floor(x1 / TILE); + const gxB = Math.floor(x2 / TILE); + if(isSolid(gxA, gy) || isSolid(gxB, gy)){ + e.y = (gy + 1) * TILE; + e.vy = 0; + } + } + } + + function resolveX(e){ + const y1 = e.y + 2; + const y2 = e.y + e.h - 2; + + // Проверяем, находимся ли мы на лестнице + const cx = e.x + e.w/2; + const cy = e.y + e.h/2; + const gx = Math.floor(cx / TILE); + const gy = Math.floor(cy / TILE); + const b = getBlock(gx, gy); + const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable; + + if(e.vx > 0){ + const gx = Math.floor((e.x + e.w)/TILE); + const gyA = Math.floor(y1/TILE); + const gyB = Math.floor(y2/TILE); + const solidA = isSolid(gx, gyA); + const solidB = isSolid(gx, gyB); + + if(solidA || solidB){ + e.x = gx*TILE - e.w; + e.vx = 0; + } + } else if(e.vx < 0){ + const gx = Math.floor(e.x/TILE); + const gyA = Math.floor(y1/TILE); + const gyB = Math.floor(y2/TILE); + const solidA = isSolid(gx, gyA); + const solidB = isSolid(gx, gyB); + + if(solidA || solidB){ + e.x = (gx+1)*TILE; + e.vx = 0; + } + } + } + + // TNT логика: цепь + усиление + const activeTNT = new Set(); // хранит key + function activateTNT(b, fuse=3.2){ + if(b.dead) return; + if(b.active) return; + b.active=true; + b.fuse=fuse; + activeTNT.add(k(b.gx,b.gy)); + } + + function explodeAt(gx,gy){ + const center = getBlock(gx,gy); + if(!center) return; + + // усиление: считаем сколько TNT рядом (в радиусе 2) и активируем их «почти сразу» + let bonus = 0; + for(let x=gx-2; x<=gx+2; x++){ + for(let y=gy-2; y<=gy+2; y++){ + const b = getBlock(x,y); + if(b && !b.dead && b.t==='tnt' && !(x===gx && y===gy)){ + bonus += 0.8; + activateTNT(b, 0.12); // цепь + } + } + } + + const power = 1 + bonus; // условная мощность + const radius = 3.2 + bonus*0.7; // радиус разрушения в тайлах + const dmgR = 150 + bonus*60; // радиус урона в пикселях + + removeBlock(gx,gy); + activeTNT.delete(k(gx,gy)); + playSound('explode1'); // Звук взрыва + spawnExplosion(gx*TILE + TILE/2, gy*TILE + TILE/2, power); + + for(let x=Math.floor(gx-radius); x<=Math.ceil(gx+radius); x++){ + for(let y=Math.floor(gy-radius); y<=Math.ceil(gy+radius); y++){ + const d = Math.hypot(x-gx, y-gy); + if(d > radius) continue; + const b = getBlock(x,y); + if(!b || b.dead) continue; + if(BLOCKS[b.t].fluid) continue; + if(BLOCKS[b.t].unbreakable) continue; + if(b.t==='tnt') { activateTNT(b, 0.12); continue; } + removeBlock(x,y); + if(inv[b.t] !== undefined && Math.random()<0.20) inv[b.t]++; // немного дропа + } + } + rebuildHotbar(); + + // урон + const hurt = (e)=>{ + const dx = (e.x+e.w/2) - (gx*TILE+TILE/2); + const dy = (e.y+e.h/2) - (gy*TILE+TILE/2); + const dist = Math.hypot(dx,dy); + if(dist < dmgR){ + const dmg = (dmgR - dist) * 0.06 * power; + if(e === player) { + const actualDamage = calculateDamage(dmg); + player.hp -= actualDamage; + } else { + e.hp -= dmg; + } + e.vx += (dx/dist || 0) * 600; + e.vy -= 320; + } + }; + hurt(player); + mobs.forEach(hurt); + } + + // Взаимодействие мышь/тап + const mouse = { x:null, y:null }; + canvas.addEventListener('pointermove', (e)=>{ + const r = canvas.getBoundingClientRect(); + mouse.x = e.clientX - r.left; + mouse.y = e.clientY - r.top; + }); + + canvas.addEventListener('pointerdown', (e)=>{ + if(craftOpen) return; + if(player.hp<=0) return; + + const r = canvas.getBoundingClientRect(); + const sx = e.clientX - r.left; + const sy = e.clientY - r.top; + + const wx = sx + camX; + const wy = sy + camY; + + const gx = Math.floor(wx / TILE); + const gy = Math.floor(wy / TILE); + + // Пробуждение: клик по любой кровати когда спишь + const b = getBlock(gx,gy); + if(player.sleeping && b && b.t==='bed'){ + player.sleeping = false; + return; + } + + if(player.sleeping) return; // Нельзя взаимодействовать во время сна + + // Клик по печи — открываем панель обжига + if(b && b.t === 'furnace' && mode() === 'mine'){ + openFurnaceUI(gx, gy); + return; + } + + // клик по мобу (в режиме mine) + if(mode()==='mine'){ + // Check server mobs first (multiplayer) + if(isMultiplayer){ + for (const [id, sm] of 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"]; + 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(); + if (inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; } } + socket.emit('mob_hurt', { id: sm.id, dmg }); + playSound('attack'); 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; - } - if (b && b.t === "campfire" && state.selected === "meat" && state.inv.meat > 0) { - playSound("fire"); - state.inv.meat--; - state.inv.cooked++; - rebuildHotbar(); - return; - } - 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; + // Local mobs (singleplayer or if not hit server mob) + 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){ + let dmg = 1; + const swordTypes = ['iron_sword','stone_sword','wood_sword']; + for (const st of swordTypes) { + if (inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; } + } + m.hp -= dmg; + m.vx += (m.x - player.x) * 2; + m.vy -= 200; + playSound('attack'); + if(m.hp<=0){ + if(m.kind === 'chicken') playSound('hurt_chicken'); + inv.meat += (m.kind==='chicken' ? 1 : 2); + if(m.kind === 'skeleton'){ + inv.arrow += 2 + Math.floor(Math.random()*3); + if(Math.random() < 0.15) inv.bow = (inv.bow||0) + 1; } + mobs.splice(i,1); + rebuildHotbar(); } - 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 = getBlock2(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 TILE2 = state.TILE; - const bx = gx * TILE2, by = gy * TILE2; - const overlap = !(bx >= state.player.x + state.player.w || bx + TILE2 <= state.player.x || by >= state.player.y + state.player.h || by + TILE2 <= state.player.y); - if (overlap) return; - setBlock(gx, gy, state.selected, 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; } - }); - } + } + + // Лук — стреляем стрелой + if(selected === 'bow' && inv.bow > 0 && inv.arrow > 0){ + const aimX = wx - player.x - player.w/2; + const aimY = wy - player.y - player.h/2; + const angle = Math.atan2(aimY, aimX); + projectiles.push({ + x: player.x + player.w/2, + y: player.y + player.h/3, + vx: Math.cos(angle) * 550, + vy: Math.sin(angle) * 550, + dmg: 10, + owner: 'player', + life: 4 + }); + inv.arrow--; + useTool('bow'); + playSound('hit1'); + rebuildHotbar(); + return; + } - // src/render/draw-fire.js - function drawFire(ctx2, wx, wy, now) { + // еда (предмет) + if(ITEMS[selected] && inv[selected]>0){ + const it = ITEMS[selected]; + if(player.hp < 100 || player.hunger < 100){ + playSound('eat1'); // Звук употребления еды + player.hunger = Math.min(100, player.hunger + it.food); + player.hp = Math.min(100, player.hp + 15); + inv[selected]--; + rebuildHotbar(); + } + return; + } + + // жарка на костре: выбран meat + клик по campfire + if(b && b.t==='campfire' && selected==='meat' && inv.meat>0){ + playSound('fire'); // Звук при жарке на костре + inv.meat--; inv.cooked++; + rebuildHotbar(); + return; + } + + // Сон на кровати: клик по bed + if(b && b.t==='bed' && isNight()){ + player.sleeping = true; + saveGame(); // Сохраняем при отходе ко сну + return; + } + + if(mode()==='mine'){ + if(!b) return; + if(BLOCKS[b.t].fluid || BLOCKS[b.t].decor) return; + + if(b.t==='tnt'){ activateTNT(b, 3.2); return; } // не взрывается сразу + + const removed = removeBlock(gx,gy); + if(removed){ + inv[removed.t] = (inv[removed.t]||0) + 1; + + // Тратим прочность кирки (если есть в инвентаре) + const pickTypes = ['iron_pickaxe','stone_pickaxe','wood_pickaxe']; + for (const pt of pickTypes) { + if (inv[pt] > 0) { + const broke = useTool(pt); + if (broke) playSound('cloth1'); // звук поломки + break; + } + } + + // Отправляем изменение блока на сервер + sendBlockChange(gx, gy, removed.t, 'remove'); + + // Звуки при добыче блоков + if(removed.t === 'glass') playSound('glass1'); + else if(removed.t === 'sand') playSound('sand1'); + else if(removed.t === 'snow') playSound('snow1'); + else if(removed.t === 'stone' || removed.t.endsWith('_ore')) playSound('stone1'); + else if(removed.t === 'wood') playSound('wood1'); + else playSound('cloth1'); + + rebuildHotbar(); + } + return; + } + + if(mode()==='build'){ + if(inv[selected] <= 0) return; + if(!BLOCKS[selected]) return; + if(b) return; // занято + + // Проверяем, ставим ли лодку + if(selected === 'boat'){ + // Лодку можно ставить только на воду + const waterBelow = getBlock(gx, gy+1); + if(!waterBelow || waterBelow.t !== 'water'){ + return; + } + + // Создаём лодку + boat.x = gx * TILE; + boat.y = gy * TILE; + boat.vx = 0; + boat.vy = 0; + boat.active = true; + boat.inWater = true; + + // Сажаем игрока в лодку + player.inBoat = true; + player.x = boat.x; + player.y = boat.y; + player.vx = 0; + player.vy = 0; + + playSound('splash'); + inv[selected]--; + rebuildHotbar(); + return; + } + + // запрет ставить в игрока + const bx = gx*TILE, by = gy*TILE; + const overlap = !(bx >= player.x+player.w || bx+TILE <= player.x || by >= player.y+player.h || by+TILE <= player.y); + if(overlap) return; + + setBlock(gx,gy,selected, true); // true = блок установлен игроком + inv[selected]--; + + // Отправляем изменение блока на сервер + sendBlockChange(gx, gy, selected, 'set'); + + // Звук при строительстве + if(selected === 'stone' || selected === 'brick') playSound('stone_build'); + else if(selected === 'wood' || selected === 'planks') playSound('wood_build'); + else if(selected === 'glass') playSound('glass1'); + else if(selected === 'sand') playSound('sand1'); + else if(selected === 'snow') playSound('snow1'); + else if(selected === 'dirt' || selected === 'grass') playSound('cloth1'); + + rebuildHotbar(); + return; + } + }); + + // Генерация (по X, на всю глубину до bedrock) + const generated = new Set(); // gx already generated + function surfaceGyAt(gx){ + // базовая поверхность выше уровня воды с вариациями + "горы" + // Используем seed для детерминированной генерации + // Увеличили амплитуду и добавили больше частот для разнообразия + const n1 = Math.sin(gx*0.025 + worldSeed*0.001)*8; // крупные горы + const n2 = Math.sin(gx*0.012 + worldSeed*0.002)*12; // средние горы + const n3 = Math.sin(gx*0.006 + worldSeed*0.003)*6; // мелкие холмы + const n4 = Math.sin(gx*0.045 + worldSeed*0.004)*4; // детали + const n5 = Math.cos(gx*0.018 + worldSeed*0.005)*5; // дополнительные вариации + const h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5); // чем меньше gy - тем выше + return h; + } + + function genColumn(gx){ + if(generated.has(gx)) return; + generated.add(gx); + + const sgy = surfaceGyAt(gx); + + // вода (если поверхность ниже уровня моря => sgy > SEA_GY) + if(sgy > SEA_GY){ + for(let gy=SEA_GY; gy SEA_GY && gy === sgy+1 && seededRandom(gx, gy) < 0.25) t='clay'; + if(gy > sgy+6 && seededRandom(gx, gy) < 0.07) t='gravel'; + + // руды: чем глубже, тем интереснее + const depth = gy - sgy; + const r = seededRandom(gx, gy); + if(t==='stone'){ + if(r < 0.06) t='coal'; + else if(r < 0.10) t='copper_ore'; + else if(r < 0.13) t='iron_ore'; + else if(depth > 40 && r < 0.145) t='gold_ore'; + else if(depth > 70 && r < 0.152) t='diamond_ore'; + } + + setBlock(gx,gy,t); + } + + // Деревья и цветы (только на траве, и не в воде) + const top = getBlock(gx, sgy); + if(top && top.t==='grass'){ + if(seededRandom(gx, sgy-1) < 0.10){ + setBlock(gx, sgy-1,'flower'); + } + if(seededRandom(gx, sgy-2) < 0.12){ + // простое дерево + setBlock(gx, sgy-1, 'wood'); + setBlock(gx, sgy-2, 'wood'); + setBlock(gx, sgy-3, 'leaves'); + setBlock(gx-1, sgy-3,'leaves'); + setBlock(gx+1, sgy-3,'leaves'); + } + } + + // Применяем серверные оверрайды для этой колонны + const colPrefix = gx + ','; + for (const [key, ov] of serverOverrides) { + if (!key.startsWith(colPrefix)) continue; + if (ov.op === 'remove') { + const b = grid.get(key); + if (b) { grid.delete(key); b.dead = true; } + } else if (ov.op === 'set') { + if (!grid.has(key)) { + const gy = parseInt(key.split(',')[1]); + const nb = { gx, gy, t: ov.t, dead: false, active: false, fuse: 0 }; + grid.set(key, nb); + blocks.push(nb); + } + } + } + } + + // Перегенерация видимых чанков (используется при загрузке сохранения) + function regenerateVisibleChunks(){ + const gx0 = Math.floor(camX/TILE); + for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){ + // Принудительно перегенерируем колонну + generated.delete(gx); + genColumn(gx); + } + } + + function ensureGenAroundCamera(){ + const gx0 = Math.floor(camX/TILE); + for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){ + genColumn(gx); + } + } + + // Лут с дерева/листвы: дерево -> wood; листья -> leaves + // (уже в mine добавляется inv[type] автоматически) + + // Рисование костра: огонь поверх текстуры + function drawFire(wx,wy,now){ const baseX = wx; const baseY = wy; - const flick = 6 + (Math.sin(now / 90) + 1) * 4; - ctx2.fillStyle = "rgba(255,140,0,0.85)"; - ctx2.beginPath(); - ctx2.moveTo(baseX + 10, baseY + 30); - ctx2.lineTo(baseX + 20, baseY + 30 - flick); - ctx2.lineTo(baseX + 30, baseY + 30); - ctx2.fill(); - ctx2.fillStyle = "rgba(255,230,150,0.75)"; - ctx2.beginPath(); - ctx2.moveTo(baseX + 14, baseY + 30); - ctx2.lineTo(baseX + 20, baseY + 30 - flick * 0.7); - ctx2.lineTo(baseX + 26, baseY + 30); - ctx2.fill(); + 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(); } - - // src/game/loop.js - var last = performance.now(); - var prevJump = false; - document.addEventListener("visibilitychange", () => { + + // Моб AI + function mobAI(m, dt){ + updateWaterFlag(m); + + if(m.kind==='zombie'){ + // активность ночью + const night = isNight(); + if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; } + const dir = Math.sign((player.x) - m.x); + m.vx = dir * m.speed; + if(m.inWater && Math.random()<0.06) m.vy = -260; + // атака + if(Math.abs((m.x+ m.w/2) - (player.x+player.w/2)) < 28 && + Math.abs((m.y+ m.h/2) - (player.y+player.h/2)) < 40 && + player.invuln <= 0){ + const damage = calculateDamage(15); + player.hp -= damage; + player.invuln = 0.8; + player.vx += dir*420; + player.vy -= 260; + playSound('hit1'); // Звук при атаке зомби + } + } else if(m.kind==='creeper'){ + // активность ночью + const night = isNight(); + if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; } + const dir = Math.sign((player.x) - m.x); + const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2)); + + // Движение к игроку + m.vx = dir * m.speed; + if(m.inWater && Math.random()<0.06) m.vy = -260; + + // Взрыв если близко к игроку + if(dist < 60){ + m.fuse -= dt; + if(m.fuse <= 0){ + explodeAt(Math.floor((m.x+m.w/2)/TILE), Math.floor((m.y+m.h/2)/TILE)); + m.hp = 0; + } + } else { + // Поджигаем если очень близко + if(dist < 40){ + m.fuse = 0.5; // Быстрый взрыв + } + } + } else if(m.kind==='skeleton'){ + // активность ночью + const night = isNight(); + if(!night){ m.hp -= 30*dt; m.vx *= 0.5; return; } + const dir = Math.sign((player.x) - m.x); + const dist = Math.hypot((player.x+player.w/2) - (m.x+m.w/2), (player.y+player.h/2) - (m.y+m.h/2)); + + // Движение к игроку + m.vx = dir * m.speed; + if(m.inWater && Math.random()<0.06) m.vy = -260; + + // Стрельба стрелами + m.shootCooldown -= dt; + if(dist < 300 && m.shootCooldown <= 0){ + m.shootCooldown = 2.0; + const dx = (player.x+player.w/2) - (m.x+m.w/2); + const dy = (player.y+player.h/2) - (m.y+m.h/2); + const angle = Math.atan2(dy, dx); + const speed = 450; + projectiles.push({ + x: m.x + m.w/2, + y: m.y + m.h/3, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + dmg: 6, + owner: 'mob', + life: 3 + }); + } + } else { + // животные + m.aiT -= dt; + if(m.aiT <= 0){ + m.aiT = 1.8 + Math.random()*2.5; + m.dir = Math.random()<0.5 ? -1 : 1; + if(Math.random()<0.25) m.dir = 0; + } + m.vx = m.dir * (m.kind==='chicken' ? 55 : 40); + if(m.inWater) m.vy = -120; + } + + // физика моба + const g = m.inWater ? GRAV_WATER : GRAV; + m.vy += g*dt; + + m.y += m.vy*dt; m.grounded=false; resolveY(m); + m.x += m.vx*dt; resolveX(m); + } + + function isNight(){ + // Автоматический цикл: ночь когда worldTime > 0.5 + return worldTime > 0.5; + } + + // Respawn + document.getElementById('respawnBtn').onclick = async () => { + playSound('click'); // Звук клика по кнопке + + console.log('=== RESPAWN CLICKED ==='); + console.log('isMultiplayer:', isMultiplayer); + console.log('otherPlayers.size:', otherPlayers.size); + console.log('player.hp before respawn:', player.hp); + + // В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке + if (isMultiplayer && otherPlayers.size > 0) { + console.log('Мультиплеер режим - возрождение в начальной точке'); + player.hp = 100; + player.hunger = 100; + player.o2 = 100; + player.vx = player.vy = 0; + player.invuln = 0; + player.x = spawnPoint.x; + player.y = spawnPoint.y; + player.fallStartY = player.y; + console.log('Возрождение в начальной точке, HP:', player.hp); + } else { + console.log('Одиночный режим - загружаем последнее сохранение'); + // Одиночный режим - загружаем последнее сохранение + const loadedSave = await loadGame(); + if(loadedSave){ + await applySave(loadedSave); + console.log('Загружено последнее сохранение после смерти, final HP:', player.hp); + } else { + // Если сохранения нет, возрождаемся в начальной точке + player.hp = 100; + player.hunger = 100; + player.o2 = 100; + player.vx = player.vy = 0; + player.invuln = 0; + player.x = spawnPoint.x; + player.y = spawnPoint.y; + player.fallStartY = player.y; + console.log('Возрождение в начальной точке, HP:', player.hp); + } + } + + console.log('player.hp after respawn logic:', player.hp); + console.log('Hiding death screen...'); + deathEl.style.display='none'; + console.log('=== RESPAWN END ==='); + }; + + // Resize + function resize(){ + W = gameEl.clientWidth; + H = gameEl.clientHeight; + canvas.width = W*dpr; + canvas.height = H*dpr; + lightC.width = W*dpr; + lightC.height = H*dpr; + ctx.setTransform(dpr,0,0,dpr,0,0); + } + window.addEventListener('resize', resize); + + // init + resize(); + rebuildHotbar(); + + // Инициализируем и загружаем сохранение + initDB().then(async () => { + // Пытаемся загрузить сохранённую игру + const loadedSave = await loadGame(); + if(loadedSave){ + await applySave(loadedSave); + console.log('Загружено сохранение, HP:', player.hp); + + // Проверяем HP после загрузки - если <= 0, возрождаемся + if (player.hp <= 0) { + console.log('WARNING: HP <= 0 после загрузки, возрождаемся'); + player.hp = 100; + player.hunger = 100; + player.o2 = 100; + player.x = spawnPoint.x; + player.y = spawnPoint.y; + player.vx = player.vy = 0; + player.invuln = 0; + player.fallStartY = player.y; + } + } else { + console.log('Сохранение не найдено, начинаем новую игру'); + + // Инициализируем игрока для новой игры + player.hp = 100; + player.hunger = 100; + player.o2 = 100; + player.vx = player.vy = 0; + player.invuln = 0; + + // старт — на поверхности (используем ту же логику что и в world_state) + const startGX = 6; + for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx); + const surfaceY = surfaceGyAt(startGX); + let safeGY = surfaceY - 1; + const aboveBlock = getBlock(startGX, surfaceY - 1); + if (aboveBlock && aboveBlock.t === 'water') { + for (let gy = SEA_GY - 1; gy >= 0; gy--) { + const b = getBlock(startGX, gy); + if (!b || b.dead || b.t === 'air' || b.t === 'water') continue; + safeGY = gy - 1; + break; + } + } + player.y = safeGY * TILE; + player.x = startGX * TILE; + player.fallStartY = player.y; + + // Обновляем spawnPoint, чтобы возрождение было на поверхности + spawnPoint.x = player.x; + spawnPoint.y = player.y; + + console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', player.y, 'player.hp=', player.hp); + console.log('spawnPoint обновлён: x=', spawnPoint.x, 'y=', spawnPoint.y); + + // Генерируем карту вокруг стартовой позиции при инициализации + for(let gx = startGX - 50; gx <= startGX + 50; gx++){ + genColumn(gx); + } + } + + // Автосейв при скрытии страницы (защита от потери прогресса) + document.addEventListener('visibilitychange', () => { + if(document.hidden){ + saveGame(); + } + }); + + // Автосейв перед закрытием страницы (защита от потери прогресса) + window.addEventListener('beforeunload', () => { + saveGame(); + }); + }).catch(err => { + console.error('Ошибка инициализации:', err); + // При ошибке начинаем новую игру + const startGX = 6; + genColumn(startGX); + player.y = (surfaceGyAt(startGX)-1)*TILE; + player.fallStartY = player.y; + + for(let gx = startGX - 50; gx <= startGX + 50; gx++){ + genColumn(gx); + } + }); + + // main loop + let last = performance.now(); + let prevJump = false; + // При возврате на вкладку — сбрасываем last чтобы не было скачка dt + document.addEventListener('visibilitychange', () => { if (!document.hidden) last = performance.now(); }); - function loop(now) { - const rawDt = Math.min(0.05, (now - last) / 1e3); + 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 inp2 = state.inp; - const clouds = state.clouds; - const mobs = state.mobs; - const projectiles = state.projectiles; - const parts2 = state.parts; - const boat = state.boat; - const jumpPressed = inp2.j && !prevJump; - prevJump = inp2.j; - if (player.sleeping && isNight()) { - state.worldTime += dt * 8 / state.DAY_LEN; + + 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()) { + // Автоматическое пробуждение когда наступает день + if(!isNight()){ player.sleeping = false; } } else { - state.worldTime += dt / state.DAY_LEN; + worldTime += dt / DAY_LEN; } - if (state.worldTime >= 1) state.worldTime -= 1; - state.camX = Math.floor(player.x + player.w / 2 - state.W / 2); - state.camY = Math.floor(player.y + player.h / 2 - state.H / 2); + 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(); - for (const c of clouds) { + + // 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; + if(c.x + c.w < camX - 400) c.x = camX + W + Math.random()*700; } + + // player updateWaterFlag(player); - if (player.headInWater) { - player.o2 = Math.max(0, player.o2 - 6 * dt); - if (player.o2 === 0) { - const damage = calculateDamage(4 * dt); + + // кислород/утопление: тратим 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); + player.o2 = Math.min(100, player.o2 + 10*dt); // замедлил восстановление в 4 раза } - player.hunger = Math.max(0, player.hunger - dt * 0.2); - if (player.sleeping) { + + // голод убывает, но HP не отнимает (как просили) + player.hunger = Math.max(0, player.hunger - dt*0.2); // замедлил в 4 раза + + // Игрок не может двигаться во время сна + if(player.sleeping){ player.vx = 0; player.vy = 0; } else { - const dir = (inp2.r ? 1 : 0) - (inp2.l ? 1 : 0); - if (dir) player.vx = dir * state.MOVE; + 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 / 1e3 - player.lastStepTime > stepInterval) { - playSound("step"); - player.lastStepTime = now / 1e3; + + // Звук шагов при движении по земле + 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 = (inp2.r ? 1 : 0) - (inp2.l ? 1 : 0); - if (dir) boat.vx = dir * state.MOVE; + + // прыжок/плавание (новая логика) + 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.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) { + 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"); + player.y += TILE; // Прыгаем из лодки + player.vy = -JUMP * 0.5; + playSound('splash'); } - } else if (player.inWater) { - player.vx *= 0.9; + + } else if(player.inWater){ + // сопротивление в воде + player.vx *= 0.90; player.vy *= 0.92; - if (!jumpPressed && !inp2.j) { - player.vy += state.GRAV_WATER * dt; + + // Если не нажимаем прыжок - тонем (гравитация в воде) + if(!jumpPressed && !inp.j){ + // Применяем гравитацию в воде - игрок тонет + player.vy += GRAV_WATER * dt; } else { - if (jumpPressed) { - player.vy = Math.min(player.vy, -520); - } else if (inp2.j) { + // Если нажимаем прыжок - поднимаемся на поверхность + 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; + // обычный прыжок (только по нажатию) + if(jumpPressed && player.grounded && !player.sleeping){ + player.vy = -JUMP; player.grounded = false; player.fallStartY = player.y; } } - if (!player.inWater && !player.inBoat) { - player.vy += state.GRAV * dt; + + // Гравитация применяется только вне воды и вне лодки + if(!player.inWater && !player.inBoat){ + player.vy += GRAV*dt; } - if (boat.active) { + + // Обновляем позицию лодки + 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 = getBlock2(boatGX, boatGY + 1); - if (!below || below.t !== "water") { + + // Лодка не выходит за пределы воды + 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 += state.TILE; + player.y += TILE; player.vy = -200; - playSound("splash"); + playSound('splash'); } } - if (player.inBoat && !boat.active) { + + // Проверяем, не доплыл ли игрок из лодки + if(player.inBoat && !boat.active){ inv.boat = (inv.boat || 0) + 1; player.inBoat = false; - player.y += state.TILE; + player.y += TILE; player.vy = -200; - playSound("splash"); + playSound('splash'); } + + // Sub-stepped physics: применяем движение мелкими шагами for (let step = 0; step < steps; step++) { - player.y += player.vy * dt; + player.y += player.vy*dt; resolveY(player); - player.x += player.vx * dt; + player.x += player.vx*dt; resolveX(player); } + + // Отправляем позицию на сервер (мультиплеер) sendPlayerPosition(); + + // Обновляем физику воды updateWaterPhysics(dt); + + // Погода и дождь updateWeather(dt); updateRain(dt); + player.invuln = Math.max(0, player.invuln - dt); - 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 }); + + // Voice position update + voicePosT += dt; + if(voicePosT > 0.5 && voiceSocket && voiceSocket.connected){ + voicePosT = 0; + voiceSocket.emit('voice_pos', { x: player.x, y: player.y }); } + + // Furnace tick tickFurnaces(dt); - if (state.currentFurnaceKey && Math.random() < 0.1) { + + // Обновляем UI печи если открыта + if(currentFurnaceKey && Math.random() < 0.1){ renderFurnaceUI(); } - for (let i = projectiles.length - 1; i >= 0; i--) { + + // 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.vy += 400 * dt; // гравитация p.life -= dt; - const gx = Math.floor(p.x / state.TILE); - const gy = Math.floor(p.y / state.TILE); - const blk = getBlock2(gx, gy); - if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid) { - if (p.owner === "player" && Math.random() < 0.5) inv.arrow++; + + // Столкновение с блоком + const gx = Math.floor(p.x / TILE); + const gy = Math.floor(p.y / TILE); + const blk = getBlock(gx, gy); + if(blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid){ + // Врезался в стену + if(p.owner === 'player' && Math.random() < 0.5) inv.arrow++; // подбираем ~50% projectiles.splice(i, 1); continue; } - if (p.owner === "mob") { - if (p.x > player.x && p.x < player.x + player.w && p.y > player.y && p.y < player.y + player.h) { - if (player.invuln <= 0) { + + // Столкновение с сущностью + 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"); + 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 }); + if(isMultiplayer){ + for (const [id, sm] of 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){ + socket.emit('mob_arrow_hit', { id: sm.id, dmg: p.dmg, vx: p.vx }); projectiles.splice(i, 1); hitMob = true; break; } } } - if (!hitMob) { - for (let j = mobs.length - 1; j >= 0; j--) { + 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) { + 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; + 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(); @@ -2249,762 +3147,469 @@ } } } - if (p.life <= 0) projectiles.splice(i, 1); + + // Таймаут + if(p.life <= 0) projectiles.splice(i, 1); } - for (const key of Array.from(state.activeTNT)) { - const b = state.grid.get(key); - if (!b || b.dead) { - state.activeTNT.delete(key); - continue; - } + + // 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); + if(b.fuse <= 0){ + explodeAt(b.gx,b.gy); } } - state.spawnT += dt; - if (!state.isMultiplayer && state.spawnT > 1.8 && mobs.length < 30) { - state.spawnT = 0; + + // mobs spawn (с обеих сторон камеры) — только в одиночном режиме + spawnT += dt; + if(!isMultiplayer && spawnT > 1.8 && mobs.length < 30){ + 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); + const gx = spawnLeft + ? Math.floor((camX - 200)/TILE) + : Math.floor((camX + W + 200)/TILE); + genColumn(gx); const sgy = surfaceGyAt(gx); - const wx = gx * state.TILE + 4; - const wy = (sgy - 2) * state.TILE; - const top = getBlock2(gx, sgy); - if (top && top.t === "water") { + const wx = gx*TILE + 4; + const wy = (sgy-2)*TILE; + + // не спавнить в воде + const top = getBlock(gx, sgy); + if(top && top.t==='water') { + // skip } else { - const night2 = isNight(); - if (night2) { - const hostileCount = mobs.filter((m) => m.kind === "zombie" || m.kind === "creeper" || m.kind === "skeleton").length; - if (hostileCount < 12) { + 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) { + if(rand < 0.35){ mobs.push(new Zombie(wx, wy)); - } else if (rand < 0.55) { + } 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)); + // Животные спавнятся и днём и ночью (с лимитом) + 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)); } } } - if (!state.isMultiplayer) { - for (let i = mobs.length - 1; i >= 0; i--) { + + // mobs update — только локальные (singleplayer) + if(!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); + if(m.hp<=0) mobs.splice(i,1); } } - for (let i = parts2.length - 1; i >= 0; i--) { - const p = parts2[i]; + + // 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) parts2.splice(i, 1); + p.x += p.vx*dt; + p.y += p.vy*dt; + p.vy += GRAV*dt; + if(p.t <= 0) parts.splice(i,1); } - if (player.hp <= 0) { - state.deathEl.style.display = "flex"; - } else if (state.deathEl.style.display === "flex") { - state.deathEl.style.display = "none"; + + // death + if(player.hp <= 0){ + deathEl.style.display='flex'; + } else if(deathEl.style.display === 'flex') { + // Если HP > 0 но экран смерти всё ещё показан - скрываем его + deathEl.style.display='none'; } - const ctx2 = state.ctx; - const W2 = state.W; - const H2 = state.H; - const camX = state.camX; - const camY = state.camY; - const TILE2 = state.TILE; - const tex2 = state.tex; + + // render const night = isNight(); - ctx2.fillStyle = night ? "#070816" : state.isRaining ? "#6B7B8D" : "#87CEEB"; - ctx2.fillRect(0, 0, W2, H2); - ctx2.save(); - ctx2.translate(-camX * 0.5, -camY * 0.15); - ctx2.fillStyle = "rgba(255,255,255,0.65)"; - for (const c of clouds) { - ctx2.fillRect(c.x, c.y, c.w, 26); - ctx2.fillRect(c.x + 20, c.y - 10, c.w * 0.6, 22); + + // sky + ctx.fillStyle = night ? '#070816' : (isRaining ? '#6B7B8D' : '#87CEEB'); + ctx.fillRect(0,0,W,H); + + // clouds (parallax x/y) + ctx.save(); + ctx.translate(-camX*0.5, -camY*0.15); + ctx.fillStyle = 'rgba(255,255,255,0.65)'; + for(const c of clouds){ + ctx.fillRect(c.x, c.y, c.w, 26); + ctx.fillRect(c.x+20, c.y-10, c.w*0.6, 22); } - ctx2.restore(); - ctx2.save(); - ctx2.translate(-camX, -camY); - const minGX = Math.floor(camX / TILE2) - 2; - const maxGX = Math.floor((camX + W2) / TILE2) + 2; - const minGY = Math.floor(camY / TILE2) - 6; - const maxGY = Math.floor((camY + H2) / TILE2) + 6; - const blocks2 = state.blocks; - for (const b of blocks2) { - if (b.dead) continue; - if (b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue; + 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) { - ctx2.save(); - ctx2.globalAlpha = def.alpha; - ctx2.drawImage(tex2[b.t], b.gx * TILE2, b.gy * TILE2, TILE2, TILE2); - ctx2.restore(); + 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 { - ctx2.drawImage(tex2[b.t], b.gx * TILE2, b.gy * TILE2, TILE2, TILE2); + ctx.drawImage(tex[b.t], b.gx*TILE, b.gy*TILE, TILE, TILE); } - if (b.t === "tnt" && b.active && Math.sin(now / 60) > 0) { - ctx2.fillStyle = "rgba(255,255,255,0.45)"; - ctx2.fillRect(b.gx * TILE2, b.gy * TILE2, TILE2, TILE2); + + // 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(ctx2, b.gx * TILE2, b.gy * TILE2, now); + + // огонь костра + if(b.t==='campfire'){ + drawFire(b.gx*TILE, b.gy*TILE, now); } - if (b.t === "furnace" && state.activeFurnaces.has(`${b.gx},${b.gy}`)) { - drawFire(ctx2, b.gx * TILE2 + 8, b.gy * TILE2 + 5, now); + // Печь — огонь когда обжигает + if(b.t==='furnace' && activeFurnaces.has(`${b.gx},${b.gy}`)){ + drawFire(b.gx*TILE + 8, b.gy*TILE + 5, now); } } - const allMobsRender = state.isMultiplayer ? Array.from(state.serverMobs.values()) : mobs; - for (const m of allMobsRender) { - if (m.kind === "zombie") { - ctx2.fillStyle = "#2ecc71"; - ctx2.fillRect(m.x, m.y, m.w, m.h); - ctx2.fillStyle = "#c0392b"; - ctx2.fillRect(m.x + 6, m.y + 12, 6, 6); - ctx2.fillRect(m.x + 22, m.y + 12, 6, 6); - } else if (m.kind === "pig") { - ctx2.fillStyle = "#ffb6c1"; - ctx2.fillRect(m.x, m.y, m.w, m.h); - ctx2.fillStyle = "#000"; - ctx2.fillRect(m.x + 22, m.y + 5, 3, 3); - ctx2.fillStyle = "#ff69b4"; - ctx2.fillRect(m.x + 28, m.y + 12, 6, 6); - } else if (m.kind === "chicken") { - ctx2.fillStyle = "#ecf0f1"; - ctx2.fillRect(m.x, m.y, m.w, m.h); - ctx2.fillStyle = "#f39c12"; - ctx2.fillRect(m.x + 18, m.y + 10, 6, 4); - ctx2.fillStyle = "#000"; - ctx2.fillRect(m.x + 8, m.y + 6, 3, 3); - } else if (m.kind === "creeper") { - ctx2.fillStyle = "#4CAF50"; - ctx2.fillRect(m.x, m.y, m.w, m.h); - ctx2.fillStyle = "#000"; - ctx2.fillRect(m.x + 8, m.y + 8, 4, 4); - ctx2.fillRect(m.x + 22, m.y + 8, 4, 4); - ctx2.fillStyle = "#000"; - ctx2.fillRect(m.x + 12, m.y + 20, 10, 4); - ctx2.fillStyle = "#4CAF50"; - ctx2.fillRect(m.x + 4, m.y + 30, 6, 20); - ctx2.fillRect(m.x + 24, m.y + 30, 6, 20); - } else if (m.kind === "skeleton") { - ctx2.fillStyle = "#ECEFF1"; - ctx2.fillRect(m.x + 10, m.y + 20, 14, 12); - ctx2.fillRect(m.x + 8, m.y + 0, 18, 18); - ctx2.fillStyle = "#000"; - ctx2.fillRect(m.x + 10, m.y + 6, 4, 4); - ctx2.fillRect(m.x + 20, m.y + 6, 4, 4); - ctx2.fillRect(m.x + 15, m.y + 12, 4, 2); - ctx2.fillStyle = "#ECEFF1"; - ctx2.fillRect(m.x + 2, m.y + 20, 6, 14); - ctx2.fillRect(m.x + 26, m.y + 20, 6, 14); - ctx2.fillRect(m.x + 10, m.y + 32, 6, 18); - ctx2.fillRect(m.x + 18, m.y + 32, 6, 18); - ctx2.save(); - ctx2.translate(m.x + 30, m.y + 22); - ctx2.strokeStyle = "#8B4513"; - ctx2.lineWidth = 2; - ctx2.beginPath(); - ctx2.arc(0, 0, 8, -Math.PI * 0.7, Math.PI * 0.7); - ctx2.stroke(); - ctx2.strokeStyle = "#ccc"; - ctx2.lineWidth = 1; - ctx2.beginPath(); - ctx2.moveTo(8 * Math.cos(-Math.PI * 0.7), 8 * Math.sin(-Math.PI * 0.7)); - ctx2.lineTo(8 * Math.cos(Math.PI * 0.7), 8 * Math.sin(Math.PI * 0.7)); - ctx2.stroke(); - ctx2.restore(); + + // mobs + const allMobsRender = isMultiplayer ? Array.from(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(); } } - if (boat.active) { - ctx2.drawImage(tex2["boat"], boat.x - (TILE2 - boat.w) / 2, boat.y - (TILE2 - boat.h) / 2, TILE2, TILE2); + + // boat (рисуем первой, чтобы игрок был внутри неё) + if(boat.active){ + ctx.drawImage(tex['boat'], boat.x - (TILE-boat.w)/2, boat.y - (TILE-boat.h)/2, TILE, TILE); } - for (const [socketId, p] of state.otherPlayers) { - if (state.heroImg.complete) { - ctx2.drawImage(state.heroImg, p.x - (TILE2 - player.w) / 2, p.y - (TILE2 - player.h) / 2, TILE2, TILE2); + + // 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 { - ctx2.fillStyle = p.color; - ctx2.fillRect(p.x, p.y, 34, 34); + ctx.fillStyle = p.color; + ctx.fillRect(p.x, p.y, 34, 34); } - ctx2.fillStyle = "#fff"; - ctx2.font = "12px system-ui"; - ctx2.textAlign = "center"; - ctx2.fillText(p.name, p.x + 17, p.y - 8); + // Имя игрока (мелко над персонажем) + ctx.fillStyle = '#fff'; + ctx.font = '12px system-ui'; + ctx.textAlign = 'center'; + ctx.fillText(p.name, p.x + 17, p.y - 8); } - if (state.heroImg.complete) { - ctx2.drawImage(state.heroImg, player.x - (TILE2 - player.w) / 2, player.y - (TILE2 - player.h) / 2, TILE2, TILE2); + + // player + if(heroImg.complete){ + ctx.drawImage(heroImg, player.x - (TILE-player.w)/2, player.y - (TILE-player.h)/2, TILE, TILE); } else { - ctx2.fillStyle = "#fff"; - ctx2.fillRect(player.x, player.y, player.w, player.h); + ctx.fillStyle='#fff'; + ctx.fillRect(player.x, player.y, player.w, player.h); } - for (const p of projectiles) { + + // projectiles (стрелы) + for(const p of projectiles){ const angle = Math.atan2(p.vy, p.vx); - ctx2.save(); - ctx2.translate(p.x, p.y); - ctx2.rotate(angle); - ctx2.fillStyle = p.owner === "mob" ? "#c0392b" : "#f1c40f"; - ctx2.fillRect(-12, -1.5, 24, 3); - ctx2.beginPath(); - ctx2.moveTo(12, -4); - ctx2.lineTo(16, 0); - ctx2.lineTo(12, 4); - ctx2.closePath(); - ctx2.fill(); - ctx2.fillStyle = "#888"; - ctx2.fillRect(-12, -3, 4, 2); - ctx2.fillRect(-12, 1, 4, 2); - ctx2.restore(); + 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(); } - for (const p of parts2) { - ctx2.fillStyle = p.c; - ctx2.fillRect(p.x - 2, p.y - 2, 4, 4); + + // 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; + + // Стрелы скелета + 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 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); - ctx2.save(); - ctx2.translate(arrowX, arrowY); - ctx2.rotate(angle); - ctx2.fillStyle = "#ECEFF1"; - ctx2.fillRect(0, -1, 16, 2); - ctx2.restore(); - if (dist < 150 && player.invuln <= 0) { + + // Рисуем стрелу + 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"); + playSound('hit1'); } } } - if (mode() === "build" && mouse.x !== null && !state.craftOpen && player.hp > 0) { + + // build ghost + if(mode()==='build' && mouse.x!==null && !craftOpen && player.hp>0){ const wx = mouse.x + camX; const wy = mouse.y + camY; - const ggx = Math.floor(wx / TILE2); - const ggy = Math.floor(wy / TILE2); - ctx2.strokeStyle = "rgba(255,255,255,0.9)"; - ctx2.lineWidth = 2; - ctx2.strokeRect(ggx * TILE2, ggy * TILE2, TILE2, TILE2); + 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); } - ctx2.restore(); - if (night) { - let castLight = function(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; + + ctx.restore(); + + // lighting overlay at night (Minecraft-style: offscreen lightmap, wall occlusion, warm flicker) + if(night){ + // 1) Рисуем тёмный оверлей на offscreen canvas + lightC.width = W*dpr; + lightC.height = H*dpr; + lightCtx.setTransform(dpr,0,0,dpr,0,0); + lightCtx.fillStyle = 'rgba(0,0,12,0.82)'; + lightCtx.fillRect(0,0,W,H); + + // 2) Вырезаем «окна света» через destination-out — свет не проходит сквозь стены + lightCtx.globalCompositeOperation = 'destination-out'; + + // Функция: рисуем мягкий луч света с затуханием за стенами + function castLight(sx, sy, radius) { + const flick = 0.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); + // 12 лучей — достаточно для мягкого круга + const steps = 12; + // Собираем дистанции до стен по лучам + const dists = new Float32Array(steps); + for(let i=0; i TILE2 * 0.3) { + // Идём по лучу пока не упрёмся в стену + for(let step=TILE*0.5; step TILE*0.3){ maxDist = step; break; } } dists[i] = maxDist; } - const cx = sx - camX, cy = sy - camY; + // Рисуем сглаженный полигон по dists + const cx = sx-camX, cy = sy-camY; + // Центр: яркая точка const maxR = Math.max(...dists); - const grad = lightCtx2.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)"); - lightCtx2.fillStyle = grad; - lightCtx2.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) lightCtx2.moveTo(px, py); - else lightCtx2.lineTo(px, py); + 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; + // Рисуем shape по dists (звездоподобный полигон) + lightCtx.beginPath(); + for(let i=0; i<=steps; i++){ + const idx = i % steps; + const nextIdx = (i+1) % steps; + const avgD = (dists[idx] + dists[nextIdx]) / 2; + const angle = (idx/steps) * Math.PI * 2; + const px = cx + Math.cos(angle) * dists[idx]; + const py = cy + Math.sin(angle) * dists[idx]; + if(i===0) lightCtx.moveTo(px, py); + else lightCtx.lineTo(px, py); } - lightCtx2.closePath(); - lightCtx2.fill(); - }; - const lightC2 = state.lightC; - const lightCtx2 = state.lightCtx; - lightC2.width = W2 * state.dpr; - lightC2.height = H2 * state.dpr; - lightCtx2.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); - lightCtx2.fillStyle = "rgba(0,0,12,0.82)"; - lightCtx2.fillRect(0, 0, W2, H2); - lightCtx2.globalCompositeOperation = "destination-out"; - for (const b of blocks2) { - if (b.dead) continue; - if (b.gx < minGX - 5 || b.gx > maxGX + 5 || b.gy < minGY - 5 || b.gy > maxGY + 5) continue; + 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 * TILE2 + TILE2 / 2, b.gy * TILE2 + TILE2 / 2, def.lightRadius); + if(def.lightRadius){ + castLight(b.gx*TILE + TILE/2, b.gy*TILE + TILE/2, def.lightRadius); } } - lightCtx2.globalCompositeOperation = "source-over"; - ctx2.drawImage(lightC2, 0, 0, W2, H2); - ctx2.save(); - ctx2.globalCompositeOperation = "lighter"; - for (const b of blocks2) { - if (b.dead) continue; - if (b.gx < minGX - 3 || b.gx > maxGX + 3 || b.gy < minGY - 3 || b.gy > maxGY + 3) continue; + + // 3) Накладываем lightmap на основной canvas + lightCtx.globalCompositeOperation = 'source-over'; + ctx.drawImage(lightC, 0, 0, W, H); + + // 4) Тёплый оверлей от источников света (additive, мягкий) + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + for(const b of blocks){ + if(b.dead) continue; + if(b.gx < minGX-3 || b.gx > maxGX+3 || b.gy < minGY-3 || b.gy > maxGY+3) continue; const def = BLOCKS[b.t]; - if (def.lightRadius) { - const flick = 0.7 + Math.sin(now / 90 + b.gx * 3.7) * 0.15 + Math.sin(now / 140 + b.gy * 2.3) * 0.15; - const wx = b.gx * TILE2 + TILE2 / 2 - camX; - const wy = b.gy * TILE2 + TILE2 / 2 - camY; + 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 = ctx2.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)"); - ctx2.fillStyle = grad; - ctx2.beginPath(); - ctx2.arc(wx, wy, r, 0, Math.PI * 2); - ctx2.fill(); + 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(); } } - ctx2.restore(); + 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 / TILE2); - state.syEl.textContent = Math.floor(player.y / TILE2); - state.todEl.textContent = night ? "\u041D\u043E\u0447\u044C" : "\u0414\u0435\u043D\u044C"; - state.worldIdEl.textContent = state.worldId; - if (state.isMultiplayer) { - document.getElementById("multiplayerStatus").style.display = "flex"; - state.playerCountEl.textContent = state.otherPlayers.size + 1; + 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"; + document.getElementById('multiplayerStatus').style.display = 'none'; } } - if (player.sleeping) { - ctx2.fillStyle = "rgba(0,0,0,0.7)"; - ctx2.fillRect(0, 0, W2, H2); - ctx2.fillStyle = "#fff"; - ctx2.font = "bold 32px system-ui"; - ctx2.textAlign = "center"; - ctx2.fillText("\u{1F4A4} \u0421\u043F\u0438\u043C...", W2 / 2, H2 / 2); - ctx2.font = "18px system-ui"; - ctx2.fillText("\u041D\u0430\u0436\u043C\u0438\u0442\u0435 \u043D\u0430 \u043A\u0440\u043E\u0432\u0430\u0442\u044C \u0447\u0442\u043E\u0431\u044B \u043F\u0440\u043E\u0441\u043D\u0443\u0442\u044C\u0441\u044F", W2 / 2, H2 / 2 + 40); + + // Индикатор сна + if(player.sleeping){ + ctx.fillStyle = 'rgba(0,0,0,0.7)'; + ctx.fillRect(0, 0, W, H); + ctx.fillStyle = '#fff'; + ctx.font = 'bold 32px system-ui'; + ctx.textAlign = 'center'; + ctx.fillText('💤 Спим...', W/2, H/2); + ctx.font = '18px system-ui'; + ctx.fillText('Нажмите на кровать чтобы проснуться', W/2, H/2 + 40); } - if (state.minimapOpen && Math.random() < 0.25) { + + // Миникарта (обновляем раз в ~4 кадра для оптимизации) + if(minimapOpen && Math.random() < 0.25){ renderMinimap(); } + requestAnimationFrame(loop); } - - // src/ui/respawn.js - 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("\u041C\u0443\u043B\u044C\u0442\u0438\u043F\u043B\u0435\u0435\u0440 \u0440\u0435\u0436\u0438\u043C - \u0432\u043E\u0437\u0440\u043E\u0436\u0434\u0435\u043D\u0438\u0435 \u0432 \u043D\u0430\u0447\u0430\u043B\u044C\u043D\u043E\u0439 \u0442\u043E\u0447\u043A\u0435"); - 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("\u0412\u043E\u0437\u0440\u043E\u0436\u0434\u0435\u043D\u0438\u0435 \u0432 \u043D\u0430\u0447\u0430\u043B\u044C\u043D\u043E\u0439 \u0442\u043E\u0447\u043A\u0435, HP:", player.hp); - } else { - console.log("\u041E\u0434\u0438\u043D\u043E\u0447\u043D\u044B\u0439 \u0440\u0435\u0436\u0438\u043C - \u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u043C \u043F\u043E\u0441\u043B\u0435\u0434\u043D\u0435\u0435 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435"); - const loadedSave = await loadGame(); - if (loadedSave) { - await applySave(loadedSave); - console.log("\u0417\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043E \u043F\u043E\u0441\u043B\u0435\u0434\u043D\u0435\u0435 \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u043F\u043E\u0441\u043B\u0435 \u0441\u043C\u0435\u0440\u0442\u0438, 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("\u0412\u043E\u0437\u0440\u043E\u0436\u0434\u0435\u043D\u0438\u0435 \u0432 \u043D\u0430\u0447\u0430\u043B\u044C\u043D\u043E\u0439 \u0442\u043E\u0447\u043A\u0435, 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 ==="); - }; - } - - // src/ui/share.js - function initShare() { - 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("\u0421\u0441\u044B\u043B\u043A\u0430 \u0441\u043A\u043E\u043F\u0438\u0440\u043E\u0432\u0430\u043D\u0430!"); - }).catch(() => { - alert("\u0421\u0441\u044B\u043B\u043A\u0430 \u043D\u0430 \u043C\u0438\u0440:\n" + shareUrlString); - }); - } else { - alert("\u0421\u0441\u044B\u043B\u043A\u0430 \u043D\u0430 \u043C\u0438\u0440:\n" + shareUrlString); - } - }; - } - - // src/input/controls.js - var 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; - }); - } - - // src/multiplayer/voice-chat.js - var voiceSocket = null; - var voiceStream = null; - var audioCtx = null; - var voiceProcessor = null; - var voiceActive = false; - var VOICE_SERVER = "https://voicegrech.mkn8n.ru"; - var voiceBtn = document.createElement("div"); - voiceBtn.innerHTML = '\u{1F3A4}/'; - voiceBtn.title = "\u0413\u043E\u043B\u043E\u0441\u043E\u0432\u043E\u0439 \u0447\u0430\u0442 (\u0432\u044B\u043A\u043B)"; - 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); - var 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 = "\u{1F50A}"; - document.querySelector(".ui").appendChild(speakingIndicator); - var speakingTimeout = null; - voiceBtn.onclick = async () => { - if (voiceActive) { - voiceActive = false; - voiceBtn.innerHTML = '\u{1F3A4}/'; - 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: 24e3 } }); - audioCtx = new AudioContext({ sampleRate: 24e3 }); - 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); - 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 * 32768 : s * 32767; - } - voiceSocket.emit("voice_data", int16.buffer); - }; - source.connect(voiceProcessor); - voiceProcessor.connect(audioCtx.createGain()); - 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 || "\u0418\u0433\u0440\u043E\u043A" }); - }); - voiceSocket.on("voice_in", (payload) => { - const { data, meta, volume } = payload; - if (!audioCtx || audioCtx.state === "closed") return; - const int16 = new Int16Array(data); - const float32 = new Float32Array(int16.length); - for (let i = 0; i < int16.length; i++) { - float32[i] = int16[i] / (int16[i] < 0 ? 32768 : 32767) * volume; - } - const buf = audioCtx.createBuffer(1, float32.length, 24e3); - 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 = `\u{1F50A} ${meta.name}`; - clearTimeout(speakingTimeout); - speakingTimeout = setTimeout(() => { - speakingIndicator.style.display = "none"; - }, 500); - }); - voiceActive = true; - voiceBtn.textContent = "\u{1F3A4}"; - voiceBtn.style.background = "#2ecc71"; - } catch (e) { - console.error("Voice error:", e); - voiceBtn.style.background = "#e74c3c"; - } - }; - function initVoice() { - 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 }); - } - } - }; - } - - // src/main.js - var urlParams2 = new URLSearchParams(window.location.search); - state.SERVER_URL = urlParams2.get("server") || "https://apigrech.mkn8n.ru"; - state.TELEGRAM_BOT_USERNAME = "Grechkacraft_bot"; - state.TELEGRAM_APP_SHORT_NAME = "minegrechka"; - if (location.protocol === "https:" && state.SERVER_URL.startsWith("http://")) { - console.warn("\u26A0\uFE0F Mixed content warning: page is HTTPS but server URL is HTTP"); - alert("\u26A0\uFE0F \u041F\u0440\u0435\u0434\u0443\u043F\u0440\u0435\u0436\u0434\u0435\u043D\u0438\u0435: \u0441\u0442\u0440\u0430\u043D\u0438\u0446\u0430 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u0430 \u043F\u043E HTTPS, \u043D\u043E \u0441\u0435\u0440\u0432\u0435\u0440 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0435\u0442 HTTP. \u042D\u0442\u043E \u043C\u043E\u0436\u0435\u0442 \u0432\u044B\u0437\u0432\u0430\u0442\u044C \u043F\u0440\u043E\u0431\u043B\u0435\u043C\u044B."); - } - state.playerName = localStorage.getItem("minegrechka_playerName") || null; - if (!state.playerName) { - state.playerName = prompt("\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0430\u0448\u0435 \u0438\u043C\u044F \u0434\u043B\u044F \u0438\u0433\u0440\u044B:") || "\u0418\u0433\u0440\u043E\u043A"; - localStorage.setItem("minegrechka_playerName", state.playerName); - console.log("Player name set:", state.playerName); - } - console.log("Current URL:", window.location.href); - var worldParam2 = urlParams2.get("world"); - console.log("world param:", worldParam2); - state.worldId = worldParam2 && worldParam2.trim() !== "" ? worldParam2 : null; - console.log("worldId after params:", state.worldId, "type:", typeof state.worldId); - if (!state.worldId) { - state.worldId = Math.random().toString(36).substring(2, 10); - console.log("Generated worldId:", state.worldId); - try { - const newUrl = new URL(window.location.href); - newUrl.searchParams.set("world", state.worldId); - const newUrlString = newUrl.toString(); - console.log("New URL to set:", newUrlString); - if (typeof window.history !== "undefined" && typeof window.history.replaceState === "function") { - window.history.replaceState(null, "", newUrlString); - console.log("URL after replaceState:", window.location.href); - } else { - console.error("History API not supported!"); - } - } catch (e) { - console.error("Error updating URL:", e); - } - console.log("Generated new worldId for browser:", state.worldId); - } - console.log("Final worldId:", state.worldId, "Player name:", state.playerName); - state.gameEl = document.getElementById("game"); - state.canvas = document.getElementById("c"); - state.ctx = state.canvas.getContext("2d"); - state.lightC = document.createElement("canvas"); - state.lightCtx = state.lightC.getContext("2d"); - state.dpr = Math.max(1, window.devicePixelRatio || 1); - function resize() { - state.W = state.gameEl.clientWidth; - state.H = state.gameEl.clientHeight; - state.canvas.width = state.W * state.dpr; - state.canvas.height = state.H * state.dpr; - state.lightC.width = state.W * state.dpr; - state.lightC.height = state.H * state.dpr; - state.ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); - } - window.addEventListener("resize", resize); - resize(); - state.otherPlayers = /* @__PURE__ */ new Map(); - state.serverMobs = /* @__PURE__ */ new Map(); - state.grid = /* @__PURE__ */ new Map(); - state.blocks = []; - state.generated = /* @__PURE__ */ new Set(); - state.serverOverrides = /* @__PURE__ */ new Map(); - state.activeFurnaces = /* @__PURE__ */ new Map(); - state.activeTNT = /* @__PURE__ */ new Set(); - state.toolDurability = /* @__PURE__ */ new Map(); - state.worldSeed = Math.floor(Math.random() * 1e6); - state.clouds = Array.from({ length: 10 }, () => ({ - x: Math.random() * 2e3, - y: -200 - Math.random() * 260, - w: 80 + Math.random() * 120, - s: 12 + Math.random() * 20 - })); - state.weatherTimer = 0; - state.weatherChangeInterval = 60 + Math.random() * 120; - state.player.x = 6 * state.TILE; - state.player.y = 0; - state.spawnPoint.x = 6 * state.TILE; - state.spawnPoint.y = 0; - state.heroImg = new Image(); - state.heroImg.src = "https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png"; - 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"); - initTextures(); - 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"); - initControls(); - initMouseHandlers(); - initChat(); - initFurnace(); - initMinimap(); - initSaveControls(); - initRespawn(); - initShare(); - initModes(); - initSocket(); - initVoice(); - rebuildHotbar(); - initDB().then(async () => { - const loadedSave = await loadGame(); - if (loadedSave) { - await applySave(loadedSave); - console.log("\u0417\u0430\u0433\u0440\u0443\u0436\u0435\u043D\u043E \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435, HP:", state.player.hp); - if (state.player.hp <= 0) { - console.log("WARNING: HP <= 0 \u043F\u043E\u0441\u043B\u0435 \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0438, \u0432\u043E\u0437\u0440\u043E\u0436\u0434\u0430\u0435\u043C\u0441\u044F"); - 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("\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u043E, \u043D\u0430\u0447\u0438\u043D\u0430\u0435\u043C \u043D\u043E\u0432\u0443\u044E \u0438\u0433\u0440\u0443"); - 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 = getBlock2(startGX, surfaceY - 1); - if (aboveBlock && aboveBlock.t === "water") { - for (let gy = state.SEA_GY - 1; gy >= 0; gy--) { - const b = getBlock2(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("\u041D\u043E\u0432\u0430\u044F \u0438\u0433\u0440\u0430: 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("\u041E\u0448\u0438\u0431\u043A\u0430 \u0438\u043D\u0438\u0446\u0438\u0430\u043B\u0438\u0437\u0430\u0446\u0438\u0438:", 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); - } - }); + requestAnimationFrame(loop); })();