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