From efcb5a0dd6b797bb8351b071d5730bc83dea8714 Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 12:04:51 +0000 Subject: [PATCH 01/15] fix: client-authoritative mobs, spawn-only server, voice chat fix --- Dockerfile | 1 + game.js | 183 +++++--- index-new.html | 97 ++++ index-old.html | 97 ++++ nginx.conf | 7 +- src/audio/sound-engine.js | 37 ++ src/config.js | 67 +++ src/core/canvas.js | 27 ++ src/core/constants.js | 32 ++ src/core/state.js | 157 +++++++ src/data/blocks.js | 30 ++ src/data/items.js | 10 + src/data/recipes.js | 32 ++ src/data/tools.js | 32 ++ src/entities/boat.js | 19 + src/entities/mob-ai.js | 108 +++++ src/entities/mobs.js | 37 ++ src/entities/player.js | 43 ++ src/game/loop.js | 758 ++++++++++++++++++++++++++++++ src/game/modes.js | 20 + src/game/save.js | 187 ++++++++ src/input/controls.js | 37 ++ src/input/mouse-handler.js | 247 ++++++++++ src/main.js | 278 +++++++++++ src/multiplayer/socket-helpers.js | 45 ++ src/multiplayer/socket.js | 279 +++++++++++ src/multiplayer/voice-chat.js | 120 +++++ src/physics/collision.js | 154 ++++++ src/physics/water-detect.js | 31 ++ src/render/draw-fire.js | 19 + src/render/lighting.js | 105 +++++ src/render/particles.js | 14 + src/render/textures.js | 104 ++++ src/ui/chat.js | 67 +++ src/ui/craft.js | 116 +++++ src/ui/dom-refs.js | 14 + src/ui/furnace.js | 126 +++++ src/ui/hotbar.js | 81 ++++ src/ui/inventory.js | 76 +++ src/ui/minimap.js | 113 +++++ src/ui/respawn.js | 56 +++ src/ui/save-controls.js | 62 +++ src/ui/share.js | 38 ++ src/world/generation.js | 126 +++++ src/world/tnt.js | 80 ++++ src/world/water.js | 126 +++++ src/world/weather.js | 59 +++ src/world/world-storage.js | 57 +++ 48 files changed, 4548 insertions(+), 63 deletions(-) create mode 100644 index-new.html create mode 100644 index-old.html create mode 100644 src/audio/sound-engine.js create mode 100644 src/config.js create mode 100644 src/core/canvas.js create mode 100644 src/core/constants.js create mode 100644 src/core/state.js create mode 100644 src/data/blocks.js create mode 100644 src/data/items.js create mode 100644 src/data/recipes.js create mode 100644 src/data/tools.js create mode 100644 src/entities/boat.js create mode 100644 src/entities/mob-ai.js create mode 100644 src/entities/mobs.js create mode 100644 src/entities/player.js create mode 100644 src/game/loop.js create mode 100644 src/game/modes.js create mode 100644 src/game/save.js create mode 100644 src/input/controls.js create mode 100644 src/input/mouse-handler.js create mode 100644 src/main.js create mode 100644 src/multiplayer/socket-helpers.js create mode 100644 src/multiplayer/socket.js create mode 100644 src/multiplayer/voice-chat.js create mode 100644 src/physics/collision.js create mode 100644 src/physics/water-detect.js create mode 100644 src/render/draw-fire.js create mode 100644 src/render/lighting.js create mode 100644 src/render/particles.js create mode 100644 src/render/textures.js create mode 100644 src/ui/chat.js create mode 100644 src/ui/craft.js create mode 100644 src/ui/dom-refs.js create mode 100644 src/ui/furnace.js create mode 100644 src/ui/hotbar.js create mode 100644 src/ui/inventory.js create mode 100644 src/ui/minimap.js create mode 100644 src/ui/respawn.js create mode 100644 src/ui/save-controls.js create mode 100644 src/ui/share.js create mode 100644 src/world/generation.js create mode 100644 src/world/tnt.js create mode 100644 src/world/water.js create mode 100644 src/world/weather.js create mode 100644 src/world/world-storage.js diff --git a/Dockerfile b/Dockerfile index 3b76dc3..1e8ecff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf COPY index.html /usr/share/nginx/html/index.html COPY style.css /usr/share/nginx/html/style.css COPY game.js /usr/share/nginx/html/game.js +COPY src/ /usr/share/nginx/html/src/ EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] diff --git a/game.js b/game.js index 40e2795..d4a42b0 100644 --- a/game.js +++ b/game.js @@ -84,7 +84,45 @@ let socket = null; let isMultiplayer = false; // Флаг для мультиплеерного режима const otherPlayers = new Map(); // socket_id -> {x, y, color} - const serverMobs = new Map(); // id -> mob (server-authoritative in MP) + 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 раз в секунду) @@ -219,11 +257,11 @@ // Обновляем счётчик игроков playerCountEl.textContent = data.players.length; } - // Server mobs + // 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 = { ...m, maxHp: m.maxHp || m.hp, vx: m.vx||0, vy: m.vy||0, grounded:false, inWater:false, aiT:0, dir:m.dir||1, dead:false, fuse:m.fuse||0, shootCooldown:2, speed: m.speed || 80 }; + const sm = createMobFromServer(m); serverMobs.set(m.id, sm); } } @@ -277,14 +315,16 @@ // === MOB SYNC (multiplayer) === socket.on('mob_spawned', (data) => { - const sm = { ...data, maxHp: data.maxHp || data.hp, vx: data.vx||0, vy: data.vy||0, grounded:false, inWater:false, aiT:0, dir:data.dir||1, dead:false, fuse:data.fuse||0, shootCooldown:2, speed: data.speed || 80 }; + 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.x=u.x; sm.y=u.y; sm.vx=u.vx; sm.vy=u.vy; sm.dir=u.dir; sm.hp=u.hp; sm.fuse=u.fuse||0; } + if (sm) { sm.hp = u.hp; sm.fuse = u.fuse != null ? u.fuse : sm.fuse; } } }); @@ -1071,7 +1111,7 @@ } // Мобы — красные (враждебные) / зелёные (животные) - const allMobsForMap = isMultiplayer ? Array.from(serverMobs.values()) : mobs; + const allMobsForMap = getAllMobs(); for (const m of allMobsForMap) { const dx = Math.floor(m.x / TILE) - startGX; const dy = Math.floor(m.y / TILE) - startGY; @@ -1259,7 +1299,12 @@ }; source.connect(voiceProcessor); - voiceProcessor.connect(audioCtx.createGain()); // не в destination — чтобы не слышать себя + // ScriptProcessor MUST connect to destination to fire onaudioprocess events + // Use a zero-gain node to silence own playback while keeping the processor active + const silentGain = audioCtx.createGain(); + silentGain.gain.value = 0; // mute — don't hear ourselves + voiceProcessor.connect(silentGain); + silentGain.connect(audioCtx.destination); // Подключаемся к голосовому серверу voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] }); @@ -2290,25 +2335,11 @@ // клик по мобу (в режиме mine) if(mode()==='mine'){ - // Check server mobs first (multiplayer) - if(isMultiplayer){ - for (const [id, sm] of serverMobs) { - if(sm.dead) continue; - if(wx>=sm.x && wx<=sm.x+sm.w && wy>=sm.y && wy<=sm.y+sm.h){ - let dmg = 1; - const swordTypes = ['iron_sword','stone_sword','wood_sword']; - for (const st of swordTypes) { - if (inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; } - } - socket.emit('mob_hurt', { id: sm.id, dmg }); - playSound('attack'); - return; - } - } - } - // Local mobs (singleplayer or if not hit server mob) - for(let i=mobs.length-1;i>=0;i--){ - const m = mobs[i]; + // 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']; @@ -2319,6 +2350,13 @@ 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); @@ -2326,7 +2364,13 @@ inv.arrow += 2 + Math.floor(Math.random()*3); if(Math.random() < 0.15) inv.bow = (inv.bow||0) + 1; } - mobs.splice(i,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; @@ -3106,39 +3150,39 @@ continue; } } else { - // Попал в моба — server mobs first in multiplayer - let hitMob = false; - if(isMultiplayer){ - for (const [id, sm] of serverMobs) { - if(sm.dead) continue; - if(p.x > sm.x && p.x < sm.x+sm.w && p.y > sm.y && p.y < sm.y+sm.h){ - socket.emit('mob_arrow_hit', { id: sm.id, dmg: p.dmg, vx: p.vx }); - projectiles.splice(i, 1); - hitMob = true; - break; - } - } - } - if(!hitMob){ - // Local mobs - for(let j = mobs.length-1; j>=0; j--){ - const m = mobs[j]; - if(p.x > m.x && p.x < m.x+m.w && p.y > m.y && p.y < m.y+m.h){ - m.hp -= p.dmg; - m.vx += p.vx * 0.2; - m.vy -= 200; + // Попал в моба — 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){ - inv.meat += (m.kind==='chicken' ? 1 : 2); - if(m.kind === 'skeleton'){ - inv.arrow += 2 + Math.floor(Math.random()*3); - if(Math.random() < 0.15) inv.bow = (inv.bow||0) + 1; - } - mobs.splice(j, 1); - rebuildHotbar(); + socket.emit('mob_died', { id: m.id }); } - projectiles.splice(i, 1); - break; } + 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; } } } @@ -3157,9 +3201,9 @@ } } - // mobs spawn (с обеих сторон камеры) — только в одиночном режиме + // mobs spawn (с обеих сторон камеры) — только в одиночном режиме (MP mobs come from server events) spawnT += dt; - if(!isMultiplayer && spawnT > 1.8 && mobs.length < 30){ + if(!isMultiplayer && spawnT > 1.8 && getAllMobs().length < 30){ spawnT = 0; // Выбираем сторону спавна (левая или правая) @@ -3201,13 +3245,28 @@ } } - // mobs update — только локальные (singleplayer) - if(!isMultiplayer){ + // 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 @@ -3286,7 +3345,7 @@ } // mobs - const allMobsRender = isMultiplayer ? Array.from(serverMobs.values()) : mobs; + const allMobsRender = getAllMobs(); for(const m of allMobsRender){ if(m.kind==='zombie'){ ctx.fillStyle = '#2ecc71'; diff --git a/index-new.html b/index-new.html new file mode 100644 index 0000000..1426899 --- /dev/null +++ b/index-new.html @@ -0,0 +1,97 @@ + + + + + +GrechkaCraft: Multiplayer + + + + + + +
+ + +
+
+
❤️ 100   🍗 100
+
🫁 100
+
📍 X:0 Y:0
+
🕒 День
+
🌐 default
+ +
+ +
⛏️
+
💾
+
🔨
+
🔄
+ +
💬
+
📦
+
🗺️
+
+
+ + + + + + + +
+
⬅️
+
⬆️
+
⬇️
+ +
+ + + + + + + + + +
+ + + + diff --git a/index-old.html b/index-old.html new file mode 100644 index 0000000..9a6d3e8 --- /dev/null +++ b/index-old.html @@ -0,0 +1,97 @@ + + + + + +GrechkaCraft: Multiplayer + + + + + + +
+ + +
+
+
❤️ 100   🍗 100
+
🫁 100
+
📍 X:0 Y:0
+
🕒 День
+
🌐 default
+ +
+ +
⛏️
+
💾
+
🔨
+
🔄
+ +
💬
+
📦
+
🗺️
+
+
+ + + + + + + +
+
⬅️
+
⬆️
+
⬇️
+ +
+ + + + + + + + + +
+ + + + diff --git a/nginx.conf b/nginx.conf index df2c109..88f2cea 100644 --- a/nginx.conf +++ b/nginx.conf @@ -3,10 +3,15 @@ server { root /usr/share/nginx/html; index index.html; - location ~* \.(js|css)$ { + # CORS for ES modules + add_header Access-Control-Allow-Origin *; + + location ~* \.(js|mjs|css)$ { add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; + add_header Access-Control-Allow-Origin *; expires 0; + default_type application/javascript; } location / { diff --git a/src/audio/sound-engine.js b/src/audio/sound-engine.js new file mode 100644 index 0000000..8d1671e --- /dev/null +++ b/src/audio/sound-engine.js @@ -0,0 +1,37 @@ +// Звуковой движок +const sounds = {}; + +export { sounds }; + +export 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'); + +export function playSound(id) { + if (sounds[id]) { + sounds[id].currentTime = 0; + sounds[id].play().catch(e => console.error('Sound error:', e)); + } +} \ No newline at end of file diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..e90eea2 --- /dev/null +++ b/src/config.js @@ -0,0 +1,67 @@ +// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ==================== +// Возможность переопределить сервер через query string +const urlParams = new URLSearchParams(window.location.search); +export const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru'; + +// Защита от mixed content +if (location.protocol === 'https:' && SERVER_URL.startsWith('http://')) { + console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP'); + alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.'); +} + +// ==================== WORLD ID И ИГРОКА ==================== +let worldId = null; +let playerName = localStorage.getItem('minegrechka_playerName') || null; + +// Запрашиваем имя игрока, если его нет +if (!playerName) { + playerName = prompt('Введите ваше имя для игры:') || 'Игрок'; + localStorage.setItem('minegrechka_playerName', playerName); + console.log('Player name set:', playerName); +} + +// Берём worldId из URL или генерируем новый +console.log('Current URL:', window.location.href); +const worldParam = urlParams.get('world'); +console.log('world param:', worldParam); + +// Проверяем на null, undefined или пустую строку +worldId = (worldParam && worldParam.trim() !== '') ? worldParam : null; + +console.log('worldId after params:', worldId, 'type:', typeof worldId); + +// Если worldId отсутствует - генерируем новый и записываем в URL +if (!worldId) { + worldId = Math.random().toString(36).substring(2, 10); + console.log('Generated worldId:', worldId); + + try { + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('world', worldId); + const newUrlString = newUrl.toString(); + console.log('New URL to set:', newUrlString); + + // Проверяем, поддерживается ли history API + if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') { + window.history.replaceState(null, '', newUrlString); + console.log('URL after replaceState:', window.location.href); + console.log('URL after replaceState (direct check):', window.location.search); + } else { + console.error('History API not supported!'); + } + } catch (e) { + console.error('Error updating URL:', e); + } + + console.log('Generated new worldId for browser:', worldId); +} + +console.log('Final worldId:', worldId, 'Player name:', playerName); + +console.log(`Server URL: ${SERVER_URL}, World ID: ${worldId}`); + +// Setters для изменения worldId/playerName из других модулей +export function setWorldId(id) { worldId = id; } +export function setPlayerName(name) { playerName = name; } + +export { worldId, playerName }; \ No newline at end of file diff --git a/src/core/canvas.js b/src/core/canvas.js new file mode 100644 index 0000000..3bd9abe --- /dev/null +++ b/src/core/canvas.js @@ -0,0 +1,27 @@ +// ==================== ХОЛСТ И РАЗМЕРЫ ==================== + +const gameEl = document.getElementById('game'); +const canvas = document.getElementById('c'); +const ctx = canvas.getContext('2d'); + +// offscreen light map (не вставляем в DOM) +const lightC = document.createElement('canvas'); +const lightCtx = lightC.getContext('2d'); + +const dpr = Math.max(1, window.devicePixelRatio || 1); +let W = 0, H = 0; + +function resize() { + W = gameEl.clientWidth; + H = gameEl.clientHeight; + canvas.width = W * dpr; + canvas.height = H * dpr; + lightC.width = W * dpr; + lightC.height = H * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); +} + +function setW(v) { W = v; } +function setH(v) { H = v; } + +export { canvas, ctx, lightC, lightCtx, dpr, W, H, gameEl, resize, setW, setH }; \ No newline at end of file diff --git a/src/core/constants.js b/src/core/constants.js new file mode 100644 index 0000000..e48a06f --- /dev/null +++ b/src/core/constants.js @@ -0,0 +1,32 @@ +// ==================== КОНСТАНТЫ ИГРЫ ==================== + +export const TILE = 40; + +// Мир +export const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов +export const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже +export const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки + +// Физика +export const GRAV = 2200; +export const GRAV_WATER = 550; +export const MOVE = 320; +export const JUMP = 760; + +// День/ночь +export const DAY_LEN = 360; // замедлил смену дня/ночи в 3 раза + +// Сохранение +export const SAVE_KEY = 'minegrechka_save'; + +// Сеть +export const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду + +// Погода +export const MAX_RAINDROPS = 200; + +// Чат +export const MAX_CHAT_MESSAGES = 50; + +// Голосовой чат +export const VOICE_SERVER = 'wss://voicegrech.mkn8n.ru'; \ No newline at end of file diff --git a/src/core/state.js b/src/core/state.js new file mode 100644 index 0000000..17552ba --- /dev/null +++ b/src/core/state.js @@ -0,0 +1,157 @@ +import { TILE } from './constants.js'; + +// Все мутабельные переменные игры в одном объекте состояния +export const state = { + // Камера + camX: 0, + camY: 0, + + // День/ночь + worldTime: 0, + isNightTime: false, + + // Мультиплеер + isMultiplayer: false, + mySocketId: null, + socket: null, + + // Инвентарь/UI + selected: 0, + showFullInventory: false, + craftOpen: false, + inventoryOpen: false, + chatOpen: false, + modeIdx: 0, + + // Мир + worldSeed: Math.floor(Math.random() * 1000000), + + // Погода + isRaining: false, + rainIntensity: 0, + weatherTimer: 0, + weatherChangeInterval: 60 + Math.random() * 120, + + // Мобы/спавн + spawnT: 0, + + // Цикл + last: 0, + prevJump: false, + + // Сеть — throttle отправки позиции + lastMoveSendTime: 0, + lastSentX: 0, + lastSentY: 0, + + // Игрок + player: { + x: 6 * TILE, + y: 0 * TILE, + w: 34, + h: 34, + vx: 0, + vy: 0, + grounded: false, + inWater: false, + headInWater: false, + hp: 100, + hunger: 100, + o2: 100, + invuln: 0, + fallStartY: 0, + lastStepTime: 0, + sleeping: false, + inBoat: false, + armor: 0, + equippedArmor: null + }, + + // Точка спавна + spawnPoint: { x: 6 * TILE, y: 0 * TILE }, + + // Инвентарь + inv: { + dirt: 6, stone: 0, sand: 0, gravel: 0, clay: 0, + wood: 0, planks: 0, ladder: 0, leaves: 0, coal: 0, + copper_ore: 0, iron_ore: 0, gold_ore: 0, diamond_ore: 0, + brick: 0, glass: 0, + tnt: 1, campfire: 0, torch: 0, + meat: 0, cooked: 0, arrow: 0, + wood_pickaxe: 0, stone_pickaxe: 0, iron_pickaxe: 0, + wood_sword: 0, stone_sword: 0, iron_sword: 0, + iron_armor: 0, + bow: 0, furnace: 0, + bed: 0, boat: 0, + iron_ingot: 0, gold_ingot: 0, copper_ingot: 0 + }, + + // Лодка + boat: { + x: 0, y: 0, + w: 34, h: 34, + vx: 0, vy: 0, + active: false, + inWater: false + }, + + // Ввод + inp: { up: false, down: false, left: false, right: false, jump: false, mine: false, build: false, bow: false }, + + // Мышь + mouse: { x: 0, y: 0 }, + + // Другие игроки (MP) + otherPlayers: new Map(), + + // Серверные мобы (MP) + serverMobs: new Map(), + + // Мобы + mobs: [], + + // Снаряды + projectiles: [], + + // Отслеживание изменений мира + placedBlocks: [], + removedBlocks: [], + + // Серверные изменения + serverOverrides: new Map(), + + // Чат + chatMessages: [], + + // Погода — капли + raindrops: [], + + // Облака + clouds: Array.from({ length: 10 }, () => ({ + x: Math.random() * 2000, + y: -200 - Math.random() * 260, + w: 80 + Math.random() * 120, + s: 12 + Math.random() * 20 + })), + + // Частицы + parts: [], + + // Активный TNT + activeTNT: new Set(), + + // Прочность инструментов + toolDurability: new Map(), + + // Последние выбранные предметы + recentItems: [], + + // Активные печи + activeFurnaces: new Map(), + + // Сгенерированные колонны + generated: new Set(), + + // Изображение героя + heroImg: null +}; \ No newline at end of file diff --git a/src/data/blocks.js b/src/data/blocks.js new file mode 100644 index 0000000..03beedf --- /dev/null +++ b/src/data/blocks.js @@ -0,0 +1,30 @@ +export const BLOCKS = { + air: { n:'Воздух', solid:false }, + grass: { n:'Трава', c:'#7cfc00', solid:true }, + dirt: { n:'Грязь', c:'#8b4513', solid:true }, + stone: { n:'Камень', c:'#7f8c8d', solid:true }, + sand: { n:'Песок', c:'#f4d06f', solid:true }, + gravel: { n:'Гравий', c:'#95a5a6', solid:true }, + clay: { n:'Глина', c:'#74b9ff', solid:true }, + wood: { n:'Дерево', c:'#d35400', solid:true }, + planks: { n:'Доски', c:'#e67e22', solid:true }, + ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true }, + leaves: { n:'Листва', c:'#2ecc71', solid:true }, + glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55 }, + water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true }, + coal: { n:'Уголь', c:'#2c3e50', solid:true }, + copper_ore:{ n:'Медь', c:'#e17055', solid:true }, + iron_ore: { n:'Железо', c:'#dcdde1', solid:true }, + iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5 }, + gold_ore: { n:'Золото', c:'#f1c40f', solid:true }, + diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true }, + brick: { n:'Кирпич', c:'#c0392b', solid:true }, + tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true }, + campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:190 }, + torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:140 }, + bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true }, + flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true }, + bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true }, + boat: { n:'Лодка', c:'#8B4513', solid:false }, + furnace: { n:'Печь', c:'#696969', solid:true, smelting:true } +}; \ No newline at end of file diff --git a/src/data/items.js b/src/data/items.js new file mode 100644 index 0000000..ea0439d --- /dev/null +++ b/src/data/items.js @@ -0,0 +1,10 @@ +export const ITEMS = { + meat: { n:'Сырое мясо', icon:'🥩', food:15 }, + cooked: { n:'Жареное мясо', icon:'🍖', food:45 }, + arrow: { n:'Стрела', icon:'➡️', stack:64 }, +}; + +// Runtime extensions — предметы от обжига +ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' }; +ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' }; +ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' }; \ No newline at end of file diff --git a/src/data/recipes.js b/src/data/recipes.js new file mode 100644 index 0000000..cc0dfa2 --- /dev/null +++ b/src/data/recipes.js @@ -0,0 +1,32 @@ +export 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 } } +]; + +// Рецепты печи (обжиг) +export 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 } // булыжник → камень +]; \ No newline at end of file diff --git a/src/data/tools.js b/src/data/tools.js new file mode 100644 index 0000000..10e56eb --- /dev/null +++ b/src/data/tools.js @@ -0,0 +1,32 @@ +export 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 } } +}; + +import { state } from '../core/state.js'; + +export function addTool(type) { + const maxDur = TOOLS[type]?.durability || 60; + const key = `${type}_${Date.now()}_${Math.random().toString(36).slice(2,6)}`; + state.toolDurability.set(key, { type, current: maxDur, max: maxDur }); +} + +export function useTool(type) { + for (const [tid, dur] of state.toolDurability) { + if (dur.type === type && dur.current > 0) { + dur.current--; + if (dur.current <= 0) { + state.toolDurability.delete(tid); + state.inv[type] = Math.max(0, (state.inv[type] || 0) - 1); + return true; // broke + } + return false; // used but not broke + } + } + return false; // no durability entry found +} \ No newline at end of file diff --git a/src/entities/boat.js b/src/entities/boat.js new file mode 100644 index 0000000..545ce6b --- /dev/null +++ b/src/entities/boat.js @@ -0,0 +1,19 @@ +// Лодка +import { state } from '../core/state.js'; + +export const boat = { + x: 0, y: 0, + w: 34, h: 34, + vx: 0, vy: 0, + active: false, + inWater: false +}; + +export function initBoat() { + boat.x = 0; + boat.y = 0; + boat.vx = 0; + boat.vy = 0; + boat.active = false; + boat.inWater = false; +} \ No newline at end of file diff --git a/src/entities/mob-ai.js b/src/entities/mob-ai.js new file mode 100644 index 0000000..08f04c1 --- /dev/null +++ b/src/entities/mob-ai.js @@ -0,0 +1,108 @@ +// Моб AI +import { state } from '../core/state.js'; +import { GRAV, GRAV_WATER, TILE } from '../core/constants.js'; +import { updateWaterFlag } from '../physics/water-detect.js'; +import { calculateDamage } from '../entities/player.js'; +import { playSound } from '../audio/sound-engine.js'; +import { explodeAt } from '../world/tnt.js'; +import { resolveY } from '../physics/collision.js'; +import { resolveX } from '../physics/collision.js'; + +export function mobAI(m, dt) { + updateWaterFlag(m); + + if (m.kind === 'zombie') { + // активность ночью + const night = isNight(); + if (!night) { m.hp -= 30 * dt; m.vx *= 0.5; return; } + const dir = Math.sign((state.player.x) - m.x); + m.vx = dir * m.speed; + if (m.inWater && Math.random() < 0.06) m.vy = -260; + // атака + if (Math.abs((m.x + m.w / 2) - (state.player.x + state.player.w / 2)) < 28 && + Math.abs((m.y + m.h / 2) - (state.player.y + state.player.h / 2)) < 40 && + state.player.invuln <= 0) { + const damage = calculateDamage(15); + state.player.hp -= damage; + state.player.invuln = 0.8; + state.player.vx += dir * 420; + state.player.vy -= 260; + playSound('hit1'); // Звук при атаке зомби + } + } else if (m.kind === 'creeper') { + // активность ночью + const night = isNight(); + if (!night) { m.hp -= 30 * dt; m.vx *= 0.5; return; } + const dir = Math.sign((state.player.x) - m.x); + const dist = Math.hypot((state.player.x + state.player.w / 2) - (m.x + m.w / 2), (state.player.y + state.player.h / 2) - (m.y + m.h / 2)); + + // Движение к игроку + m.vx = dir * m.speed; + if (m.inWater && Math.random() < 0.06) m.vy = -260; + + // Взрыв если близко к игроку + if (dist < 60) { + m.fuse -= dt; + if (m.fuse <= 0) { + explodeAt(Math.floor((m.x + m.w / 2) / TILE), Math.floor((m.y + m.h / 2) / TILE)); + m.hp = 0; + } + } else { + // Поджигаем если очень близко + if (dist < 40) { + m.fuse = 0.5; // Быстрый взрыв + } + } + } else if (m.kind === 'skeleton') { + // активность ночью + const night = isNight(); + if (!night) { m.hp -= 30 * dt; m.vx *= 0.5; return; } + const dir = Math.sign((state.player.x) - m.x); + const dist = Math.hypot((state.player.x + state.player.w / 2) - (m.x + m.w / 2), (state.player.y + state.player.h / 2) - (m.y + m.h / 2)); + + // Движение к игроку + m.vx = dir * m.speed; + if (m.inWater && Math.random() < 0.06) m.vy = -260; + + // Стрельба стрелами + m.shootCooldown -= dt; + if (dist < 300 && m.shootCooldown <= 0) { + m.shootCooldown = 2.0; + const dx = (state.player.x + state.player.w / 2) - (m.x + m.w / 2); + const dy = (state.player.y + state.player.h / 2) - (m.y + m.h / 2); + const angle = Math.atan2(dy, dx); + const speed = 450; + state.projectiles.push({ + x: m.x + m.w / 2, + y: m.y + m.h / 3, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + dmg: 6, + owner: 'mob', + life: 3 + }); + } + } else { + // животные + m.aiT -= dt; + if (m.aiT <= 0) { + m.aiT = 1.8 + Math.random() * 2.5; + m.dir = Math.random() < 0.5 ? -1 : 1; + if (Math.random() < 0.25) m.dir = 0; + } + m.vx = m.dir * (m.kind === 'chicken' ? 55 : 40); + if (m.inWater) m.vy = -120; + } + + // физика моба + const g = m.inWater ? GRAV_WATER : GRAV; + m.vy += g * dt; + + m.y += m.vy * dt; m.grounded = false; resolveY(m); + m.x += m.vx * dt; resolveX(m); +} + +export function isNight() { + // Автоматический цикл: ночь когда worldTime > 0.5 + return state.worldTime > 0.5; +} \ No newline at end of file diff --git a/src/entities/mobs.js b/src/entities/mobs.js new file mode 100644 index 0000000..be54425 --- /dev/null +++ b/src/entities/mobs.js @@ -0,0 +1,37 @@ +// Сущности: животные + зомби + +export class Entity { + constructor(x, y, w, h) { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + this.vx = 0; + this.vy = 0; + this.hp = 3; + this.grounded = false; + this.inWater = false; + this.aiT = 0; + this.dir = 1; + } +} + +export class Pig extends Entity { + constructor(x, y) { super(x, y, 34, 24); this.kind = 'pig'; this.hp = 2; } +} + +export class Chicken extends Entity { + constructor(x, y) { super(x, y, 26, 22); this.kind = 'chicken'; this.hp = 1; } +} + +export class Zombie extends Entity { + constructor(x, y) { super(x, y, 34, 50); this.kind = 'zombie'; this.hp = 4; this.speed = 80 + Math.random() * 40; } +} + +export class Creeper extends Entity { + constructor(x, y) { super(x, y, 34, 50); this.kind = 'creeper'; this.hp = 4; this.speed = 60 + Math.random() * 30; this.fuse = 3.2; } +} + +export class Skeleton extends Entity { + constructor(x, y) { super(x, y, 34, 50); this.kind = 'skeleton'; this.hp = 4; this.speed = 70 + Math.random() * 30; this.shootCooldown = 0; } +} \ No newline at end of file diff --git a/src/entities/player.js b/src/entities/player.js new file mode 100644 index 0000000..6503201 --- /dev/null +++ b/src/entities/player.js @@ -0,0 +1,43 @@ +import { TILE } from '../core/constants.js'; +import { state } from '../core/state.js'; + +// Функция для расчёта урона с учётом брони +function calculateDamage(baseDamage) { + // Броня снижает урон пропорционально + // armor: 0 = без брони (100% урона) + // armor: 0.5 = железная броня (50% урона) + const reduction = state.player.armor; + const actualDamage = baseDamage * (1 - reduction); + console.log('[DAMAGE] Base:', baseDamage, '- Armor:', reduction, '- Actual:', actualDamage.toFixed(1)); + return actualDamage; +} + +// Инициализация игрока +function initPlayer() { + state.player = { + x: 6 * TILE, + y: 0 * TILE, + w: 34, + h: 34, + vx: 0, + vy: 0, + grounded: false, + inWater: false, + headInWater: false, + hp: 100, + hunger: 100, + o2: 100, + invuln: 0, + fallStartY: 0, + lastStepTime: 0, + sleeping: false, + inBoat: false, + armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня) + equippedArmor: null // Тип надетой брони + }; + + // Сохраняем начальную позицию для возрождения + state.spawnPoint = { x: 6 * TILE, y: 0 * TILE }; +} + +export { calculateDamage, initPlayer }; \ No newline at end of file diff --git a/src/game/loop.js b/src/game/loop.js new file mode 100644 index 0000000..d30fff5 --- /dev/null +++ b/src/game/loop.js @@ -0,0 +1,758 @@ +// ==================== ГЛАВНЫЙ ИГРОВОЙ ЦИКЛ ==================== +import { state } from '../core/state.js'; +import { BLOCKS } from '../data/blocks.js'; +import { playSound } from '../audio/sound-engine.js'; +import { getBlock } from '../world/world-storage.js'; +import { updateWaterPhysics } from '../physics/water.js'; +import { updateWaterFlag } from '../physics/water-detect.js'; +import { resolveY, resolveX } from '../physics/collision.js'; +import { calculateDamage } from '../entities/player.js'; +import { isNight } from '../entities/mob-ai.js'; +import { tickFurnaces } from '../ui/furnace.js'; +import { renderFurnaceUI } from '../ui/furnace.js'; +import { updateWeather, updateRain, drawRain } from '../world/weather.js'; +import { ensureGenAroundCamera, surfaceGyAt, genColumn } from '../world/generation.js'; +import { Zombie, Creeper, Skeleton, Pig, Chicken } from '../entities/mobs.js'; +import { mobAI } from '../entities/mob-ai.js'; +import { explodeAt } from '../world/tnt.js'; +import { sendPlayerPosition } from '../multiplayer/socket-helpers.js'; +import { renderMinimap } from '../ui/minimap.js'; +import { rebuildHotbar } from '../ui/hotbar.js'; +import { mode } from '../game/modes.js'; +import { mouse } from '../input/mouse-handler.js'; +import { drawFire } from '../render/draw-fire.js'; + +// ==================== LOOP ==================== +let last = performance.now(); +let prevJump = false; +// При возврате на вкладку — сбрасываем last чтобы не было скачка dt +document.addEventListener('visibilitychange', () => { + if (!document.hidden) last = performance.now(); +}); + + + +export function loop(now) { + const rawDt = Math.min(0.05, (now - last) / 1000); + last = now; + // Sub-stepping: разбиваем физику на шаги ≤16ms чтобы не проваливаться сквозь блоки + const PHYSICS_STEP = 0.016; + const steps = Math.max(1, Math.ceil(rawDt / PHYSICS_STEP)); + const dt = rawDt / steps; + + const player = state.player; + const inv = state.inv; + const inp = state.inp; + const clouds = state.clouds; + const mobs = state.mobs; + const projectiles = state.projectiles; + const parts = state.parts; + const boat = state.boat; + + const jumpPressed = inp.j && !prevJump; + prevJump = inp.j; + + // Ускорение времени во время сна + if (player.sleeping && isNight()) { + state.worldTime += dt * 8 / state.DAY_LEN; // В 8 раз быстрее + // Восстанавливаем здоровье во время сна + player.hp = Math.min(100, player.hp + dt * 20); + // Автоматическое пробуждение когда наступает день + if (!isNight()) { + player.sleeping = false; + } + } else { + state.worldTime += dt / state.DAY_LEN; + } + if (state.worldTime >= 1) state.worldTime -= 1; + + // камера следует за игроком по X/Y + state.camX = Math.floor((player.x + player.w / 2) - state.W / 2); + state.camY = Math.floor((player.y + player.h / 2) - state.H / 2); + + ensureGenAroundCamera(); + + // clouds parallax + for (const c of clouds) { + c.x -= c.s * dt; + if (c.x + c.w < state.camX - 400) c.x = state.camX + state.W + Math.random() * 700; + } + + // player + updateWaterFlag(player); + + // кислород/утопление: тратим O2 только если голова под водой, иначе восстанавливаем + if (player.headInWater) { + player.o2 = Math.max(0, player.o2 - 6 * dt); // замедлил в 3.7 раза + if (player.o2 === 0) { + const damage = calculateDamage(4 * dt); + player.hp -= damage; + } + } else { + player.o2 = Math.min(100, player.o2 + 10 * dt); // замедлил восстановление в 4 раза + } + + // голод убывает, но HP не отнимает (как просили) + player.hunger = Math.max(0, player.hunger - dt * 0.2); // замедлил в 4 раза + + // Игрок не может двигаться во время сна + if (player.sleeping) { + player.vx = 0; + player.vy = 0; + } else { + const dir = (inp.r ? 1 : 0) - (inp.l ? 1 : 0); + if (dir) player.vx = dir * state.MOVE; + else player.vx *= 0.82; + } + + // Звук шагов при движении по земле + if (player.grounded && !player.inWater && Math.abs(player.vx) > 50) { + const stepInterval = 0.35; // Интервал между шагами в секундах + if (now / 1000 - player.lastStepTime > stepInterval) { + playSound('step'); + player.lastStepTime = now / 1000; + } + } + + // прыжок/плавание (новая логика) + if (player.inBoat) { + // Игрок в лодке - лодка следует за игроком + const dir = (inp.r ? 1 : 0) - (inp.l ? 1 : 0); + if (dir) boat.vx = dir * state.MOVE; + else boat.vx *= 0.95; + + // Лодка плавает на воде + boat.vy = 0; + + // Игрок следует за лодкой (сидит внутри неё) + player.x = boat.x + 2; // Игрок по центру лодки + player.y = boat.y - 4; // Игрок выше лодки (сидит внутри) + player.vx = boat.vx; + player.vy = boat.vy; + player.grounded = true; + player.inWater = false; // Игрок не в воде когда в лодке + + // Прыжок из лодки (высадка) + if (jumpPressed) { + // Возвращаем лодку в инвентарь + inv.boat = (inv.boat || 0) + 1; + + player.inBoat = false; + boat.active = false; + player.y += state.TILE; // Прыгаем из лодки + player.vy = -state.JUMP * 0.5; + playSound('splash'); + } + + } else if (player.inWater) { + // сопротивление в воде + player.vx *= 0.90; + player.vy *= 0.92; + + // Если не нажимаем прыжок - тонем (гравитация в воде) + if (!jumpPressed && !inp.j) { + // Применяем гравитацию в воде - игрок тонет + player.vy += state.GRAV_WATER * dt; + } else { + // Если нажимаем прыжок - поднимаемся на поверхность + if (jumpPressed) { + player.vy = Math.min(player.vy, -520); // рывок вверх + } else if (inp.j) { + // если держим — мягкое всплытие + player.vy = Math.min(player.vy, -260); + } + } + + } else { + // обычный прыжок (только по нажатию) + if (jumpPressed && player.grounded && !player.sleeping) { + player.vy = -state.JUMP; + player.grounded = false; + player.fallStartY = player.y; + } + } + + // Гравитация применяется только вне воды и вне лодки + if (!player.inWater && !player.inBoat) { + player.vy += state.GRAV * dt; + } + + // Обновляем позицию лодки + if (boat.active) { + boat.x += boat.vx * dt; + boat.y += boat.vy * dt; + + // Лодка не выходит за пределы воды + const boatGX = Math.floor(boat.x / state.TILE); + const boatGY = Math.floor(boat.y / state.TILE); + const below = getBlock(boatGX, boatGY + 1); + + if (!below || below.t !== 'water') { + // Если лодка вышла из воды - выкидываем игрока + inv.boat = (inv.boat || 0) + 1; + player.inBoat = false; + boat.active = false; + player.y += state.TILE; + player.vy = -200; + playSound('splash'); + } + } + + // Проверяем, не доплыл ли игрок из лодки + if (player.inBoat && !boat.active) { + inv.boat = (inv.boat || 0) + 1; + player.inBoat = false; + player.y += state.TILE; + player.vy = -200; + playSound('splash'); + } + + // Sub-stepped physics: применяем движение мелкими шагами + for (let step = 0; step < steps; step++) { + player.y += player.vy * dt; + resolveY(player); + player.x += player.vx * dt; + resolveX(player); + } + + // Отправляем позицию на сервер (мультиплеер) + sendPlayerPosition(); + + // Обновляем физику воды + updateWaterPhysics(dt); + + // Погода и дождь + updateWeather(dt); + updateRain(dt); + + player.invuln = Math.max(0, player.invuln - dt); + + // Voice position update + state.voicePosT += dt; + if (state.voicePosT > 0.5 && state.voiceSocket && state.voiceSocket.connected) { + state.voicePosT = 0; + state.voiceSocket.emit('voice_pos', { x: player.x, y: player.y }); + } + + // Furnace tick + tickFurnaces(dt); + + // Обновляем UI печи если открыта + if (state.currentFurnaceKey && Math.random() < 0.1) { + renderFurnaceUI(); + } + + // Projectile tick (стрелы) + for (let i = projectiles.length - 1; i >= 0; i--) { + const p = projectiles[i]; + p.x += p.vx * dt; + p.y += p.vy * dt; + p.vy += 400 * dt; // гравитация + p.life -= dt; + + // Столкновение с блоком + const gx = Math.floor(p.x / state.TILE); + const gy = Math.floor(p.y / state.TILE); + const blk = getBlock(gx, gy); + if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid) { + // Врезался в стену + if (p.owner === 'player' && Math.random() < 0.5) inv.arrow++; // подбираем ~50% + projectiles.splice(i, 1); + continue; + } + + // Столкновение с сущностью + if (p.owner === 'mob') { + // Попал в игрока + if (p.x > player.x && p.x < player.x + player.w && p.y > player.y && p.y < player.y + player.h) { + if (player.invuln <= 0) { + player.hp -= calculateDamage(p.dmg); + player.invuln = 0.4; + player.vx += p.vx * 0.3; + player.vy -= 150; + playSound('hit1'); + } + projectiles.splice(i, 1); + continue; + } + } else { + // Попал в моба — server mobs first in multiplayer + let hitMob = false; + if (state.isMultiplayer) { + for (const [id, sm] of state.serverMobs) { + if (sm.dead) continue; + if (p.x > sm.x && p.x < sm.x + sm.w && p.y > sm.y && p.y < sm.y + sm.h) { + state.socket.emit('mob_arrow_hit', { id: sm.id, dmg: p.dmg, vx: p.vx }); + projectiles.splice(i, 1); + hitMob = true; + break; + } + } + } + if (!hitMob) { + // Local mobs + for (let j = mobs.length - 1; j >= 0; j--) { + const m = mobs[j]; + if (p.x > m.x && p.x < m.x + m.w && p.y > m.y && p.y < m.y + m.h) { + m.hp -= p.dmg; + m.vx += p.vx * 0.2; + m.vy -= 200; + if (m.hp <= 0) { + inv.meat += (m.kind === 'chicken' ? 1 : 2); + if (m.kind === 'skeleton') { + inv.arrow += 2 + Math.floor(Math.random() * 3); + if (Math.random() < 0.15) inv.bow = (inv.bow || 0) + 1; + } + mobs.splice(j, 1); + rebuildHotbar(); + } + projectiles.splice(i, 1); + break; + } + } + } + } + + // Таймаут + if (p.life <= 0) projectiles.splice(i, 1); + } + + // TNT tick + for (const key of Array.from(state.activeTNT)) { + const b = state.grid.get(key); + if (!b || b.dead) { state.activeTNT.delete(key); continue; } + b.fuse -= dt; + if (b.fuse <= 0) { + explodeAt(b.gx, b.gy); + } + } + + // mobs spawn (с обеих сторон камеры) — только в одиночном режиме + state.spawnT += dt; + if (!state.isMultiplayer && state.spawnT > 1.8 && mobs.length < 30) { + state.spawnT = 0; + + // Выбираем сторону спавна (левая или правая) + const spawnLeft = Math.random() < 0.5; + const gx = spawnLeft + ? Math.floor((state.camX - 200) / state.TILE) + : Math.floor((state.camX + state.W + 200) / state.TILE); + + genColumn(gx); + const sgy = surfaceGyAt(gx); + const wx = gx * state.TILE + 4; + const wy = (sgy - 2) * state.TILE; + + // не спавнить в воде + const top = getBlock(gx, sgy); + if (top && top.t === 'water') { + // skip + } else { + const night = isNight(); + if (night) { + // Ночью спавним враждебных мобов (максимум 12 хостайл) + const hostileCount = mobs.filter(m => m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton').length; + if (hostileCount < 12) { + const rand = Math.random(); + if (rand < 0.35) { + mobs.push(new Zombie(wx, wy)); + } else if (rand < 0.55) { + mobs.push(new Creeper(wx, wy)); + } else { + mobs.push(new Skeleton(wx, wy)); + } + } + } + // Животные спавнятся и днём и ночью (с лимитом) + const animalCount = mobs.filter(m => m.kind === 'pig' || m.kind === 'chicken').length; + if (animalCount < 8) { + mobs.push(Math.random() < 0.5 ? new Pig(wx, wy) : new Chicken(wx, wy)); + } + } + } + + // mobs update — только локальные (singleplayer) + if (!state.isMultiplayer) { + for (let i = mobs.length - 1; i >= 0; i--) { + const m = mobs[i]; + mobAI(m, dt); + if (m.hp <= 0) mobs.splice(i, 1); + } + } + + // particles + for (let i = parts.length - 1; i >= 0; i--) { + const p = parts[i]; + p.t -= dt; + p.x += p.vx * dt; + p.y += p.vy * dt; + p.vy += state.GRAV * dt; + if (p.t <= 0) parts.splice(i, 1); + } + + // death + if (player.hp <= 0) { + state.deathEl.style.display = 'flex'; + } else if (state.deathEl.style.display === 'flex') { + // Если HP > 0 но экран смерти всё ещё показан - скрываем его + state.deathEl.style.display = 'none'; + } + + // ==================== RENDER ==================== + const ctx = state.ctx; + const W = state.W; + const H = state.H; + const camX = state.camX; + const camY = state.camY; + const TILE = state.TILE; + const tex = state.tex; + const night = isNight(); + + // sky + ctx.fillStyle = night ? '#070816' : (state.isRaining ? '#6B7B8D' : '#87CEEB'); + ctx.fillRect(0, 0, W, H); + + // clouds (parallax x/y) + ctx.save(); + ctx.translate(-camX * 0.5, -camY * 0.15); + ctx.fillStyle = 'rgba(255,255,255,0.65)'; + for (const c of clouds) { + ctx.fillRect(c.x, c.y, c.w, 26); + ctx.fillRect(c.x + 20, c.y - 10, c.w * 0.6, 22); + } + ctx.restore(); + + // world + ctx.save(); + ctx.translate(-camX, -camY); + + const minGX = Math.floor(camX / TILE) - 2; + const maxGX = Math.floor((camX + W) / TILE) + 2; + const minGY = Math.floor(camY / TILE) - 6; + const maxGY = Math.floor((camY + H) / TILE) + 6; + const blocks = state.blocks; + + // draw blocks (по массиву, но фильтруем диапазоном) + for (const b of blocks) { + if (b.dead) continue; + if (b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue; + + const def = BLOCKS[b.t]; + if (def.alpha) { + ctx.save(); + ctx.globalAlpha = def.alpha; + ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE); + ctx.restore(); + } else { + ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE); + } + + // TNT мигает, если активирован + if (b.t === 'tnt' && b.active && Math.sin(now / 60) > 0) { + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + ctx.fillRect(b.gx * TILE, b.gy * TILE, TILE, TILE); + } + + // огонь костра + if (b.t === 'campfire') { + drawFire(ctx, b.gx * TILE, b.gy * TILE, now); + } + // Печь — огонь когда обжигает + if (b.t === 'furnace' && state.activeFurnaces.has(`${b.gx},${b.gy}`)) { + drawFire(ctx, b.gx * TILE + 8, b.gy * TILE + 5, now); + } + } + + // mobs + const allMobsRender = state.isMultiplayer ? Array.from(state.serverMobs.values()) : mobs; + for (const m of allMobsRender) { + if (m.kind === 'zombie') { + ctx.fillStyle = '#2ecc71'; + ctx.fillRect(m.x, m.y, m.w, m.h); + ctx.fillStyle = '#c0392b'; + ctx.fillRect(m.x + 6, m.y + 12, 6, 6); + ctx.fillRect(m.x + 22, m.y + 12, 6, 6); + } else if (m.kind === 'pig') { + ctx.fillStyle = '#ffb6c1'; + ctx.fillRect(m.x, m.y, m.w, m.h); + ctx.fillStyle = '#000'; + ctx.fillRect(m.x + 22, m.y + 5, 3, 3); + ctx.fillStyle = '#ff69b4'; + ctx.fillRect(m.x + 28, m.y + 12, 6, 6); + } else if (m.kind === 'chicken') { + // chicken + ctx.fillStyle = '#ecf0f1'; + ctx.fillRect(m.x, m.y, m.w, m.h); + ctx.fillStyle = '#f39c12'; + ctx.fillRect(m.x + 18, m.y + 10, 6, 4); + ctx.fillStyle = '#000'; + ctx.fillRect(m.x + 8, m.y + 6, 3, 3); + } else if (m.kind === 'creeper') { + // creeper + ctx.fillStyle = '#4CAF50'; + ctx.fillRect(m.x, m.y, m.w, m.h); + // Глаза + ctx.fillStyle = '#000'; + ctx.fillRect(m.x + 8, m.y + 8, 4, 4); + ctx.fillRect(m.x + 22, m.y + 8, 4, 4); + // Рот + ctx.fillStyle = '#000'; + ctx.fillRect(m.x + 12, m.y + 20, 10, 4); + // Ноги + ctx.fillStyle = '#4CAF50'; + ctx.fillRect(m.x + 4, m.y + 30, 6, 20); + ctx.fillRect(m.x + 24, m.y + 30, 6, 20); + } else if (m.kind === 'skeleton') { + // skeleton - детализированный + ctx.fillStyle = '#ECEFF1'; + ctx.fillRect(m.x + 10, m.y + 20, 14, 12); + ctx.fillRect(m.x + 8, m.y + 0, 18, 18); + ctx.fillStyle = '#000'; + ctx.fillRect(m.x + 10, m.y + 6, 4, 4); + ctx.fillRect(m.x + 20, m.y + 6, 4, 4); + ctx.fillRect(m.x + 15, m.y + 12, 4, 2); + ctx.fillStyle = '#ECEFF1'; + ctx.fillRect(m.x + 2, m.y + 20, 6, 14); + ctx.fillRect(m.x + 26, m.y + 20, 6, 14); + ctx.fillRect(m.x + 10, m.y + 32, 6, 18); + ctx.fillRect(m.x + 18, m.y + 32, 6, 18); + // Лук в руке + ctx.save(); + ctx.translate(m.x + 30, m.y + 22); + ctx.strokeStyle = '#8B4513'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(0, 0, 8, -Math.PI * 0.7, Math.PI * 0.7); + ctx.stroke(); + ctx.strokeStyle = '#ccc'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(8 * Math.cos(-Math.PI * 0.7), 8 * Math.sin(-Math.PI * 0.7)); + ctx.lineTo(8 * Math.cos(Math.PI * 0.7), 8 * Math.sin(Math.PI * 0.7)); + ctx.stroke(); + ctx.restore(); + } + } + + // boat (рисуем первой, чтобы игрок был внутри неё) + if (boat.active) { + ctx.drawImage(tex['boat'], boat.x - (TILE - boat.w) / 2, boat.y - (TILE - boat.h) / 2, TILE, TILE); + } + + // other players (multiplayer) + for (const [socketId, p] of state.otherPlayers) { + if (state.heroImg.complete) { + ctx.drawImage(state.heroImg, p.x - (TILE - player.w) / 2, p.y - (TILE - player.h) / 2, TILE, TILE); + } else { + ctx.fillStyle = p.color; + ctx.fillRect(p.x, p.y, 34, 34); + } + ctx.fillStyle = '#fff'; + ctx.font = '12px system-ui'; + ctx.textAlign = 'center'; + ctx.fillText(p.name, p.x + 17, p.y - 8); + } + + // player + if (state.heroImg.complete) { + ctx.drawImage(state.heroImg, player.x - (TILE - player.w) / 2, player.y - (TILE - player.h) / 2, TILE, TILE); + } else { + ctx.fillStyle = '#fff'; + ctx.fillRect(player.x, player.y, player.w, player.h); + } + + // projectiles (стрелы) + for (const p of projectiles) { + const angle = Math.atan2(p.vy, p.vx); + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate(angle); + ctx.fillStyle = p.owner === 'mob' ? '#c0392b' : '#f1c40f'; + ctx.fillRect(-12, -1.5, 24, 3); + ctx.beginPath(); + ctx.moveTo(12, -4); + ctx.lineTo(16, 0); + ctx.lineTo(12, 4); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#888'; + ctx.fillRect(-12, -3, 4, 2); + ctx.fillRect(-12, 1, 4, 2); + ctx.restore(); + } + + // particles + for (const p of parts) { + ctx.fillStyle = p.c; + ctx.fillRect(p.x - 2, p.y - 2, 4, 4); + } + + // Стрелы скелета + for (const m of mobs) { + if (m.kind === 'skeleton' && m.shootCooldown > 0.5) { + const arrowX = m.x + m.w / 2; + const arrowY = m.y + 15; + const targetX = player.x + player.w / 2; + const targetY = player.y + player.h / 2; + const angle = Math.atan2(targetY - arrowY, targetX - arrowX); + const dx = targetX - arrowX; + const dy = targetY - arrowY; + const dist = Math.hypot(dx, dy); + + ctx.save(); + ctx.translate(arrowX, arrowY); + ctx.rotate(angle); + ctx.fillStyle = '#ECEFF1'; + ctx.fillRect(0, -1, 16, 2); + ctx.restore(); + + if (dist < 150 && player.invuln <= 0) { + player.hp -= 8; + player.invuln = 0.5; + player.vx += Math.cos(angle) * 300; + player.vy -= 200; + playSound('hit1'); + } + } + } + + // build ghost + if (mode() === 'build' && mouse.x !== null && !state.craftOpen && player.hp > 0) { + const wx = mouse.x + camX; + const wy = mouse.y + camY; + const ggx = Math.floor(wx / TILE); + const ggy = Math.floor(wy / TILE); + ctx.strokeStyle = 'rgba(255,255,255,0.9)'; + ctx.lineWidth = 2; + ctx.strokeRect(ggx * TILE, ggy * TILE, TILE, TILE); + } + + ctx.restore(); + + // lighting overlay at night (Minecraft-style: offscreen lightmap, wall occlusion, warm flicker) + if (night) { + const lightC = state.lightC; + const lightCtx = state.lightCtx; + + lightC.width = W * state.dpr; + lightC.height = H * state.dpr; + lightCtx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); + lightCtx.fillStyle = 'rgba(0,0,12,0.82)'; + lightCtx.fillRect(0, 0, W, H); + + lightCtx.globalCompositeOperation = 'destination-out'; + + function castLight(sx, sy, radius) { + const flick = 0.88 + Math.sin(now / 80 + sx * 0.01) * 0.06 + Math.sin(now / 130 + sy * 0.02) * 0.06; + const r = radius * flick; + const steps2 = 12; + const dists = new Float32Array(steps2); + for (let i = 0; i < steps2; i++) { + const ang = (i / steps2) * Math.PI * 2; + const ddx = Math.cos(ang); + const ddy = Math.sin(ang); + let maxDist = r; + for (let step = TILE * 0.5; step < r; step += TILE * 0.6) { + const lgx = Math.floor((sx + ddx * step) / TILE); + const lgy = Math.floor((sy + ddy * step) / TILE); + const blk = getBlock(lgx, lgy); + if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid && step > TILE * 0.3) { + maxDist = step; + break; + } + } + dists[i] = maxDist; + } + const cx = sx - camX, cy = sy - camY; + const maxR = Math.max(...dists); + const grad = lightCtx.createRadialGradient(cx, cy, 0, cx, cy, maxR); + grad.addColorStop(0, 'rgba(255,255,255,1)'); + grad.addColorStop(0.5, 'rgba(255,255,255,0.65)'); + grad.addColorStop(1, 'rgba(255,255,255,0)'); + lightCtx.fillStyle = grad; + lightCtx.beginPath(); + for (let i = 0; i <= steps2; i++) { + const idx = i % steps2; + const ang = (idx / steps2) * Math.PI * 2; + const px = cx + Math.cos(ang) * dists[idx]; + const py = cy + Math.sin(ang) * dists[idx]; + if (i === 0) lightCtx.moveTo(px, py); + else lightCtx.lineTo(px, py); + } + lightCtx.closePath(); + lightCtx.fill(); + } + + for (const b of blocks) { + if (b.dead) continue; + if (b.gx < minGX - 5 || b.gx > maxGX + 5 || b.gy < minGY - 5 || b.gy > maxGY + 5) continue; + const def = BLOCKS[b.t]; + if (def.lightRadius) { + castLight(b.gx * TILE + TILE / 2, b.gy * TILE + TILE / 2, def.lightRadius); + } + } + + lightCtx.globalCompositeOperation = 'source-over'; + ctx.drawImage(lightC, 0, 0, W, H); + + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + for (const b of blocks) { + if (b.dead) continue; + if (b.gx < minGX - 3 || b.gx > maxGX + 3 || b.gy < minGY - 3 || b.gy > maxGY + 3) continue; + const def = BLOCKS[b.t]; + if (def.lightRadius) { + const flick = 0.7 + Math.sin(now / 90 + b.gx * 3.7) * 0.15 + Math.sin(now / 140 + b.gy * 2.3) * 0.15; + const wx = b.gx * TILE + TILE / 2 - camX; + const wy = b.gy * TILE + TILE / 2 - camY; + const r = def.lightRadius * 0.6 * flick; + const grad = ctx.createRadialGradient(wx, wy, 0, wx, wy, r); + grad.addColorStop(0, `rgba(255,180,80,${0.12 * flick})`); + grad.addColorStop(0.5, `rgba(255,140,40,${0.06 * flick})`); + grad.addColorStop(1, 'rgba(255,100,20,0)'); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(wx, wy, r, 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.restore(); + } + + // Дождь (после ночного оверлея) + drawRain(); + if (Math.random() < 0.25) { + state.hpEl.textContent = Math.max(0, Math.ceil(player.hp)); + state.foodEl.textContent = Math.ceil(player.hunger); + document.getElementById('o2').textContent = Math.ceil(player.o2); + state.sxEl.textContent = Math.floor(player.x / TILE); + state.syEl.textContent = Math.floor(player.y / TILE); + state.todEl.textContent = night ? 'Ночь' : 'День'; + state.worldIdEl.textContent = state.worldId; + if (state.isMultiplayer) { + document.getElementById('multiplayerStatus').style.display = 'flex'; + state.playerCountEl.textContent = state.otherPlayers.size + 1; + } else { + document.getElementById('multiplayerStatus').style.display = 'none'; + } + } + + // Индикатор сна + if (player.sleeping) { + ctx.fillStyle = 'rgba(0,0,0,0.7)'; + ctx.fillRect(0, 0, W, H); + ctx.fillStyle = '#fff'; + ctx.font = 'bold 32px system-ui'; + ctx.textAlign = 'center'; + ctx.fillText('💤 Спим...', W / 2, H / 2); + ctx.font = '18px system-ui'; + ctx.fillText('Нажмите на кровать чтобы проснуться', W / 2, H / 2 + 40); + } + + // Миникарта (обновляем раз в ~4 кадра для оптимизации) + if (state.minimapOpen && Math.random() < 0.25) { + renderMinimap(); + } + + requestAnimationFrame(loop); +} \ No newline at end of file diff --git a/src/game/modes.js b/src/game/modes.js new file mode 100644 index 0000000..90d09f9 --- /dev/null +++ b/src/game/modes.js @@ -0,0 +1,20 @@ +// ==================== РЕЖИМЫ ==================== +import { state } from '../core/state.js'; +import { playSound } from '../audio/sound-engine.js'; + +const MODES = [{ id: 'mine', icon: '⛏️' }, { id: 'build', icon: '🧱' }]; + +export { MODES }; + +export function mode() { + return MODES[state.modeIdx].id; +} + +export function initModes() { + const modeBtn = document.getElementById('modeBtn'); + modeBtn.onclick = () => { + playSound('click'); // Звук клика по кнопке режима + state.modeIdx = (state.modeIdx + 1) % MODES.length; + modeBtn.textContent = MODES[state.modeIdx].icon; + }; +} \ No newline at end of file diff --git a/src/game/save.js b/src/game/save.js new file mode 100644 index 0000000..fd788ae --- /dev/null +++ b/src/game/save.js @@ -0,0 +1,187 @@ +// ==================== СИСТЕМА СОХРАНЕНИЯ ИГРЫ ==================== +import { state } from '../core/state.js'; +import { getBlock, setBlock, removeBlock } from '../world/world-storage.js'; +import { regenerateVisibleChunks } from '../world/generation.js'; +import { rebuildHotbar } from '../ui/hotbar.js'; + +const SAVE_KEY = state.SAVE_KEY; +let db = null; // Оставляем для совместимости, но не используем + +// Инициализация (localStorage + in-memory fallback) +export function initDB() { + return new Promise((resolve) => { + console.log('Используем localStorage для сохранений (sandbox режим)'); + resolve(null); + }); +} + +export function saveGame() { + const saveData = { + version: 2, + worldSeed: state.worldSeed, + player: { + x: state.player.x, + y: state.player.y, + hp: state.player.hp, + hunger: state.player.hunger, + o2: state.player.o2 + }, + inventory: state.inv, + time: state.worldTime, + isNight: state.isNightTime, + // Сохраняем только изменения + placedBlocks: state.placedBlocks.slice(), + removedBlocks: state.removedBlocks.slice() + }; + + const saveSize = JSON.stringify(saveData).length; + console.log('Сохранение: player HP:', state.player.hp, 'hunger:', state.player.hunger, 'o2:', state.player.o2); + + // Пробуем сохранить в localStorage (основной метод для персистентности) + try { + localStorage.setItem(SAVE_KEY, JSON.stringify(saveData)); + console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`); + } catch (e) { + console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e); + + // Если localStorage недоступен, используем in-memory fallback + state.inMemorySave = saveData; + console.log(`Игра сохранена в памяти (размер: ${saveSize} байт)`); + } +} + +export function loadGame() { + return new Promise((resolve, reject) => { + // Пробуем localStorage + try { + const localSave = localStorage.getItem(SAVE_KEY); + if (localSave) { + const parsed = JSON.parse(localSave); + console.log('Загружено из localStorage, player HP:', parsed.player?.hp); + resolve(parsed); + return; + } + } catch (e) { + console.warn('Ошибка доступа к localStorage:', e); + } + + // Если localStorage недоступен, используем in-memory сохранение + if (state.inMemorySave) { + console.log('Загружено из in-memory сохранения, player HP:', state.inMemorySave.player?.hp); + resolve(state.inMemorySave); + return; + } + + console.log('Сохранение не найдено'); + resolve(null); + }); +} + +// Миграция с версии 1 на версию 2 +export function migrateV1toV2(saveData) { + console.log('Миграция сохранения с версии 1 на версию 2...'); + + // Сохраняем seed из текущей игры (так как v1 его не хранил) + saveData.worldSeed = state.worldSeed; + + // Инициализируем массивы изменений + saveData.placedBlocks = []; + saveData.removedBlocks = []; + + // Для v1 нам нужно сравнить сгенерированные блоки с тем, что должно быть по seed + // Это сложно сделать без полной перегенерации, поэтому для v1 сохраняем только seed + // и при загрузке просто перегенерируем мир + + // Удаляем старые данные + delete saveData.generatedBlocks; + + saveData.version = 2; + console.log('Миграция завершена'); +} + +export async function applySave(saveData) { + if (!saveData) return; + + console.log('=== applySave START ==='); + console.log('player HP before applySave:', state.player.hp); + console.log('saveData.player.hp:', saveData.player?.hp); + + // Миграция версий + if (saveData.version === 1) { + migrateV1toV2(saveData); + } + + // Восстанавливаем seed + if (saveData.worldSeed !== undefined) { + state.worldSeed = saveData.worldSeed; + } + + // Восстанавливаем игрока + if (saveData.player) { + state.player.x = saveData.player.x; + state.player.y = saveData.player.y; + state.player.hunger = saveData.player.hunger; + state.player.o2 = saveData.player.o2; + + // Обновляем spawnPoint на позицию из сохранения + state.spawnPoint.x = state.player.x; + state.spawnPoint.y = state.player.y; + + // Проверяем HP из сохранения - если <= 0, устанавливаем 100 + const savedHP = saveData.player.hp; + console.log('Saved HP from file:', savedHP); + if (savedHP <= 0) { + console.log('WARNING: Saved HP is <= 0, setting to 100!'); + state.player.hp = 100; + } else { + state.player.hp = savedHP; + } + console.log('player HP after restore:', state.player.hp); + console.log('spawnPoint обновлён из сохранения: x=', state.spawnPoint.x, 'y=', state.spawnPoint.y); + } else { + console.log('No player data in save, setting default HP: 100'); + state.player.hp = 100; + } + + console.log('=== applySave END ==='); + + // Восстанавливаем инвентарь + if (saveData.inventory) { + for (const key in saveData.inventory) { + state.inv[key] = saveData.inventory[key]; + } + } + + // Восстанавливаем время + if (saveData.time !== undefined) { + state.worldTime = saveData.time; + } + + // Восстанавливаем день/ночь + if (saveData.isNight !== undefined) { + state.isNightTime = saveData.isNight; + } + + // Перегенерируем мир по seed + regenerateVisibleChunks(); + + // Применяем изменения (только для v2) + if (saveData.version === 2) { + // Применяем блоки, установленные игроком + for (const block of saveData.placedBlocks) { + setBlock(block.gx, block.gy, block.t, true); + } + + // Применяем удалённые блоки + for (const block of saveData.removedBlocks) { + removeBlock(block.gx, block.gy); + } + + // Восстанавливаем массивы изменений + state.placedBlocks = saveData.placedBlocks || []; + state.removedBlocks = saveData.removedBlocks || []; + } + + rebuildHotbar(); + console.log('Игра загружена'); +} \ No newline at end of file diff --git a/src/input/controls.js b/src/input/controls.js new file mode 100644 index 0000000..c9a9614 --- /dev/null +++ b/src/input/controls.js @@ -0,0 +1,37 @@ +// ==================== УПРАВЛЕНИЕ ==================== +import { state } from '../core/state.js'; + +export const inp = state.inp; + +export function bindHold(el, key) { + const down = (e) => { e.preventDefault(); state.inp[key] = true; }; + const up = (e) => { e.preventDefault(); state.inp[key] = false; }; + el.addEventListener('pointerdown', down); + el.addEventListener('pointerup', up); + el.addEventListener('pointerleave', up); +} + +export function initControls() { + const leftBtn = document.getElementById('left'); + const rightBtn = document.getElementById('right'); + const jumpBtn = document.getElementById('jump'); + const downBtn = document.getElementById('down'); + + if (leftBtn) bindHold(leftBtn, 'l'); + if (rightBtn) bindHold(rightBtn, 'r'); + if (jumpBtn) bindHold(jumpBtn, 'j'); + if (downBtn) bindHold(downBtn, 's'); + + window.addEventListener('keydown', (e) => { + if (e.code === 'KeyA' || e.code === 'ArrowLeft') state.inp.l = true; + if (e.code === 'KeyD' || e.code === 'ArrowRight') state.inp.r = true; + if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') state.inp.j = true; + if (e.code === 'KeyS' || e.code === 'ArrowDown') state.inp.s = true; + }); + window.addEventListener('keyup', (e) => { + if (e.code === 'KeyA' || e.code === 'ArrowLeft') state.inp.l = false; + if (e.code === 'KeyD' || e.code === 'ArrowRight') state.inp.r = false; + if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') state.inp.j = false; + if (e.code === 'KeyS' || e.code === 'ArrowDown') state.inp.s = false; + }); +} \ No newline at end of file diff --git a/src/input/mouse-handler.js b/src/input/mouse-handler.js new file mode 100644 index 0000000..cfb6014 --- /dev/null +++ b/src/input/mouse-handler.js @@ -0,0 +1,247 @@ +// ==================== ВЗАИМОДЕЙСТВИЕ МЫШЬ/ТАП ==================== +import { state } from '../core/state.js'; +import { BLOCKS } from '../data/blocks.js'; +import { ITEMS } from '../data/items.js'; +import { TOOLS } from '../data/tools.js'; +import { getBlock, setBlock, removeBlock } from '../world/world-storage.js'; +import { playSound } from '../audio/sound-engine.js'; +import { rebuildHotbar } from '../ui/hotbar.js'; +import { mode } from '../game/modes.js'; +import { openFurnaceUI } from '../ui/furnace.js'; +import { saveGame } from '../game/save.js'; +import { useTool } from '../data/tools.js'; +import { activateTNT } from '../world/tnt.js'; +import { sendBlockChange } from '../multiplayer/socket-helpers.js'; +import { isNight } from '../entities/mob-ai.js'; + +export const mouse = { x: null, y: null }; + +export function initMouseHandlers() { + const canvas = state.canvas; + + canvas.addEventListener('pointermove', (e) => { + const r = canvas.getBoundingClientRect(); + mouse.x = e.clientX - r.left; + mouse.y = e.clientY - r.top; + }); + + canvas.addEventListener('pointerdown', (e) => { + if (state.craftOpen) return; + if (state.player.hp <= 0) return; + + const r = canvas.getBoundingClientRect(); + const sx = e.clientX - r.left; + const sy = e.clientY - r.top; + + const wx = sx + state.camX; + const wy = sy + state.camY; + + const gx = Math.floor(wx / state.TILE); + const gy = Math.floor(wy / state.TILE); + + // Пробуждение: клик по любой кровати когда спишь + const b = getBlock(gx, gy); + if (state.player.sleeping && b && b.t === 'bed') { + state.player.sleeping = false; + return; + } + + if (state.player.sleeping) return; // Нельзя взаимодействовать во время сна + + // Клик по печи — открываем панель обжига + if (b && b.t === 'furnace' && mode() === 'mine') { + openFurnaceUI(gx, gy); + return; + } + + // клик по мобу (в режиме mine) + if (mode() === 'mine') { + // Check server mobs first (multiplayer) + if (state.isMultiplayer) { + for (const [id, sm] of state.serverMobs) { + if (sm.dead) continue; + if (wx >= sm.x && wx <= sm.x + sm.w && wy >= sm.y && wy <= sm.y + sm.h) { + let dmg = 1; + const swordTypes = ['iron_sword', 'stone_sword', 'wood_sword']; + for (const st of swordTypes) { + if (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; } + } + state.socket.emit('mob_hurt', { id: sm.id, dmg }); + playSound('attack'); + return; + } + } + } + // Local mobs (singleplayer or if not hit server mob) + for (let i = state.mobs.length - 1; i >= 0; i--) { + const m = state.mobs[i]; + if (wx >= m.x && wx <= m.x + m.w && wy >= m.y && wy <= m.y + m.h) { + let dmg = 1; + const swordTypes = ['iron_sword', 'stone_sword', 'wood_sword']; + for (const st of swordTypes) { + if (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; } + } + m.hp -= dmg; + m.vx += (m.x - state.player.x) * 2; + m.vy -= 200; + playSound('attack'); + if (m.hp <= 0) { + if (m.kind === 'chicken') playSound('hurt_chicken'); + state.inv.meat += (m.kind === 'chicken' ? 1 : 2); + if (m.kind === 'skeleton') { + state.inv.arrow += 2 + Math.floor(Math.random() * 3); + if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1; + } + state.mobs.splice(i, 1); + rebuildHotbar(); + } + return; + } + } + } + + // Лук — стреляем стрелой + if (state.selected === 'bow' && state.inv.bow > 0 && state.inv.arrow > 0) { + const aimX = wx - state.player.x - state.player.w / 2; + const aimY = wy - state.player.y - state.player.h / 2; + const angle = Math.atan2(aimY, aimX); + state.projectiles.push({ + x: state.player.x + state.player.w / 2, + y: state.player.y + state.player.h / 3, + vx: Math.cos(angle) * 550, + vy: Math.sin(angle) * 550, + dmg: 10, + owner: 'player', + life: 4 + }); + state.inv.arrow--; + useTool('bow'); + playSound('hit1'); + rebuildHotbar(); + return; + } + + // еда (предмет) + if (ITEMS[state.selected] && state.inv[state.selected] > 0) { + const it = ITEMS[state.selected]; + if (state.player.hp < 100 || state.player.hunger < 100) { + playSound('eat1'); // Звук употребления еды + state.player.hunger = Math.min(100, state.player.hunger + it.food); + state.player.hp = Math.min(100, state.player.hp + 15); + state.inv[state.selected]--; + rebuildHotbar(); + } + return; + } + + // жарка на костре: выбран meat + клик по campfire + if (b && b.t === 'campfire' && state.selected === 'meat' && state.inv.meat > 0) { + playSound('fire'); // Звук при жарке на костре + state.inv.meat--; + state.inv.cooked++; + rebuildHotbar(); + return; + } + + // Сон на кровати: клик по bed + if (b && b.t === 'bed' && isNight()) { + state.player.sleeping = true; + saveGame(); // Сохраняем при отходе ко сну + return; + } + + if (mode() === 'mine') { + if (!b) return; + if (BLOCKS[b.t].fluid || BLOCKS[b.t].decor) return; + + if (b.t === 'tnt') { activateTNT(b, 3.2); return; } // не взрывается сразу + + const removed = removeBlock(gx, gy); + if (removed) { + state.inv[removed.t] = (state.inv[removed.t] || 0) + 1; + + // Тратим прочность кирки (если есть в инвентаре) + const pickTypes = ['iron_pickaxe', 'stone_pickaxe', 'wood_pickaxe']; + for (const pt of pickTypes) { + if (state.inv[pt] > 0) { + const broke = useTool(pt); + if (broke) playSound('cloth1'); // звук поломки + break; + } + } + + // Отправляем изменение блока на сервер + sendBlockChange(gx, gy, removed.t, 'remove'); + + // Звуки при добыче блоков + if (removed.t === 'glass') playSound('glass1'); + else if (removed.t === 'sand') playSound('sand1'); + else if (removed.t === 'snow') playSound('snow1'); + else if (removed.t === 'stone' || removed.t.endsWith('_ore')) playSound('stone1'); + else if (removed.t === 'wood') playSound('wood1'); + else playSound('cloth1'); + + rebuildHotbar(); + } + return; + } + + if (mode() === 'build') { + if (state.inv[state.selected] <= 0) return; + if (!BLOCKS[state.selected]) return; + if (b) return; // занято + + // Проверяем, ставим ли лодку + if (state.selected === 'boat') { + // Лодку можно ставить только на воду + const waterBelow = getBlock(gx, gy + 1); + if (!waterBelow || waterBelow.t !== 'water') { + return; + } + + // Создаём лодку + state.boat.x = gx * state.TILE; + state.boat.y = gy * state.TILE; + state.boat.vx = 0; + state.boat.vy = 0; + state.boat.active = true; + state.boat.inWater = true; + + // Сажаем игрока в лодку + state.player.inBoat = true; + state.player.x = state.boat.x; + state.player.y = state.boat.y; + state.player.vx = 0; + state.player.vy = 0; + + playSound('splash'); + state.inv[state.selected]--; + rebuildHotbar(); + return; + } + + // запрет ставить в игрока + const TILE = state.TILE; + const bx = gx * TILE, by = gy * TILE; + const overlap = !(bx >= state.player.x + state.player.w || bx + TILE <= state.player.x || by >= state.player.y + state.player.h || by + TILE <= state.player.y); + if (overlap) return; + + setBlock(gx, gy, state.selected, true); // true = блок установлен игроком + state.inv[state.selected]--; + + // Отправляем изменение блока на сервер + sendBlockChange(gx, gy, state.selected, 'set'); + + // Звук при строительстве + if (state.selected === 'stone' || state.selected === 'brick') playSound('stone_build'); + else if (state.selected === 'wood' || state.selected === 'planks') playSound('wood_build'); + else if (state.selected === 'glass') playSound('glass1'); + else if (state.selected === 'sand') playSound('sand1'); + else if (state.selected === 'snow') playSound('snow1'); + else if (state.selected === 'dirt' || state.selected === 'grass') playSound('cloth1'); + + rebuildHotbar(); + return; + } + }); +} \ No newline at end of file diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..a95594a --- /dev/null +++ b/src/main.js @@ -0,0 +1,278 @@ +// ==================== ENTRY POINT ==================== +import { state } from './core/state.js'; +import { initSocket } from './multiplayer/socket.js'; +import { sendBlockChange, sendPlayerPosition } from './multiplayer/socket-helpers.js'; +import { loadSound, playSound } from './audio/sound-engine.js'; +import { BLOCKS } from './data/blocks.js'; +import { ITEMS } from './data/items.js'; +import { TOOLS } from './data/tools.js'; +import { SMELTING_RECIPES } from './data/recipes.js'; +import { initTextures } from './render/textures.js'; +import { getBlock, setBlock, removeBlock } from './world/world-storage.js'; +import { genColumn, surfaceGyAt, ensureGenAroundCamera, regenerateVisibleChunks } from './world/generation.js'; +import { initDB, loadGame, applySave, saveGame } from './game/save.js'; +import { loop } from './game/loop.js'; +import { initChat } from './ui/chat.js'; +import { initFurnace } from './ui/furnace.js'; +import { initMinimap } from './ui/minimap.js'; +import { initSaveControls, updateSaveButtonVisibility } from './ui/save-controls.js'; +import { initRespawn } from './ui/respawn.js'; +import { initShare } from './ui/share.js'; +import { initControls } from './input/controls.js'; +import { initMouseHandlers } from './input/mouse-handler.js'; +import { initModes } from './game/modes.js'; +import { initVoice } from './multiplayer/voice-chat.js'; +import { resolveY, resolveX } from './physics/collision.js'; +import { calculateDamage } from './entities/player.js'; +import { updateWaterFlag } from './physics/water-detect.js'; +import { updateWaterPhysics } from './physics/water.js'; +import { explodeAt, activateTNT } from './world/tnt.js'; +import { useTool } from './data/tools.js'; +import { rebuildHotbar } from './ui/hotbar.js'; + +// ==================== 1) CONFIG — parse URL/getName ==================== +const urlParams = new URLSearchParams(window.location.search); +state.SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru'; +state.TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot'; +state.TELEGRAM_APP_SHORT_NAME = 'minegrechka'; + +// Защита от mixed content +if (location.protocol === 'https:' && state.SERVER_URL.startsWith('http://')) { + console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP'); + alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.'); +} + +// Player name +state.playerName = localStorage.getItem('minegrechka_playerName') || null; +if (!state.playerName) { + state.playerName = prompt('Введите ваше имя для игры:') || 'Игрок'; + localStorage.setItem('minegrechka_playerName', state.playerName); + console.log('Player name set:', state.playerName); +} + +// World ID from URL or generate new +console.log('Current URL:', window.location.href); +const worldParam = urlParams.get('world'); +console.log('world param:', worldParam); + +state.worldId = (worldParam && worldParam.trim() !== '') ? worldParam : null; +console.log('worldId after params:', state.worldId, 'type:', typeof state.worldId); + +if (!state.worldId) { + state.worldId = Math.random().toString(36).substring(2, 10); + console.log('Generated worldId:', state.worldId); + + try { + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('world', state.worldId); + const newUrlString = newUrl.toString(); + console.log('New URL to set:', newUrlString); + + if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') { + window.history.replaceState(null, '', newUrlString); + console.log('URL after replaceState:', window.location.href); + } else { + console.error('History API not supported!'); + } + } catch (e) { + console.error('Error updating URL:', e); + } + + console.log('Generated new worldId for browser:', state.worldId); +} + +console.log('Final worldId:', state.worldId, 'Player name:', state.playerName); + +// ==================== 2) CANVAS — get elements, resize ==================== +state.gameEl = document.getElementById('game'); +state.canvas = document.getElementById('c'); +state.ctx = state.canvas.getContext('2d'); + +// offscreen light map +state.lightC = document.createElement('canvas'); +state.lightCtx = state.lightC.getContext('2d'); + +state.dpr = Math.max(1, window.devicePixelRatio || 1); + +function resize() { + state.W = state.gameEl.clientWidth; + state.H = state.gameEl.clientHeight; + state.canvas.width = state.W * state.dpr; + state.canvas.height = state.H * state.dpr; + state.lightC.width = state.W * state.dpr; + state.lightC.height = state.H * state.dpr; + state.ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); +} +window.addEventListener('resize', resize); +resize(); + +// ==================== 3) STATE — init player, inv, etc ==================== +state.otherPlayers = new Map(); +state.serverMobs = new Map(); +state.grid = new Map(); +state.blocks = []; +state.generated = new Set(); +state.serverOverrides = new Map(); +state.activeFurnaces = new Map(); +state.activeTNT = new Set(); +state.toolDurability = new Map(); +state.worldSeed = Math.floor(Math.random() * 1000000); + +// Clouds +state.clouds = Array.from({ length: 10 }, () => ({ + x: Math.random() * 2000, + y: -200 - Math.random() * 260, + w: 80 + Math.random() * 120, + s: 12 + Math.random() * 20 +})); + +// Weather +state.weatherTimer = 0; +state.weatherChangeInterval = 60 + Math.random() * 120; + +// Player spawn +state.player.x = 6 * state.TILE; +state.player.y = 0; +state.spawnPoint.x = 6 * state.TILE; +state.spawnPoint.y = 0; + +// Hero image +state.heroImg = new Image(); +state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png'; + +// UI elements +state.hpEl = document.getElementById('hp'); +state.foodEl = document.getElementById('food'); +state.sxEl = document.getElementById('sx'); +state.syEl = document.getElementById('sy'); +state.todEl = document.getElementById('tod'); +state.worldIdEl = document.getElementById('worldId'); +state.playerCountEl = document.getElementById('playerCount'); +state.deathEl = document.getElementById('death'); + +// ==================== 4) TEXTURES — initTextures() ==================== +initTextures(); + +// ==================== 5) AUDIO — loadSound for each ==================== +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'); + +// ==================== 6) CONTROLS — initControls ==================== +initControls(); + +// ==================== 7) MOUSE — initMouseHandlers ==================== +initMouseHandlers(); + +// ==================== 8) UI MODULES — init all UI ==================== +initChat(); +initFurnace(); +initMinimap(); +initSaveControls(); +initRespawn(); +initShare(); +initModes(); + +// ==================== 9) SOCKET — initSocket ==================== +initSocket(); + +// ==================== 10) VOICE — initVoice ==================== +initVoice(); + +// ==================== 11) SAVE — loadGame, applySave ==================== +rebuildHotbar(); + +initDB().then(async () => { + const loadedSave = await loadGame(); + if (loadedSave) { + await applySave(loadedSave); + console.log('Загружено сохранение, HP:', state.player.hp); + + if (state.player.hp <= 0) { + console.log('WARNING: HP <= 0 после загрузки, возрождаемся'); + state.player.hp = 100; + state.player.hunger = 100; + state.player.o2 = 100; + state.player.x = state.spawnPoint.x; + state.player.y = state.spawnPoint.y; + state.player.vx = state.player.vy = 0; + state.player.invuln = 0; + state.player.fallStartY = state.player.y; + } + } else { + console.log('Сохранение не найдено, начинаем новую игру'); + + state.player.hp = 100; + state.player.hunger = 100; + state.player.o2 = 100; + state.player.vx = state.player.vy = 0; + state.player.invuln = 0; + + // старт — на поверхности + const startGX = 6; + for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx); + const surfaceY = surfaceGyAt(startGX); + let safeGY = surfaceY - 1; + const aboveBlock = getBlock(startGX, surfaceY - 1); + if (aboveBlock && aboveBlock.t === 'water') { + for (let gy = state.SEA_GY - 1; gy >= 0; gy--) { + const b = getBlock(startGX, gy); + if (!b || b.dead || b.t === 'air' || b.t === 'water') continue; + safeGY = gy - 1; + break; + } + } + state.player.y = safeGY * state.TILE; + state.player.x = startGX * state.TILE; + state.player.fallStartY = state.player.y; + + state.spawnPoint.x = state.player.x; + state.spawnPoint.y = state.player.y; + + console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', state.player.y); + + // Генерируем карту вокруг стартовой позиции + for (let gx = startGX - 50; gx <= startGX + 50; gx++) { + genColumn(gx); + } + } + + // Автосейв при скрытии страницы + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + saveGame(); + } + }); + + // Автосейв перед закрытием страницы + window.addEventListener('beforeunload', () => { + saveGame(); + }); +}).catch(err => { + console.error('Ошибка инициализации:', err); + const startGX = 6; + genColumn(startGX); + state.player.y = (surfaceGyAt(startGX) - 1) * state.TILE; + state.player.fallStartY = state.player.y; + + for (let gx = startGX - 50; gx <= startGX + 50; gx++) { + genColumn(gx); + } +}); + +// ==================== 13) LOOP — startLoop ==================== +requestAnimationFrame(loop); \ No newline at end of file diff --git a/src/multiplayer/socket-helpers.js b/src/multiplayer/socket-helpers.js new file mode 100644 index 0000000..5823d0a --- /dev/null +++ b/src/multiplayer/socket-helpers.js @@ -0,0 +1,45 @@ +// Socket helpers: цвет, отправка позиции/блоков +import { state } from '../core/state.js'; +import { SERVER_URL } from '../config.js'; + +// Генерация случайного цвета для игрока на основе socket_id +export function getRandomPlayerColor(socketId) { + const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4']; + let hash = 0; + for (let i = 0; i < socketId.length; i++) { + hash = ((hash << 5) - hash) + socketId.charCodeAt(i); + hash = hash & hash; + } + return colors[Math.abs(hash) % colors.length]; +} + +// Throttle для отправки позиции (10-20 раз в секунду) +let lastMoveSendTime = 0; +const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду +let lastSentX = 0, lastSentY = 0; + +// Отправка позиции игрока (с throttle) +export function sendPlayerPosition() { + if (!state.isMultiplayer || !state.socket || !state.socket.connected) return; + + const now = performance.now() / 1000; + if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return; + + // Отправляем только если позиция изменилась + const dx = Math.abs(state.player.x - lastSentX); + const dy = Math.abs(state.player.y - lastSentY); + if (dx < 1 && dy < 1) return; + + lastMoveSendTime = now; + lastSentX = state.player.x; + lastSentY = state.player.y; + + state.socket.emit('player_move', { x: state.player.x, y: state.player.y, player_name: state.playerName }); +} + +// Отправка изменения блока +export function sendBlockChange(gx, gy, t, op) { + if (!state.isMultiplayer || !state.socket || !state.socket.connected) return; + + state.socket.emit('block_change', { gx, gy, t, op }); +} \ No newline at end of file diff --git a/src/multiplayer/socket.js b/src/multiplayer/socket.js new file mode 100644 index 0000000..94bebb8 --- /dev/null +++ b/src/multiplayer/socket.js @@ -0,0 +1,279 @@ +// Socket.IO клиент — инициализация и все обработчики +import { state } from '../core/state.js'; +import { SERVER_URL, worldId, playerName } from '../config.js'; +import { TILE, SEA_GY } from '../core/constants.js'; +import { BLOCKS } from '../data/blocks.js'; +import { k, setBlock, removeBlock, grid, blocks } from '../world/world-storage.js'; +import { genColumn, surfaceGyAt } from '../world/generation.js'; +import { addChatMessage } from '../ui/chat.js'; +import { calculateDamage } from '../entities/player.js'; +import { playSound } from '../audio/sound-engine.js'; +import { rebuildHotbar } from '../ui/hotbar.js'; +import { explodeAt } from '../world/tnt.js'; +import { updateSaveButtonVisibility } from '../ui/save-controls.js'; +import { getRandomPlayerColor, sendPlayerPosition, sendBlockChange } from './socket-helpers.js'; + +let socket = null; + +export { socket }; + +export function initSocket() { + try { + socket = io(SERVER_URL, { + path: '/socket.io/', + transports: ['websocket', 'polling'] + }); + + socket.on('connect', () => { + console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${worldId}`); + state.mySocketId = socket.id; + state.isMultiplayer = true; + + // Присоединяемся к миру + socket.emit('join_world', { world_id: worldId, player_name: playerName }); + + // Показываем в UI + state.worldIdEl.textContent = worldId; + state.multiplayerStatus.style.display = 'block'; + }); + + socket.on('connect_error', (error) => { + console.error('Socket connection error:', error); + state.isMultiplayer = false; + }); + + socket.on('disconnect', () => { + console.log('Disconnected from server'); + state.isMultiplayer = false; + state.otherPlayers.clear(); + state.multiplayerStatus.style.display = 'none'; + }); + + // Обработка world_state + socket.on('world_state', (data) => { + console.log('Received world_state:', data); + + // Устанавливаем seed и перегенерируем мир если он изменился + if (data.seed !== undefined && data.seed !== state.worldSeed) { + const oldSeed = state.worldSeed; + state.worldSeed = data.seed; + console.log('World seed changed from', oldSeed, 'to', state.worldSeed); + + // Очищаем и перегенерируем мир с новым seed + state.generated.clear(); + grid.clear(); + blocks.length = 0; + state.placedBlocks = []; + state.removedBlocks = []; + console.log('World regenerated with new seed:', state.worldSeed); + } + + // Применяем блоки — сохраняем в state.serverOverrides для применения после genColumn + if (data.blocks && Array.isArray(data.blocks)) { + for (const block of data.blocks) { + const key = k(block.gx, block.gy); + state.serverOverrides.set(key, { op: block.op, t: block.t }); + // Также пробуем применить сразу (если колонна уже сгенерирована) + if (block.op === 'set') { + setBlock(block.gx, block.gy, block.t, false); + } else if (block.op === 'remove') { + removeBlock(block.gx, block.gy); + } + } + } + + // Устанавливаем время + if (data.time !== undefined) { + state.worldTime = data.time; + state.isNightTime = state.worldTime > 0.5; + } + + // Всегда считаем spawnPoint на клиенте по той же формуле что и surfaceGyAt + // Это гарантирует совпадение с terrain generation + { + const startGX = 6; + // Генерируем колонну и соседние для безопасного спавна + for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx); + const surfaceY = surfaceGyAt(startGX); + // Ищем ближайшую небудущую позицию сверху вниз от поверхности + let safeGY = surfaceY - 1; + // Проверяем что над поверхностью воздух (не в воде) + const aboveBlock = getBlock(startGX, surfaceY - 1); + if (aboveBlock && aboveBlock.t === 'water') { + // Если в воде — ищем поверхность выше уровня моря + for (let gy = SEA_GY - 1; gy >= 0; gy--) { + const b = getBlock(startGX, gy); + if (!b || b.dead || b.t === 'air' || b.t === 'water') continue; + safeGY = gy - 1; + break; + } + } + state.spawnPoint.x = startGX * TILE; + state.spawnPoint.y = safeGY * TILE; + console.log('Client-side spawn point:', state.spawnPoint, 'surfaceY:', surfaceY, 'safeGY:', safeGY); + } + + // Устанавливаем игрока в точку спавна + state.player.x = state.spawnPoint.x; + state.player.y = state.spawnPoint.y; + state.player.vx = 0; + state.player.vy = 0; + state.player.fallStartY = state.player.y; + console.log('Player moved to spawn point:', state.player.x, state.player.y); + + // Устанавливаем HP на 100% при каждом подключении к миру + state.player.hp = 100; + state.player.hunger = 100; + state.player.o2 = 100; + state.player.invuln = 0; + console.log('[MULTIPLAYER CONNECT] Player HP set to 100% on connect'); + + // Обновляем список игроков + if (data.players && Array.isArray(data.players)) { + state.otherPlayers.clear(); + for (const p of data.players) { + if (p.socket_id !== state.mySocketId) { + state.otherPlayers.set(p.socket_id, { + x: p.x, + y: p.y, + color: getRandomPlayerColor(p.socket_id), + name: p.player_name || 'Игрок' + }); + } + } + // Обновляем счётчик игроков + state.playerCountEl.textContent = data.players.length; + } + // Server mobs + if (data.mobs && Array.isArray(data.mobs)) { + state.serverMobs.clear(); + for (const m of data.mobs) { + const sm = { ...m, maxHp: m.maxHp || m.hp, vx: m.vx || 0, vy: m.vy || 0, grounded: false, inWater: false, aiT: 0, dir: m.dir || 1, dead: false, fuse: m.fuse || 0, shootCooldown: 2, speed: m.speed || 80 }; + state.serverMobs.set(m.id, sm); + } + } + }); + + // Игрок присоединился + socket.on('player_joined', (data) => { + console.log('Player joined:', data.socket_id); + if (data.socket_id !== state.mySocketId) { + // Генерируем безопасную позицию для нового игрока + const spawnGX = 6; + genColumn(spawnGX); + const surfaceY = surfaceGyAt(spawnGX); + const safeSpawnX = spawnGX * TILE; + const safeSpawnY = (surfaceY - 1) * TILE; + + state.otherPlayers.set(data.socket_id, { + x: safeSpawnX, + y: safeSpawnY, + color: getRandomPlayerColor(data.socket_id), + name: data.player_name || 'Игрок' + }); + addChatMessage('Система', `Игрок присоединился`); + // Обновляем видимость кнопки сохранения + updateSaveButtonVisibility(); + } + }); + + // Игрок переместился + socket.on('player_moved', (data) => { + if (data.socket_id !== state.mySocketId && state.otherPlayers.has(data.socket_id)) { + const p = state.otherPlayers.get(data.socket_id); + p.x = data.x; + p.y = data.y; + // Обновляем имя, если оно пришло + if (data.player_name) { + p.name = data.player_name; + } + } + }); + + // Игрок покинул + socket.on('player_left', (data) => { + console.log('Player left:', data.socket_id); + state.otherPlayers.delete(data.socket_id); + addChatMessage('Система', `Игрок покинул игру`); + // Обновляем видимость кнопки сохранения + updateSaveButtonVisibility(); + }); + + // === MOB SYNC (multiplayer) === + + socket.on('mob_spawned', (data) => { + const sm = { ...data, maxHp: data.maxHp || data.hp, vx: data.vx || 0, vy: data.vy || 0, grounded: false, inWater: false, aiT: 0, dir: data.dir || 1, dead: false, fuse: data.fuse || 0, shootCooldown: 2, speed: data.speed || 80 }; + state.serverMobs.set(data.id, sm); + }); + + socket.on('mob_positions', (arr) => { + for (const u of arr) { + const sm = state.serverMobs.get(u.id); + if (sm) { sm.x = u.x; sm.y = u.y; sm.vx = u.vx; sm.vy = u.vy; sm.dir = u.dir; sm.hp = u.hp; sm.fuse = u.fuse || 0; } + } + }); + + socket.on('mob_despawned', (data) => { state.serverMobs.delete(data.id); }); + + socket.on('mob_died', (data) => { + const sm = state.serverMobs.get(data.id); + if (sm && data.killer === state.mySocketId) { + // Give loot to the killer + if (sm.kind === 'chicken') playSound('hurt_chicken'); + state.inv.meat += (sm.kind === 'chicken' ? 1 : 2); + if (sm.kind === 'skeleton') { + state.inv.arrow += 2 + Math.floor(Math.random() * 3); + if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1; + } + rebuildHotbar(); + } + state.serverMobs.delete(data.id); + }); + + socket.on('mob_hurt_ack', (data) => { + const sm = state.serverMobs.get(data.id); + if (sm) sm.hp = data.hp; + }); + + socket.on('mob_explode', (data) => { + explodeAt(data.gx, data.gy); + state.serverMobs.delete(data.id); + }); + + socket.on('mob_shoot', (data) => { + state.projectiles.push({ + x: data.x, y: data.y, vx: data.vx, vy: data.vy, + dmg: data.dmg, owner: 'mob', life: data.life + }); + }); + + // Блок изменён + socket.on('block_changed', (data) => { + const key = k(data.gx, data.gy); + state.serverOverrides.set(key, { op: data.op, t: data.t }); + if (data.op === 'set') { + setBlock(data.gx, data.gy, data.t, false); + } else if (data.op === 'remove') { + removeBlock(data.gx, data.gy); + } + }); + + // Сообщение в чат + socket.on('chat_message', (data) => { + const senderName = data.socket_id === state.mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`; + addChatMessage(senderName, data.message); + }); + + // Обновление времени + socket.on('time_update', (data) => { + if (data.time !== undefined) { + state.worldTime = data.time; + state.isNightTime = state.worldTime > 0.5; + } + }); + + } catch (e) { + console.error('Error initializing socket:', e); + state.isMultiplayer = false; + } +} \ No newline at end of file diff --git a/src/multiplayer/voice-chat.js b/src/multiplayer/voice-chat.js new file mode 100644 index 0000000..affc6b4 --- /dev/null +++ b/src/multiplayer/voice-chat.js @@ -0,0 +1,120 @@ +// Голосовой чат +import { state } from '../core/state.js'; +import { worldId } from '../config.js'; + +let voiceSocket = null; +let voiceStream = null; +let audioCtx = null; +let voiceProcessor = null; +let voiceActive = false; +const VOICE_SERVER = 'https://voicegrech.mkn8n.ru'; + +// Кнопка микрофона +const voiceBtn = document.createElement('div'); +voiceBtn.innerHTML = '🎤/'; +voiceBtn.title = 'Голосовой чат (выкл)'; +voiceBtn.style.cssText = 'position:absolute;top:74px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;'; +document.querySelector('.ui').appendChild(voiceBtn); + +// Индикатор говорящего +const speakingIndicator = document.createElement('div'); +speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;'; +speakingIndicator.textContent = '🔊'; +document.querySelector('.ui').appendChild(speakingIndicator); +let speakingTimeout = null; + +voiceBtn.onclick = async () => { + if (voiceActive) { + // Выключить + voiceActive = false; + voiceBtn.innerHTML = '🎤/'; + voiceBtn.style.background = '#555'; + if (voiceStream) { + voiceStream.getTracks().forEach(t => t.stop()); + voiceStream = null; + } + if (voiceProcessor) { voiceProcessor.disconnect(); voiceProcessor = null; } + if (audioCtx) { audioCtx.close(); audioCtx = null; } + if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; } + return; + } + + // Включить + try { + voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 24000 } }); + audioCtx = new AudioContext({ sampleRate: 24000 }); + const source = audioCtx.createMediaStreamSource(voiceStream); + voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1); + + voiceProcessor.onaudioprocess = (e) => { + if (!voiceActive || !voiceSocket || !voiceSocket.connected) return; + const pcm = e.inputBuffer.getChannelData(0); + // Конвертируем float32 → int16 для экономии трафика + const int16 = new Int16Array(pcm.length); + for (let i = 0; i < pcm.length; i++) { + const s = Math.max(-1, Math.min(1, pcm[i])); + int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; + } + voiceSocket.emit('voice_data', int16.buffer); + }; + + source.connect(voiceProcessor); + voiceProcessor.connect(audioCtx.createGain()); // не в destination — чтобы не слышать себя + + // Подключаемся к голосовому серверу + voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] }); + voiceSocket.on('connect', () => { + voiceSocket.emit('voice_join', { world_id: worldId, x: state.player.x, y: state.player.y, name: state.playerName || 'Игрок' }); + }); + + voiceSocket.on('voice_in', (payload) => { + // Воспроизводим входящий голос + const { data, meta, volume } = payload; + if (!audioCtx || audioCtx.state === 'closed') return; + + // Int16 → Float32 + const int16 = new Int16Array(data); + const float32 = new Float32Array(int16.length); + for (let i = 0; i < int16.length; i++) { + float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF) * volume; + } + + const buf = audioCtx.createBuffer(1, float32.length, 24000); + buf.getChannelData(0).set(float32); + const src = audioCtx.createBufferSource(); + src.buffer = buf; + + const gain = audioCtx.createGain(); + gain.gain.value = volume; + src.connect(gain).connect(audioCtx.destination); + src.start(); + + // Индикатор + speakingIndicator.style.display = 'block'; + speakingIndicator.textContent = `🔊 ${meta.name}`; + clearTimeout(speakingTimeout); + speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500); + }); + + voiceActive = true; + voiceBtn.textContent = '🎤'; + voiceBtn.style.background = '#2ecc71'; + } catch (e) { + console.error('Voice error:', e); + voiceBtn.style.background = '#e74c3c'; + } +}; + +export function initVoice() { + // Voice position update hook — should be called from main loop + let voicePosT = 0; + return { + update(dt) { + voicePosT += dt; + if (voicePosT > 0.5 && voiceSocket && voiceSocket.connected) { + voicePosT = 0; + voiceSocket.emit('voice_pos', { x: state.player.x, y: state.player.y }); + } + } + }; +} \ No newline at end of file diff --git a/src/physics/collision.js b/src/physics/collision.js new file mode 100644 index 0000000..4fd0f1b --- /dev/null +++ b/src/physics/collision.js @@ -0,0 +1,154 @@ +import { TILE } from '../core/constants.js'; +import { BLOCKS } from '../data/blocks.js'; +import { state } from '../core/state.js'; +import { getBlock, isSolid } from '../world/world-storage.js'; +import { calculateDamage } from '../entities/player.js'; + +function resolveY(e) { + // Всегда пересчитываем grounded (не держим "липким") + e.grounded = false; + + const x1 = e.x + 2; + const x2 = e.x + e.w - 2; + + // Проверяем, находится ли игрок на лестнице (по центру) + const cx = e.x + e.w / 2; + const cy = e.y + e.h / 2; + const gx = Math.floor(cx / TILE); + const gy = Math.floor(cy / TILE); + const b = getBlock(gx, gy); + const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable; + + // Если на лестнице - можно двигаться вверх/вниз + if (onLadder) { + e.grounded = true; + + // Если нажимаем прыжок на лестнице - поднимаемся + if (state.inp.jump) { + e.vy = -200; + } + // Если нажимаем вниз - спускаемся + else if (state.inp.down) { + e.vy = 100; + } + // Иначе - остаёмся на месте (нет гравитации) + else { + e.vy = 0; + } + return; + } + + // Проверяем, можно ли запрыгнуть на лестницу рядом (слева или справа) + const leftGX = Math.floor((e.x - 4) / TILE); + const rightGX = Math.floor((e.x + e.w + 4) / TILE); + const playerGY = Math.floor((e.y + e.h / 2) / TILE); + + const leftBlock = getBlock(leftGX, playerGY); + const rightBlock = getBlock(rightGX, playerGY); + const leftLadder = leftBlock && BLOCKS[leftBlock.t] && BLOCKS[leftBlock.t].climbable; + const rightLadder = rightBlock && BLOCKS[rightBlock.t] && BLOCKS[rightBlock.t].climbable; + + // Если рядом есть лестница и игрок прыгает - притягиваем к ней + if ((leftLadder || rightLadder) && state.inp.jump && e.vy < 0) { + // Перемещаем игрока к лестнице + if (leftLadder && e.x > leftGX * TILE + TILE / 2) { + e.x = leftGX * TILE + TILE / 2 - e.w / 2; + } else if (rightLadder && e.x < rightGX * TILE + TILE / 2) { + e.x = rightGX * TILE + TILE / 2 - e.w / 2; + } + e.grounded = true; + e.vy = -150; // меньший прыжок при запрыгивании на лестницу + return; + } + + // 1) Если движемся вниз ИЛИ стоим (vy >= 0) — проверяем опору под ногами + // Берём точку на 1px ниже стопы, чтобы не зависеть от ровного попадания в границу тайла. + if (e.vy >= 0) { + const probeY = e.y + e.h + 1; + const gy = Math.floor(probeY / TILE); + const gxA = Math.floor(x1 / TILE); + const gxB = Math.floor(x2 / TILE); + + if (isSolid(gxA, gy) || isSolid(gxB, gy)) { + e.y = gy * TILE - e.h; // прижимаем к полу + e.vy = 0; + e.grounded = true; + + // урон от падения — только игроку и только не в воде + if (e === state.player && !state.player.inWater) { + const fallTiles = (e.y - e.fallStartY) / TILE; + if (fallTiles > 6) { + const damage = calculateDamage((fallTiles - 6) * 10); + state.player.hp -= damage; + } + } + if (e === state.player) e.fallStartY = e.y; + } + } + + // 2) Проверка на возможность запрыгнуть на блок, если игрок рядом с ним + if (e.vy < 0 && e === state.player) { + const gy = Math.floor(e.y / TILE); + const gxA = Math.floor(x1 / TILE); + const gxB = Math.floor(x2 / TILE); + + // Проверяем, есть ли блок рядом с игроком + if ((isSolid(gxA, gy) || isSolid(gxB, gy)) && !isSolid(gxA, gy - 1) && !isSolid(gxB, gy - 1)) { + e.y = (gy + 1) * TILE; + e.vy = 0; + e.grounded = true; + if (e === state.player) e.fallStartY = e.y; + console.log("Jumped onto block!"); + } + } + + // 2) Если движемся вверх — проверяем потолок + if (e.vy < 0) { + const gy = Math.floor(e.y / TILE); + const gxA = Math.floor(x1 / TILE); + const gxB = Math.floor(x2 / TILE); + if (isSolid(gxA, gy) || isSolid(gxB, gy)) { + e.y = (gy + 1) * TILE; + e.vy = 0; + } + } +} + +function resolveX(e) { + const y1 = e.y + 2; + const y2 = e.y + e.h - 2; + + // Проверяем, находимся ли мы на лестнице + const cx = e.x + e.w / 2; + const cy = e.y + e.h / 2; + const gx = Math.floor(cx / TILE); + const gy = Math.floor(cy / TILE); + const b = getBlock(gx, gy); + const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable; + + if (e.vx > 0) { + const gx = Math.floor((e.x + e.w) / TILE); + const gyA = Math.floor(y1 / TILE); + const gyB = Math.floor(y2 / TILE); + const solidA = isSolid(gx, gyA); + const solidB = isSolid(gx, gyB); + + if (solidA || solidB) { + e.x = gx * TILE - e.w; + e.vx = 0; + } + } else if (e.vx < 0) { + const gx = Math.floor(e.x / TILE); + const gyA = Math.floor(y1 / TILE); + const gyB = Math.floor(y2 / TILE); + const solidA = isSolid(gx, gyA); + const solidB = isSolid(gx, gyB); + + if (solidA || solidB) { + e.x = (gx + 1) * TILE; + e.vx = 0; + } + } +} + +export { resolveY, resolveX }; \ No newline at end of file diff --git a/src/physics/water-detect.js b/src/physics/water-detect.js new file mode 100644 index 0000000..8b7b80d --- /dev/null +++ b/src/physics/water-detect.js @@ -0,0 +1,31 @@ +import { TILE } from '../core/constants.js'; +import { state } from '../core/state.js'; +import { getBlock } from '../world/world-storage.js'; +import { playSound } from '../audio/sound-engine.js'; + +function isWaterAt(px, py) { + const gx = Math.floor(px / TILE); + const gy = Math.floor(py / TILE); + const b = getBlock(gx, gy); + return !!(b && b.t === 'water'); +} + +function updateWaterFlag(e) { + const cx = e.x + e.w / 2; + const wasInWater = e.inWater; + + // В воде, если в воде хотя бы центр/ноги (чтобы корректно работать у поверхности) + const mid = isWaterAt(cx, e.y + e.h / 2); + const feet = isWaterAt(cx, e.y + e.h - 2); + e.inWater = mid || feet; + + // Голова под водой — для кислорода/урона + e.headInWater = isWaterAt(cx, e.y + 4); + + // Звук при падении в воду + if (e === state.player && !wasInWater && e.inWater && e.vy > 100) { + playSound('splash'); + } +} + +export { isWaterAt, updateWaterFlag }; \ No newline at end of file diff --git a/src/render/draw-fire.js b/src/render/draw-fire.js new file mode 100644 index 0000000..7c56e97 --- /dev/null +++ b/src/render/draw-fire.js @@ -0,0 +1,19 @@ +// Рисование костра: огонь поверх текстуры +export function drawFire(ctx, wx, wy, now) { + const baseX = wx; + const baseY = wy; + const flick = 6 + (Math.sin(now / 90) + 1) * 4; + ctx.fillStyle = 'rgba(255,140,0,0.85)'; + ctx.beginPath(); + ctx.moveTo(baseX + 10, baseY + 30); + ctx.lineTo(baseX + 20, baseY + 30 - flick); + ctx.lineTo(baseX + 30, baseY + 30); + ctx.fill(); + + ctx.fillStyle = 'rgba(255,230,150,0.75)'; + ctx.beginPath(); + ctx.moveTo(baseX + 14, baseY + 30); + ctx.lineTo(baseX + 20, baseY + 30 - (flick * 0.7)); + ctx.lineTo(baseX + 26, baseY + 30); + ctx.fill(); +} \ No newline at end of file diff --git a/src/render/lighting.js b/src/render/lighting.js new file mode 100644 index 0000000..7900450 --- /dev/null +++ b/src/render/lighting.js @@ -0,0 +1,105 @@ +// Lighting overlay at night (Minecraft-style: offscreen lightmap, wall occlusion, warm flicker) +import { state } from '../core/state.js'; +import { TILE } from '../core/constants.js'; +import { BLOCKS } from '../data/blocks.js'; +import { getBlock } from '../world/world-storage.js'; + +export function renderLighting(ctx, W, H, camX, camY, lightC, lightCtx, dpr, blocks, minGX, maxGX, minGY, maxGY, now) { + // 1) Рисуем тёмный оверлей на offscreen canvas + lightC.width = W * dpr; + lightC.height = H * dpr; + lightCtx.setTransform(dpr, 0, 0, dpr, 0, 0); + lightCtx.fillStyle = 'rgba(0,0,12,0.82)'; + lightCtx.fillRect(0, 0, W, H); + + // 2) Вырезаем «окна света» через destination-out — свет не проходит сквозь стены + lightCtx.globalCompositeOperation = 'destination-out'; + + // Функция: рисуем мягкий луч света с затуханием за стенами + function castLight(sx, sy, radius) { + const flick = 0.88 + Math.sin(now / 80 + sx * 0.01) * 0.06 + Math.sin(now / 130 + sy * 0.02) * 0.06; + const r = radius * flick; + // 12 лучей — достаточно для мягкого круга + const steps = 12; + // Собираем дистанции до стен по лучам + const dists = new Float32Array(steps); + for (let i = 0; i < steps; i++) { + const angle = (i / steps) * Math.PI * 2; + const dx = Math.cos(angle); + const dy = Math.sin(angle); + let maxDist = r; + // Идём по лучу пока не упрёмся в стену + for (let step = TILE * 0.5; step < r; step += TILE * 0.6) { + const gx = Math.floor((sx + dx * step) / TILE); + const gy = Math.floor((sy + dy * step) / TILE); + const blk = getBlock(gx, gy); + if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid && step > 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.5, 'rgba(255,255,255,0.65)'); + grad.addColorStop(1, 'rgba(255,255,255,0)'); + lightCtx.fillStyle = grad; + // Рисуем shape по dists (звездоподобный полигон) + lightCtx.beginPath(); + for (let i = 0; i <= steps; i++) { + const idx = i % steps; + const nextIdx = (i + 1) % steps; + const avgD = (dists[idx] + dists[nextIdx]) / 2; + const angle = (idx / steps) * Math.PI * 2; + const px = cx + Math.cos(angle) * dists[idx]; + const py = cy + Math.sin(angle) * dists[idx]; + if (i === 0) lightCtx.moveTo(px, py); + else lightCtx.lineTo(px, py); + } + 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.6 * flick; + const grad = ctx.createRadialGradient(wx, wy, 0, wx, wy, r); + grad.addColorStop(0, `rgba(255,180,80,${0.12 * flick})`); + grad.addColorStop(0.5, `rgba(255,140,40,${0.06 * flick})`); + grad.addColorStop(1, 'rgba(255,100,20,0)'); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(wx, wy, r, 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.restore(); +} \ No newline at end of file diff --git a/src/render/particles.js b/src/render/particles.js new file mode 100644 index 0000000..0b2e764 --- /dev/null +++ b/src/render/particles.js @@ -0,0 +1,14 @@ +// Частицы (взрыв) +export const parts = []; + +export function spawnExplosion(x, y, power) { + const n = Math.floor(16 + power * 10); + for (let i = 0; i < n; i++) { + parts.push({ + x, y, + vx: (Math.random() - 0.5) * (300 + power * 200), + vy: (Math.random() - 0.5) * (300 + power * 200), + t: 0.7, c: '#ffa500' + }); + } +} \ No newline at end of file diff --git a/src/render/textures.js b/src/render/textures.js new file mode 100644 index 0000000..1d0e575 --- /dev/null +++ b/src/render/textures.js @@ -0,0 +1,104 @@ +// Текстуры блоков (простые) +import { BLOCKS } from '../data/blocks.js'; + +export const tex = {}; + +export 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; +} + +export function initTextures() { + Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k)); +} \ No newline at end of file diff --git a/src/ui/chat.js b/src/ui/chat.js new file mode 100644 index 0000000..cd95fb0 --- /dev/null +++ b/src/ui/chat.js @@ -0,0 +1,67 @@ +// ==================== ЧАТ ==================== +import { state } from '../core/state.js'; +import { playSound } from '../audio/sound-engine.js'; + +const chatMessages = []; +const MAX_CHAT_MESSAGES = 20; + +export 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(); +} + +export 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; +} + +export function sendChatMessage(message) { + if (!message || message.trim() === '') return; + + if (state.isMultiplayer && state.socket && state.socket.connected) { + state.socket.emit('chat_message', { message: message.trim() }); + } else { + addChatMessage('Вы', message.trim()); + } +} + +export function initChat() { + document.getElementById('chatToggle').onclick = () => { + playSound('click'); + state.chatOpen = !state.chatOpen; + document.getElementById('chatPanel').style.display = state.chatOpen ? 'block' : 'none'; + if (state.chatOpen) { + document.getElementById('chatInput').focus(); + } + }; + + document.getElementById('chatClose').onclick = () => { + playSound('click'); + state.chatOpen = false; + document.getElementById('chatPanel').style.display = 'none'; + }; + + document.getElementById('chatSend').onclick = () => { + const input = document.getElementById('chatInput'); + sendChatMessage(input.value); + input.value = ''; + }; + + document.getElementById('chatInput').addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + sendChatMessage(e.target.value); + e.target.value = ''; + } + }); +} \ No newline at end of file diff --git a/src/ui/craft.js b/src/ui/craft.js new file mode 100644 index 0000000..deaa3db --- /dev/null +++ b/src/ui/craft.js @@ -0,0 +1,116 @@ +// Craft UI +import { state } from '../core/state.js'; +import { BLOCKS } from '../data/blocks.js'; +import { ITEMS } from '../data/items.js'; +import { TOOLS } from '../data/tools.js'; +import { RECIPES } from '../data/recipes.js'; +import { addTool } from '../data/tools.js'; +import { tex } from '../render/textures.js'; +import { playSound } from '../audio/sound-engine.js'; +import { rebuildHotbar } from './hotbar.js'; +import { renderInventory } from './inventory.js'; + +export function canCraft(r) { + const inv = state.inv; + console.log('[CRAFT] Checking recipe for:', r.out, '- cost:', r.cost); + for (const res in r.cost) { + const have = inv[res] || 0; + const need = r.cost[res]; + console.log('[CRAFT] Resource:', res, '- have:', have, '- need:', need, '- can craft:', have >= need); + if (have < need) return false; + } + return true; +} + +export function renderCraft() { + const inv = state.inv; + const recipesEl = state.recipesEl; + recipesEl.innerHTML = ''; + for (const r of RECIPES) { + const row = document.createElement('div'); + row.className = 'recipe'; + const icon = document.createElement('div'); + icon.className = 'ricon'; + // Иконка — блок, инструмент или предмет + if (tex[r.out]) { + icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`; + } else if (TOOLS[r.out]) { + icon.textContent = TOOLS[r.out].icon; + icon.style.fontSize = '24px'; + icon.style.display = 'flex'; + icon.style.alignItems = 'center'; + icon.style.justifyContent = 'center'; + } else if (ITEMS[r.out]) { + icon.textContent = ITEMS[r.out].icon; + icon.style.fontSize = '24px'; + icon.style.display = 'flex'; + icon.style.alignItems = 'center'; + icon.style.justifyContent = 'center'; + } + const info = document.createElement('div'); + info.className = 'rinfo'; + const nm = document.createElement('div'); + nm.className = 'rname'; + const itemName = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out; + nm.textContent = `${itemName} x${r.qty}`; + const cs = document.createElement('div'); + cs.className = 'rcost'; + cs.textContent = Object.keys(r.cost).map(x => { + const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x; + return `${cn}: ${(inv[x] || 0)}/${r.cost[x]}`; + }).join(' '); + info.appendChild(nm); info.appendChild(cs); + const btn = document.createElement('button'); + btn.className = 'rcraft'; + btn.textContent = 'Создать'; + btn.disabled = !canCraft(r); + btn.onclick = () => { + if (!canCraft(r)) return; + playSound('click'); + for (const res in r.cost) inv[res] -= r.cost[res]; + inv[r.out] = (inv[r.out] || 0) + r.qty; + if (TOOLS[r.out]) addTool(r.out); + rebuildHotbar(); + renderCraft(); + }; + row.appendChild(icon); row.appendChild(info); row.appendChild(btn); + recipesEl.appendChild(row); + } +} + +let craftOpen = false; +let inventoryOpen = false; + +export function toggleCraft() { + playSound('click'); // Звук клика по кнопке + craftOpen = !craftOpen; + state.craftPanel.style.display = craftOpen ? 'block' : 'none'; + if (craftOpen) { + renderCraft(); + // Закрываем инвентарь если открыт крафт + inventoryOpen = false; + state.inventoryPanel.style.display = 'none'; + } +} + +export function closeCraft() { + playSound('click'); // Звук клика по кнопке + craftOpen = false; + state.craftPanel.style.display = 'none'; +} + +export function toggleInventory() { + playSound('click'); // Звук клика по кнопке + inventoryOpen = true; + state.inventoryPanel.style.display = 'block'; + renderInventory(); + // Закрываем крафт если открыт инвентарь + craftOpen = false; + state.craftPanel.style.display = 'none'; +} + +export function closeInventory() { + playSound('click'); // Звук клика по кнопке + inventoryOpen = false; + state.inventoryPanel.style.display = 'none'; +} \ No newline at end of file diff --git a/src/ui/dom-refs.js b/src/ui/dom-refs.js new file mode 100644 index 0000000..f280ccb --- /dev/null +++ b/src/ui/dom-refs.js @@ -0,0 +1,14 @@ +// DOM-ссылки — все document.getElementById +export const hpEl = document.getElementById('hp'); +export const foodEl = document.getElementById('food'); +export const sxEl = document.getElementById('sx'); +export const syEl = document.getElementById('sy'); +export const todEl = document.getElementById('tod'); +export const worldIdEl = document.getElementById('worldId'); +export const playerCountEl = document.getElementById('playerCount'); +export const hotbarEl = document.getElementById('hotbar'); +export const craftPanel = document.getElementById('craftPanel'); +export const recipesEl = document.getElementById('recipes'); +export const deathEl = document.getElementById('death'); +export const inventoryPanel = document.getElementById('inventoryPanel'); +export const inventoryGrid = document.getElementById('inventoryGrid'); \ No newline at end of file diff --git a/src/ui/furnace.js b/src/ui/furnace.js new file mode 100644 index 0000000..8a4b87c --- /dev/null +++ b/src/ui/furnace.js @@ -0,0 +1,126 @@ +// ==================== ПЕЧЬ (ОБЖИГ) ==================== +import { state } from '../core/state.js'; +import { BLOCKS } from '../data/blocks.js'; +import { ITEMS } from '../data/items.js'; +import { SMELTING_RECIPES } from '../data/recipes.js'; +import { playSound } from '../audio/sound-engine.js'; +import { rebuildHotbar } from '../ui/hotbar.js'; +import { getBlock } from '../world/world-storage.js'; + +const furnacePanel = document.getElementById('furnacePanel'); +const furnaceContent = document.getElementById('furnaceContent'); + +export function initFurnace() { + document.getElementById('furnaceClose').onclick = () => { + furnacePanel.style.display = 'none'; + state.currentFurnaceKey = null; + }; +} + +export function openFurnaceUI(gx, gy) { + state.currentFurnaceKey = `${gx},${gy}`; + furnacePanel.style.display = 'block'; + renderFurnaceUI(); +} + +export function renderFurnaceUI() { + if (!state.currentFurnaceKey) return; + + // Проверяем что печь всё ещё существует + const [fgx, fgy] = state.currentFurnaceKey.split(',').map(Number); + const fb = getBlock(fgx, fgy); + if (!fb || fb.t !== 'furnace') { + furnacePanel.style.display = 'none'; + state.currentFurnaceKey = null; + return; + } + + // Текущий процесс обжига + const active = state.activeFurnaces.get(state.currentFurnaceKey); + + let html = '
'; + + // Доступные рецепты — показываем только те, для которых есть ресурсы + for (let i = 0; i < SMELTING_RECIPES.length; i++) { + const recipe = SMELTING_RECIPES[i]; + const haveCount = state.inv[recipe.in] || 0; + const canSmelt = haveCount >= recipe.qty; + + // Иконка результата + const outDef = BLOCKS[recipe.out]; + const outItem = ITEMS[recipe.out]; + const iconStr = outItem ? outItem.icon : (outDef ? '🧱' : '❓'); + const nameStr = outItem ? outItem.n : (outDef ? outDef.n : recipe.out); + const inItem = ITEMS[recipe.in]; + const inDef = BLOCKS[recipe.in]; + const inName = inItem ? inItem.n : (inDef ? inDef.n : recipe.in); + + html += `
`; + html += `
${iconStr}
`; + html += `
`; + html += `
${nameStr}
`; + html += `
${inName} x${recipe.qty} (есть: ${haveCount}) • ${recipe.time}с
`; + html += `
`; + html += ``; + html += `
`; + } + + // Текущий прогресс + if (active) { + const pct = Math.min(100, Math.floor((active.progress / active.recipe.time) * 100)); + html += `
`; + html += `
🔥 Обжиг: ${pct}%
`; + html += `
`; + html += `
`; + html += `
`; + } + + html += '
'; + furnaceContent.innerHTML = html; +} + +// Глобальная функция для кнопки обжига +window._smelt = (recipeIdx) => { + if (!state.currentFurnaceKey) return; + const recipe = SMELTING_RECIPES[recipeIdx]; + if ((state.inv[recipe.in] || 0) < recipe.qty) return; + + // Уже обжигаем в этой печи? + if (state.activeFurnaces.has(state.currentFurnaceKey)) return; + + // Забираем ресурсы + state.inv[recipe.in] -= recipe.qty; + + // Запускаем обжиг + state.activeFurnaces.set(state.currentFurnaceKey, { + recipe: recipe, + progress: 0 + }); + + playSound('fire'); + rebuildHotbar(); + renderFurnaceUI(); +}; + +// Тик печей — вызывается в главном цикле +export function tickFurnaces(dt) { + for (const [key, furnace] of state.activeFurnaces) { + furnace.progress += dt; + if (furnace.progress >= furnace.recipe.time) { + // Обжиг завершён — выдаём результат + const outItem = furnace.recipe.out; + if (ITEMS[outItem]) { + state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty; + } else if (BLOCKS[outItem]) { + state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty; + } + playSound('stone_build'); + state.activeFurnaces.delete(key); + + // Если эта печь открыта — обновляем UI + if (key === state.currentFurnaceKey) { + renderFurnaceUI(); + } + } + } +} \ No newline at end of file diff --git a/src/ui/hotbar.js b/src/ui/hotbar.js new file mode 100644 index 0000000..deccf0c --- /dev/null +++ b/src/ui/hotbar.js @@ -0,0 +1,81 @@ +// Hotbar UI +import { state } from '../core/state.js'; +import { BLOCKS } from '../data/blocks.js'; +import { ITEMS } from '../data/items.js'; +import { TOOLS } from '../data/tools.js'; +import { tex } from '../render/textures.js'; +import { playSound } from '../audio/sound-engine.js'; + +export function rebuildHotbar() { + const hotbarEl = state.hotbarEl; + const inv = state.inv; + const selected = state.selected; + const recentItems = state.recentItems; + const toolDurability = state.toolDurability; + + hotbarEl.innerHTML = ''; + + // Показываем последние 5 выбранных предметов (если они есть в инвентаре) + const items = recentItems.filter(id => inv[id] > 0).slice(0, 5); + + for (const id of items) { + const s = document.createElement('div'); + s.className = 'slot' + (id === selected ? ' sel' : ''); + if (BLOCKS[id]) { + s.style.backgroundImage = `url(${tex[id].toDataURL()})`; + s.style.backgroundSize = 'cover'; + } else if (ITEMS[id]) { + s.textContent = ITEMS[id].icon; + } else if (TOOLS[id]) { + s.textContent = TOOLS[id].icon; + } else if (id === 'iron_armor') { + s.textContent = '🛡️'; + s.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)'; + } + const c = document.createElement('div'); + c.className = 'count'; + c.textContent = inv[id]; + s.appendChild(c); + s.onclick = () => { + playSound('click'); // Звук клика по инвентарю + state.selected = id; + // Обновляем список последних предметов + state.recentItems = state.recentItems.filter(item => item !== id); // Удаляем если уже есть + state.recentItems.unshift(id); // Добавляем в начало + state.recentItems = state.recentItems.slice(0, 5); // Оставляем только 5 + rebuildHotbar(); + }; + + // Показываем индикатор надетой брони + if (id === 'iron_armor' && state.player.equippedArmor === 'iron_armor') { + const equipped = document.createElement('div'); + equipped.className = 'equipped-indicator'; + equipped.textContent = '✓'; + s.appendChild(equipped); + } + + // Durability bar для инструментов + if (TOOLS[id] && inv[id] > 0) { + // Находим текущую прочность + let curDur = 0, maxDur = TOOLS[id].durability; + for (const [tid, dur] of toolDurability) { + if (dur.type === id) { + curDur = dur.current; + maxDur = dur.max; + break; + } + } + if (maxDur > 0) { + const bar = document.createElement('div'); + bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;border-radius:0 0 6px 6px;overflow:hidden;`; + const fill = document.createElement('div'); + const pct = curDur / maxDur; + const color = pct > 0.5 ? '#2ecc71' : pct > 0.25 ? '#f39c12' : '#e74c3c'; + fill.style.cssText = `width:${pct * 100}%;height:100%;background:${color};`; + bar.appendChild(fill); + s.appendChild(bar); + } + } + hotbarEl.appendChild(s); + } +} \ No newline at end of file diff --git a/src/ui/inventory.js b/src/ui/inventory.js new file mode 100644 index 0000000..c7cc20c --- /dev/null +++ b/src/ui/inventory.js @@ -0,0 +1,76 @@ +// Inventory UI +import { state } from '../core/state.js'; +import { BLOCKS } from '../data/blocks.js'; +import { ITEMS } from '../data/items.js'; +import { TOOLS } from '../data/tools.js'; +import { tex } from '../render/textures.js'; +import { playSound } from '../audio/sound-engine.js'; +import { rebuildHotbar } from './hotbar.js'; + +export function renderInventory() { + const inventoryGrid = state.inventoryGrid; + const inv = state.inv; + const selected = state.selected; + inventoryGrid.innerHTML = ''; + + // Создаём сетку инвентаря 7x3 + const items = Object.keys(inv).filter(id => inv[id] > 0); + + // Добавляем пустые слоты для полной сетки + for (let i = 0; i < 21; i++) { + const slot = document.createElement('div'); + slot.className = 'inv-slot' + (i < items.length && items[i] === selected ? ' sel' : ''); + + if (i < items.length) { + const id = items[i]; + if (BLOCKS[id]) { + slot.style.backgroundImage = `url(${tex[id].toDataURL()})`; + slot.style.backgroundSize = 'cover'; + } else if (ITEMS[id]) { + slot.textContent = ITEMS[id].icon; + } else if (TOOLS[id]) { + slot.textContent = TOOLS[id].icon; + } else if (id === 'iron_armor') { + slot.textContent = '🛡️'; + slot.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)'; + } + + const count = document.createElement('div'); + count.className = 'inv-count'; + count.textContent = inv[id]; + slot.appendChild(count); + + slot.onclick = () => { + playSound('click'); // Звук клика по инвентарю + state.selected = id; + // Обновляем список последних предметов + state.recentItems = state.recentItems.filter(item => item !== id); // Удаляем если уже есть + state.recentItems.unshift(id); // Добавляем в начало + state.recentItems = state.recentItems.slice(0, 5); // Оставляем только 5 + rebuildHotbar(); + renderInventory(); + }; + + // Двойной клик для надевания брони + slot.ondblclick = () => { + if (id === 'iron_armor' && inv.iron_armor > 0) { + // Если уже надета броня - снимаем её + if (state.player.equippedArmor === 'iron_armor') { + state.player.equippedArmor = null; + state.player.armor = 0; + console.log('[ARMOR] Iron armor unequipped'); + } else { + // Надеваем броню + state.player.equippedArmor = 'iron_armor'; + state.player.armor = BLOCKS['iron_armor'].armor; + console.log('[ARMOR] Iron armor equipped - armor:', state.player.armor); + } + playSound('click'); + renderInventory(); + } + }; + } + + inventoryGrid.appendChild(slot); + } +} \ No newline at end of file diff --git a/src/ui/minimap.js b/src/ui/minimap.js new file mode 100644 index 0000000..7a21a92 --- /dev/null +++ b/src/ui/minimap.js @@ -0,0 +1,113 @@ +// ==================== МИНИКАРТА ==================== +import { state } from '../core/state.js'; +import { BLOCKS } from '../data/blocks.js'; +import { getBlock } from '../world/world-storage.js'; +import { playSound } from '../audio/sound-engine.js'; +import { isNight } from '../entities/mob-ai.js'; + +const minimapWrap = document.getElementById('minimapWrap'); +const minimapCanvas = document.getElementById('minimap'); +const minimapCtx = minimapCanvas.getContext('2d'); + +// Цвета блоков для миникарты (по 1 пикселю на блок) +const MINIMAP_COLORS = { + grass: '#4a8c2a', dirt: '#6b3410', stone: '#6a6a6a', sand: '#d4b84a', + gravel: '#888', clay: '#5a9ad4', wood: '#a63d00', planks: '#c46a10', + leaves: '#2a8a3a', glass: '#aaddee', water: '#2980b9', coal: '#1a1a2a', + copper_ore: '#a05535', iron_ore: '#b0b0b0', gold_ore: '#d4a017', + diamond_ore: '#0090d0', brick: '#8a2010', tnt: '#c02020', + campfire: '#d08020', torch: '#d0a020', bedrock: '#1a1a1a', + flower: '#d03050', bed: '#c03060', ladder: '#a04000', boat: '#6b3410' +}; + +export function initMinimap() { + document.getElementById('mapToggle').onclick = () => { + playSound('click'); + state.minimapOpen = !state.minimapOpen; + minimapWrap.style.display = state.minimapOpen ? 'block' : 'none'; + }; +} + +export function renderMinimap() { + if (!state.minimapOpen) return; + const mW = minimapCanvas.width; + const mH = minimapCanvas.height; + const scale = 2; // пикселей на блок + const TILE = state.TILE; + const player = state.player; + + // Область карты — центрирована на игроке + const pGX = Math.floor(player.x / TILE); + const pGY = Math.floor(player.y / TILE); + const viewW = Math.floor(mW / scale); + const viewH = Math.floor(mH / scale); + const startGX = pGX - Math.floor(viewW / 2); + const startGY = pGY - Math.floor(viewH / 2); + + // Очищаем + minimapCtx.fillStyle = isNight() ? '#070816' : '#87CEEB'; + minimapCtx.fillRect(0, 0, mW, mH); + + // Рисуем блоки + const imgData = minimapCtx.createImageData(mW, mH); + const data = imgData.data; + + for (let dx = 0; dx < viewW; dx++) { + for (let dy = 0; dy < viewH; dy++) { + const gx = startGX + dx; + const gy = startGY + dy; + const b = getBlock(gx, gy); + if (!b || b.dead || b.t === 'air') continue; + + const color = MINIMAP_COLORS[b.t]; + if (!color) continue; + + // Парсим hex цвет + const r = parseInt(color.slice(1, 3), 16); + const g = parseInt(color.slice(3, 5), 16); + const bl = parseInt(color.slice(5, 7), 16); + + // Заполняем scale x scale пикселей + for (let sx = 0; sx < scale; sx++) { + for (let sy = 0; sy < scale; sy++) { + const px = dx * scale + sx; + const py = dy * scale + sy; + if (px >= mW || py >= mH) continue; + const idx = (py * mW + px) * 4; + data[idx] = r; + data[idx + 1] = g; + data[idx + 2] = bl; + data[idx + 3] = 255; + } + } + } + } + + minimapCtx.putImageData(imgData, 0, 0); + + // Игрок — белый пиксель по центру + minimapCtx.fillStyle = '#fff'; + minimapCtx.fillRect(Math.floor(mW / 2) - 2, Math.floor(mH / 2) - 2, 4, 4); + + // Другие игроки — жёлтые точки + for (const [sid, p] of state.otherPlayers) { + const dx = Math.floor(p.x / TILE) - startGX; + const dy = Math.floor(p.y / TILE) - startGY; + if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) { + minimapCtx.fillStyle = '#f1c40f'; + minimapCtx.fillRect(dx * scale - 1, dy * scale - 1, 3, 3); + } + } + + // Мобы — красные (враждебные) / зелёные (животные) + const allMobsForMap = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs; + for (const m of allMobsForMap) { + const dx = Math.floor(m.x / TILE) - startGX; + const dy = Math.floor(m.y / TILE) - startGY; + if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) { + const hostile = m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton'; + minimapCtx.fillStyle = hostile ? '#e74c3c' : '#2ecc71'; + minimapCtx.fillRect(dx * scale, dy * scale, 2, 2); + } + } +} \ No newline at end of file diff --git a/src/ui/respawn.js b/src/ui/respawn.js new file mode 100644 index 0000000..17ab794 --- /dev/null +++ b/src/ui/respawn.js @@ -0,0 +1,56 @@ +// ==================== RESPAWN ==================== +import { state } from '../core/state.js'; +import { playSound } from '../audio/sound-engine.js'; +import { loadGame, applySave } from '../game/save.js'; + +export function initRespawn() { + document.getElementById('respawnBtn').onclick = async () => { + playSound('click'); // Звук клика по кнопке + + console.log('=== RESPAWN CLICKED ==='); + console.log('isMultiplayer:', state.isMultiplayer); + console.log('otherPlayers.size:', state.otherPlayers.size); + console.log('player.hp before respawn:', state.player.hp); + + const player = state.player; + const deathEl = state.deathEl; + + // В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке + if (state.isMultiplayer && state.otherPlayers.size > 0) { + console.log('Мультиплеер режим - возрождение в начальной точке'); + player.hp = 100; + player.hunger = 100; + player.o2 = 100; + player.vx = player.vy = 0; + player.invuln = 0; + player.x = state.spawnPoint.x; + player.y = state.spawnPoint.y; + player.fallStartY = player.y; + console.log('Возрождение в начальной точке, HP:', player.hp); + } else { + console.log('Одиночный режим - загружаем последнее сохранение'); + // Одиночный режим - загружаем последнее сохранение + const loadedSave = await loadGame(); + if (loadedSave) { + await applySave(loadedSave); + console.log('Загружено последнее сохранение после смерти, final HP:', player.hp); + } else { + // Если сохранения нет, возрождаемся в начальной точке + player.hp = 100; + player.hunger = 100; + player.o2 = 100; + player.vx = player.vy = 0; + player.invuln = 0; + player.x = state.spawnPoint.x; + player.y = state.spawnPoint.y; + player.fallStartY = player.y; + console.log('Возрождение в начальной точке, HP:', player.hp); + } + } + + console.log('player.hp after respawn logic:', player.hp); + console.log('Hiding death screen...'); + deathEl.style.display = 'none'; + console.log('=== RESPAWN END ==='); + }; +} \ No newline at end of file diff --git a/src/ui/save-controls.js b/src/ui/save-controls.js new file mode 100644 index 0000000..445a7c4 --- /dev/null +++ b/src/ui/save-controls.js @@ -0,0 +1,62 @@ +// ==================== КНОПКИ СОХРАНЕНИЯ / СБРОСА ==================== +import { state } from '../core/state.js'; +import { playSound } from '../audio/sound-engine.js'; +import { saveGame } from '../game/save.js'; + +export function initSaveControls() { + const saveBtn = document.getElementById('saveBtn'); + saveBtn.onclick = () => { + playSound('click'); + saveGame(); + alert('Игра сохранена!'); + }; + + const resetBtn = document.getElementById('resetBtn'); + resetBtn.onclick = () => { + if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) { + playSound('click'); + + // Удаляем сохранение из localStorage + try { + localStorage.removeItem(state.SAVE_KEY); + console.log('Сохранение удалено из localStorage'); + } catch (e) { + console.warn('Ошибка удаления сохранения:', e); + } + + // Сбрасываем in-memory сохранение + state.inMemorySave = null; + + // Генерируем новый worldId + state.worldId = Math.random().toString(36).substring(2, 10); + console.log('Новый worldId после сброса:', state.worldId); + + // Обновляем URL + try { + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('world', state.worldId); + const newUrlString = newUrl.toString(); + + if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') { + window.history.replaceState(null, '', newUrlString); + console.log('URL обновлён:', newUrlString); + } + } catch (e) { + console.error('Ошибка обновления URL:', e); + } + + // Перезагружаем страницу + location.reload(); + } + }; +} + +// Показываем кнопку сохранения только если играем одни +export function updateSaveButtonVisibility() { + const saveBtn = document.getElementById('saveBtn'); + if (state.isMultiplayer && state.otherPlayers.size > 0) { + saveBtn.style.display = 'none'; + } else { + saveBtn.style.display = 'flex'; + } +} \ No newline at end of file diff --git a/src/ui/share.js b/src/ui/share.js new file mode 100644 index 0000000..32d72a0 --- /dev/null +++ b/src/ui/share.js @@ -0,0 +1,38 @@ +// ==================== ПОДЕЛИТЬСЯ МИРОМ ==================== +import { state } from '../core/state.js'; + +export function initShare() { + // Обработчик клика на worldId для копирования ссылки + document.getElementById('worldId').onclick = () => { + const shareUrl = new URL(window.location.href); + shareUrl.searchParams.set('world', state.worldId); + const shareUrlString = shareUrl.toString(); + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(shareUrlString).then(() => { + alert('Ссылка скопирована!'); + }).catch(() => { + alert('Ссылка на мир:\n' + shareUrlString); + }); + } else { + alert('Ссылка на мир:\n' + shareUrlString); + } + }; +} + +export function shareWorld() { + const shareUrl = new URL(window.location.href); + shareUrl.searchParams.set('world', state.worldId); + const shareUrlString = shareUrl.toString(); + + // Копируем в буфер обмена + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(shareUrlString).then(() => { + alert('Ссылка скопирована!'); + }).catch(() => { + alert('Ссылка на мир:\n' + shareUrlString); + }); + } else { + alert('Ссылка на мир:\n' + shareUrlString); + } +} \ No newline at end of file diff --git a/src/world/generation.js b/src/world/generation.js new file mode 100644 index 0000000..8da9697 --- /dev/null +++ b/src/world/generation.js @@ -0,0 +1,126 @@ +import { TILE, SEA_GY, BEDROCK_GY, GEN_MARGIN_X } from '../core/constants.js'; +import { state } from '../core/state.js'; +import { grid, blocks, setBlock, getBlock, k } from './world-storage.js'; + +// Генерация (по X, на всю глубину до bedrock) +const generated = state.generated; // Set of gx already generated + +function surfaceGyAt(gx) { + // базовая поверхность выше уровня воды с вариациями + "горы" + // Используем seed для детерминированной генерации + // Увеличили амплитуду и добавили больше частот для разнообразия + const n1 = Math.sin(gx * 0.025 + state.worldSeed * 0.001) * 8; // крупные горы + const n2 = Math.sin(gx * 0.012 + state.worldSeed * 0.002) * 12; // средние горы + const n3 = Math.sin(gx * 0.006 + state.worldSeed * 0.003) * 6; // мелкие холмы + const n4 = Math.sin(gx * 0.045 + state.worldSeed * 0.004) * 4; // детали + const n5 = Math.cos(gx * 0.018 + state.worldSeed * 0.005) * 5; // дополнительные вариации + 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 < sgy; gy++) { + setBlock(gx, gy, 'water'); + } + // пляж + setBlock(gx, sgy, 'sand'); + } else { + // верхний блок: снег на высоких точках + if (sgy < SEA_GY - 10) setBlock(gx, sgy, 'stone'); + else setBlock(gx, sgy, 'grass'); + } + + // подповерхностные слои + for (let gy = sgy + 1; gy <= BEDROCK_GY; gy++) { + if (gy === BEDROCK_GY) { + setBlock(gx, gy, 'bedrock'); + continue; + } + + let t = 'stone'; + + // ближе к поверхности + if (gy <= sgy + 3) t = 'dirt'; + + // биомы/материалы + if (sgy > SEA_GY && gy === sgy + 1 && seededRandom(gx, gy) < 0.25) t = 'clay'; + if (gy > sgy + 6 && seededRandom(gx, gy) < 0.07) t = 'gravel'; + + // руды: чем глубже, тем интереснее + const depth = gy - sgy; + const r = seededRandom(gx, gy); + if (t === 'stone') { + if (r < 0.06) t = 'coal'; + else if (r < 0.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 state.serverOverrides) { + if (!key.startsWith(colPrefix)) continue; + if (ov.op === 'remove') { + const b = grid.get(key); + if (b) { grid.delete(key); b.dead = true; } + } else if (ov.op === 'set') { + if (!grid.has(key)) { + const gy = parseInt(key.split(',')[1]); + const nb = { gx, gy, t: ov.t, dead: false, active: false, fuse: 0 }; + grid.set(key, nb); + blocks.push(nb); + } + } + } +} + +// Перегенерация видимых чанков (используется при загрузке сохранения) +function regenerateVisibleChunks() { + const gx0 = Math.floor(state.camX / TILE); + for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) { + // Принудительно перегенерируем колонну + generated.delete(gx); + genColumn(gx); + } +} + +function ensureGenAroundCamera() { + const gx0 = Math.floor(state.camX / TILE); + for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) { + genColumn(gx); + } +} + +function seededRandom(gx, gy) { + const n = Math.sin(gx * 12.9898 + gy * 78.233 + state.worldSeed * 0.1) * 43758.5453; + return n - Math.floor(n); +} + +export { generated, surfaceGyAt, genColumn, regenerateVisibleChunks, ensureGenAroundCamera, seededRandom }; \ No newline at end of file diff --git a/src/world/tnt.js b/src/world/tnt.js new file mode 100644 index 0000000..11e145b --- /dev/null +++ b/src/world/tnt.js @@ -0,0 +1,80 @@ +import { TILE } from '../core/constants.js'; +import { BLOCKS } from '../data/blocks.js'; +import { state } from '../core/state.js'; +import { getBlock, removeBlock, k } from './world-storage.js'; +import { calculateDamage } from '../entities/player.js'; +import { playSound } from '../audio/sound-engine.js'; +import { spawnExplosion } from '../render/particles.js'; +import { rebuildHotbar } from '../ui/hotbar.js'; + +// TNT логика: цепь + усиление +function activateTNT(b, fuse = 3.2) { + if (b.dead) return; + if (b.active) return; + b.active = true; + b.fuse = fuse; + state.activeTNT.add(k(b.gx, b.gy)); +} + +function explodeAt(gx, gy) { + const center = getBlock(gx, gy); + if (!center) return; + + // усиление: считаем сколько TNT рядом (в радиусе 2) и активируем их «почти сразу» + let bonus = 0; + for (let x = gx - 2; x <= gx + 2; x++) { + for (let y = gy - 2; y <= gy + 2; y++) { + const b = getBlock(x, y); + if (b && !b.dead && b.t === 'tnt' && !(x === gx && y === gy)) { + bonus += 0.8; + activateTNT(b, 0.12); // цепь + } + } + } + + const power = 1 + bonus; // условная мощность + const radius = 3.2 + bonus * 0.7; // радиус разрушения в тайлах + const dmgR = 150 + bonus * 60; // радиус урона в пикселях + + removeBlock(gx, gy); + state.activeTNT.delete(k(gx, gy)); + playSound('explode1'); // Звук взрыва + spawnExplosion(gx * TILE + TILE / 2, gy * TILE + TILE / 2, power); + + for (let x = Math.floor(gx - radius); x <= Math.ceil(gx + radius); x++) { + for (let y = Math.floor(gy - radius); y <= Math.ceil(gy + radius); y++) { + const d = Math.hypot(x - gx, y - gy); + if (d > radius) continue; + const b = getBlock(x, y); + if (!b || b.dead) continue; + if (BLOCKS[b.t].fluid) continue; + if (BLOCKS[b.t].unbreakable) continue; + if (b.t === 'tnt') { activateTNT(b, 0.12); continue; } + removeBlock(x, y); + if (state.inv[b.t] !== undefined && Math.random() < 0.20) state.inv[b.t]++; // немного дропа + } + } + rebuildHotbar(); + + // урон + const hurt = (e) => { + const dx = (e.x + e.w / 2) - (gx * TILE + TILE / 2); + const dy = (e.y + e.h / 2) - (gy * TILE + TILE / 2); + const dist = Math.hypot(dx, dy); + if (dist < dmgR) { + const dmg = (dmgR - dist) * 0.06 * power; + if (e === state.player) { + const actualDamage = calculateDamage(dmg); + state.player.hp -= actualDamage; + } else { + e.hp -= dmg; + } + e.vx += (dx / dist || 0) * 600; + e.vy -= 320; + } + }; + hurt(state.player); + state.mobs.forEach(hurt); +} + +export { activateTNT, explodeAt }; \ No newline at end of file diff --git a/src/world/water.js b/src/world/water.js new file mode 100644 index 0000000..efb6627 --- /dev/null +++ b/src/world/water.js @@ -0,0 +1,126 @@ +import { TILE, SEA_GY } from '../core/constants.js'; +import { state } from '../core/state.js'; +import { W, H } from '../core/canvas.js'; +import { grid, blocks, k, isSolid } from './world-storage.js'; + +// Физика жидкости +const waterUpdateQueue = new Set(); +let waterUpdateTimer = 0; +const WATER_UPDATE_INTERVAL = 0.05; // Обновляем воду каждые 0.05 секунды + +function updateWaterPhysics(dt) { + waterUpdateTimer += dt; + if (waterUpdateTimer < WATER_UPDATE_INTERVAL) return; + waterUpdateTimer = 0; + + // Ограничиваем количество водных блоков для обработки (оптимизация) + const MAX_WATER_BLOCKS_PER_UPDATE = 50; + let processedCount = 0; + + // Собираем только видимые водные блоки в очередь (оптимизация) + waterUpdateQueue.clear(); + const minGX = Math.floor(state.camX / TILE) - 10; + const maxGX = Math.floor((state.camX + W) / TILE) + 10; + const minGY = Math.floor(state.camY / TILE) - 10; + const maxGY = Math.floor((state.camY + H) / TILE) + 10; + + for (const b of blocks) { + if (processedCount >= MAX_WATER_BLOCKS_PER_UPDATE) break; + if (!b.dead && b.t === 'water' && + b.gx >= minGX && b.gx <= maxGX && + b.gy >= minGY && b.gy <= maxGY) { + waterUpdateQueue.add(k(b.gx, b.gy)); + processedCount++; + } + } + + // Обновляем воду с ограничением глубины распространения + const processed = new Set(); + const toAdd = []; + const MAX_WATER_DEPTH = 20; // Максимальная глубина распространения воды + + for (const key of waterUpdateQueue) { + if (processed.has(key)) continue; + const b = grid.get(key); + if (!b || b.dead) continue; + processed.add(key); + + const gx = b.gx; + const gy = b.gy; + + // Проверяем глубину - не распространяем воду слишком глубоко + if (gy > SEA_GY + MAX_WATER_DEPTH) continue; + + // Проверяем, можно ли воде упасть вниз + const belowKey = k(gx, gy + 1); + const below = grid.get(belowKey); + + // Внизу пусто - вода создаёт новый блок внизу (но не удаляется сверху) + if (!below || below.dead) { + // Ограничиваем создание новых водных блоков + if (toAdd.length < 20) { // Максимум 20 новых блоков за обновление + toAdd.push({ gx, gy: gy + 1, t: 'water' }); + processed.add(belowKey); + } + continue; + } + + // Если внизу не вода и не твёрдый блок - вода может течь вниз + if (!isSolid(gx, gy + 1) && below && below.t !== 'water') { + if (toAdd.length < 20) { + toAdd.push({ gx, gy: gy + 1, t: 'water' }); + processed.add(belowKey); + } + continue; + } + + // Если внизу твёрдый блок или вода - вода растекается горизонтально + // Проверяем левую сторону + const leftKey = k(gx - 1, gy); + const left = grid.get(leftKey); + if (!left || left.dead) { + if (toAdd.length < 20) { + toAdd.push({ gx: gx - 1, gy, t: 'water' }); + processed.add(leftKey); + } + continue; + } + + // Проверяем правую сторону + const rightKey = k(gx + 1, gy); + const right = grid.get(rightKey); + if (!right || right.dead) { + if (toAdd.length < 20) { + toAdd.push({ gx: gx + 1, gy, t: 'water' }); + processed.add(rightKey); + } + continue; + } + } + + // Применяем изменения (только добавляем новые блоки) + for (const newData of toAdd) { + const key = k(newData.gx, newData.gy); + if (!grid.has(key)) { + const b = { + gx: newData.gx, + gy: newData.gy, + t: newData.t, + dead: false, + active: false, + fuse: 0 + }; + grid.set(key, b); + blocks.push(b); + } + } + + // Очищаем мёртвые блоки из массива + for (let i = blocks.length - 1; i >= 0; i--) { + if (blocks[i].dead) { + blocks.splice(i, 1); + } + } +} + +export { updateWaterPhysics }; \ No newline at end of file diff --git a/src/world/weather.js b/src/world/weather.js new file mode 100644 index 0000000..a140737 --- /dev/null +++ b/src/world/weather.js @@ -0,0 +1,59 @@ +// ==================== ПОГОДА ==================== +import { state } from '../core/state.js'; +import { isNight } from '../entities/mob-ai.js'; + +export function updateWeather(dt) { + state.weatherTimer += dt; + if (state.weatherTimer >= state.weatherChangeInterval) { + state.weatherTimer = 0; + state.weatherChangeInterval = 60 + Math.random() * 120; + // Выбираем новую погоду: 40% дождь днём, 25% дождь ночью, остальное ясно + const nightChance = isNight() ? 0.25 : 0.40; + state.isRaining = Math.random() < nightChance; + } + // Плавная интерполяция интенсивности + const target = state.isRaining ? (0.4 + Math.random() * 0.01) : 0; + state.rainIntensity += (target - state.rainIntensity) * dt * 0.5; + if (state.rainIntensity < 0.01) state.rainIntensity = 0; +} + +export function updateRain(dt) { + if (!state.isRaining || state.rainIntensity < 0.01) { + state.raindrops.length = 0; + return; + } + // Спавн капель + const spawnRate = Math.floor(state.rainIntensity * 60 * dt); // ~60 капель/сек при интенсити=1 + for (let i = 0; i < spawnRate && state.raindrops.length < state.MAX_RAINDROPS; i++) { + state.raindrops.push({ + x: state.camX + Math.random() * state.W, + y: state.camY - 20, + vy: 400 + Math.random() * 200, + len: 8 + Math.random() * 12 + }); + } + // Обновление + for (let i = state.raindrops.length - 1; i >= 0; i--) { + const d = state.raindrops[i]; + d.y += d.vy * dt; + d.x -= 30 * dt; // лёгкий ветер + if (d.y > state.camY + state.H + 20) { + state.raindrops.splice(i, 1); + } + } +} + +export function drawRain() { + if (state.raindrops.length === 0) return; + const ctx = state.ctx; + ctx.save(); + ctx.strokeStyle = 'rgba(174,194,224,0.5)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + for (const d of state.raindrops) { + ctx.moveTo(d.x, d.y); + ctx.lineTo(d.x - 3, d.y + d.len); + } + ctx.stroke(); + ctx.restore(); +} \ No newline at end of file diff --git a/src/world/world-storage.js b/src/world/world-storage.js new file mode 100644 index 0000000..fb249cc --- /dev/null +++ b/src/world/world-storage.js @@ -0,0 +1,57 @@ +import { BLOCKS } from '../data/blocks.js'; +import { state } from '../core/state.js'; + +// Мир-хранилище +const grid = new Map(); // key "gx,gy" => {gx,gy,t, ...} +const blocks = []; // для рендера/перебора видимых + +function k(gx, gy) { return gx + ',' + gy; } + +function getBlock(gx, gy) { return grid.get(k(gx, gy)); } + +function hasBlock(gx, gy) { return grid.has(k(gx, gy)); } + +function isSolid(gx, gy) { + const b = getBlock(gx, gy); + if (!b || b.dead) return false; + const def = BLOCKS[b.t]; + return !!def.solid && !def.fluid && !def.decor; +} + +function setBlock(gx, gy, t, isPlayerPlaced = false) { + const key = k(gx, gy); + if (grid.has(key)) return false; + const b = { gx, gy, t, dead: false, active: false, fuse: 0 }; + grid.set(key, b); + blocks.push(b); + + // Отслеживаем блоки, установленные игроком + if (isPlayerPlaced) { + state.placedBlocks.push({ gx, gy, t }); + } + + return true; +} + +function removeBlock(gx, gy) { + const key = k(gx, gy); + const b = grid.get(key); + if (!b) return null; + if (BLOCKS[b.t].unbreakable) return null; + grid.delete(key); + b.dead = true; + + // Отслеживаем удалённые блоки + const wasPlayerPlaced = state.placedBlocks.some(pb => pb.gx === gx && pb.gy === gy); + if (wasPlayerPlaced) { + // Удаляем из placedBlocks + state.placedBlocks = state.placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy)); + } else { + // Это природный блок - добавляем в removedBlocks + state.removedBlocks.push({ gx, gy }); + } + + return b; +} + +export { grid, blocks, k, getBlock, hasBlock, isSolid, setBlock, removeBlock }; \ No newline at end of file From d3e2ebca787e983f151e2bea84907f6b6c1d89e0 Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 12:10:16 +0000 Subject: [PATCH 02/15] fix: replace ScriptProcessor with MediaRecorder for voice chat --- game.js | 99 ++++++++++++++++++++++++++++++------------------------ index.html | 2 +- 2 files changed, 56 insertions(+), 45 deletions(-) diff --git a/game.js b/game.js index d4a42b0..2a4eb80 100644 --- a/game.js +++ b/game.js @@ -1245,7 +1245,7 @@ let voiceSocket = null; let voiceStream = null; let audioCtx = null; - let voiceProcessor = null; + let voiceRecorder = null; let voiceActive = false; const VOICE_SERVER = 'https://voicegrech.mkn8n.ru'; @@ -1269,11 +1269,11 @@ voiceActive = false; voiceBtn.innerHTML = '🎤/'; voiceBtn.style.background = '#555'; + if (voiceRecorder) { voiceRecorder.stop(); voiceRecorder = null; } 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; @@ -1281,30 +1281,8 @@ // Включить try { - voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 24000 } }); + voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); audioCtx = new AudioContext({ sampleRate: 24000 }); - const source = audioCtx.createMediaStreamSource(voiceStream); - voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1); - - voiceProcessor.onaudioprocess = (e) => { - if (!voiceActive || !voiceSocket || !voiceSocket.connected) return; - const pcm = e.inputBuffer.getChannelData(0); - // Конвертируем float32 → int16 для экономии трафика - const int16 = new Int16Array(pcm.length); - for (let i = 0; i < pcm.length; i++) { - const s = Math.max(-1, Math.min(1, pcm[i])); - int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; - } - voiceSocket.emit('voice_data', int16.buffer); - }; - - source.connect(voiceProcessor); - // ScriptProcessor MUST connect to destination to fire onaudioprocess events - // Use a zero-gain node to silence own playback while keeping the processor active - const silentGain = audioCtx.createGain(); - silentGain.gain.value = 0; // mute — don't hear ourselves - voiceProcessor.connect(silentGain); - silentGain.connect(audioCtx.destination); // Подключаемся к голосовому серверу voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] }); @@ -1312,35 +1290,68 @@ voiceSocket.emit('voice_join', { world_id: worldId, x: player.x, y: player.y, name: playerName || 'Игрок' }); }); - voiceSocket.on('voice_in', (payload) => { - // Воспроизводим входящий голос + voiceSocket.on('voice_in', async (payload) => { + // Воспроизводим входящий голос (WebM/Opus or raw PCM) const { data, meta, volume } = payload; - if (!audioCtx || audioCtx.state === 'closed') return; - - // Int16 → Float32 - const int16 = new Int16Array(data); - const float32 = new Float32Array(int16.length); - for (let i = 0; i < int16.length; i++) { - float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF) * volume; + if (!audioCtx || audioCtx.state === 'closed') { + audioCtx = new AudioContext({ sampleRate: 24000 }); } + if (audioCtx.state === 'suspended') await audioCtx.resume(); - const buf = audioCtx.createBuffer(1, float32.length, 24000); - buf.getChannelData(0).set(float32); - const src = audioCtx.createBufferSource(); - src.buffer = buf; - - const gain = audioCtx.createGain(); - gain.gain.value = volume; - src.connect(gain).connect(audioCtx.destination); - src.start(); + try { + // Try WebM/Opus decode first (if server relayed MediaRecorder output) + const blob = new Blob([data], { type: 'audio/webm; codecs=opus' }); + const arrayBuf = await blob.arrayBuffer(); + const decoded = await audioCtx.decodeAudioData(arrayBuf); + const src = audioCtx.createBufferSource(); + src.buffer = decoded; + const gain = audioCtx.createGain(); + gain.gain.value = volume || 1; + src.connect(gain).connect(audioCtx.destination); + src.start(); + } catch(_) { + // Fallback: raw PCM int16 → float32 + try { + 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); + } + const buf = audioCtx.createBuffer(1, float32.length, 24000); + buf.getChannelData(0).set(float32); + const src = audioCtx.createBufferSource(); + src.buffer = buf; + const gain = audioCtx.createGain(); + gain.gain.value = volume || 1; + src.connect(gain).connect(audioCtx.destination); + src.start(); + } catch(e2) { /* ignore decode errors */ } + } // Индикатор speakingIndicator.style.display = 'block'; - speakingIndicator.textContent = `🔊 ${meta.name}`; + speakingIndicator.textContent = '🔊 ' + (meta.name || '???'); clearTimeout(speakingTimeout); speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500); }); + // MediaRecorder — moderne API, works in all browsers without ScriptProcessor + const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') + ? 'audio/webm;codecs=opus' : 'audio/webm'; + voiceRecorder = new MediaRecorder(voiceStream, { + mimeType, + audioBitsPerSecond: 16000 + }); + voiceRecorder.ondataavailable = (e) => { + if (!voiceActive || !voiceSocket || !voiceSocket.connected) return; + if (e.data.size > 0) { + e.data.arrayBuffer().then(buf => { + voiceSocket.emit('voice_data', buf); + }); + } + }; + voiceRecorder.start(100); // 100ms chunks for low latency + voiceActive = true; voiceBtn.textContent = '🎤'; voiceBtn.style.background = '#2ecc71'; diff --git a/index.html b/index.html index 9a6d3e8..5a090a1 100644 --- a/index.html +++ b/index.html @@ -92,6 +92,6 @@ - + From cc46b93e96f5754c7f562a0683351437d70158f6 Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 12:13:43 +0000 Subject: [PATCH 03/15] fix: replace MediaRecorder with AudioWorklet (Blob URL) for voice capture --- game.js | 102 +++++++++++++++++++++++++---------------------------- index.html | 2 +- 2 files changed, 50 insertions(+), 54 deletions(-) diff --git a/game.js b/game.js index 2a4eb80..b73196c 100644 --- a/game.js +++ b/game.js @@ -1245,10 +1245,32 @@ let voiceSocket = null; let voiceStream = null; let audioCtx = null; - let voiceRecorder = null; + let voiceWorklet = null; let voiceActive = false; const VOICE_SERVER = 'https://voicegrech.mkn8n.ru'; + // AudioWorklet processor as Blob URL — no separate file needed + const workletCode = ` + class VoiceProcessor extends AudioWorkletProcessor { + process(inputs) { + const ch = inputs[0]; + if (ch && ch[0]) { + const pcm = ch[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; + } + this.port.postMessage(int16.buffer, [int16.buffer]); + } + return true; + } + } + registerProcessor('voice-pcm', VoiceProcessor); + `; + const workletBlob = new Blob([workletCode], { type: 'application/javascript' }); + const workletUrl = URL.createObjectURL(workletBlob); + // Кнопка микрофона const voiceBtn = document.createElement('div'); voiceBtn.innerHTML = '🎤/'; @@ -1269,7 +1291,7 @@ voiceActive = false; voiceBtn.innerHTML = '🎤/'; voiceBtn.style.background = '#555'; - if (voiceRecorder) { voiceRecorder.stop(); voiceRecorder = null; } + if (voiceWorklet) { voiceWorklet.disconnect(); voiceWorklet = null; } if (voiceStream) { voiceStream.getTracks().forEach(t => t.stop()); voiceStream = null; @@ -1283,6 +1305,16 @@ try { voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); audioCtx = new AudioContext({ sampleRate: 24000 }); + if (audioCtx.state === 'suspended') await audioCtx.resume(); + + await audioCtx.audioWorklet.addModule(workletUrl); + const source = audioCtx.createMediaStreamSource(voiceStream); + voiceWorklet = new AudioWorkletNode(audioCtx, 'voice-pcm'); + voiceWorklet.port.onmessage = (e) => { + if (!voiceActive || !voiceSocket || !voiceSocket.connected) return; + voiceSocket.emit('voice_data', e.data); + }; + source.connect(voiceWorklet); // Подключаемся к голосовому серверу voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] }); @@ -1290,43 +1322,24 @@ voiceSocket.emit('voice_join', { world_id: worldId, x: player.x, y: player.y, name: playerName || 'Игрок' }); }); - voiceSocket.on('voice_in', async (payload) => { - // Воспроизводим входящий голос (WebM/Opus or raw PCM) + voiceSocket.on('voice_in', (payload) => { + // Воспроизводим входящий голос — raw PCM int16 const { data, meta, volume } = payload; - if (!audioCtx || audioCtx.state === 'closed') { - audioCtx = new AudioContext({ sampleRate: 24000 }); - } - if (audioCtx.state === 'suspended') await audioCtx.resume(); + if (!audioCtx || audioCtx.state === 'closed') return; - try { - // Try WebM/Opus decode first (if server relayed MediaRecorder output) - const blob = new Blob([data], { type: 'audio/webm; codecs=opus' }); - const arrayBuf = await blob.arrayBuffer(); - const decoded = await audioCtx.decodeAudioData(arrayBuf); - const src = audioCtx.createBufferSource(); - src.buffer = decoded; - const gain = audioCtx.createGain(); - gain.gain.value = volume || 1; - src.connect(gain).connect(audioCtx.destination); - src.start(); - } catch(_) { - // Fallback: raw PCM int16 → float32 - try { - 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); - } - const buf = audioCtx.createBuffer(1, float32.length, 24000); - buf.getChannelData(0).set(float32); - const src = audioCtx.createBufferSource(); - src.buffer = buf; - const gain = audioCtx.createGain(); - gain.gain.value = volume || 1; - src.connect(gain).connect(audioCtx.destination); - src.start(); - } catch(e2) { /* ignore decode errors */ } + const int16 = new Int16Array(data); + const float32 = new Float32Array(int16.length); + for (let i = 0; i < int16.length; i++) { + float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF) * (volume || 1); } + 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 = 1; + src.connect(gain).connect(audioCtx.destination); + src.start(); // Индикатор speakingIndicator.style.display = 'block'; @@ -1335,23 +1348,6 @@ speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500); }); - // MediaRecorder — moderne API, works in all browsers without ScriptProcessor - const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') - ? 'audio/webm;codecs=opus' : 'audio/webm'; - voiceRecorder = new MediaRecorder(voiceStream, { - mimeType, - audioBitsPerSecond: 16000 - }); - voiceRecorder.ondataavailable = (e) => { - if (!voiceActive || !voiceSocket || !voiceSocket.connected) return; - if (e.data.size > 0) { - e.data.arrayBuffer().then(buf => { - voiceSocket.emit('voice_data', buf); - }); - } - }; - voiceRecorder.start(100); // 100ms chunks for low latency - voiceActive = true; voiceBtn.textContent = '🎤'; voiceBtn.style.background = '#2ecc71'; diff --git a/index.html b/index.html index 5a090a1..01c2bce 100644 --- a/index.html +++ b/index.html @@ -92,6 +92,6 @@ - + From 6b6125ae81efb065ba9869b094c498b321decd3e Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 12:51:45 +0000 Subject: [PATCH 04/15] feat: voice mode switcher (near/world) + mob fixes --- game.js | 97 ++++++++++++++++++++++++++++++++----------------- index.html | 2 +- voice-test.html | 70 +++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 34 deletions(-) create mode 100644 voice-test.html diff --git a/game.js b/game.js index b73196c..a3e37ce 100644 --- a/game.js +++ b/game.js @@ -1245,39 +1245,42 @@ let voiceSocket = null; let voiceStream = null; let audioCtx = null; - let voiceWorklet = null; + let voiceProcessor = null; let voiceActive = false; + let voiceMode = 'near'; // 'near' or 'world' + let voiceDebugCount = 0; const VOICE_SERVER = 'https://voicegrech.mkn8n.ru'; - // AudioWorklet processor as Blob URL — no separate file needed - const workletCode = ` - class VoiceProcessor extends AudioWorkletProcessor { - process(inputs) { - const ch = inputs[0]; - if (ch && ch[0]) { - const pcm = ch[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; - } - this.port.postMessage(int16.buffer, [int16.buffer]); - } - return true; - } - } - registerProcessor('voice-pcm', VoiceProcessor); - `; - const workletBlob = new Blob([workletCode], { type: 'application/javascript' }); - const workletUrl = URL.createObjectURL(workletBlob); - // Кнопка микрофона 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;'; + voiceBtn.style.cssText = 'position:absolute;top:74px;right:170px;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:112px;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;'; @@ -1291,11 +1294,11 @@ voiceActive = false; voiceBtn.innerHTML = '🎤/'; voiceBtn.style.background = '#555'; - if (voiceWorklet) { voiceWorklet.disconnect(); voiceWorklet = null; } 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; @@ -1306,26 +1309,53 @@ 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); - await audioCtx.audioWorklet.addModule(workletUrl); const source = audioCtx.createMediaStreamSource(voiceStream); - voiceWorklet = new AudioWorkletNode(audioCtx, 'voice-pcm'); - voiceWorklet.port.onmessage = (e) => { - if (!voiceActive || !voiceSocket || !voiceSocket.connected) return; - voiceSocket.emit('voice_data', e.data); + voiceProcessor = audioCtx.createScriptProcessor(2048, 1, 1); + console.log('[voice] ScriptProcessor created, bufferSize=2048'); + + 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); }; - source.connect(voiceWorklet); + + // 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', () => { - voiceSocket.emit('voice_join', { world_id: worldId, x: player.x, y: player.y, name: playerName || 'Игрок' }); + 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); }); voiceSocket.on('voice_in', (payload) => { // Воспроизводим входящий голос — raw PCM int16 const { data, meta, volume } = payload; if (!audioCtx || audioCtx.state === 'closed') return; + console.log('[voice] voice_in from', meta.name, 'volume:', volume, 'bytes:', data.byteLength); const int16 = new Int16Array(data); const float32 = new Float32Array(int16.length); @@ -1351,8 +1381,9 @@ voiceActive = true; voiceBtn.textContent = '🎤'; voiceBtn.style.background = '#2ecc71'; + console.log('[voice] Voice chat ACTIVE'); } catch(e) { - console.error('Voice error:', e); + console.error('[voice] Error:', e); voiceBtn.style.background = '#e74c3c'; } }; diff --git a/index.html b/index.html index 01c2bce..cffd717 100644 --- a/index.html +++ b/index.html @@ -92,6 +92,6 @@ - + diff --git a/voice-test.html b/voice-test.html new file mode 100644 index 0000000..887203d --- /dev/null +++ b/voice-test.html @@ -0,0 +1,70 @@ + +Voice Test +

Voice Capture Test

+ +
+ + + From 5774a4176115976ee823f253041c559a2634a40b Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 13:07:06 +0000 Subject: [PATCH 05/15] fix: voice btns row2 align, custom modals, panel-header css, close btn fix --- game.js | 53 ++++++++++++++++++++++++++++++++++++++++++++++++----- index.html | 4 ++-- style.css | 13 +++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/game.js b/game.js index a3e37ce..1b5c8ae 100644 --- a/game.js +++ b/game.js @@ -1,5 +1,48 @@ (() => { // ==================== КОНФИГУРАЦИЯ СЕРВЕРА ==================== +// === 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'; @@ -1255,14 +1298,14 @@ const voiceBtn = document.createElement('div'); voiceBtn.innerHTML = '🎤/'; voiceBtn.title = 'Голосовой чат (выкл)'; - voiceBtn.style.cssText = 'position:absolute;top:74px;right:170px;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;'; + 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:112px;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;'; + 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') { @@ -1640,13 +1683,13 @@ saveBtn.onclick = () => { playSound('click'); saveGame(); - alert('Игра сохранена!'); + customAlert('Игра сохранена!'); }; // Кнопка сброса игры (удаление сохранения и создание нового мира) const resetBtn = document.getElementById('resetBtn'); resetBtn.onclick = () => { - if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) { + customConfirm('Вы уверены, что хотите удалить сохранение и начать новую игру?', () => { playSound('click'); // Удаляем сохранение из localStorage @@ -1680,7 +1723,7 @@ // Перезагружаем страницу location.reload(); - } + }); }; // Показываем кнопку сохранения только если играем одни diff --git a/index.html b/index.html index cffd717..fae2b2f 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ GrechkaCraft: Multiplayer - + @@ -92,6 +92,6 @@ - + diff --git a/style.css b/style.css index 09f40eb..54d8a5f 100644 --- a/style.css +++ b/style.css @@ -91,3 +91,16 @@ body.touch-device #hotbar { #death { display:none; position:absolute; inset:0; background: rgba(60,0,0,0.88); z-index:200; color:#fff; pointer-events:auto; align-items:center; justify-content:center; flex-direction:column; gap:12px; } #death button { padding:12px 18px; font-size:18px; font-weight:900; border:none; border-radius:12px; cursor:pointer; } + +/* Panel header + close button fix */ +.panel-header { display:flex; justify-content:space-between; align-items:center; color:#fff; font-weight:900; font-size:18px; margin-bottom:12px; padding-bottom:8px; border-bottom:1px solid rgba(255,255,255,0.15); } +.panel-header .close { background:#c0392b; border:none; color:#fff; font-weight:900; padding:8px 12px; border-radius:10px; cursor:pointer; font-size:16px; min-width:36px; text-align:center; flex-shrink:0; margin-left:12px; } + +/* Custom modal (alerts/confirms) */ +.custom-modal-overlay { position:absolute; inset:0; background:rgba(0,0,0,0.7); z-index:9999; display:flex; align-items:center; justify-content:center; } +.custom-modal-box { background:#1a1a2e; border:2px solid #e74c3c; border-radius:16px; padding:24px 32px; color:#fff; font-size:16px; font-weight:700; text-align:center; max-width:320px; box-shadow:0 8px 32px rgba(0,0,0,0.5); } +.custom-modal-box .modal-btns { display:flex; gap:10px; justify-content:center; margin-top:16px; } +.custom-modal-box button { font-weight:900; padding:10px 20px; border-radius:10px; font-size:15px; cursor:pointer; border:none; color:#fff; } +.custom-modal-box .btn-yes { background:#e74c3c; } +.custom-modal-box .btn-no { background:#555; } +.custom-modal-box .btn-ok { background:#2ecc71; } From a59c84535a2b7220477e2c275aede89865d07670 Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 13:10:55 +0000 Subject: [PATCH 06/15] fix: brighter torch/campfire light, 24 rays, wider radius, warmer glow --- game.js | 20 ++++++++++---------- index.html | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/game.js b/game.js index 1b5c8ae..aa4c81c 100644 --- a/game.js +++ b/game.js @@ -644,8 +644,8 @@ function customConfirm(msg, onYes) { diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true }, brick: { n:'Кирпич', c:'#c0392b', solid:true }, tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true }, - campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:190 }, - torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:140 }, + 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 }, @@ -3621,10 +3621,10 @@ function customConfirm(msg, onYes) { // Функция: рисуем мягкий луч света с затуханием за стенами function castLight(sx, sy, radius) { - const flick = 0.88 + Math.sin(now/80 + sx*0.01)*0.06 + Math.sin(now/130 + sy*0.02)*0.06; + const 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; - // 12 лучей — достаточно для мягкого круга - const steps = 12; + // 24 луча — мягкий круглый свет + const steps = 24; // Собираем дистанции до стен по лучам const dists = new Float32Array(steps); for(let i=0; i - + From 7eef966f6e0a0e6f68a3676ce274bc108c980ea8 Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 13:13:13 +0000 Subject: [PATCH 07/15] =?UTF-8?q?fix:=20voice=20chat=20=E2=80=94=20fade=20?= =?UTF-8?q?in/out=20chunks,=20bigger=20buffer,=20volume=20boost=20to=20red?= =?UTF-8?q?uce=20bubbling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game.js | 47 +++++++++++++++++++++++++++++++++-------------- index.html | 2 +- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/game.js b/game.js index aa4c81c..5b67ec3 100644 --- a/game.js +++ b/game.js @@ -1355,8 +1355,8 @@ function customConfirm(msg, onYes) { console.log('[voice] AudioContext state:', audioCtx.state, 'sampleRate:', audioCtx.sampleRate); const source = audioCtx.createMediaStreamSource(voiceStream); - voiceProcessor = audioCtx.createScriptProcessor(2048, 1, 1); - console.log('[voice] ScriptProcessor created, bufferSize=2048'); + voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1); + console.log('[voice] ScriptProcessor created, bufferSize=4096'); voiceProcessor.onaudioprocess = (e) => { if (!voiceActive) return; @@ -1394,25 +1394,44 @@ function customConfirm(msg, onYes) { console.error('[voice] Socket connect error:', err.message); }); - voiceSocket.on('voice_in', (payload) => { - // Воспроизводим входящий голос — raw PCM int16 - const { data, meta, volume } = payload; - if (!audioCtx || audioCtx.state === 'closed') return; - console.log('[voice] voice_in from', meta.name, 'volume:', volume, 'bytes:', data.byteLength); - - const int16 = new Int16Array(data); - const float32 = new Float32Array(int16.length); - for (let i = 0; i < int16.length; i++) { - float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF) * (volume || 1); + // Очередь воспроизведения голоса — склеиваем чанки без щелчков + 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 = 1; + gain.gain.value = Math.min(1, volume * 1.5); // усилить тихий голос src.connect(gain).connect(audioCtx.destination); - src.start(); + // Склеиваем: начинаем сразу после предыдущего чанка + 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'; diff --git a/index.html b/index.html index 3b45c69..0a78f09 100644 --- a/index.html +++ b/index.html @@ -92,6 +92,6 @@ - + From edb08094dba9e447f0364d92d5bae771baf00e7c Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 13:17:11 +0000 Subject: [PATCH 08/15] =?UTF-8?q?fix:=20minimap=20position=20=E2=80=94=20r?= =?UTF-8?q?ight=20of=20stats=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 0a78f09..ebd1e71 100644 --- a/index.html +++ b/index.html @@ -35,7 +35,7 @@ - - + From 2ebb457fc56c0ad4818334410f57c5d740bb051d Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 13:29:26 +0000 Subject: [PATCH 09/15] feat: XP/level system, mob loot drops, level-up popup --- game.js | 166 ++- game.js.bak | 3769 +++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 3 +- 3 files changed, 3920 insertions(+), 18 deletions(-) create mode 100644 game.js.bak 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
@@ -92,6 +93,6 @@ - + From 233ff0297697e6c01b9615c6cbc0637cc45ca329 Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 13:34:12 +0000 Subject: [PATCH 10/15] =?UTF-8?q?fix:=20jitter=20buffer=20for=20voice=20ch?= =?UTF-8?q?at=20=E2=80=94=20120ms=20delay,=20continuous=20scheduling,=20ga?= =?UTF-8?q?in=20ramp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game.js | 44 +- game.js.bak | 3769 --------------------------------------------------- index.html | 2 +- 3 files changed, 26 insertions(+), 3789 deletions(-) delete mode 100644 game.js.bak diff --git a/game.js b/game.js index 5ff93dd..3c035d9 100644 --- a/game.js +++ b/game.js @@ -1343,6 +1343,7 @@ function customConfirm(msg, onYes) { if (voiceActive) { // Выключить voiceActive = false; + jBufNextTime = 0; voiceBtn.innerHTML = '🎤/'; voiceBtn.style.background = '#555'; if (voiceStream) { @@ -1402,35 +1403,40 @@ function customConfirm(msg, onYes) { 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; + // === Jitter Buffer для голоса === + // Накапливаем чанки, затем льём непрерывно через один ScriptProcessor + const jBuf = []; // очередь { float32, volume } + const JBUF_DELAY = 0.12; // начальная задержка 120мс — буфер против джиттера + let jBufNextTime = 0; // когда следующий чанк должен стартовать + let jBufDrift = 0; // коррекция дрифта + + function scheduleVoiceChunk(float32, volume) { + const now = audioCtx.currentTime; + // Первый чанк — добавляем задержку + if (jBufNextTime === 0) { + jBufNextTime = now + JBUF_DELAY; } - // Fade out конец - for (let i = 0; i < FADE && i < float32.length; i++) { - float32[float32.length - 1 - i] *= i / FADE; + // Если чанк пришёл с опозданием (разрыв сети) — не делаем промежуток + if (jBufNextTime < now) { + jBufNextTime = now + 0.005; // минимальный зазор 5мс } 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); // усилить тихий голос + const vol = Math.min(1.4, volume * 1.5); // усиление, макс 1.4 + gain.gain.setValueAtTime(vol, jBufNextTime); + // Лёгкий fade в конце чанка (последние 128 сэмплов) для склейки + gain.gain.linearRampToValueAtTime(vol * 0.3, jBufNextTime + float32.length / 24000 - 0.005); + gain.gain.linearRampToValueAtTime(0, jBufNextTime + float32.length / 24000); src.connect(gain).connect(audioCtx.destination); - // Склеиваем: начинаем сразу после предыдущего чанка - const when = voicePlaying ? audioCtx.currentTime + 0.02 : audioCtx.currentTime; - voicePlaying = true; - src.start(when); - src.onended = () => { voicePlaying = false; }; + src.start(jBufNextTime); + jBufNextTime += float32.length / 24000; // время звучания этого чанка } voiceSocket.on('voice_in', (payload) => { - // Воспроизводим входящий голос — raw PCM int16 + // Воспроизводим входящий голос — raw PCM int16 через jitter buffer const { data, meta, volume } = payload; if (!audioCtx || audioCtx.state === 'closed') return; @@ -1439,7 +1445,7 @@ function customConfirm(msg, onYes) { for (let i = 0; i < int16.length; i++) { float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF); } - playVoiceChunk(float32, volume || 1); + scheduleVoiceChunk(float32, volume || 1); // Индикатор speakingIndicator.style.display = 'block'; diff --git a/game.js.bak b/game.js.bak deleted file mode 100644 index 5b67ec3..0000000 --- a/game.js.bak +++ /dev/null @@ -1,3769 +0,0 @@ -(() => { - // ==================== КОНФИГУРАЦИЯ СЕРВЕРА ==================== -// === 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 6a87a52..1905551 100644 --- a/index.html +++ b/index.html @@ -93,6 +93,6 @@ - + From 0e12ed7a183f9e293ce794eb43948f45c069e2d8 Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 13:52:18 +0000 Subject: [PATCH 11/15] =?UTF-8?q?fix:=20ring=20buffer=20voice=20playback?= =?UTF-8?q?=20=E2=80=94=20continuous=20stream,=20no=20BufferSource=20per?= =?UTF-8?q?=20chunk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game.js | 94 +++++++++++++++++++++++++++++++++--------------------- index.html | 2 +- 2 files changed, 59 insertions(+), 37 deletions(-) diff --git a/game.js b/game.js index 3c035d9..6f82f1a 100644 --- a/game.js +++ b/game.js @@ -1343,7 +1343,7 @@ function customConfirm(msg, onYes) { if (voiceActive) { // Выключить voiceActive = false; - jBufNextTime = 0; + ringReady = 0; ringRead = ringWrite; voicePlayActive = false; voiceBtn.innerHTML = '🎤/'; voiceBtn.style.background = '#555'; if (voiceStream) { @@ -1403,55 +1403,77 @@ function customConfirm(msg, onYes) { console.error('[voice] Socket connect error:', err.message); }); - // === Jitter Buffer для голоса === - // Накапливаем чанки, затем льём непрерывно через один ScriptProcessor - const jBuf = []; // очередь { float32, volume } - const JBUF_DELAY = 0.12; // начальная задержка 120мс — буфер против джиттера - let jBufNextTime = 0; // когда следующий чанк должен стартовать - let jBufDrift = 0; // коррекция дрифта - - function scheduleVoiceChunk(float32, volume) { - const now = audioCtx.currentTime; - // Первый чанк — добавляем задержку - if (jBufNextTime === 0) { - jBufNextTime = now + JBUF_DELAY; + // === Ring Buffer + ScriptProcessor приём голоса === + // Единый непрерывный поток вместо отдельных BufferSource на чанк + const RING_SIZE = 24000 * 3; // 3 секунды ring buffer + const ringBuf = new Float32Array(RING_SIZE); + let ringWrite = 0; // позиция записи + let ringRead = 0; // позиция чтения + let ringReady = 0; // сколько сэмплов готово + let voicePlayActive = false; + const JBUF_TARGET = 2400; // целевой jitter buffer: 100мс при 24kHz + let jbufFill = 0; // текущее заполнение + let lastVoiceFrom = ''; // кто говорит (для индикатора) + + // Воспроизводящий ScriptProcessor — читает из ring buffer + const playProcessor = audioCtx.createScriptProcessor(2048, 1, 1); + playProcessor.onaudioprocess = (e) => { + const out = e.outputBuffer.getChannelData(0); + if (ringReady < 1) { + // Тишина — нет голоса + out.fill(0); + return; } - // Если чанк пришёл с опозданием (разрыв сети) — не делаем промежуток - if (jBufNextTime < now) { - jBufNextTime = now + 0.005; // минимальный зазор 5мс + // Ждём накопления jitter buffer перед стартом + if (!voicePlayActive && ringReady >= JBUF_TARGET) { + voicePlayActive = true; } - const buf = audioCtx.createBuffer(1, float32.length, 24000); - buf.getChannelData(0).set(float32); - const src = audioCtx.createBufferSource(); - src.buffer = buf; - const gain = audioCtx.createGain(); - const vol = Math.min(1.4, volume * 1.5); // усиление, макс 1.4 - gain.gain.setValueAtTime(vol, jBufNextTime); - // Лёгкий fade в конце чанка (последние 128 сэмплов) для склейки - gain.gain.linearRampToValueAtTime(vol * 0.3, jBufNextTime + float32.length / 24000 - 0.005); - gain.gain.linearRampToValueAtTime(0, jBufNextTime + float32.length / 24000); - src.connect(gain).connect(audioCtx.destination); - src.start(jBufNextTime); - jBufNextTime += float32.length / 24000; // время звучания этого чанка - } + if (!voicePlayActive) { + out.fill(0); + return; + } + // Читаем из ring buffer + for (let i = 0; i < out.length; i++) { + if (ringReady > 0) { + out[i] = ringBuf[ringRead]; + ringRead = (ringRead + 1) % RING_SIZE; + ringReady--; + } else { + out[i] = 0; + } + } + }; + const playGain = audioCtx.createGain(); + playGain.gain.value = 1.0; + playProcessor.connect(playGain).connect(audioCtx.destination); voiceSocket.on('voice_in', (payload) => { - // Воспроизводим входящий голос — raw PCM int16 через jitter buffer + // Пишем входящий голос в ring buffer const { data, meta, volume } = payload; if (!audioCtx || audioCtx.state === 'closed') return; const int16 = new Int16Array(data); - const float32 = new Float32Array(int16.length); + const vol = Math.min(1.4, (volume || 1) * 1.5); for (let i = 0; i < int16.length; i++) { - float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF); + const sample = (int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF)) * vol; + ringBuf[ringWrite] = sample; + ringWrite = (ringWrite + 1) % RING_SIZE; + ringReady = Math.min(ringReady + 1, RING_SIZE); } - scheduleVoiceChunk(float32, volume || 1); + // Сброс jitter fill если пауза была + jbufFill = ringReady; + lastVoiceFrom = meta.name || '???'; // Индикатор speakingIndicator.style.display = 'block'; - speakingIndicator.textContent = '🔊 ' + (meta.name || '???'); + speakingIndicator.textContent = '🔊 ' + lastVoiceFrom; clearTimeout(speakingTimeout); - speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500); + speakingTimeout = setTimeout(() => { + speakingIndicator.style.display = 'none'; + voicePlayActive = false; // сброс при паузе + ringReady = 0; // очистить буфер + ringRead = ringWrite; // синхронизировать + }, 600); }); voiceActive = true; diff --git a/index.html b/index.html index 1905551..422a27b 100644 --- a/index.html +++ b/index.html @@ -93,6 +93,6 @@ - + From 2ee82a45b04baaf42dc14be55bd4e4669cb4ca65 Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 14:04:55 +0000 Subject: [PATCH 12/15] fix: deselect items on second click, inventory toggle close, cache bust v22 --- game.js | 42 ++++++++++++++++++++++++++---------------- index.html | 2 +- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/game.js b/game.js index 6f82f1a..42618bb 100644 --- a/game.js +++ b/game.js @@ -1525,11 +1525,16 @@ function customConfirm(msg, onYes) { s.appendChild(c); s.onclick = () => { playSound('click'); // Звук клика по инвентарю - selected=id; - // Обновляем список последних предметов - recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть - recentItems.unshift(id); // Добавляем в начало - recentItems = recentItems.slice(0, 5); // Оставляем только 5 + if(selected === id) { + // Повторный клик — снимаем выбор, возвращаем к первому блоку + selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt'; + } else { + selected = id; + // Обновляем список последних предметов + recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть + recentItems.unshift(id); // Добавляем в начало + recentItems = recentItems.slice(0, 5); // Оставляем только 5 + } rebuildHotbar(); }; @@ -1599,11 +1604,14 @@ function customConfirm(msg, onYes) { slot.onclick = () => { playSound('click'); // Звук клика по инвентарю - selected = id; - // Обновляем список последних предметов - recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть - recentItems.unshift(id); // Добавляем в начало - recentItems = recentItems.slice(0, 5); // Оставляем только 5 + if(selected === id) { + selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt'; + } else { + selected = id; + recentItems = recentItems.filter(item => item !== id); + recentItems.unshift(id); + recentItems = recentItems.slice(0, 5); + } rebuildHotbar(); renderInventory(); }; @@ -1719,12 +1727,14 @@ function customConfirm(msg, onYes) { // Кнопка открытия инвентаря document.getElementById('invToggle').onclick = () => { playSound('click'); // Звук клика по кнопке - inventoryOpen = true; - inventoryPanel.style.display = 'block'; - renderInventory(); - // Закрываем крафт если открыт инвентарь - craftOpen = false; - craftPanel.style.display = 'none'; + inventoryOpen = !inventoryOpen; + inventoryPanel.style.display = inventoryOpen ? 'block' : 'none'; + if(inventoryOpen) { + renderInventory(); + // Закрываем крафт если открыт инвентарь + craftOpen = false; + craftPanel.style.display = 'none'; + } }; document.getElementById('inventoryClose').onclick = () => { diff --git a/index.html b/index.html index 422a27b..a57c073 100644 --- a/index.html +++ b/index.html @@ -93,6 +93,6 @@ - + From 714e4cf16207a888fc414ee4348b3f6452d2ac60 Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 14:08:55 +0000 Subject: [PATCH 13/15] =?UTF-8?q?fix:=20voice=20v3=20=E2=80=94=20smaller?= =?UTF-8?q?=20send=20chunks,=20200ms=20jbuf,=20smooth=20fade=20on=20underr?= =?UTF-8?q?un,=201.5s=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game.js | 30 +++++++++++++++++++++--------- index.html | 2 +- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/game.js b/game.js index 42618bb..f304d36 100644 --- a/game.js +++ b/game.js @@ -1364,8 +1364,8 @@ function customConfirm(msg, onYes) { 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 = audioCtx.createScriptProcessor(2048, 1, 1); + console.log('[voice] ScriptProcessor created, bufferSize=2048'); voiceProcessor.onaudioprocess = (e) => { if (!voiceActive) return; @@ -1411,17 +1411,22 @@ function customConfirm(msg, onYes) { let ringRead = 0; // позиция чтения let ringReady = 0; // сколько сэмплов готово let voicePlayActive = false; - const JBUF_TARGET = 2400; // целевой jitter buffer: 100мс при 24kHz + const JBUF_TARGET = 4800; // целевой jitter buffer: 200мс при 24kHz let jbufFill = 0; // текущее заполнение let lastVoiceFrom = ''; // кто говорит (для индикатора) // Воспроизводящий ScriptProcessor — читает из ring buffer const playProcessor = audioCtx.createScriptProcessor(2048, 1, 1); + let lastSample = 0; // для плавного fade при underrun playProcessor.onaudioprocess = (e) => { const out = e.outputBuffer.getChannelData(0); if (ringReady < 1) { - // Тишина — нет голоса - out.fill(0); + // Плавный fade-out от последнего сэмпла к тишине + for (let i = 0; i < out.length; i++) { + lastSample *= 0.9; + out[i] = lastSample; + } + voicePlayActive = false; return; } // Ждём накопления jitter buffer перед стартом @@ -1429,17 +1434,24 @@ function customConfirm(msg, onYes) { voicePlayActive = true; } if (!voicePlayActive) { - out.fill(0); + // Плавно затихаем пока буфер копится + for (let i = 0; i < out.length; i++) { + lastSample *= 0.95; + out[i] = lastSample; + } return; } - // Читаем из ring buffer + // Читаем из ring buffer с плавным fade-in на старте for (let i = 0; i < out.length; i++) { if (ringReady > 0) { out[i] = ringBuf[ringRead]; + lastSample = out[i]; // запоминаем для fade-out ringRead = (ringRead + 1) % RING_SIZE; ringReady--; } else { - out[i] = 0; + // Underrun — плавно затихаем + lastSample *= 0.85; + out[i] = lastSample; } } }; @@ -1473,7 +1485,7 @@ function customConfirm(msg, onYes) { voicePlayActive = false; // сброс при паузе ringReady = 0; // очистить буфер ringRead = ringWrite; // синхронизировать - }, 600); + }, 1500); }); voiceActive = true; diff --git a/index.html b/index.html index a57c073..f249f4c 100644 --- a/index.html +++ b/index.html @@ -93,6 +93,6 @@ - + From 980ba6a5416f37f627333d975896c9a1b7b83ce4 Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 17:27:10 +0000 Subject: [PATCH 14/15] feat: biomes, weather, new mobs, crops, structures, level unlocks --- game.js | 911 +++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 764 insertions(+), 147 deletions(-) diff --git a/game.js b/game.js index f304d36..4b00664 100644 --- a/game.js +++ b/game.js @@ -141,7 +141,11 @@ function customConfirm(msg, onYes) { 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 } + chicken: { w: 26, h: 22, hp: 1, speed: 55, hostile: false, fuse: 0, shootCooldown: 2 }, + scorpion: { w: 26, h: 26, hp: 3, speed: 90, hostile: true, fuse: 0, shootCooldown: 0, biome:'desert' }, + polar_bear:{ w: 40, h: 34, hp: 8, speed: 50, hostile: false, fuse: 0, shootCooldown: 2, biome:'tundra' }, + slime: { w: 24, h: 24, hp: 2, speed: 30, hostile: true, fuse: 0, shootCooldown: 2, biome:'swamp' }, + eagle: { w: 30, h: 22, hp: 3, speed: 120, hostile: true, fuse: 0, shootCooldown: 0, biome:'mountains' } }; const props = kindProps[data.kind] || kindProps['pig']; // fallback return { @@ -654,7 +658,30 @@ function customConfirm(msg, onYes) { 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 } + furnace: { n:'Печь', c:'#696969', solid:true, smelting:true }, + // === BIOME BLOCKS === + snow: { n:'Снег', c:'#ecf0f1', solid:true }, + ice: { n:'Лёд', c:'#74b9ff', solid:true, slip:true }, + cactus: { n:'Кактус', c:'#27ae60', solid:true, hurt:true }, + mushroom: { n:'Гриб', c:'#e74c3c', solid:false, decor:true }, + moss: { n:'Мох', c:'#0a6640', solid:true }, + swamp_water:{ n:'Болотная вода', c:'rgba(100,120,40,0.6)', solid:false, fluid:true, poison:true }, + farmland: { n:'Грядка', c:'#8B6914', solid:true, farmable:true }, + dead_bush: { n:'Сухой куст', c:'#b2bec3', solid:false, decor:true }, + spruce_leaves:{ n:'Ель', c:'#0a6640', solid:true }, + // === CROP STAGES === + wheat_stage0:{ n:'Росток пшеницы', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'wheat_stage1' }, + wheat_stage1:{ n:'Пшеница', c:'#7dcea0', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'wheat_stage2' }, + wheat_stage2:{ n:'Пшеница', c:'#b8d730', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'wheat_stage3' }, + wheat_stage3:{ n:'Пшеница', c:'#f1c40f', solid:false, decor:true, harvestable:true, harvestItem:'wheat', harvestQty:2 }, + carrot_stage0:{ n:'Росток моркови', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'carrot_stage1' }, + carrot_stage1:{ n:'Морковь', c:'#f0c27a', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'carrot_stage2' }, + carrot_stage2:{ n:'Морковь', c:'#e8a040', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'carrot_stage3' }, + carrot_stage3:{ n:'Морковь', c:'#e67e22', solid:false, decor:true, harvestable:true, harvestItem:'carrot', harvestQty:3 }, + potato_stage0:{ n:'Росток картофеля', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'potato_stage1' }, + potato_stage1:{ n:'Картофель', c:'#c8b888', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'potato_stage2' }, + potato_stage2:{ n:'Картофель', c:'#bfaa78', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'potato_stage3' }, + potato_stage3:{ n:'Картофель', c:'#dfe6e9', solid:false, decor:true, harvestable:true, harvestItem:'potato', harvestQty:2 } }; const ITEMS = { @@ -664,7 +691,21 @@ function customConfirm(msg, onYes) { chicken_meat: { n:'Курица', icon:'🍗', food:12, stack:64 }, feather: { n:'Перо', icon:'🪶', stack:64 }, bone: { n:'Кость', icon:'🦴', stack:64 }, - gunpowder: { n:'Порох', icon:'💥', stack:64 } + gunpowder: { n:'Порох', icon:'💥', stack:64 }, + // === FARMING ITEMS === + wheat: { n:'Пшеница', icon:'🌾', stack:64 }, + bread: { n:'Хлеб', icon:'🍞', food:30 }, + carrot: { n:'Морковь', icon:'🥕', food:8, stack:64 }, + potato: { n:'Картофель', icon:'🥔', stack:64 }, + baked_potato:{ n:'Печёная картошка', icon:'🥔', food:25 }, + mushroom_stew:{ n:'Грибной суп', icon:'🍲', food:40 }, + // === MOB DROP ITEMS === + scorpion_stinger:{ n:'Жало скорпиона', icon:'🔺', stack:64 }, + polar_fur: { n:'Шкура медведя', icon:'🧥', stack:64 }, + slime_ball: { n:'Слизь', icon:'🟢', stack:64 }, + eagle_feather:{ n:'Перо орла', icon:'🪶', stack:64 }, + // === NEW ARMOR & TOOLS === + gold_armor: { n:'Золотая броня', icon:'🛡️', stack:1, armor:0.65 } }; // Seed мира для детерминированной генерации @@ -680,13 +721,16 @@ function customConfirm(msg, onYes) { // Инструменты 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 } } + wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', durability: 60, miningPower: 1, craft: { wood: 3, planks: 2 }, requiredLevel: 1 }, + stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 }, requiredLevel: 2 }, + iron_pickaxe: { n:'Железная кирка', icon:'⛏️', durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 }, requiredLevel: 3 }, + wood_sword: { n:'Деревянный меч', icon:'⚔️', durability: 40, damage: 5, craft: { wood: 2, planks: 1 }, requiredLevel: 1 }, + stone_sword: { n:'Каменный меч', icon:'⚔️', durability: 100, damage: 8, craft: { wood: 1, stone: 2 }, requiredLevel: 2 }, + iron_sword: { n:'Железный меч', icon:'⚔️', durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 }, requiredLevel: 3 }, + bow: { n:'Лук', icon:'🏹', durability: 150, craft: { wood: 3, planks: 2 }, requiredLevel: 4 }, + diamond_pickaxe: { n:'Алмазная кирка', icon:'⛏️', durability: 500, miningPower: 5, craft: { wood: 1, diamond_ore: 2 }, requiredLevel: 7 }, + diamond_sword: { n:'Алмазный меч', icon:'⚔️', durability: 400, damage: 18, craft: { wood: 1, diamond_ore: 2 }, requiredLevel: 7 }, + hoe: { n:'Мотыга', icon:'🔨', durability: 80, tillTo: 'farmland', craft: { wood: 2, planks: 1 }, requiredLevel: 1 } }; // Текстуры блоков (простые) @@ -773,6 +817,96 @@ function customConfirm(msg, onYes) { g.fillRect(4, 28, 24, 3); return c; } + // === BIOME BLOCK TEXTURES === + if (type === 'snow') { + g.fillStyle = '#ecf0f1'; + g.fillRect(0, 0, 32, 32); + g.fillStyle = '#dfe6e9'; + for (let i = 0; i < 4; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 5, 3); + return c; + } + if (type === 'ice') { + g.fillStyle = '#74b9ff'; + g.fillRect(0, 0, 32, 32); + g.strokeStyle = 'rgba(255,255,255,0.4)'; + g.beginPath(); g.moveTo(4,10); g.lineTo(20,16); g.lineTo(10,28); g.stroke(); + g.beginPath(); g.moveTo(18,4); g.lineTo(28,12); g.stroke(); + return c; + } + if (type === 'cactus') { + g.fillStyle = '#27ae60'; + g.fillRect(6, 2, 20, 28); + g.fillStyle = '#2ecc71'; + g.fillRect(2, 8, 6, 4); + g.fillRect(24, 14, 6, 4); + g.fillStyle = '#1e8449'; + g.fillRect(12, 0, 2, 30); + g.fillRect(18, 0, 2, 30); + return c; + } + if (type === 'mushroom') { + g.fillStyle = '#f5e6cc'; g.fillRect(14, 20, 4, 12); + g.fillStyle = '#e74c3c'; g.beginPath(); g.arc(16, 14, 10, Math.PI, 0); g.fill(); + g.fillStyle = '#fff'; g.fillRect(10, 10, 3, 3); g.fillRect(17, 8, 4, 3); + return c; + } + if (type === 'moss') { + g.fillStyle = '#0a6640'; + g.fillRect(0, 0, 32, 32); + g.fillStyle = '#1e8449'; + for (let i = 0; i < 6; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 4, 3); + return c; + } + if (type === 'swamp_water') { + g.fillStyle = 'rgba(100,120,40,0.6)'; + g.fillRect(0, 0, 32, 32); + g.fillStyle = 'rgba(80,100,20,0.3)'; + g.fillRect(0, 10, 32, 2); + g.fillRect(0, 22, 32, 2); + return c; + } + if (type === 'farmland') { + g.fillStyle = '#8B6914'; + g.fillRect(0, 0, 32, 32); + g.fillStyle = '#7a5c10'; + for (let i = 0; i < 4; i++) g.fillRect(0, 6+i*8, 32, 2); + g.fillStyle = '#6B4E0A'; + g.fillRect(8, 2, 2, 28); + g.fillRect(18, 2, 2, 28); + return c; + } + if (type === 'dead_bush') { + g.fillStyle = '#b2bec3'; + g.fillRect(12, 16, 2, 16); + g.fillRect(8, 14, 8, 2); + g.fillRect(16, 12, 8, 2); + g.fillRect(6, 18, 4, 2); + return c; + } + if (type === 'spruce_leaves') { + g.fillStyle = '#0a6640'; + g.fillRect(0, 0, 32, 32); + g.fillStyle = '#0d7a4d'; + for (let i = 0; i < 5; i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6, 4); + return c; + } + // CROP STAGES + if (type.startsWith('wheat_stage') || type.startsWith('carrot_stage') || type.startsWith('potato_stage')) { + const st = parseInt(type.charAt(type.length-1)); + const colors = type.startsWith('wheat') ? ['#a8e6a0','#7dcea0','#b8d730','#f1c40f'] : + type.startsWith('carrot') ? ['#a8e6a0','#f0c27a','#e8a040','#e67e22'] : + ['#a8e6a0','#c8b888','#bfaa78','#dfe6e9']; + g.fillStyle = '#5d4037'; + g.fillRect(15, 18, 2, 14); + if (st >= 1) { + g.fillStyle = colors[st]; + g.fillRect(10, 8 + (3-st)*3, 12, 10); + } else { + g.fillStyle = '#a8e6a0'; + g.fillRect(14, 22, 4, 4); + } + return c; + } g.fillStyle = t.c || '#000'; g.fillRect(0,0,32,32); @@ -965,10 +1099,16 @@ function customConfirm(msg, onYes) { 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, + iron_armor:0, gold_armor:0, bow:0, furnace:0, bed:0, boat:0, - iron_ingot:0, gold_ingot:0, copper_ingot:0 + iron_ingot:0, gold_ingot:0, copper_ingot:0, + diamond_pickaxe:0, diamond_sword:0, hoe:0, + wheat:0, bread:0, carrot:0, potato:0, baked_potato:0, + scorpion_stinger:0, polar_fur:0, slime_ball:0, eagle_feather:0, + snow:0, ice:0, cactus:0, mushroom:0, moss:0, farmland:0, + spruce_leaves:0, dead_bush:0, + wheat_stage0:0, carrot_stage0:0, potato_stage0:0 }; let selected = 'dirt'; @@ -1014,25 +1154,31 @@ function customConfirm(msg, onYes) { } 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 } } + { out:'planks', qty:4, cost:{ wood:1 }, requiredLevel:1 }, + { out:'ladder', qty:3, cost:{ planks:7 }, requiredLevel:1 }, + { out:'torch', qty:2, cost:{ coal:1, planks:1 }, requiredLevel:1 }, + { out:'glass', qty:1, cost:{ sand:3 }, requiredLevel:1 }, + { out:'brick', qty:1, cost:{ stone:2, clay:1 }, requiredLevel:1 }, + { out:'campfire', qty:1, cost:{ wood:1, coal:1 }, requiredLevel:1 }, + { out:'tnt', qty:1, cost:{ sand:2, coal:1 }, requiredLevel:8 }, + { out:'bed', qty:1, cost:{ wood: 3, planks: 3 }, requiredLevel:1 }, + { out:'boat', qty:1, cost:{ wood: 5 }, requiredLevel:2 }, + { out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 }, requiredLevel:1 }, + { out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 }, requiredLevel:2 }, + { out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 }, requiredLevel:3 }, + { out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 }, requiredLevel:1 }, + { out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 }, requiredLevel:2 }, + { out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 }, requiredLevel:3 }, + { out:'iron_armor', qty:1, cost:{ iron_ore: 5 }, requiredLevel:5 }, + { out:'gold_armor', qty:1, cost:{ gold_ore: 8 }, requiredLevel:6 }, + { out:'furnace', qty:1, cost:{ stone: 8 }, requiredLevel:3 }, + { out:'bow', qty:1, cost:{ wood: 3, planks: 2 }, requiredLevel:4 }, + { out:'arrow', qty:4, cost:{ stone: 1, wood: 1 }, requiredLevel:1 }, + // === NEW RECIPES === + { out:'bread', qty:1, cost:{ wheat: 3 }, requiredLevel:1 }, + { out:'diamond_pickaxe', qty:1, cost:{ wood: 1, diamond_ore: 2 }, requiredLevel:7 }, + { out:'diamond_sword', qty:1, cost:{ wood: 1, diamond_ore: 2 }, requiredLevel:7 }, + { out:'hoe', qty:1, cost:{ wood: 2, planks: 1 }, requiredLevel:1 } ]; // Рецепты печи (обжиг) @@ -1043,13 +1189,17 @@ function customConfirm(msg, onYes) { { in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 }, // золото → слиток { in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 }, // медь → слиток { in:'meat', qty:1, out:'cooked', outQty:1, time:2 }, // сырое мясо → жареное - { in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 } // булыжник → камень + { in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 }, // булыжник → камень + { in:'potato', qty:1, out:'baked_potato', outQty:1, time:2 } // картофель → печёная картошка ]; // Новые предметы от обжига ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' }; ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' }; ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' }; + ITEMS.diamond_pickaxe = { n:'Алмазная кирка', icon:'⛏️', durability:500, miningPower:5 }; + ITEMS.diamond_sword = { n:'Алмазный меч', icon:'⚔️', durability:400, damage:18 }; + ITEMS.hoe = { n:'Мотыга', icon:'🔨', durability:80, tillTo:'farmland' }; // Активные печи: Map ключа блока → { recipe, progress, totalTime } const activeFurnaces = new Map(); @@ -1089,7 +1239,13 @@ function customConfirm(msg, onYes) { 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' + flower: '#d03050', bed: '#c03060', ladder: '#a04000', boat: '#6b3410', + snow: '#ecf0f1', ice: '#74b9ff', cactus: '#27ae60', mushroom: '#e74c3c', + moss: '#0a6640', swamp_water: '#687828', farmland: '#8B6914', + dead_bush: '#b2bec3', spruce_leaves: '#0a6640', + wheat_stage0: '#a8e6a0', wheat_stage1: '#7dcea0', wheat_stage2: '#b8d730', wheat_stage3: '#f1c40f', + carrot_stage0: '#a8e6a0', carrot_stage1: '#f0c27a', carrot_stage2: '#e8a040', carrot_stage3: '#e67e22', + potato_stage0: '#a8e6a0', potato_stage1: '#c8b888', potato_stage2: '#bfaa78', potato_stage3: '#dfe6e9' }; function renderMinimap() { @@ -1645,6 +1801,17 @@ function customConfirm(msg, onYes) { playSound('click'); renderInventory(); } + if(id === 'gold_armor' && inv.gold_armor > 0) { + if(player.equippedArmor === 'gold_armor') { + player.equippedArmor = null; + player.armor = 0; + } else { + player.equippedArmor = 'gold_armor'; + player.armor = ITEMS['gold_armor'].armor; + } + playSound('click'); + renderInventory(); + } }; } @@ -1667,6 +1834,8 @@ function customConfirm(msg, onYes) { for(const r of RECIPES){ const row = document.createElement('div'); row.className='recipe'; + const reqLv = r.requiredLevel || 1; + const locked = player.level < reqLv; const icon = document.createElement('div'); icon.className='ricon'; // Иконка — блок, инструмент или предмет @@ -1690,20 +1859,23 @@ function customConfirm(msg, onYes) { 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}`; + nm.textContent = `${itemName} x${r.qty}` + (locked ? ` (Lv.${reqLv})` : ''); + if(locked) { nm.style.color = '#888'; nm.style.textDecoration = 'line-through'; } const cs = document.createElement('div'); cs.className='rcost'; cs.textContent = Object.keys(r.cost).map(x => { const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x; return `${cn}: ${(inv[x]||0)}/${r.cost[x]}`; }).join(' '); + if(locked) cs.style.color = '#666'; info.appendChild(nm); info.appendChild(cs); const btn = document.createElement('button'); btn.className='rcraft'; btn.textContent='Создать'; - btn.disabled = !canCraft(r); + btn.disabled = !canCraft(r) || locked; + if(locked) btn.title = `Требуется уровень ${reqLv}`; btn.onclick = () => { - if(!canCraft(r)) return; + if(!canCraft(r) || locked) return; playSound('click'); for(const res in r.cost) inv[res]-=r.cost[res]; inv[r.out] = (inv[r.out]||0) + r.qty; @@ -1889,6 +2061,7 @@ function customConfirm(msg, onYes) { hunger: 100, o2: 100, invuln: 0, + slowTimer: 0, // яд скорпиона — замедление fallStartY: 0, lastStepTime: 0, sleeping: false, @@ -1919,13 +2092,17 @@ function customConfirm(msg, onYes) { 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}] + creeper: [{item:'gunpowder',min:1,max:2,chance:1}], + scorpion: [{item:'scorpion_stinger',min:0,max:1,chance:0.4}], + polar_bear:[{item:'polar_fur',min:1,max:2,chance:0.8},{item:'meat',min:1,max:2,chance:1}], + slime: [{item:'slime_ball',min:1,max:2,chance:1}], + eagle: [{item:'eagle_feather',min:1,max:2,chance:0.7}] }; return table[kind] || []; } function getMobXP(kind) { - const xpTable = { chicken:3, pig:5, zombie:10, skeleton:12, creeper:15 }; + const xpTable = { chicken:3, pig:5, zombie:10, skeleton:12, creeper:15, scorpion:8, polar_bear:20, slime:5, eagle:15 }; return xpTable[kind] || 0; } @@ -1946,11 +2123,22 @@ function customConfirm(msg, onYes) { } } + const LEVEL_UNLOCKS = { + 2: 'Каменные инструменты, Лодка', + 3: 'Железные инструменты, Печь', + 4: 'Лук и стрелы', + 5: 'Железная броня', + 6: 'Золотая броня', + 7: 'Алмазные инструменты', + 8: 'TNT' + }; + function grantXP(amount) { player.xp += amount; while (player.xp >= xpForLevel(player.level + 1)) { player.level++; - levelUpPopup = { text: '⭐ Уровень ' + player.level + '!', timer: 180 }; + const unlock = LEVEL_UNLOCKS[player.level] || ''; + levelUpPopup = { text: '⭐ Уровень ' + player.level + '!' + (unlock ? ' ' + unlock : ''), timer: 240 }; } } @@ -2231,64 +2419,113 @@ function customConfirm(msg, onYes) { // Дождь let isRaining = false; let rainIntensity = 0; // 0..1 - let weatherTimer = 0; - let weatherChangeInterval = 60 + Math.random() * 120; // смена погоды 60-180с + const snowflakes = []; // снежинки для тундры + const MAX_SNOWFLAKES = 150; 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 growthTimers = {}; // ключ: "gx,gy" → { stage:0-3, growTimer:X } + + // Старая функция заменена — теперь погода через биомы (см. weatherState выше) + // updateWeather(dt) вызывается из основного цикла — биом-зависимая + + // Интеграция: определяем isRaining из weatherState для визуализации + function syncWeatherVisual() { + isRaining = (weatherState.type === 'rain' || weatherState.type === 'storm'); + if (weatherState.type === 'clear') { + rainIntensity *= 0.95; // плавное затухание + if (rainIntensity < 0.01) rainIntensity = 0; } - // Плавная интерполяция интенсивности - 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) { + syncWeatherVisual(); + // Дождь + if ((weatherState.type === 'rain' || weatherState.type === 'storm') && rainIntensity < weatherState.intensity * 0.6) { + rainIntensity += dt * 0.3; + } else if (weatherState.type === 'clear' || weatherState.type === 'snow' || weatherState.type === 'fog') { + rainIntensity = Math.max(0, rainIntensity - dt * 0.5); + } + if (rainIntensity < 0.01) { raindrops.length = 0; - 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); + } else { + const spawnRate = Math.floor(rainIntensity * 80 * dt); + for (let i = 0; i < spawnRate && raindrops.length < MAX_RAINDROPS; i++) { + raindrops.push({ + x: camX + Math.random() * W, + y: camY - 20, + vy: 400 + Math.random() * 200, + len: 8 + Math.random() * 12 + }); } + for (let i = raindrops.length - 1; i >= 0; i--) { + const d = raindrops[i]; + d.y += d.vy * dt; + d.x -= 30 * dt; + if (d.y > camY + H + 20) raindrops.splice(i, 1); + } + } + // Снег + if (weatherState.type === 'snow') { + const spawnRate = Math.floor(weatherState.intensity * 30 * dt); + for (let i = 0; i < spawnRate && snowflakes.length < MAX_SNOWFLAKES; i++) { + snowflakes.push({ + x: camX + Math.random() * W, + y: camY - 10, + vy: 40 + Math.random() * 60, + vx: (Math.random() - 0.5) * 30, + size: 2 + Math.random() * 3 + }); + } + } + for (let i = snowflakes.length - 1; i >= 0; i--) { + const s = snowflakes[i]; + s.y += s.vy * dt; + s.x += s.vx * dt + Math.sin(s.y * 0.02) * 10 * dt; + if (s.y > camY + H + 20 || weatherState.type !== 'snow') snowflakes.splice(i, 1); } } function drawRain() { - if (raindrops.length === 0) 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); + // Дождь + if (raindrops.length > 0) { + ctx.save(); + ctx.strokeStyle = (weatherState.type === 'storm') ? 'rgba(174,194,224,0.7)' : 'rgba(174,194,224,0.5)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + for (const d of raindrops) { + ctx.moveTo(d.x, d.y); + ctx.lineTo(d.x - 3, d.y + d.len); + } + ctx.stroke(); + ctx.restore(); + } + // Снег + if (snowflakes.length > 0) { + ctx.save(); + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + for (const s of snowflakes) { + ctx.beginPath(); + ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + // Гроза — вспышка + if (weatherState.type === 'storm' && Math.random() < 0.003) { + ctx.save(); + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.fillRect(camX, camY, W, H); + ctx.restore(); + } + // Туман — серый оверлей + if (weatherState.type === 'fog') { + ctx.save(); + ctx.fillStyle = 'rgba(200,200,200,0.4)'; + ctx.fillRect(camX, camY, W, H); + ctx.restore(); } - ctx.stroke(); - ctx.restore(); } // Частицы (взрыв) @@ -2681,7 +2918,7 @@ function customConfirm(msg, onYes) { if(ITEMS[selected] && inv[selected]>0){ const it = ITEMS[selected]; if(player.hp < 100 || player.hunger < 100){ - playSound('eat1'); // Звук употребления еды + playSound('eat1'); player.hunger = Math.min(100, player.hunger + it.food); player.hp = Math.min(100, player.hp + 15); inv[selected]--; @@ -2689,6 +2926,34 @@ function customConfirm(msg, onYes) { } return; } + + //Посадка семян на грядку + if(b && b.t === 'farmland' && mode()==='build'){ + const seedMap = { wheat: 'wheat_stage0', carrot: 'carrot_stage0', potato: 'potato_stage0' }; + if(seedMap[selected] && inv[selected] > 0){ + inv[selected]--; + const cropType = seedMap[selected]; + setBlock(gx, gy-1, cropType); + growthTimers[gx+','+(gy-1)] = { stage:0, growTimer: 10+Math.random()*5 }; + sendBlockChange(gx, gy-1, cropType, 'add'); + playSound('cloth1'); + rebuildHotbar(); + return; + } + } + + // Мотыга — превращает grass/dirt в farmland (в любом режиме) + if(selected === 'hoe' && inv.hoe > 0 && b){ + if(b.t === 'grass' || b.t === 'dirt'){ + setBlock(gx, gy, 'farmland'); + sendBlockChange(gx, gy, b.t, 'remove'); + sendBlockChange(gx, gy, 'farmland', 'add'); + useTool('hoe'); + playSound('cloth1'); + rebuildHotbar(); + return; + } + } // жарка на костре: выбран meat + клик по campfire if(b && b.t==='campfire' && selected==='meat' && inv.meat>0){ @@ -2707,7 +2972,19 @@ function customConfirm(msg, onYes) { if(mode()==='mine'){ if(!b) return; - if(BLOCKS[b.t].fluid || BLOCKS[b.t].decor) return; + if(BLOCKS[b.t].fluid) return; + // Клик на урожайную культуру — сбор + if(BLOCKS[b.t].harvestable){ + const hInfo = BLOCKS[b.t]; + inv[hInfo.harvestItem] = (inv[hInfo.harvestItem]||0) + hInfo.harvestQty; + removeBlock(gx, gy); + sendBlockChange(gx, gy, b.t, 'remove'); + delete growthTimers[gx+','+gy]; + playSound('cloth1'); + rebuildHotbar(); + return; + } + if(BLOCKS[b.t].decor) return; if(b.t==='tnt'){ activateTNT(b, 3.2); return; } // не взрывается сразу @@ -2799,86 +3076,279 @@ function customConfirm(msg, onYes) { } }); - // Генерация (по X, на всю глубину до bedrock) + // ==================== БИОМЫ ==================== + const BIOMES = { + plains: { name:'Равнина', surface:'grass', subsurface:'dirt', trees:true, flowers:true, treeChance:0.12 }, + desert: { name:'Пустыня', surface:'sand', subsurface:'sand', trees:false, flowers:false, treeChance:0 }, + tundra: { name:'Тундра', surface:'snow', subsurface:'dirt', trees:true, flowers:false, treeChance:0.06 }, + swamp: { name:'Болото', surface:'moss', subsurface:'dirt', trees:true, flowers:false, treeChance:0.10 }, + mountains:{ name:'Горы', surface:'stone', subsurface:'stone', trees:false, flowers:false, treeChance:0 } + }; + + function getBiome(gx) { + const temp = Math.sin(gx*0.003 + worldSeed*0.01)*0.5 + Math.sin(gx*0.007 + worldSeed*0.02)*0.3 + 0.5; + const humid = Math.sin(gx*0.004 + worldSeed*0.015 + 1000)*0.5 + Math.cos(gx*0.006 + worldSeed*0.02 + 2000)*0.3 + 0.5; + const mtVal = Math.sin(gx*0.001 + worldSeed*0.005)*0.5 + 0.5; + if (temp > 0.7) return 'desert'; + if (temp < 0.3) return 'tundra'; + if (humid > 0.7 && temp >= 0.3 && temp <= 0.7) return 'swamp'; + if (temp >= 0.5 && temp <= 0.7 && mtVal > 0.75) return 'mountains'; + return 'plains'; + } + + const biomeCache = {}; + function getCachedBiome(gx) { + const chunk = Math.floor(gx / 8); // cache per 8-tile chunk for smoother biomes + if (biomeCache[chunk] === undefined) biomeCache[chunk] = getBiome(chunk * 8); + return biomeCache[chunk]; + } + + // ==================== ПОГОДА ==================== + const weatherState = { type: 'clear', intensity: 0, timer: 0, duration: 180, nextChange: 120 + Math.random()*180 }; + const BIOME_WEATHER = { + plains: { clear:0.50, rain:0.30, storm:0.10, snow:0, fog:0.10 }, + desert: { clear:0.80, rain:0.05, storm:0, snow:0, fog:0.15 }, + tundra: { clear:0.20, rain:0, storm:0.10, snow:0.60, fog:0.10 }, + swamp: { clear:0.20, rain:0.30, storm:0.10, snow:0, fog:0.40 }, + mountains:{ clear:0.40, rain:0.30, storm:0.10, snow:0.10, fog:0.10 } + }; + + function updateWeather(dt) { + weatherState.timer += dt; + if (weatherState.timer >= weatherState.nextChange) { + weatherState.timer = 0; + weatherState.nextChange = 60 + Math.random() * 240; + const biome = getCachedBiome(Math.floor(player.x / TILE)); + const probs = BIOME_WEATHER[biome] || BIOME_WEATHER.plains; + const r = Math.random(); + let cum = 0; + if ((cum += probs.clear) > r) { weatherState.type = 'clear'; } + else if ((cum += probs.rain) > r) { weatherState.type = 'rain'; } + else if ((cum += probs.storm) > r) { weatherState.type = 'storm'; } + else if ((cum += probs.snow) > r) { weatherState.type = 'snow'; } + else { weatherState.type = 'fog'; } + weatherState.duration = 60 + Math.random() * 300; + } + // Intensity interpolation + const target = (weatherState.type === 'clear') ? 0 : 1; + weatherState.intensity += (target - weatherState.intensity) * dt * 0.5; + } + + function getWeatherSpeedMultiplier() { + if (weatherState.type === 'rain') return 0.85; + if (weatherState.type === 'snow') return 0.7; + if (weatherState.type === 'storm') return 0.85; + return 1; + } + + function isOutdoorLight(lx, ly) { + // Check if position is outdoors (no block above) + const aboveGy = Math.floor(ly / TILE) - 1; + const aboveGx = Math.floor(lx / TILE); + const above = getBlock(aboveGx, aboveGy); + return !above || !BLOCKS[above.t]?.solid; + } + + // ==================== СТРУКТУРЫ МИРА ==================== + function placeStructure(startGx, startGy, pattern) { + for (let dy = 0; dy < pattern.length; dy++) { + for (let dx = 0; dx < pattern[dy].length; dx++) { + const bt = pattern[dy][dx]; + if (bt && bt !== 'air') { + setBlock(startGx + dx, startGy + dy, bt); + } + } + } + } + + // Пирамида в пустыне + const PYRAMID_PATTERN = [ + ['sand','sand','sand','sand','sand','sand','sand'], + ['sand','stone','stone','stone','stone','stone','sand'], + ['sand','stone','air','air','air','stone','sand'], + ['sand','stone','air','air','air','stone','sand'], + ['sand','stone','air','air','air','stone','sand'], + ['sand','sand','stone','stone','stone','sand','sand'] + ]; + + // Дом в равнине + const HOUSE_PATTERN = [ + ['air','planks','planks','planks','planks','air'], + ['planks','air','air','air','air','planks'], + ['planks','air','torch','air','air','planks'], + ['planks','air','air','air','air','planks'], + ['planks','planks','air','planks','planks','planks'] + ]; + + // Хижина в болоте + const HUT_PATTERN = [ + ['air','wood','wood','wood','air'], + ['wood','air','air','air','wood'], + ['wood','air','torch','air','wood'], + ['moss','moss','air','moss','moss'] + ]; + + // ==================== ГЕНЕРАЦИЯ ==================== const generated = new Set(); // gx already generated - function surfaceGyAt(gx){ - // базовая поверхность выше уровня воды с вариациями + "горы" - // Используем 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 - тем выше + function surfaceGyAt(gx) { + const biome = getCachedBiome(gx); + // Base noise (same for all biomes) + const n1 = Math.sin(gx*0.025 + worldSeed*0.001)*8; + const n2 = Math.sin(gx*0.012 + worldSeed*0.002)*12; + const n3 = Math.sin(gx*0.006 + worldSeed*0.003)*6; + const n4 = Math.sin(gx*0.045 + worldSeed*0.004)*4; + const n5 = Math.cos(gx*0.018 + worldSeed*0.005)*5; + let h; + switch(biome) { + case 'desert': + h = Math.floor(SEA_GY - 4 + n3*0.3 + n4*0.5); // flatter, slightly higher + break; + case 'tundra': + h = Math.floor(SEA_GY - 6 + n2*0.5 + n3*0.4 + n5*0.3); // gentle rolling + break; + case 'swamp': + h = Math.floor(SEA_GY - 2 + n3*0.2 + n4*0.3); // very flat, near sea level + h = Math.max(h, SEA_GY - 3); // never too deep + break; + case 'mountains': + h = Math.floor(SEA_GY - 15 + n1*1.5 + n2*1.2 + n3); // tall peaks + break; + default: // plains + h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5); + } return h; } - - function genColumn(gx){ + + 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) { + // ниже уровня моря — заливаем водой + for(let gy = SEA_GY; gy < sgy; gy++) { + const blockType = (biome === 'swamp' && gy >= SEA_GY - 1) ? 'swamp_water' : 'water'; + setBlock(gx, gy, blockType); } - // пляж setBlock(gx, sgy, 'sand'); } else { - // верхний блок: снег на высоких точках - if(sgy < SEA_GY - 10) setBlock(gx, sgy, 'stone'); - else setBlock(gx, sgy, 'grass'); - } - - // подповерхностные слои - for(let gy=sgy+1; gy<=BEDROCK_GY; gy++){ - if(gy === BEDROCK_GY){ - setBlock(gx,gy,'bedrock'); - continue; + // поверхность + const b = BIOMES[biome]; + setBlock(gx, sgy, b.surface); + // болото: случайные лужи болотной воды + if(biome === 'swamp' && seededRandom(gx*3, sgy) < 0.15) { + setBlock(gx, sgy-1, 'swamp_water'); } - - let t = 'stone'; - - // ближе к поверхности - if(gy <= sgy+3) t = 'dirt'; - - // биомы/материалы - if(sgy > SEA_GY && gy === sgy+1 && seededRandom(gx, gy) < 0.25) t='clay'; - if(gy > sgy+6 && seededRandom(gx, gy) < 0.07) t='gravel'; - - // руды: чем глубже, тем интереснее + // тундра: лёд на воде рядом + if(biome === 'tundra' && sgy === SEA_GY && seededRandom(gx, SEA_GY-1) < 0.3) { + setBlock(gx, SEA_GY-1, 'ice'); + } + } + + // === Подповерхностные слои === + for(let gy = sgy+1; gy <= BEDROCK_GY; gy++) { + if(gy === BEDROCK_GY) { setBlock(gx,gy,'bedrock'); continue; } + + let t = BIOMES[biome].subsurface; + + // глубже — камень + if(gy > sgy + 3) t = 'stone'; + + // пустыня: sand глубже + if(biome === 'desert' && gy <= sgy + 6) t = 'sand'; + + // болото: глина ближе к поверхности + if(biome === 'swamp' && gy <= sgy + 2 && seededRandom(gx, gy) < 0.3) t = 'clay'; + + // горы: gravel + if(biome === 'mountains' && gy > sgy + 4 && seededRandom(gx, gy) < 0.12) t = 'gravel'; + + // общие руды const depth = gy - sgy; const r = seededRandom(gx, gy); - if(t==='stone'){ - if(r < 0.06) t='coal'; - else if(r < 0.10) t='copper_ore'; - else if(r < 0.13) t='iron_ore'; - else if(depth > 40 && r < 0.145) t='gold_ore'; - else if(depth > 70 && r < 0.152) t='diamond_ore'; + 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); + + setBlock(gx, gy, t); } - - // Деревья и цветы (только на траве, и не в воде) + + // === Растительность и декор === + const b = BIOMES[biome]; 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){ - // простое дерево + + // цветы (только plain) + if(biome === 'plains' && top && top.t === 'grass' && seededRandom(gx, sgy-1) < 0.10) { + setBlock(gx, sgy-1, 'flower'); + } + + // деревья + if(b.trees && seededRandom(gx*7, sgy-2) < b.treeChance) { + if(biome === 'tundra') { + // ёлки (треугольные, 3-5 высоты) + const th = 3 + Math.floor(seededRandom(gx, sgy) * 3); + for(let i = 0; i < th; i++) setBlock(gx, sgy-1-i, 'wood'); + // крона (треугольник) + for(let row = 0; row < th; row++) { + const w = Math.min(row + 1, 2); + for(let dx = -w; dx <= w; dx++) { + const ly = sgy-1-th+row; + if(ly >= 0) setBlock(gx+dx, ly, 'spruce_leaves'); + } + } + } else if(biome === 'swamp') { + // болотное дерево (короче, с мхом) + setBlock(gx, sgy-1, 'wood'); + setBlock(gx, sgy-2, 'moss'); + setBlock(gx-1, sgy-2, 'leaves'); + setBlock(gx+1, sgy-2, 'leaves'); + } else { + // обычное дерево (plains) setBlock(gx, sgy-1, 'wood'); setBlock(gx, sgy-2, 'wood'); setBlock(gx, sgy-3, 'leaves'); - setBlock(gx-1, sgy-3,'leaves'); - setBlock(gx+1, sgy-3,'leaves'); + setBlock(gx-1, sgy-3, 'leaves'); + setBlock(gx+1, sgy-3, 'leaves'); } } + // кактусы (пустыня) + if(biome === 'desert' && seededRandom(gx*11, sgy) < 0.04) { + const ch = 1 + Math.floor(seededRandom(gx, sgy+1) * 2); + for(let i = 0; i < ch; i++) setBlock(gx, sgy-1-i, 'cactus'); + } + + // грибы (болото) + if(biome === 'swamp' && seededRandom(gx*13, sgy) < 0.06) { + setBlock(gx, sgy-1, 'mushroom'); + } + + // сухие кусты (пустыня) + if(biome === 'desert' && seededRandom(gx*17, sgy) < 0.05) { + setBlock(gx, sgy-1, 'dead_bush'); + } + + // === Структуры мира === + // Пирамида в пустыне + if(biome === 'desert' && ((gx % 200 + 200) % 200) === 47 && sgy < SEA_GY && sgy > SEA_GY - 5) { + placeStructure(gx, sgy - 5, PYRAMID_PATTERN); + } + // Дом в равнине + if(biome === 'plains' && ((gx % 150 + 150) % 150) === 33 && sgy < SEA_GY) { + placeStructure(gx, sgy - 4, HOUSE_PATTERN); + } + // Хижина в болоте + if(biome === 'swamp' && ((gx % 180 + 180) % 180) === 55 && sgy < SEA_GY) { + placeStructure(gx, sgy - 3, HUT_PATTERN); + } + // Применяем серверные оверрайды для этой колонны const colPrefix = gx + ','; for (const [key, ov] of serverOverrides) { @@ -3012,8 +3482,76 @@ function customConfirm(msg, onYes) { life: 3 }); } + } else if(m.kind==='scorpion') { + // Скорпион — бежит к игроку, яд (замедление) + const dir = Math.sign((player.x) - m.x); + m.vx = dir * m.speed; + if(m.inWater && Math.random()<0.06) m.vy = -260; + // Яд при касании — замедление на 3 сек + if(Math.abs((m.x+ m.w/2) - (player.x+player.w/2)) < 28 && + Math.abs((m.y+ m.h/2) - (player.y+player.h/2)) < 40 && + player.invuln <= 0){ + const damage = calculateDamage(8); + player.hp -= damage; + player.invuln = 0.8; + player.slowTimer = 3; // замедление + player.vx += dir*300; + player.vy -= 200; + playSound('hit1'); + } + } else if(m.kind==='polar_bear') { + // Белый медведь — нейтрален, атакует если ударили (hostile пока нет, атакует через proximity) + m.aiT -= dt; + if(m.aiT <= 0){ + m.aiT = 2.0 + Math.random()*3; + m.dir = Math.random()<0.5 ? -1 : 1; + if(Math.random()<0.3) m.dir = 0; + } + m.vx = m.dir * m.speed; + if(m.inWater) m.vy = -120; + } else if(m.kind==='slime') { + // Слизь — прыгает к игроку + const dir = Math.sign((player.x+player.w/2) - (m.x+m.w/2)); + m.aiT -= dt; + if(m.aiT <= 0){ + m.aiT = 1.5 + Math.random()*1.5; + m.dir = dir; + // Прыжок + m.vy = -200; + } + m.vx = m.dir * m.speed; + } else if(m.kind==='eagle') { + // Орёл — летает, атакует пикированием + const dx = (player.x+player.w/2) - (m.x+m.w/2); + const dy = (player.y+player.h/2) - (m.y+m.h/2); + const dist = Math.hypot(dx, dy); + if(dist < 400) { + // Пикирует на игрока + m.vx = Math.sign(dx) * m.speed; + m.vy = dy > 0 ? 60 : -60; // летит вниз к игроку + } else { + // Патрулирует + m.aiT -= dt; + if(m.aiT <= 0){ + m.aiT = 2+Math.random()*3; + m.dir = Math.random()<0.5 ? -1 : 1; + } + m.vx = m.dir * m.speed * 0.5; + m.vy = Math.sin(performance.now()/1000) * 30; // мягкое покачивание + } + // Атака при касании + if(dist < 30 && player.invuln <= 0){ + const damage = calculateDamage(10); + player.hp -= damage; + player.invuln = 0.8; + player.vy -= 200; + playSound('hit1'); + } + // Орёл не падает — летающий моб + m.vy *= 0.5; + m.grounded = false; } else { - // животные + // животные (pig, chicken) m.aiT -= dt; if(m.aiT <= 0){ m.aiT = 1.8 + Math.random()*2.5; @@ -3229,7 +3767,10 @@ function customConfirm(msg, onYes) { c.x -= c.s * dt; if(c.x + c.w < camX - 400) c.x = camX + W + Math.random()*700; } - + + // Погода (биом-зависимая) + updateWeather(dt); + // player updateWaterFlag(player); @@ -3253,7 +3794,8 @@ function customConfirm(msg, onYes) { player.vy = 0; } else { const dir = (inp.r?1:0) - (inp.l?1:0); - if(dir) player.vx = dir*MOVE; + const speedMult = getWeatherSpeedMultiplier() * (player.slowTimer > 0 ? 0.4 : 1.0); + if(dir) player.vx = dir * MOVE * speedMult; else player.vx *= 0.82; } @@ -3378,6 +3920,32 @@ function customConfirm(msg, onYes) { updateRain(dt); player.invuln = Math.max(0, player.invuln - dt); + if(player.slowTimer > 0) player.slowTimer = Math.max(0, player.slowTimer - dt); + + // Рост культур + for(const key of Object.keys(growthTimers)){ + const tile = growthTimers[key]; + if(tile.stage < 3){ + tile.growTimer -= dt; + if(tile.growTimer <= 0){ + tile.stage++; + tile.growTimer = 8 + Math.random()*6; + // Обновляем визуальный блок + const [gxStr, gyStr] = key.split(','); + const gx = parseInt(gxStr), gy = parseInt(gyStr); + const curBlock = getBlock(gx, gy); + if(curBlock && curBlock.t !== curBlock.t.replace(/_stage\d/, '_stage'+tile.stage)){ + // Вычисляем следующую стадию + const baseType = curBlock.t.replace(/_stage\d/, ''); + const nextType = baseType + '_stage' + tile.stage; + setBlock(gx, gy, nextType); + sendBlockChange(gx, gy, nextType, 'add'); + // Проверяем созрел ли + if(tile.stage >= 3) delete growthTimers[key]; + } + } + } + } // Voice position update voicePosT += dt; @@ -3694,6 +4262,55 @@ function customConfirm(msg, onYes) { ctx.lineTo(8*Math.cos(Math.PI*0.7), 8*Math.sin(Math.PI*0.7)); ctx.stroke(); ctx.restore(); + } else if(m.kind==='scorpion') { + // скорпион — оранжево-коричневый + ctx.fillStyle = '#d35400'; + ctx.fillRect(m.x+2, m.y+4, m.w-4, m.h-8); + ctx.fillStyle = '#c0392b'; + ctx.fillRect(m.x+m.w-4, m.y-6, 4, 10); + ctx.fillRect(m.x+m.w-2, m.y-10, 3, 5); + ctx.fillStyle = '#000'; + ctx.fillRect(m.x+4, m.y+6, 3, 3); + ctx.fillRect(m.x+m.w-7, m.y+6, 3, 3); + ctx.fillStyle = '#d35400'; + ctx.fillRect(m.x-4, m.y+8, 6, 4); + ctx.fillRect(m.x+m.w-2, m.y+8, 6, 4); + } else if(m.kind==='polar_bear') { + ctx.fillStyle = '#ecf0f1'; + ctx.fillRect(m.x+2, m.y+4, m.w-4, m.h-4); + ctx.fillRect(m.x+8, m.y-2, m.w-16, 10); + ctx.fillStyle = '#bdc3c7'; + ctx.fillRect(m.x+m.w/2-3, m.y+2, 6, 4); + ctx.fillStyle = '#000'; + ctx.fillRect(m.x+12, m.y+1, 3, 3); + ctx.fillRect(m.x+m.w-14, m.y+1, 3, 3); + ctx.fillStyle = '#ecf0f1'; + ctx.fillRect(m.x+8, m.y-4, 4, 4); + ctx.fillRect(m.x+m.w-12, m.y-4, 4, 4); + } else if(m.kind==='slime') { + const bounce = Math.sin(performance.now()/200)*3; + ctx.fillStyle = 'rgba(46,204,113,0.8)'; + ctx.fillRect(m.x-1, m.y-1+bounce, m.w+2, m.h+2); + ctx.fillStyle = 'rgba(39,174,96,0.9)'; + ctx.fillRect(m.x+1, m.y+1+bounce, m.w-2, m.h-2); + ctx.fillStyle = '#fff'; + ctx.fillRect(m.x+4, m.y+6+bounce, 6, 6); + ctx.fillRect(m.x+m.w-10, m.y+6+bounce, 6, 6); + ctx.fillStyle = '#000'; + ctx.fillRect(m.x+6, m.y+8+bounce, 3, 3); + ctx.fillRect(m.x+m.w-8, m.y+8+bounce, 3, 3); + } else if(m.kind==='eagle') { + ctx.fillStyle = '#8B4513'; + ctx.fillRect(m.x+8, m.y+6, m.w-16, m.h-10); + const wingY = Math.sin(performance.now()/150)*4; + ctx.fillRect(m.x-6, m.y+4+wingY, 16, 6); + ctx.fillRect(m.x+m.w-10, m.y+4-wingY, 16, 6); + ctx.fillStyle = '#fff'; + ctx.fillRect(m.x+m.w/2-3, m.y+2, 6, 6); + ctx.fillStyle = '#f39c12'; + ctx.fillRect(m.x+m.w/2-1, m.y+6, 4, 3); + ctx.fillStyle = '#000'; + ctx.fillRect(m.x+m.w/2-1, m.y+3, 2, 2); } } From 0a31f92af8dbd23df9682257cbd9268e0725f377 Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 17:48:01 +0000 Subject: [PATCH 15/15] =?UTF-8?q?feat:=20voice=20chat=20v3=20=E2=80=94=20A?= =?UTF-8?q?udioWorklet,=20Opus/PCM,=20per-speaker,=20spatial=20audio,=20VA?= =?UTF-8?q?D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game.js | 754 ++++++-- game.js.bak.v23 | 4568 +++++++++++++++++++++++++++++++++++++++++++++++ index.html | 2 +- 3 files changed, 5180 insertions(+), 144 deletions(-) create mode 100644 game.js.bak.v23 diff --git a/game.js b/game.js index 4b00664..3822c0b 100644 --- a/game.js +++ b/game.js @@ -1448,24 +1448,400 @@ function customConfirm(msg, onYes) { } } - // ==================== ГОЛОСОВОЙ ЧАТ ==================== + + + // ==================== ГОЛОСОВОЙ ЧАТ v3 ==================== + // ==================== ГОЛОСОВОЙ ЧАТ v3 ==================== +// Per-speaker architecture, AudioWorklet, VAD, Opus/PCM, spatial audio +// Replaces lines 1449-1665 in game.js + let voiceSocket = null; let voiceStream = null; let audioCtx = null; - let voiceProcessor = null; let voiceActive = false; - let voiceMode = 'near'; // 'near' or 'world' - let voiceDebugCount = 0; + let voiceMode = 'near'; const VOICE_SERVER = 'https://voicegrech.mkn8n.ru'; + + // Voice config + const VC = { + sampleRate: 16000, // 16kHz — sufficient for voice, saves 33% bandwidth + frameMs: 20, // 20ms frames = 320 samples @ 16kHz + samplesPerFrame: 320, // 16000 * 0.02 + vadThreshold: 0.008, // RMS threshold for voice detection + vadHangover: 5, // 100ms hangover after speech ends + jbufTargetMs: 80, // Target jitter: 80ms (was 200ms) + jbufMinMs: 40, + jbufMaxMs: 200, + maxSpeakers: 6, + voiceRadius: 600, + opusBitrate: 16000, + posUpdateMs: 200, // Update position every 200ms (was 500ms) + }; + + // Codec state + let voiceCodec = 'pcm'; // 'opus' or 'pcm' + let voiceEncoder = null; // WebCodecs AudioEncoder + let voiceSeq = 0; + let voiceTimestamp = 0; + let wasSpeaking = false; + let silenceFrames = 0; + let captureNode = null; + let playbackNode = null; + + // Per-speaker map + const remoteSpeakers = new Map(); // socketId → { jitterBuf, lastFrame, gain, panner, lowpass, decoder, codec, x, y, name, mode, speaking, lastActive } + + // ==================== WORKLET CODE (inline strings) ==================== + + const voiceCaptureWorkletCode = ` +class VoiceCaptureProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this._buf = new Float32Array(320); // 20ms @ 16kHz + this._pos = 0; + this._speaking = false; + this._silenceFrames = 0; + this._hangover = 5; // 100ms + this._vadThreshold = 0.008; + this._lastRms = 0; + } + process(inputs) { + const input = inputs[0]; + if (!input || !input[0]) return true; + const ch = input[0]; + let i = 0; + while (i < ch.length) { + const rem = this._buf.length - this._pos; + const n = Math.min(rem, ch.length - i); + this._buf.set(ch.subarray(i, i + n), this._pos); + this._pos += n; + i += n; + if (this._pos >= this._buf.length) { + this._processFrame(); + this._pos = 0; + } + } + return true; + } + _processFrame() { + let sum = 0; + for (let i = 0; i < this._buf.length; i++) sum += this._buf[i] * this._buf[i]; + const rms = Math.sqrt(sum / this._buf.length); + this._lastRms = rms; + + if (rms > this._vadThreshold) { + this._speaking = true; + this._silenceFrames = 0; + } else { + this._silenceFrames++; + if (this._silenceFrames > this._hangover) this._speaking = false; + } + + this.port.postMessage({ + type: 'frame', + samples: this._buf.slice(), + speaking: this._speaking, + rms: rms + }); + } +} +registerProcessor('voice-capture', VoiceCaptureProcessor); +`; - // Кнопка микрофона + const voicePlaybackWorkletCode = ` +class VoicePlaybackProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.speakers = new Map(); // id → { ringBuf, readPos, writePos, ready, gain, pan, lastSample, active } + this.RING_SIZE = 48000; // 3 seconds @ 16kHz + this.port.onmessage = (e) => { + const d = e.data; + if (d.type === 'addSpeaker') { + this.speakers.set(d.id, { + ringBuf: new Float32Array(this.RING_SIZE), + readPos: 0, writePos: 0, ready: 0, + gain: d.gain || 1, pan: d.pan || 0, + lastSample: 0, active: false, + jbufTarget: 320 * 4, // ~80ms in samples + fadeOut: 0 + }); + } else if (d.type === 'removeSpeaker') { + this.speakers.delete(d.id); + } else if (d.type === 'pushFrames') { + const sp = this.speakers.get(d.id); + if (!sp) return; + const frames = d.samples; // Float32Array + for (let i = 0; i < frames.length; i++) { + sp.ringBuf[sp.writePos] = frames[i]; + sp.writePos = (sp.writePos + 1) % this.RING_SIZE; + if (sp.ready < this.RING_SIZE) sp.ready++; + } + sp.active = true; + sp.fadeOut = 0; + } else if (d.type === 'updateSpatial') { + const sp = this.speakers.get(d.id); + if (sp) { + sp.gain = d.gain; + sp.pan = d.pan; + } + } + }; + } + process(inputs, outputs) { + const output = outputs[0]; + if (!output || !output[0]) return true; + const left = output[0]; + const right = output[1] || output[0]; // mono fallback + + // Clear output + for (let i = 0; i < left.length; i++) { left[i] = 0; right[i] = 0; } + + for (const [id, sp] of this.speakers) { + if (sp.ready < 1 && sp.active) { + // Fade out over ~20ms (128 samples) + sp.fadeOut++; + if (sp.fadeOut > 128) { sp.active = false; continue; } + const fade = 1 - (sp.fadeOut / 128); + const g = sp.gain * fade; + const lg = g * Math.cos((sp.pan + 1) * Math.PI / 4); + const rg = g * Math.sin((sp.pan + 1) * Math.PI / 4); + // Repeat last sample with fade + for (let i = 0; i < left.length; i++) { + left[i] += sp.lastSample * lg; + right[i] += sp.lastSample * rg; + } + continue; + } + if (!sp.active && sp.ready < sp.jbufTarget) continue; // Wait for jitter buffer to fill + if (!sp.active && sp.ready >= sp.jbufTarget) sp.active = true; + if (!sp.active) continue; + + sp.fadeOut = 0; + const g = sp.gain; + // Constant-power pan law + const lg = g * Math.cos((sp.pan + 1) * Math.PI / 4); + const rg = g * Math.sin((sp.pan + 1) * Math.PI / 4); + + for (let i = 0; i < left.length; i++) { + if (sp.ready > 0) { + const s = sp.ringBuf[sp.readPos]; + sp.lastSample = s; + left[i] += s * lg; + right[i] += s * rg; + sp.readPos = (sp.readPos + 1) % this.RING_SIZE; + sp.ready--; + } else { + // Underrun — fade from last sample + sp.lastSample *= 0.85; + left[i] += sp.lastSample * lg; + right[i] += sp.lastSample * rg; + } + } + } + return true; + } +} +registerProcessor('voice-playback', VoicePlaybackProcessor); +`; + + // ==================== Opus ENCODE/DECODE ==================== + + async function initVoiceEncoder() { + if ('AudioEncoder' in window) { + try { + // Check if Opus is supported + const support = await AudioEncoder.isConfigSupported({ + codec: 'opus', + sampleRate: VC.sampleRate, + numberOfChannels: 1, + bitrate: VC.opusBitrate + }); + if (!support.supported) throw new Error('Opus not supported'); + + voiceEncoder = new AudioEncoder({ + output: (chunk) => { + // chunk: EncodedAudioChunk with Opus data + const data = new Uint8Array(chunk.byteLength); + chunk.copyTo(data); + // Send as binary frame via Socket.IO + voiceSocket.emit('voice_data', { + codec: 'opus', + data: data.buffer, + seq: voiceSeq++, + ts: chunk.timestamp, + speaking: true + }); + }, + error: (e) => { + console.error('[voice] encoder error:', e); + voiceCodec = 'pcm'; + voiceEncoder = null; + } + }); + + await voiceEncoder.configure({ + codec: 'opus', + sampleRate: VC.sampleRate, + numberOfChannels: 1, + bitrate: VC.opusBitrate + }); + + voiceCodec = 'opus'; + console.log('[voice] WebCodecs Opus encoder initialized @', VC.opusBitrate, 'bps'); + return; + } catch (e) { + console.warn('[voice] WebCodecs Opus not available, PCM fallback:', e.message); + } + } + voiceCodec = 'pcm'; + console.log('[voice] Using PCM @', VC.sampleRate, 'Hz'); + } + + async function initDecoderForSpeaker(speakerId, codec) { + const sp = remoteSpeakers.get(speakerId); + if (!sp) return; + + if (codec === 'opus' && 'AudioDecoder' in window) { + try { + const decoder = new AudioDecoder({ + output: (audioData) => { + const samples = new Float32Array(audioData.numberOfFrames); + audioData.copyTo(samples, { planeIndex: 0 }); + audioData.close(); + // Push decoded PCM to worklet + if (playbackNode) { + playbackNode.port.postMessage({ + type: 'pushFrames', + id: speakerId, + samples: samples + }); + } + }, + error: (e) => { + console.error('[voice] decoder error for', speakerId, e); + sp.codec = 'pcm'; // Fallback to PCM + } + }); + + await decoder.configure({ + codec: 'opus', + sampleRate: VC.sampleRate, + numberOfChannels: 1 + }); + + sp.decoder = decoder; + sp.codec = 'opus'; + console.log('[voice] Opus decoder initialized for', speakerId); + } catch (e) { + console.warn('[voice] Opus decoder failed, PCM fallback:', e.message); + sp.codec = 'pcm'; + } + } else { + sp.codec = 'pcm'; + } + } + + // ==================== RemoteSpeaker helper ==================== + + function createRemoteSpeaker(socketId, name, codec) { + const sp = { + id: socketId, + name: name || '???', + codec: codec || 'pcm', + decoder: null, + x: 0, y: 0, + mode: 'near', + speaking: false, + lastActive: Date.now(), + // Audio nodes per speaker (managed on main thread for smooth ramps) + gainNode: audioCtx.createGain(), + pannerNode: audioCtx.createStereoPanner(), + lowpassNode: audioCtx.createBiquadFilter(), + }; + // lowpass for distance muffling + sp.lowpassNode.type = 'lowpass'; + sp.lowpassNode.frequency.value = 4000; + sp.lowpassNode.Q.value = 0.7; + // Connect: lowpass → panner → gain → (connected to mixer later) + sp.lowpassNode.connect(sp.pannerNode); + sp.pannerNode.connect(sp.gainNode); + sp.gainNode.gain.value = 0; // Start silent + + remoteSpeakers.set(socketId, sp); + initDecoderForSpeaker(socketId, codec); + return sp; + } + + function removeRemoteSpeaker(socketId) { + const sp = remoteSpeakers.get(socketId); + if (!sp) return; + try { sp.gainNode.disconnect(); } catch(e) {} + try { sp.pannerNode.disconnect(); } catch(e) {} + try { sp.lowpassNode.disconnect(); } catch(e) {} + if (sp.decoder) { try { sp.decoder.close(); } catch(e) {} } + remoteSpeakers.delete(socketId); + if (playbackNode) { + playbackNode.port.postMessage({ type: 'removeSpeaker', id: socketId }); + } + } + + function updateSpeakerSpatial(sp) { + if (!audioCtx) return; + // Calculate distance-based audio + const dx = sp.x - player.x; + const dy = sp.y - player.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + const now = audioCtx.currentTime; + const rampTime = 0.05; // 50ms smooth ramp + + if (sp.mode === 'world' && voiceMode === 'world') { + // World mode: full volume, no panning, no distance filter + sp.gainNode.gain.cancelScheduledValues(now); + sp.gainNode.gain.linearRampToValueAtTime(1.0, now + rampTime); + sp.pannerNode.pan.cancelScheduledValues(now); + sp.pannerNode.pan.linearRampToValueAtTime(0, now + rampTime); + sp.lowpassNode.frequency.cancelScheduledValues(now); + sp.lowpassNode.frequency.linearRampToValueAtTime(4000, now + rampTime); + } else if (dist > VC.voiceRadius) { + // Out of range + sp.gainNode.gain.cancelScheduledValues(now); + sp.gainNode.gain.linearRampToValueAtTime(0, now + rampTime); + } else { + // Near mode with spatial audio + const volume = 1 / (1 + dist / 150); // Perceptual falloff + const pan = Math.max(-1, Math.min(1, dx / 300)); // Stereo pan based on X + const cutoff = 4000 - (3800 * (dist / VC.voiceRadius)); // Muffle at distance + + sp.gainNode.gain.cancelScheduledValues(now); + sp.gainNode.gain.linearRampToValueAtTime(volume, now + rampTime); + sp.pannerNode.pan.cancelScheduledValues(now); + sp.pannerNode.pan.linearRampToValueAtTime(pan, now + rampTime); + sp.lowpassNode.frequency.cancelScheduledValues(now); + sp.lowpassNode.frequency.linearRampToValueAtTime(Math.max(200, cutoff), now + rampTime); + } + + // Update worklet spatial params + if (playbackNode) { + const currentGain = sp.gainNode.gain.value; + const currentPan = sp.pannerNode.pan.value; + playbackNode.port.postMessage({ + type: 'updateSpatial', + id: sp.id, + gain: currentGain, + pan: currentPan + }); + } + } + + // ==================== UI ==================== + const voiceBtn = document.createElement('div'); voiceBtn.innerHTML = '🎤/'; voiceBtn.title = 'Голосовой чат (выкл)'; voiceBtn.style.cssText = 'position:absolute;top:74px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;'; document.querySelector('.ui').appendChild(voiceBtn); - - // Кнопка режима голоса (близко / весь мир) + const voiceModeBtn = document.createElement('div'); voiceModeBtn.innerHTML = '📢'; voiceModeBtn.title = 'Режим: рядом (600px)'; @@ -1486,177 +1862,269 @@ function customConfirm(msg, onYes) { if (voiceSocket && voiceSocket.connected) { voiceSocket.emit('voice_mode', { mode: voiceMode }); } + // Update spatial for all speakers + for (const [id, sp] of remoteSpeakers) updateSpeakerSpatial(sp); }; - - // Индикатор говорящего + + // Speaking indicators per players const speakingIndicator = document.createElement('div'); - speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;'; - speakingIndicator.textContent = '🔊'; + speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;max-width:200px;line-height:1.4;'; document.querySelector('.ui').appendChild(speakingIndicator); - let speakingTimeout = null; - + let speakingTimeouts = new Map(); // socketId → timeout + + // Codec indicator + const codecIndicator = document.createElement('div'); + codecIndicator.style.cssText = 'position:absolute;top:180px;right:10px;z-index:200;font-size:10px;color:rgba(255,255,255,0.5);pointer-events:none;'; + codecIndicator.textContent = ''; + document.querySelector('.ui').appendChild(codecIndicator); + + // ==================== VOICE ON/OFF ==================== + voiceBtn.onclick = async () => { if (voiceActive) { - // Выключить + // === DISABLE === voiceActive = false; - ringReady = 0; ringRead = ringWrite; voicePlayActive = false; voiceBtn.innerHTML = '🎤/'; voiceBtn.style.background = '#555'; - if (voiceStream) { - voiceStream.getTracks().forEach(t => t.stop()); - voiceStream = null; + codecIndicator.textContent = ''; + + // Clean up capture + if (captureNode) { captureNode.disconnect(); captureNode = null; } + if (voiceStream) { voiceStream.getTracks().forEach(t => t.stop()); voiceStream = null; } + + // Clean up playback + if (playbackNode) { playbackNode.disconnect(); playbackNode = null; } + + // Clean up per-speaker nodes + for (const [id, sp] of remoteSpeakers) { + try { sp.gainNode.disconnect(); } catch(e) {} + try { sp.pannerNode.disconnect(); } catch(e) {} + try { sp.lowpassNode.disconnect(); } catch(e) {} + if (sp.decoder) { try { sp.decoder.close(); } catch(e) {} } } - if (voiceProcessor) { voiceProcessor.disconnect(); voiceProcessor = null; } + remoteSpeakers.clear(); + + // Clean up encoder + if (voiceEncoder) { try { voiceEncoder.close(); } catch(e) {} voiceEncoder = null; } + if (audioCtx) { audioCtx.close(); audioCtx = null; } if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; } + speakingIndicator.style.display = 'none'; return; } - - // Включить + + // === ENABLE === try { - voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); - 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(2048, 1, 1); - console.log('[voice] ScriptProcessor created, bufferSize=2048'); - - 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); + voiceStream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + echoCancellationType: 'browser', + noiseSuppressionType: 'browser', + sampleRate: { ideal: VC.sampleRate }, + channelCount: { ideal: 1 } } - 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])); + }); + + audioCtx = new AudioContext({ sampleRate: VC.sampleRate }); + if (audioCtx.state === 'suspended') await audioCtx.resume(); + console.log('[voice] AudioContext:', audioCtx.state, 'sampleRate:', audioCtx.sampleRate); + + // Register worklets via Blob URLs + const captureURL = URL.createObjectURL(new Blob([voiceCaptureWorkletCode], { type: 'application/javascript' })); + const playbackURL = URL.createObjectURL(new Blob([voicePlaybackWorkletCode], { type: 'application/javascript' })); + + await audioCtx.audioWorklet.addModule(captureURL); + await audioCtx.audioWorklet.addModule(playbackURL); + URL.revokeObjectURL(captureURL); + URL.revokeObjectURL(playbackURL); + console.log('[voice] AudioWorklets registered'); + + // Connect mic → capture worklet + const micSource = audioCtx.createMediaStreamSource(voiceStream); + captureNode = new AudioWorkletNode(audioCtx, 'voice-capture'); + micSource.connect(captureNode); + // Do NOT connect captureNode to destination — we don't hear ourselves + + // Connect playback worklet → per-speaker gain nodes → destination + playbackNode = new AudioWorkletNode(audioCtx, 'voice-playback', { numberOfOutputs: 1, outputChannelCount: [2] }); + playbackNode.connect(audioCtx.destination); + // Per-speaker nodes will connect between playback worklet output and destination + // Actually: playback worklet mixes internally → stereo output → destination + // Per-speaker Web Audio nodes (lowpass, pan, gain) used for spatial UPDATES only (values sent to worklet via messages) + // The actual mixing happens inside the worklet + + // Handle capture frames + voiceSeq = 0; + voiceTimestamp = 0; + wasSpeaking = false; + captureNode.port.onmessage = (e) => { + const { type, samples, speaking, rms } = e.data; + if (type !== 'frame') return; + if (!voiceActive || !voiceSocket || !voiceSocket.connected) return; + + voiceTimestamp += VC.samplesPerFrame; // 320 samples * (1/16000) = 20ms per frame + + if (!speaking && !wasSpeaking) { + // Complete silence — don't transmit + silenceFrames++; + return; + } + + if (voiceCodec === 'opus' && voiceEncoder) { + // Encode with Opus via WebCodecs + try { + const audioData = new AudioData({ + format: 'float32-planar', + sampleRate: VC.sampleRate, + numberOfFrames: samples.length, + numberOfChannels: 1, + timestamp: voiceTimestamp * (1000000 / VC.sampleRate), // microseconds + data: samples + }); + voiceEncoder.encode(audioData); + audioData.close(); + } catch (err) { + // Fallback: send as PCM + sendPCMFrame(samples); + } + } else { + sendPCMFrame(samples); + } + + wasSpeaking = speaking; + silenceFrames = 0; + }; + + function sendPCMFrame(samples) { + const int16 = new Int16Array(samples.length); + for (let i = 0; i < samples.length; i++) { + const s = Math.max(-1, Math.min(1, samples[i])); int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; } - voiceSocket.emit('voice_data', 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.emit('voice_data', { + codec: 'pcm', + data: int16.buffer, + seq: voiceSeq++, + ts: performance.now(), + speaking: wasSpeaking || speaking + }); + } + + // Initialize encoder + await initVoiceEncoder(); + codecIndicator.textContent = voiceCodec === 'opus' ? '🔊 Opus' : '🔊 PCM'; + + // Connect to voice server 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 }); + console.log('[voice] Connected, id:', voiceSocket.id, 'codec:', voiceCodec); + voiceSocket.emit('voice_join', { + world_id: worldId, + x: player.x, y: player.y, + name: playerName || 'Игрок', + mode: voiceMode, + codec: voiceCodec + }); }); + voiceSocket.on('connect_error', (err) => { - console.error('[voice] Socket connect error:', err.message); + console.error('[voice] Connect error:', err.message); }); - - // === Ring Buffer + ScriptProcessor приём голоса === - // Единый непрерывный поток вместо отдельных BufferSource на чанк - const RING_SIZE = 24000 * 3; // 3 секунды ring buffer - const ringBuf = new Float32Array(RING_SIZE); - let ringWrite = 0; // позиция записи - let ringRead = 0; // позиция чтения - let ringReady = 0; // сколько сэмплов готово - let voicePlayActive = false; - const JBUF_TARGET = 4800; // целевой jitter buffer: 200мс при 24kHz - let jbufFill = 0; // текущее заполнение - let lastVoiceFrom = ''; // кто говорит (для индикатора) - - // Воспроизводящий ScriptProcessor — читает из ring buffer - const playProcessor = audioCtx.createScriptProcessor(2048, 1, 1); - let lastSample = 0; // для плавного fade при underrun - playProcessor.onaudioprocess = (e) => { - const out = e.outputBuffer.getChannelData(0); - if (ringReady < 1) { - // Плавный fade-out от последнего сэмпла к тишине - for (let i = 0; i < out.length; i++) { - lastSample *= 0.9; - out[i] = lastSample; - } - voicePlayActive = false; - return; - } - // Ждём накопления jitter buffer перед стартом - if (!voicePlayActive && ringReady >= JBUF_TARGET) { - voicePlayActive = true; - } - if (!voicePlayActive) { - // Плавно затихаем пока буфер копится - for (let i = 0; i < out.length; i++) { - lastSample *= 0.95; - out[i] = lastSample; - } - return; - } - // Читаем из ring buffer с плавным fade-in на старте - for (let i = 0; i < out.length; i++) { - if (ringReady > 0) { - out[i] = ringBuf[ringRead]; - lastSample = out[i]; // запоминаем для fade-out - ringRead = (ringRead + 1) % RING_SIZE; - ringReady--; - } else { - // Underrun — плавно затихаем - lastSample *= 0.85; - out[i] = lastSample; - } - } - }; - const playGain = audioCtx.createGain(); - playGain.gain.value = 1.0; - playProcessor.connect(playGain).connect(audioCtx.destination); - + voiceSocket.on('voice_in', (payload) => { - // Пишем входящий голос в ring buffer - const { data, meta, volume } = payload; + const { data, meta } = payload; if (!audioCtx || audioCtx.state === 'closed') return; - - const int16 = new Int16Array(data); - const vol = Math.min(1.4, (volume || 1) * 1.5); - for (let i = 0; i < int16.length; i++) { - const sample = (int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF)) * vol; - ringBuf[ringWrite] = sample; - ringWrite = (ringWrite + 1) % RING_SIZE; - ringReady = Math.min(ringReady + 1, RING_SIZE); + + let sp = remoteSpeakers.get(meta.from); + if (!sp) { + // New speaker — create per-speaker audio nodes + sp = createRemoteSpeaker(meta.from, meta.name, meta.codec || 'pcm'); } - // Сброс jitter fill если пауза была - jbufFill = ringReady; - lastVoiceFrom = meta.name || '???'; - - // Индикатор + + // Update position + sp.x = meta.x || 0; + sp.y = meta.y || 0; + sp.mode = meta.mode || 'near'; + sp.lastActive = Date.now(); + updateSpeakerSpatial(sp); + + // Decode and push to worklet + if ((meta.codec || 'pcm') === 'opus' && sp.decoder) { + // Opus decode + try { + const uint8 = new Uint8Array(data); + const chunk = new EncodedAudioChunk({ + type: 'key', // Opus frames are self-decodable + timestamp: performance.now() * 1000, // microseconds + data: uint8 + }); + sp.decoder.decode(chunk); + } catch (err) { + // Opus decode failed, try PCM fallback + decodeAndPushPCM(sp.id, data); + } + } else { + decodeAndPushPCM(sp.id, data); + } + + // Update speaking indicator + sp.speaking = true; speakingIndicator.style.display = 'block'; - speakingIndicator.textContent = '🔊 ' + lastVoiceFrom; - clearTimeout(speakingTimeout); - speakingTimeout = setTimeout(() => { - speakingIndicator.style.display = 'none'; - voicePlayActive = false; // сброс при паузе - ringReady = 0; // очистить буфер - ringRead = ringWrite; // синхронизировать - }, 1500); + speakingIndicator.textContent = '🔊 ' + sp.name; + clearTimeout(speakingTimeouts.get(meta.from)); + speakingTimeouts.set(meta.from, setTimeout(() => { + sp.speaking = false; + // Check if anyone is still speaking + let anyoneSpeaking = false; + for (const [, s] of remoteSpeakers) { if (s.speaking) { anyoneSpeaking = true; break; } } + if (!anyoneSpeaking) speakingIndicator.style.display = 'none'; + speakingTimeouts.delete(meta.from); + }, 1500)); }); - + + voiceSocket.on('voice_leave', (data) => { + removeRemoteSpeaker(data.id); + }); + + voiceSocket.on('disconnect', () => { + console.log('[voice] Disconnected'); + // Clear all speakers on disconnect + for (const [id] of remoteSpeakers) removeRemoteSpeaker(id); + }); + voiceActive = true; voiceBtn.textContent = '🎤'; voiceBtn.style.background = '#2ecc71'; - console.log('[voice] Voice chat ACTIVE'); + console.log('[voice] Voice chat ACTIVE, codec:', voiceCodec); + } catch(e) { console.error('[voice] Error:', e); voiceBtn.style.background = '#e74c3c'; } }; - - // Обновляем позицию для voice server - const origPlayerMove = () => {}; - // Хук в главный цикл — обновляем позицию каждые ~500ms + + // ==================== PCM decode helper ==================== + + function decodeAndPushPCM(speakerId, buffer) { + const int16 = new Int16Array(buffer); + const float32 = new Float32Array(int16.length); + for (let i = 0; i < int16.length; i++) { + float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF); + } + if (playbackNode) { + playbackNode.port.postMessage({ + type: 'pushFrames', + id: speakerId, + samples: float32 + }); + } + } + + // ==================== Voice position update ==================== + let voicePosT = 0; // Клик на часы для включения ночи diff --git a/game.js.bak.v23 b/game.js.bak.v23 new file mode 100644 index 0000000..4b00664 --- /dev/null +++ b/game.js.bak.v23 @@ -0,0 +1,4568 @@ +(() => { + // ==================== КОНФИГУРАЦИЯ СЕРВЕРА ==================== +// === 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 }, + scorpion: { w: 26, h: 26, hp: 3, speed: 90, hostile: true, fuse: 0, shootCooldown: 0, biome:'desert' }, + polar_bear:{ w: 40, h: 34, hp: 8, speed: 50, hostile: false, fuse: 0, shootCooldown: 2, biome:'tundra' }, + slime: { w: 24, h: 24, hp: 2, speed: 30, hostile: true, fuse: 0, shootCooldown: 2, biome:'swamp' }, + eagle: { w: 30, h: 22, hp: 3, speed: 120, hostile: true, fuse: 0, shootCooldown: 0, biome:'mountains' } + }; + const props = kindProps[data.kind] || kindProps['pig']; // fallback + return { + id: data.id, + kind: data.kind, + x: data.x, + y: data.y, + w: props.w, + h: props.h, + hp: data.hp || props.hp, + maxHp: data.maxHp || data.hp || props.hp, + speed: props.speed, + hostile: props.hostile, + vx: 0, + vy: 0, + grounded: false, + inWater: false, + aiT: 0, + dir: data.dir || 1, + dead: false, + fuse: props.fuse, + shootCooldown: props.shootCooldown + }; + } + let mySocketId = null; + + // Throttle для отправки позиции (10-20 раз в секунду) + let lastMoveSendTime = 0; + const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду + let lastSentX = 0, lastSentY = 0; + + function initSocket() { + try { + socket = io(SERVER_URL, { + path: '/socket.io/', + transports: ['websocket', 'polling'] + }); + + socket.on('connect', () => { + console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${worldId}`); + mySocketId = socket.id; + isMultiplayer = true; + + // Присоединяемся к миру + socket.emit('join_world', { world_id: worldId, player_name: playerName }); + + // Показываем в UI + worldIdEl.textContent = worldId; + // XP/Level display + const lvXpNext = xpForLevel(player.level + 1); + const lvXpCur = xpForLevel(player.level); + const xpInLevel = player.xp - lvXpCur; + const xpNeeded = lvXpNext - lvXpCur; + document.getElementById('xplevel').textContent = player.level; + document.getElementById('xpbar').textContent = xpInLevel + '/' + xpNeeded; + multiplayerStatus.style.display = 'block'; + }); + + socket.on('connect_error', (error) => { + console.error('Socket connection error:', error); + isMultiplayer = false; + }); + + socket.on('disconnect', () => { + console.log('Disconnected from server'); + isMultiplayer = false; + otherPlayers.clear(); + multiplayerStatus.style.display = 'none'; + }); + + // Обработка 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'); + spawnDrops(sm.x, sm.y, sm.kind); + grantXP(getMobXP(sm.kind)); + rebuildHotbar(); + } + serverMobs.delete(data.id); + }); + + socket.on('mob_hurt_ack', (data) => { + const sm = serverMobs.get(data.id); + if (sm) sm.hp = data.hp; + }); + + socket.on('mob_explode', (data) => { + explodeAt(data.gx, data.gy); + serverMobs.delete(data.id); + }); + + socket.on('mob_shoot', (data) => { + projectiles.push({ + x: data.x, y: data.y, vx: data.vx, vy: data.vy, + dmg: data.dmg, owner: 'mob', life: data.life + }); + }); + + // Блок изменён + socket.on('block_changed', (data) => { + const key = k(data.gx, data.gy); + serverOverrides.set(key, { op: data.op, t: data.t }); + if (data.op === 'set') { + setBlock(data.gx, data.gy, data.t, false); + } else if (data.op === 'remove') { + removeBlock(data.gx, data.gy); + } + }); + + // Сообщение в чат + socket.on('chat_message', (data) => { + const senderName = data.socket_id === mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`; + addChatMessage(senderName, data.message); + }); + + // Обновление времени + socket.on('time_update', (data) => { + if (data.time !== undefined) { + worldTime = data.time; + isNightTime = worldTime > 0.5; + } + }); + + } catch (e) { + console.error('Error initializing socket:', e); + isMultiplayer = false; + } + } + + // Генерация случайного цвета для игрока на основе socket_id + function getRandomPlayerColor(socketId) { + const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4']; + let hash = 0; + for (let i = 0; i < socketId.length; i++) { + hash = ((hash << 5) - hash) + socketId.charCodeAt(i); + hash = hash & hash; + } + return colors[Math.abs(hash) % colors.length]; + } + + // Отправка позиции игрока (с throttle) + function sendPlayerPosition() { + if (!isMultiplayer || !socket || !socket.connected) return; + + const now = performance.now() / 1000; + if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return; + + // Отправляем только если позиция изменилась + const dx = Math.abs(player.x - lastSentX); + const dy = Math.abs(player.y - lastSentY); + if (dx < 1 && dy < 1) return; + + lastMoveSendTime = now; + lastSentX = player.x; + lastSentY = player.y; + + socket.emit('player_move', { x: player.x, y: player.y, player_name: playerName }); + } + + // Отправка изменения блока + function sendBlockChange(gx, gy, t, op) { + if (!isMultiplayer || !socket || !socket.connected) return; + + socket.emit('block_change', { gx, gy, t, op }); + } + + // ==================== ЧАТ ==================== + const chatMessages = []; + const MAX_CHAT_MESSAGES = 20; + + function addChatMessage(sender, message) { + const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); + chatMessages.push({ sender, message, time }); + if (chatMessages.length > MAX_CHAT_MESSAGES) { + chatMessages.shift(); + } + renderChatMessages(); + } + + function renderChatMessages() { + const chatMessagesEl = document.getElementById('chatMessages'); + if (!chatMessagesEl) return; + + chatMessagesEl.innerHTML = chatMessages.map(m => + `
${m.time} ${m.sender}: ${m.message}
` + ).join(''); + + // Прокручиваем вниз + chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight; + } + + function sendChatMessage(message) { + if (!message || message.trim() === '') return; + + if (isMultiplayer && socket && socket.connected) { + socket.emit('chat_message', { message: message.trim() }); + } else { + addChatMessage('Вы', message.trim()); + } + } + + // ==================== ПОДЕЛИТЬСЯ МИРОМ ==================== + function shareWorld() { + const shareUrl = new URL(window.location.href); + shareUrl.searchParams.set('world', worldId); + const shareUrlString = shareUrl.toString(); + + // Копируем в буфер обмена + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(shareUrlString).then(() => { + alert('Ссылка скопирована!'); + }).catch(() => { + alert('Ссылка на мир:\n' + shareUrlString); + }); + } else { + alert('Ссылка на мир:\n' + shareUrlString); + } + } + + // ==================== ИНИЦИАЛИЗАЦИЯ UI ==================== + let chatOpen = false; + + document.getElementById('chatToggle').onclick = () => { + playSound('click'); + chatOpen = !chatOpen; + document.getElementById('chatPanel').style.display = chatOpen ? 'block' : 'none'; + if (chatOpen) { + document.getElementById('chatInput').focus(); + } + }; + + document.getElementById('chatClose').onclick = () => { + playSound('click'); + chatOpen = false; + document.getElementById('chatPanel').style.display = 'none'; + }; + + document.getElementById('chatSend').onclick = () => { + const input = document.getElementById('chatInput'); + sendChatMessage(input.value); + input.value = ''; + }; + + document.getElementById('chatInput').addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + sendChatMessage(e.target.value); + e.target.value = ''; + } + }); + + // ==================== ИНИЦИАЛИЗАЦИЯ СОКЕТА ==================== + // Инициализируем 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 }, + // === BIOME BLOCKS === + snow: { n:'Снег', c:'#ecf0f1', solid:true }, + ice: { n:'Лёд', c:'#74b9ff', solid:true, slip:true }, + cactus: { n:'Кактус', c:'#27ae60', solid:true, hurt:true }, + mushroom: { n:'Гриб', c:'#e74c3c', solid:false, decor:true }, + moss: { n:'Мох', c:'#0a6640', solid:true }, + swamp_water:{ n:'Болотная вода', c:'rgba(100,120,40,0.6)', solid:false, fluid:true, poison:true }, + farmland: { n:'Грядка', c:'#8B6914', solid:true, farmable:true }, + dead_bush: { n:'Сухой куст', c:'#b2bec3', solid:false, decor:true }, + spruce_leaves:{ n:'Ель', c:'#0a6640', solid:true }, + // === CROP STAGES === + wheat_stage0:{ n:'Росток пшеницы', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'wheat_stage1' }, + wheat_stage1:{ n:'Пшеница', c:'#7dcea0', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'wheat_stage2' }, + wheat_stage2:{ n:'Пшеница', c:'#b8d730', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'wheat_stage3' }, + wheat_stage3:{ n:'Пшеница', c:'#f1c40f', solid:false, decor:true, harvestable:true, harvestItem:'wheat', harvestQty:2 }, + carrot_stage0:{ n:'Росток моркови', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'carrot_stage1' }, + carrot_stage1:{ n:'Морковь', c:'#f0c27a', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'carrot_stage2' }, + carrot_stage2:{ n:'Морковь', c:'#e8a040', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'carrot_stage3' }, + carrot_stage3:{ n:'Морковь', c:'#e67e22', solid:false, decor:true, harvestable:true, harvestItem:'carrot', harvestQty:3 }, + potato_stage0:{ n:'Росток картофеля', c:'#a8e6a0', solid:false, decor:true, growthStage:0, growthTime:30, growsTo:'potato_stage1' }, + potato_stage1:{ n:'Картофель', c:'#c8b888', solid:false, decor:true, growthStage:1, growthTime:30, growsTo:'potato_stage2' }, + potato_stage2:{ n:'Картофель', c:'#bfaa78', solid:false, decor:true, growthStage:2, growthTime:30, growsTo:'potato_stage3' }, + potato_stage3:{ n:'Картофель', c:'#dfe6e9', solid:false, decor:true, harvestable:true, harvestItem:'potato', harvestQty:2 } + }; + + const ITEMS = { + meat: { n:'Сырое мясо', icon:'🥩', food:15 }, + cooked: { n:'Жареное мясо', icon:'🍖', food:45 }, + arrow: { n:'Стрела', icon:'➡️', stack:64 }, + chicken_meat: { n:'Курица', icon:'🍗', food:12, stack:64 }, + feather: { n:'Перо', icon:'🪶', stack:64 }, + bone: { n:'Кость', icon:'🦴', stack:64 }, + gunpowder: { n:'Порох', icon:'💥', stack:64 }, + // === FARMING ITEMS === + wheat: { n:'Пшеница', icon:'🌾', stack:64 }, + bread: { n:'Хлеб', icon:'🍞', food:30 }, + carrot: { n:'Морковь', icon:'🥕', food:8, stack:64 }, + potato: { n:'Картофель', icon:'🥔', stack:64 }, + baked_potato:{ n:'Печёная картошка', icon:'🥔', food:25 }, + mushroom_stew:{ n:'Грибной суп', icon:'🍲', food:40 }, + // === MOB DROP ITEMS === + scorpion_stinger:{ n:'Жало скорпиона', icon:'🔺', stack:64 }, + polar_fur: { n:'Шкура медведя', icon:'🧥', stack:64 }, + slime_ball: { n:'Слизь', icon:'🟢', stack:64 }, + eagle_feather:{ n:'Перо орла', icon:'🪶', stack:64 }, + // === NEW ARMOR & TOOLS === + gold_armor: { n:'Золотая броня', icon:'🛡️', stack:1, armor:0.65 } + }; + + // Seed мира для детерминированной генерации + // Инициализируем случайным seed, но он будет перезаписан сервером в мультиплеере + 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 }, requiredLevel: 1 }, + stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 }, requiredLevel: 2 }, + iron_pickaxe: { n:'Железная кирка', icon:'⛏️', durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 }, requiredLevel: 3 }, + wood_sword: { n:'Деревянный меч', icon:'⚔️', durability: 40, damage: 5, craft: { wood: 2, planks: 1 }, requiredLevel: 1 }, + stone_sword: { n:'Каменный меч', icon:'⚔️', durability: 100, damage: 8, craft: { wood: 1, stone: 2 }, requiredLevel: 2 }, + iron_sword: { n:'Железный меч', icon:'⚔️', durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 }, requiredLevel: 3 }, + bow: { n:'Лук', icon:'🏹', durability: 150, craft: { wood: 3, planks: 2 }, requiredLevel: 4 }, + diamond_pickaxe: { n:'Алмазная кирка', icon:'⛏️', durability: 500, miningPower: 5, craft: { wood: 1, diamond_ore: 2 }, requiredLevel: 7 }, + diamond_sword: { n:'Алмазный меч', icon:'⚔️', durability: 400, damage: 18, craft: { wood: 1, diamond_ore: 2 }, requiredLevel: 7 }, + hoe: { n:'Мотыга', icon:'🔨', durability: 80, tillTo: 'farmland', craft: { wood: 2, planks: 1 }, requiredLevel: 1 } + }; + + // Текстуры блоков (простые) + const tex = {}; + function makeTex(type) { + const t = BLOCKS[type]; + const c = document.createElement('canvas'); + c.width = 32; c.height = 32; + const g = c.getContext('2d'); + + if (type === 'tnt') { + g.fillStyle='#c0392b'; g.fillRect(0,0,32,32); + g.fillStyle='#fff'; g.fillRect(0,12,32,8); + g.fillStyle='#000'; g.font='bold 10px sans-serif'; g.fillText('TNT',6,20); + return c; + } + if (type === 'campfire') { + g.fillStyle='#5d4037'; g.fillRect(4,26,24,6); + g.fillStyle='#3e2723'; g.fillRect(7,23,18,4); + return c; + } + if (type === 'torch') { + g.fillStyle='#6d4c41'; g.fillRect(14,10,4,18); + g.fillStyle='#f39c12'; g.fillRect(12,6,8,8); + return c; + } + if (type === 'glass') { + g.fillStyle='rgba(200,240,255,0.25)'; g.fillRect(0,0,32,32); + g.strokeStyle='rgba(255,255,255,0.65)'; g.strokeRect(2,2,28,28); + g.beginPath(); g.moveTo(5,27); g.lineTo(27,5); g.stroke(); + return c; + } + if (type === 'water') { + g.fillStyle = t.c; g.fillRect(0,0,32,32); + g.fillStyle = 'rgba(255,255,255,0.08)'; + g.fillRect(0,6,32,2); + return c; + } + if (type === 'bed') { + // Основание кровати + g.fillStyle = '#e91e63'; + g.fillRect(0, 0, 32, 32); + // Подушка + g.fillStyle = '#f8bbd0'; + g.fillRect(2, 2, 14, 14); + // Одеяло + g.fillStyle = '#c2185b'; + g.fillRect(16, 4, 14, 24); + // Детали одеяла + g.fillStyle = '#e91e63'; + g.fillRect(18, 6, 10, 20); + return c; + } + if (type === 'flower') { + g.fillStyle='#2ecc71'; g.fillRect(14,14,4,18); + g.fillStyle=t.c; g.beginPath(); g.arc(16,12,6,0,6.28); g.fill(); + return c; + } + if (type === 'boat') { + // Корпус лодки + g.fillStyle = '#8B4513'; + g.fillRect(2, 12, 28, 8); + // Борта + g.fillStyle = '#A0522D'; + g.fillRect(0, 10, 32, 12); + // Внутренность + g.fillStyle = '#DEB887'; + g.fillRect(4, 14, 24, 4); + // Дно + g.fillStyle = '#654321'; + g.fillRect(2, 20, 28, 4); + return c; + } + if (type === 'ladder') { + // Боковые стойки лестницы + g.fillStyle = '#8B4513'; + g.fillRect(4, 0, 4, 32); + g.fillRect(24, 0, 4, 32); + // Ступени + g.fillStyle = '#A0522D'; + g.fillRect(4, 4, 24, 3); + g.fillRect(4, 12, 24, 3); + g.fillRect(4, 20, 24, 3); + g.fillRect(4, 28, 24, 3); + return c; + } + // === BIOME BLOCK TEXTURES === + if (type === 'snow') { + g.fillStyle = '#ecf0f1'; + g.fillRect(0, 0, 32, 32); + g.fillStyle = '#dfe6e9'; + for (let i = 0; i < 4; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 5, 3); + return c; + } + if (type === 'ice') { + g.fillStyle = '#74b9ff'; + g.fillRect(0, 0, 32, 32); + g.strokeStyle = 'rgba(255,255,255,0.4)'; + g.beginPath(); g.moveTo(4,10); g.lineTo(20,16); g.lineTo(10,28); g.stroke(); + g.beginPath(); g.moveTo(18,4); g.lineTo(28,12); g.stroke(); + return c; + } + if (type === 'cactus') { + g.fillStyle = '#27ae60'; + g.fillRect(6, 2, 20, 28); + g.fillStyle = '#2ecc71'; + g.fillRect(2, 8, 6, 4); + g.fillRect(24, 14, 6, 4); + g.fillStyle = '#1e8449'; + g.fillRect(12, 0, 2, 30); + g.fillRect(18, 0, 2, 30); + return c; + } + if (type === 'mushroom') { + g.fillStyle = '#f5e6cc'; g.fillRect(14, 20, 4, 12); + g.fillStyle = '#e74c3c'; g.beginPath(); g.arc(16, 14, 10, Math.PI, 0); g.fill(); + g.fillStyle = '#fff'; g.fillRect(10, 10, 3, 3); g.fillRect(17, 8, 4, 3); + return c; + } + if (type === 'moss') { + g.fillStyle = '#0a6640'; + g.fillRect(0, 0, 32, 32); + g.fillStyle = '#1e8449'; + for (let i = 0; i < 6; i++) g.fillRect((Math.random()*26)|0, (Math.random()*26)|0, 4, 3); + return c; + } + if (type === 'swamp_water') { + g.fillStyle = 'rgba(100,120,40,0.6)'; + g.fillRect(0, 0, 32, 32); + g.fillStyle = 'rgba(80,100,20,0.3)'; + g.fillRect(0, 10, 32, 2); + g.fillRect(0, 22, 32, 2); + return c; + } + if (type === 'farmland') { + g.fillStyle = '#8B6914'; + g.fillRect(0, 0, 32, 32); + g.fillStyle = '#7a5c10'; + for (let i = 0; i < 4; i++) g.fillRect(0, 6+i*8, 32, 2); + g.fillStyle = '#6B4E0A'; + g.fillRect(8, 2, 2, 28); + g.fillRect(18, 2, 2, 28); + return c; + } + if (type === 'dead_bush') { + g.fillStyle = '#b2bec3'; + g.fillRect(12, 16, 2, 16); + g.fillRect(8, 14, 8, 2); + g.fillRect(16, 12, 8, 2); + g.fillRect(6, 18, 4, 2); + return c; + } + if (type === 'spruce_leaves') { + g.fillStyle = '#0a6640'; + g.fillRect(0, 0, 32, 32); + g.fillStyle = '#0d7a4d'; + for (let i = 0; i < 5; i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6, 4); + return c; + } + // CROP STAGES + if (type.startsWith('wheat_stage') || type.startsWith('carrot_stage') || type.startsWith('potato_stage')) { + const st = parseInt(type.charAt(type.length-1)); + const colors = type.startsWith('wheat') ? ['#a8e6a0','#7dcea0','#b8d730','#f1c40f'] : + type.startsWith('carrot') ? ['#a8e6a0','#f0c27a','#e8a040','#e67e22'] : + ['#a8e6a0','#c8b888','#bfaa78','#dfe6e9']; + g.fillStyle = '#5d4037'; + g.fillRect(15, 18, 2, 14); + if (st >= 1) { + g.fillStyle = colors[st]; + g.fillRect(10, 8 + (3-st)*3, 12, 10); + } else { + g.fillStyle = '#a8e6a0'; + g.fillRect(14, 22, 4, 4); + } + return c; + } + + g.fillStyle = t.c || '#000'; + g.fillRect(0,0,32,32); + + g.fillStyle = 'rgba(0,0,0,0.10)'; + for (let i=0;i<6;i++) g.fillRect((Math.random()*28)|0, (Math.random()*28)|0, 4,4); + + if (type.endsWith('_ore') || type==='coal') { + g.fillStyle = 'rgba(0,0,0,0.35)'; + for (let i=0;i<4;i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6,6); + } + return c; + } + Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k)); + + // Мир-хранилище + const grid = new Map(); // key "gx,gy" => {gx,gy,t, ...} + const blocks = []; // для рендера/перебора видимых + function k(gx,gy){ return gx+','+gy; } + function getBlock(gx,gy){ return grid.get(k(gx,gy)); } + function hasBlock(gx,gy){ return grid.has(k(gx,gy)); } + function isSolid(gx,gy){ + const b = getBlock(gx,gy); + if(!b || b.dead) return false; + const def = BLOCKS[b.t]; + return !!def.solid && !def.fluid && !def.decor; + } + function setBlock(gx,gy,t, isPlayerPlaced = false){ + const key = k(gx,gy); + if(grid.has(key)) return false; + const b = { gx, gy, t, dead:false, active:false, fuse:0 }; + grid.set(key, b); + blocks.push(b); + + // Отслеживаем блоки, установленные игроком + if(isPlayerPlaced){ + placedBlocks.push({gx, gy, t}); + } + + return true; + } + function removeBlock(gx,gy){ + const key = k(gx,gy); + const b = grid.get(key); + if(!b) return null; + if(BLOCKS[b.t].unbreakable) return null; + grid.delete(key); + b.dead = true; + + // Отслеживаем удалённые блоки + const wasPlayerPlaced = placedBlocks.some(pb => pb.gx === gx && pb.gy === gy); + if(wasPlayerPlaced){ + // Удаляем из placedBlocks + placedBlocks = placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy)); + } else { + // Это природный блок - добавляем в removedBlocks + removedBlocks.push({gx, gy}); + } + + return b; + } + + // Физика жидкости + const waterUpdateQueue = new Set(); + let waterUpdateTimer = 0; + const WATER_UPDATE_INTERVAL = 0.05; // Обновляем воду каждые 0.05 секунды + + function updateWaterPhysics(dt){ + waterUpdateTimer += dt; + if(waterUpdateTimer < WATER_UPDATE_INTERVAL) return; + waterUpdateTimer = 0; + + // Ограничиваем количество водных блоков для обработки (оптимизация) + const MAX_WATER_BLOCKS_PER_UPDATE = 50; + let processedCount = 0; + + // Собираем только видимые водные блоки в очередь (оптимизация) + waterUpdateQueue.clear(); + const minGX = Math.floor(camX/TILE) - 10; + const maxGX = Math.floor((camX+W)/TILE) + 10; + const minGY = Math.floor(camY/TILE) - 10; + const maxGY = Math.floor((camY+H)/TILE) + 10; + + for(const b of blocks){ + if(processedCount >= MAX_WATER_BLOCKS_PER_UPDATE) break; + if(!b.dead && b.t === 'water' && + b.gx >= minGX && b.gx <= maxGX && + b.gy >= minGY && b.gy <= maxGY){ + waterUpdateQueue.add(k(b.gx, b.gy)); + processedCount++; + } + } + + // Обновляем воду с ограничением глубины распространения + const processed = new Set(); + const toAdd = []; + const MAX_WATER_DEPTH = 20; // Максимальная глубина распространения воды + + for(const key of waterUpdateQueue){ + if(processed.has(key)) continue; + const b = grid.get(key); + if(!b || b.dead) continue; + processed.add(key); + + const gx = b.gx; + const gy = b.gy; + + // Проверяем глубину - не распространяем воду слишком глубоко + if(gy > SEA_GY + MAX_WATER_DEPTH) continue; + + // Проверяем, можно ли воде упасть вниз + const belowKey = k(gx, gy + 1); + const below = grid.get(belowKey); + + // Внизу пусто - вода создаёт новый блок внизу (но не удаляется сверху) + if(!below || below.dead){ + // Ограничиваем создание новых водных блоков + if(toAdd.length < 20){ // Максимум 20 новых блоков за обновление + toAdd.push({gx, gy: gy + 1, t: 'water'}); + processed.add(belowKey); + } + continue; + } + + // Если внизу не вода и не твёрдый блок - вода может течь вниз + if(!isSolid(gx, gy + 1) && below && below.t !== 'water'){ + if(toAdd.length < 20){ + toAdd.push({gx, gy: gy + 1, t: 'water'}); + processed.add(belowKey); + } + continue; + } + + // Если внизу твёрдый блок или вода - вода растекается горизонтально + // Проверяем левую сторону + const leftKey = k(gx - 1, gy); + const left = grid.get(leftKey); + if(!left || left.dead){ + if(toAdd.length < 20){ + toAdd.push({gx: gx - 1, gy, t: 'water'}); + processed.add(leftKey); + } + continue; + } + + // Проверяем правую сторону + const rightKey = k(gx + 1, gy); + const right = grid.get(rightKey); + if(!right || right.dead){ + if(toAdd.length < 20){ + toAdd.push({gx: gx + 1, gy, t: 'water'}); + processed.add(rightKey); + } + continue; + } + } + + // Применяем изменения (только добавляем новые блоки) + for(const newData of toAdd){ + const key = k(newData.gx, newData.gy); + if(!grid.has(key)){ + const b = { + gx: newData.gx, + gy: newData.gy, + t: newData.t, + dead: false, + active: false, + fuse: 0 + }; + grid.set(key, b); + blocks.push(b); + } + } + + // Очищаем мёртвые блоки из массива + for(let i = blocks.length - 1; i >= 0; i--){ + if(blocks[i].dead){ + blocks.splice(i, 1); + } + } + } + + // Инвентарь + const inv = { + dirt:6, stone:0, sand:0, gravel:0, clay:0, + wood:0, planks:0, ladder:0, leaves:0, coal:0, + copper_ore:0, iron_ore:0, gold_ore:0, diamond_ore:0, + brick:0, glass:0, + tnt:1, campfire:0, torch:0, + meat:0, cooked:0, arrow:0, + wood_pickaxe:0, stone_pickaxe:0, iron_pickaxe:0, + wood_sword:0, stone_sword:0, iron_sword:0, + iron_armor:0, gold_armor:0, + bow:0, furnace:0, + bed:0, boat:0, + iron_ingot:0, gold_ingot:0, copper_ingot:0, + diamond_pickaxe:0, diamond_sword:0, hoe:0, + wheat:0, bread:0, carrot:0, potato:0, baked_potato:0, + scorpion_stinger:0, polar_fur:0, slime_ball:0, eagle_feather:0, + snow:0, ice:0, cactus:0, mushroom:0, moss:0, farmland:0, + spruce_leaves:0, dead_bush:0, + wheat_stage0:0, carrot_stage0:0, potato_stage0:0 + }; + let selected = 'dirt'; + + // Прочность инструментов: Map<"tooltype_id", {current, max}> + // При крафте инструмента создаём запись с max durability + const toolDurability = new Map(); + + function addTool(type) { + const def = TOOLS[type]; + if (!def) return; + const id = `${type}_${Date.now()}_${Math.random().toString(36).slice(2,6)}`; + toolDurability.set(id, { type, current: def.durability, max: def.durability }); + return id; + } + + function getToolDurability(id) { + return toolDurability.get(id); + } + + // Найти лучший инструмент данного типа в инвентаре + function findBestTool(toolType) { + if (inv[toolType] <= 0) return null; + // Возвращаем первый попавшийся — упрощённо + return toolType; + } + + // Использовать инструмент (уменьшить прочность). Возвращает true если сломался + function useTool(toolType) { + // Ищем любой инструмент этого типа с прочностью + for (const [id, dur] of toolDurability) { + if (dur.type === toolType) { + dur.current--; + if (dur.current <= 0) { + toolDurability.delete(id); + inv[toolType]--; + rebuildHotbar(); + return true; // сломался + } + return false; + } + } + return false; + } + + const RECIPES = [ + { out:'planks', qty:4, cost:{ wood:1 }, requiredLevel:1 }, + { out:'ladder', qty:3, cost:{ planks:7 }, requiredLevel:1 }, + { out:'torch', qty:2, cost:{ coal:1, planks:1 }, requiredLevel:1 }, + { out:'glass', qty:1, cost:{ sand:3 }, requiredLevel:1 }, + { out:'brick', qty:1, cost:{ stone:2, clay:1 }, requiredLevel:1 }, + { out:'campfire', qty:1, cost:{ wood:1, coal:1 }, requiredLevel:1 }, + { out:'tnt', qty:1, cost:{ sand:2, coal:1 }, requiredLevel:8 }, + { out:'bed', qty:1, cost:{ wood: 3, planks: 3 }, requiredLevel:1 }, + { out:'boat', qty:1, cost:{ wood: 5 }, requiredLevel:2 }, + { out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 }, requiredLevel:1 }, + { out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 }, requiredLevel:2 }, + { out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 }, requiredLevel:3 }, + { out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 }, requiredLevel:1 }, + { out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 }, requiredLevel:2 }, + { out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 }, requiredLevel:3 }, + { out:'iron_armor', qty:1, cost:{ iron_ore: 5 }, requiredLevel:5 }, + { out:'gold_armor', qty:1, cost:{ gold_ore: 8 }, requiredLevel:6 }, + { out:'furnace', qty:1, cost:{ stone: 8 }, requiredLevel:3 }, + { out:'bow', qty:1, cost:{ wood: 3, planks: 2 }, requiredLevel:4 }, + { out:'arrow', qty:4, cost:{ stone: 1, wood: 1 }, requiredLevel:1 }, + // === NEW RECIPES === + { out:'bread', qty:1, cost:{ wheat: 3 }, requiredLevel:1 }, + { out:'diamond_pickaxe', qty:1, cost:{ wood: 1, diamond_ore: 2 }, requiredLevel:7 }, + { out:'diamond_sword', qty:1, cost:{ wood: 1, diamond_ore: 2 }, requiredLevel:7 }, + { out:'hoe', qty:1, cost:{ wood: 2, planks: 1 }, requiredLevel:1 } + ]; + + // Рецепты печи (обжиг) + const SMELTING_RECIPES = [ + { in:'sand', qty:1, out:'glass', outQty:1, time:3 }, // песок → стекло + { in:'clay', qty:1, out:'brick', outQty:1, time:3 }, // глина → кирпич + { in:'iron_ore', qty:1, out:'iron_ingot', outQty:1, time:5 }, // железо → слиток + { in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 }, // золото → слиток + { in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 }, // медь → слиток + { in:'meat', qty:1, out:'cooked', outQty:1, time:2 }, // сырое мясо → жареное + { in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 }, // булыжник → камень + { in:'potato', qty:1, out:'baked_potato', outQty:1, time:2 } // картофель → печёная картошка + ]; + + // Новые предметы от обжига + ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' }; + ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' }; + ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' }; + ITEMS.diamond_pickaxe = { n:'Алмазная кирка', icon:'⛏️', durability:500, miningPower:5 }; + ITEMS.diamond_sword = { n:'Алмазный меч', icon:'⚔️', durability:400, damage:18 }; + ITEMS.hoe = { n:'Мотыга', icon:'🔨', durability:80, tillTo:'farmland' }; + + // Активные печи: Map ключа блока → { recipe, progress, totalTime } + const activeFurnaces = new Map(); + + // UI + const hpEl = document.getElementById('hp'); + const foodEl = document.getElementById('food'); + const 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', + snow: '#ecf0f1', ice: '#74b9ff', cactus: '#27ae60', mushroom: '#e74c3c', + moss: '#0a6640', swamp_water: '#687828', farmland: '#8B6914', + dead_bush: '#b2bec3', spruce_leaves: '#0a6640', + wheat_stage0: '#a8e6a0', wheat_stage1: '#7dcea0', wheat_stage2: '#b8d730', wheat_stage3: '#f1c40f', + carrot_stage0: '#a8e6a0', carrot_stage1: '#f0c27a', carrot_stage2: '#e8a040', carrot_stage3: '#e67e22', + potato_stage0: '#a8e6a0', potato_stage1: '#c8b888', potato_stage2: '#bfaa78', potato_stage3: '#dfe6e9' + }; + + function renderMinimap() { + if (!minimapOpen) return; + const mW = minimapCanvas.width; + const mH = minimapCanvas.height; + const scale = 2; // пикселей на блок + + // Область карты — центрирована на игроке + const pGX = Math.floor(player.x / TILE); + const 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; + ringReady = 0; ringRead = ringWrite; voicePlayActive = 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(2048, 1, 1); + console.log('[voice] ScriptProcessor created, bufferSize=2048'); + + 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); + }); + + // === Ring Buffer + ScriptProcessor приём голоса === + // Единый непрерывный поток вместо отдельных BufferSource на чанк + const RING_SIZE = 24000 * 3; // 3 секунды ring buffer + const ringBuf = new Float32Array(RING_SIZE); + let ringWrite = 0; // позиция записи + let ringRead = 0; // позиция чтения + let ringReady = 0; // сколько сэмплов готово + let voicePlayActive = false; + const JBUF_TARGET = 4800; // целевой jitter buffer: 200мс при 24kHz + let jbufFill = 0; // текущее заполнение + let lastVoiceFrom = ''; // кто говорит (для индикатора) + + // Воспроизводящий ScriptProcessor — читает из ring buffer + const playProcessor = audioCtx.createScriptProcessor(2048, 1, 1); + let lastSample = 0; // для плавного fade при underrun + playProcessor.onaudioprocess = (e) => { + const out = e.outputBuffer.getChannelData(0); + if (ringReady < 1) { + // Плавный fade-out от последнего сэмпла к тишине + for (let i = 0; i < out.length; i++) { + lastSample *= 0.9; + out[i] = lastSample; + } + voicePlayActive = false; + return; + } + // Ждём накопления jitter buffer перед стартом + if (!voicePlayActive && ringReady >= JBUF_TARGET) { + voicePlayActive = true; + } + if (!voicePlayActive) { + // Плавно затихаем пока буфер копится + for (let i = 0; i < out.length; i++) { + lastSample *= 0.95; + out[i] = lastSample; + } + return; + } + // Читаем из ring buffer с плавным fade-in на старте + for (let i = 0; i < out.length; i++) { + if (ringReady > 0) { + out[i] = ringBuf[ringRead]; + lastSample = out[i]; // запоминаем для fade-out + ringRead = (ringRead + 1) % RING_SIZE; + ringReady--; + } else { + // Underrun — плавно затихаем + lastSample *= 0.85; + out[i] = lastSample; + } + } + }; + const playGain = audioCtx.createGain(); + playGain.gain.value = 1.0; + playProcessor.connect(playGain).connect(audioCtx.destination); + + voiceSocket.on('voice_in', (payload) => { + // Пишем входящий голос в ring buffer + const { data, meta, volume } = payload; + if (!audioCtx || audioCtx.state === 'closed') return; + + const int16 = new Int16Array(data); + const vol = Math.min(1.4, (volume || 1) * 1.5); + for (let i = 0; i < int16.length; i++) { + const sample = (int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF)) * vol; + ringBuf[ringWrite] = sample; + ringWrite = (ringWrite + 1) % RING_SIZE; + ringReady = Math.min(ringReady + 1, RING_SIZE); + } + // Сброс jitter fill если пауза была + jbufFill = ringReady; + lastVoiceFrom = meta.name || '???'; + + // Индикатор + speakingIndicator.style.display = 'block'; + speakingIndicator.textContent = '🔊 ' + lastVoiceFrom; + clearTimeout(speakingTimeout); + speakingTimeout = setTimeout(() => { + speakingIndicator.style.display = 'none'; + voicePlayActive = false; // сброс при паузе + ringReady = 0; // очистить буфер + ringRead = ringWrite; // синхронизировать + }, 1500); + }); + + 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'); // Звук клика по инвентарю + if(selected === id) { + // Повторный клик — снимаем выбор, возвращаем к первому блоку + selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt'; + } else { + selected = id; + // Обновляем список последних предметов + recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть + recentItems.unshift(id); // Добавляем в начало + recentItems = recentItems.slice(0, 5); // Оставляем только 5 + } + rebuildHotbar(); + }; + + // Показываем индикатор надетой брони + if(id === 'iron_armor' && player.equippedArmor === 'iron_armor') { + const equipped = document.createElement('div'); + equipped.className = 'equipped-indicator'; + equipped.textContent = '✓'; + s.appendChild(equipped); + } + + // Durability bar для инструментов + if(TOOLS[id] && inv[id] > 0) { + // Находим текущую прочность + let curDur = 0, maxDur = TOOLS[id].durability; + for (const [tid, dur] of toolDurability) { + if (dur.type === id) { + curDur = dur.current; + maxDur = dur.max; + break; + } + } + if (maxDur > 0) { + const bar = document.createElement('div'); + bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;border-radius:0 0 6px 6px;overflow:hidden;`; + const fill = document.createElement('div'); + const pct = curDur / maxDur; + const color = pct > 0.5 ? '#2ecc71' : pct > 0.25 ? '#f39c12' : '#e74c3c'; + fill.style.cssText = `width:${pct*100}%;height:100%;background:${color};`; + bar.appendChild(fill); + s.appendChild(bar); + } + } + hotbarEl.appendChild(s); + } + } + + function renderInventory() { + inventoryGrid.innerHTML = ''; + + // Создаём сетку инвентаря 7x3 + const items = Object.keys(inv).filter(id => inv[id] > 0); + + // Добавляем пустые слоты для полной сетки + for(let i = 0; i < 21; i++) { + const slot = document.createElement('div'); + slot.className = 'inv-slot' + (i < items.length && items[i] === selected ? ' sel' : ''); + + if(i < items.length) { + const id = items[i]; + if(BLOCKS[id]) { + slot.style.backgroundImage = `url(${tex[id].toDataURL()})`; + slot.style.backgroundSize = 'cover'; + } else if(ITEMS[id]) { + slot.textContent = ITEMS[id].icon; + } else if(TOOLS[id]) { + slot.textContent = TOOLS[id].icon; + } else if(id === 'iron_armor') { + slot.textContent = '🛡️'; + slot.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)'; + } + + const count = document.createElement('div'); + count.className = 'inv-count'; + count.textContent = inv[id]; + slot.appendChild(count); + + slot.onclick = () => { + playSound('click'); // Звук клика по инвентарю + if(selected === id) { + selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt'; + } else { + selected = id; + recentItems = recentItems.filter(item => item !== id); + recentItems.unshift(id); + recentItems = recentItems.slice(0, 5); + } + rebuildHotbar(); + renderInventory(); + }; + + // Двойной клик для надевания брони + slot.ondblclick = () => { + if(id === 'iron_armor' && inv.iron_armor > 0) { + // Если уже надета броня - снимаем её + if(player.equippedArmor === 'iron_armor') { + player.equippedArmor = null; + player.armor = 0; + console.log('[ARMOR] Iron armor unequipped'); + } else { + // Надеваем броню + player.equippedArmor = 'iron_armor'; + player.armor = BLOCKS['iron_armor'].armor; + console.log('[ARMOR] Iron armor equipped - armor:', player.armor); + } + playSound('click'); + renderInventory(); + } + if(id === 'gold_armor' && inv.gold_armor > 0) { + if(player.equippedArmor === 'gold_armor') { + player.equippedArmor = null; + player.armor = 0; + } else { + player.equippedArmor = 'gold_armor'; + player.armor = ITEMS['gold_armor'].armor; + } + playSound('click'); + renderInventory(); + } + }; + } + + inventoryGrid.appendChild(slot); + } + } + + function canCraft(r){ + console.log('[CRAFT] Checking recipe for:', r.out, '- cost:', r.cost); + for(const res in r.cost){ + const have = inv[res] || 0; + const need = r.cost[res]; + console.log('[CRAFT] Resource:', res, '- have:', have, '- need:', need, '- can craft:', have >= need); + if(have < need) return false; + } + return true; + } + function renderCraft(){ + recipesEl.innerHTML=''; + for(const r of RECIPES){ + const row = document.createElement('div'); + row.className='recipe'; + const reqLv = r.requiredLevel || 1; + const locked = player.level < reqLv; + const icon = document.createElement('div'); + icon.className='ricon'; + // Иконка — блок, инструмент или предмет + if(tex[r.out]){ + icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`; + } else if(TOOLS[r.out]){ + icon.textContent = TOOLS[r.out].icon; + icon.style.fontSize = '24px'; + icon.style.display = 'flex'; + icon.style.alignItems = 'center'; + icon.style.justifyContent = 'center'; + } else if(ITEMS[r.out]){ + icon.textContent = ITEMS[r.out].icon; + icon.style.fontSize = '24px'; + icon.style.display = 'flex'; + icon.style.alignItems = 'center'; + icon.style.justifyContent = 'center'; + } + const info = document.createElement('div'); + info.className='rinfo'; + const nm = document.createElement('div'); + nm.className='rname'; + const itemName = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out; + nm.textContent = `${itemName} x${r.qty}` + (locked ? ` (Lv.${reqLv})` : ''); + if(locked) { nm.style.color = '#888'; nm.style.textDecoration = 'line-through'; } + const cs = document.createElement('div'); + cs.className='rcost'; + cs.textContent = Object.keys(r.cost).map(x => { + const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x; + return `${cn}: ${(inv[x]||0)}/${r.cost[x]}`; + }).join(' '); + if(locked) cs.style.color = '#666'; + info.appendChild(nm); info.appendChild(cs); + const btn = document.createElement('button'); + btn.className='rcraft'; + btn.textContent='Создать'; + btn.disabled = !canCraft(r) || locked; + if(locked) btn.title = `Требуется уровень ${reqLv}`; + btn.onclick = () => { + if(!canCraft(r) || locked) return; + playSound('click'); + for(const res in r.cost) inv[res]-=r.cost[res]; + inv[r.out] = (inv[r.out]||0) + r.qty; + if(TOOLS[r.out]) addTool(r.out); + rebuildHotbar(); + renderCraft(); + }; + row.appendChild(icon); row.appendChild(info); row.appendChild(btn); + recipesEl.appendChild(row); + } + } + + let craftOpen=false; + let inventoryOpen = false; + + document.getElementById('craftBtn').onclick = () => { + playSound('click'); // Звук клика по кнопке + craftOpen = !craftOpen; + craftPanel.style.display = craftOpen ? 'block' : 'none'; + if(craftOpen) { + renderCraft(); + // Закрываем инвентарь если открыт крафт + inventoryOpen = false; + inventoryPanel.style.display = 'none'; + } + }; + document.getElementById('craftClose').onclick = () => { + playSound('click'); // Звук клика по кнопке + craftOpen = false; + craftPanel.style.display = 'none'; + }; + + // Кнопка открытия инвентаря + document.getElementById('invToggle').onclick = () => { + playSound('click'); // Звук клика по кнопке + inventoryOpen = !inventoryOpen; + inventoryPanel.style.display = inventoryOpen ? 'block' : 'none'; + if(inventoryOpen) { + renderInventory(); + // Закрываем крафт если открыт инвентарь + craftOpen = false; + craftPanel.style.display = 'none'; + } + }; + + document.getElementById('inventoryClose').onclick = () => { + playSound('click'); // Звук клика по кнопке + inventoryOpen = false; + inventoryPanel.style.display = 'none'; + }; + + // Кнопка сохранения игры (только для одиночного режима) + const saveBtn = document.getElementById('saveBtn'); + saveBtn.onclick = () => { + playSound('click'); + saveGame(); + customAlert('Игра сохранена!'); + }; + + // Кнопка сброса игры (удаление сохранения и создание нового мира) + const resetBtn = document.getElementById('resetBtn'); + resetBtn.onclick = () => { + customConfirm('Вы уверены, что хотите удалить сохранение и начать новую игру?', () => { + playSound('click'); + + // Удаляем сохранение из localStorage + try { + localStorage.removeItem(SAVE_KEY); + console.log('Сохранение удалено из localStorage'); + } catch (e) { + console.warn('Ошибка удаления сохранения:', e); + } + + // Сбрасываем in-memory сохранение + inMemorySave = null; + + // Генерируем новый worldId + worldId = Math.random().toString(36).substring(2, 10); + console.log('Новый worldId после сброса:', worldId); + + // Обновляем URL + try { + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('world', worldId); + const newUrlString = newUrl.toString(); + + if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') { + window.history.replaceState(null, '', newUrlString); + console.log('URL обновлён:', newUrlString); + } + } catch (e) { + console.error('Ошибка обновления URL:', e); + } + + // Перезагружаем страницу + location.reload(); + }); + }; + + // Показываем кнопку сохранения только если играем одни + function updateSaveButtonVisibility() { + if (isMultiplayer && otherPlayers.size > 0) { + saveBtn.style.display = 'none'; + } else { + saveBtn.style.display = 'flex'; + } + } + + // Режимы + const MODES = [{id:'mine',icon:'⛏️'},{id:'build',icon:'🧱'}]; + let modeIdx=0; + const modeBtn = document.getElementById('modeBtn'); + function mode(){ return MODES[modeIdx].id; } + modeBtn.onclick = () => { + playSound('click'); // Звук клика по кнопке режима + modeIdx=(modeIdx+1)%MODES.length; modeBtn.textContent=MODES[modeIdx].icon; + }; + + // День/ночь (автоматический цикл) + let isNightTime = false; + + // Управление + const inp = { l:false, r:false, j:false, s:false }; + function bindHold(el, key){ + const down=(e)=>{ e.preventDefault(); inp[key]=true; }; + const up=(e)=>{ e.preventDefault(); inp[key]=false; }; + el.addEventListener('pointerdown', down); + el.addEventListener('pointerup', up); + el.addEventListener('pointerleave', up); + } + const leftBtn = document.getElementById('left'); + const rightBtn = document.getElementById('right'); + const jumpBtn = document.getElementById('jump'); + const downBtn = document.getElementById('down'); + + if(leftBtn) bindHold(leftBtn,'l'); + if(rightBtn) bindHold(rightBtn,'r'); + if(jumpBtn) bindHold(jumpBtn,'j'); + if(downBtn) bindHold(downBtn,'s'); + + window.addEventListener('keydown', (e)=>{ + if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=true; + if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=true; + if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=true; + if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=true; + }); + window.addEventListener('keyup', (e)=>{ + if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=false; + if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=false; + if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=false; + if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=false; + }); + + // Лодка + const boat = { + x: 0, y: 0, + w: 34, h: 34, + vx: 0, vy: 0, + active: false, + inWater: false + }; + + // Функция для расчёта урона с учётом брони + function calculateDamage(baseDamage) { + // Броня снижает урон пропорционально + // armor: 0 = без брони (100% урона) + // armor: 0.5 = железная броня (50% урона) + const reduction = player.armor; + const actualDamage = baseDamage * (1 - reduction); + console.log('[DAMAGE] Base:', baseDamage, '- Armor:', reduction, '- Actual:', actualDamage.toFixed(1)); + return actualDamage; + } + + // Игрок + const player = { + x: 6*TILE, y: 0*TILE, + w: 34, h: 34, + vx: 0, vy: 0, + grounded: false, + inWater: false, + headInWater: false, + hp: 100, + hunger: 100, + o2: 100, + invuln: 0, + slowTimer: 0, // яд скорпиона — замедление + fallStartY: 0, + lastStepTime: 0, + sleeping: false, + inBoat: false, + armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня) + equippedArmor: null, // Тип надетой брони + xp: 0, + level: 1 + }; + + // Сохраняем начальную позицию для возрождения + const spawnPoint = { x: 6*TILE, y: 0*TILE }; + + // Система дропов с мобов + const drops = []; // {x, y, vy, item, qty, age} + let levelUpPopup = null; // {text, timer} + + function xpForLevel(lv) { + if (lv <= 1) return 0; + const thresholds = [0, 50, 150, 300, 500, 800, 1200, 1700, 2300, 3000]; + if (lv - 1 < thresholds.length) return thresholds[lv - 1]; + return Math.floor(3000 + (lv - 10) * (lv - 10) * 50 + (lv - 10) * 200); + } + + function getMobLoot(kind) { + const table = { + chicken: [{item:'chicken_meat',min:1,max:2,chance:1},{item:'feather',min:0,max:1,chance:0.5}], + pig: [{item:'meat',min:1,max:2,chance:1}], + zombie: [{item:'meat',min:0,max:1,chance:0.5},{item:'iron_ingot',min:0,max:1,chance:0.3}], + skeleton: [{item:'arrow',min:2,max:4,chance:1},{item:'bone',min:0,max:2,chance:0.6},{item:'bow',min:1,max:1,chance:0.15}], + creeper: [{item:'gunpowder',min:1,max:2,chance:1}], + scorpion: [{item:'scorpion_stinger',min:0,max:1,chance:0.4}], + polar_bear:[{item:'polar_fur',min:1,max:2,chance:0.8},{item:'meat',min:1,max:2,chance:1}], + slime: [{item:'slime_ball',min:1,max:2,chance:1}], + eagle: [{item:'eagle_feather',min:1,max:2,chance:0.7}] + }; + return table[kind] || []; + } + + function getMobXP(kind) { + const xpTable = { chicken:3, pig:5, zombie:10, skeleton:12, creeper:15, scorpion:8, polar_bear:20, slime:5, eagle:15 }; + return xpTable[kind] || 0; + } + + function spawnDrops(mx, my, kind) { + const loot = getMobLoot(kind); + for (const entry of loot) { + if (Math.random() > entry.chance) continue; + const qty = entry.min + Math.floor(Math.random() * (entry.max - entry.min + 1)); + if (qty <= 0) continue; + drops.push({ + x: mx + (Math.random() - 0.5) * 20, + y: my + (Math.random() - 0.5) * 10, + vy: -1 - Math.random() * 2, + item: entry.item, + qty: qty, + age: 0 + }); + } + } + + const LEVEL_UNLOCKS = { + 2: 'Каменные инструменты, Лодка', + 3: 'Железные инструменты, Печь', + 4: 'Лук и стрелы', + 5: 'Железная броня', + 6: 'Золотая броня', + 7: 'Алмазные инструменты', + 8: 'TNT' + }; + + function grantXP(amount) { + player.xp += amount; + while (player.xp >= xpForLevel(player.level + 1)) { + player.level++; + const unlock = LEVEL_UNLOCKS[player.level] || ''; + levelUpPopup = { text: '⭐ Уровень ' + player.level + '!' + (unlock ? ' ' + unlock : ''), timer: 240 }; + } + } + + function pickupDrops() { + for (let i = drops.length - 1; i >= 0; i--) { + const d = drops[i]; + const dx = player.x + player.w/2 - d.x; + const dy = player.y + player.h/2 - d.y; + if (dx*dx + dy*dy < 30*30) { + if (!inv[d.item]) inv[d.item] = 0; + inv[d.item] += d.qty; + drops.splice(i, 1); + rebuildHotbar(); + } + } + } + + function drawDrops(ctx) { + for (let i = drops.length - 1; i >= 0; i--) { + const d = drops[i]; + d.age++; + if (d.age > 3600) { drops.splice(i, 1); continue; } // 60 sec at 60fps + // Bounce animation + const bounce = Math.abs(Math.sin(d.age * 0.05)) * 6; + const dy = d.y - bounce; + const sx = d.x - camX; + const sy = dy - camY; + // Skip if off screen + if (sx < -40 || sx > W + 40 || sy < -40 || sy > H + 40) continue; + // Glow effect + ctx.save(); + ctx.globalAlpha = 0.3; + ctx.fillStyle = '#ffff00'; + ctx.beginPath(); + ctx.arc(sx, sy, 14, 0, Math.PI*2); + ctx.fill(); + ctx.restore(); + // Item icon + ctx.save(); + ctx.font = '16px system-ui'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const itemDef = ITEMS[d.item]; + const icon = itemDef ? itemDef.icon : '🎁'; + const label = d.qty > 1 ? icon + '×' + d.qty : icon; + ctx.fillText(label, sx, sy); + ctx.restore(); + } + } + + function drawLevelUpPopup(ctx) { + if (!levelUpPopup) return; + levelUpPopup.timer--; + if (levelUpPopup.timer <= 0) { levelUpPopup = null; return; } + const alpha = Math.min(1, levelUpPopup.timer / 60); + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = '#FFD700'; + ctx.font = 'bold 36px system-ui'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.strokeStyle = '#000'; + ctx.lineWidth = 3; + ctx.strokeText(levelUpPopup.text, W/2, H/2 - 60); + ctx.fillText(levelUpPopup.text, W/2, H/2 - 60); + ctx.restore(); + } + + // Система сохранения игры (localStorage + in-memory fallback) + const SAVE_KEY = 'minegrechka_save'; + let db = null; // Оставляем для совместимости, но не используем + let inMemorySave = null; // Запасное сохранение в памяти + + // Инициализация (localStorage + in-memory fallback) + function initDB(){ + return new Promise((resolve) => { + console.log('Используем localStorage для сохранений (sandbox режим)'); + resolve(null); + }); + } + + // Детерминированный генератор псевдослучайных чисел на основе seed + function seededRandom(gx, gy){ + const n = Math.sin(gx * 12.9898 + gy * 78.233 + worldSeed * 0.1) * 43758.5453; + return n - Math.floor(n); + } + + function saveGame(){ + const saveData = { + version: 2, + worldSeed: worldSeed, + player: { + x: player.x, + y: player.y, + hp: player.hp, + hunger: player.hunger, + o2: player.o2, + xp: player.xp, + level: player.level + }, + 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; + player.xp = saveData.player.xp || 0; + player.level = saveData.player.level || 1; + + // Обновляем spawnPoint на позицию из сохранения + spawnPoint.x = player.x; + spawnPoint.y = player.y; + + // Проверяем HP из сохранения - если <= 0, устанавливаем 100 + const savedHP = saveData.player.hp; + console.log('Saved HP from file:', savedHP); + if(savedHP <= 0){ + console.log('WARNING: Saved HP is <= 0, setting to 100!'); + player.hp = 100; + } else { + player.hp = savedHP; + } + console.log('player HP after restore:', player.hp); + console.log('spawnPoint обновлён из сохранения: x=', spawnPoint.x, 'y=', spawnPoint.y); + } else { + console.log('No player data in save, setting default HP: 100'); + player.hp = 100; + } + + console.log('=== applySave END ==='); + + // Восстанавливаем инвентарь + if(saveData.inventory){ + for(const key in saveData.inventory){ + inv[key] = saveData.inventory[key]; + } + } + + // Восстанавливаем время + if(saveData.time !== undefined){ + worldTime = saveData.time; + } + + // Восстанавливаем день/ночь + if(saveData.isNight !== undefined){ + isNightTime = saveData.isNight; + } + + // Перегенерируем мир по seed + regenerateVisibleChunks(); + + // Применяем изменения (только для v2) + if(saveData.version === 2){ + // Применяем блоки, установленные игроком + for(const block of saveData.placedBlocks){ + setBlock(block.gx, block.gy, block.t, true); + } + + // Применяем удалённые блоки + for(const block of saveData.removedBlocks){ + removeBlock(block.gx, block.gy); + } + + // Восстанавливаем массивы изменений + placedBlocks = saveData.placedBlocks || []; + removedBlocks = saveData.removedBlocks || []; + } + + rebuildHotbar(); + console.log('Игра загружена'); + } + + // Камера (двухосевая) + let camX=0, camY=0; + + // День/ночь + let worldTime=0; + const DAY_LEN=360; // замедлил смену дня/ночи в 3 раза + + // Облака + const clouds = Array.from({length:10}, ()=>({ + x: Math.random()*2000, + y: -200 - Math.random()*260, // выше экрана, потому что мир по Y теперь двигается + w: 80+Math.random()*120, + s: 12+Math.random()*20 + })); + + // Дождь + let isRaining = false; + let rainIntensity = 0; // 0..1 + const snowflakes = []; // снежинки для тундры + const MAX_SNOWFLAKES = 150; + const raindrops = []; + const MAX_RAINDROPS = 200; + + // ==================== РОСТ КУЛЬТУР ==================== + const growthTimers = {}; // ключ: "gx,gy" → { stage:0-3, growTimer:X } + + // Старая функция заменена — теперь погода через биомы (см. weatherState выше) + // updateWeather(dt) вызывается из основного цикла — биом-зависимая + + // Интеграция: определяем isRaining из weatherState для визуализации + function syncWeatherVisual() { + isRaining = (weatherState.type === 'rain' || weatherState.type === 'storm'); + if (weatherState.type === 'clear') { + rainIntensity *= 0.95; // плавное затухание + if (rainIntensity < 0.01) rainIntensity = 0; + } + } + + function updateRain(dt) { + syncWeatherVisual(); + // Дождь + if ((weatherState.type === 'rain' || weatherState.type === 'storm') && rainIntensity < weatherState.intensity * 0.6) { + rainIntensity += dt * 0.3; + } else if (weatherState.type === 'clear' || weatherState.type === 'snow' || weatherState.type === 'fog') { + rainIntensity = Math.max(0, rainIntensity - dt * 0.5); + } + if (rainIntensity < 0.01) { + raindrops.length = 0; + } else { + const spawnRate = Math.floor(rainIntensity * 80 * dt); + for (let i = 0; i < spawnRate && raindrops.length < MAX_RAINDROPS; i++) { + raindrops.push({ + x: camX + Math.random() * W, + y: camY - 20, + vy: 400 + Math.random() * 200, + len: 8 + Math.random() * 12 + }); + } + for (let i = raindrops.length - 1; i >= 0; i--) { + const d = raindrops[i]; + d.y += d.vy * dt; + d.x -= 30 * dt; + if (d.y > camY + H + 20) raindrops.splice(i, 1); + } + } + // Снег + if (weatherState.type === 'snow') { + const spawnRate = Math.floor(weatherState.intensity * 30 * dt); + for (let i = 0; i < spawnRate && snowflakes.length < MAX_SNOWFLAKES; i++) { + snowflakes.push({ + x: camX + Math.random() * W, + y: camY - 10, + vy: 40 + Math.random() * 60, + vx: (Math.random() - 0.5) * 30, + size: 2 + Math.random() * 3 + }); + } + } + for (let i = snowflakes.length - 1; i >= 0; i--) { + const s = snowflakes[i]; + s.y += s.vy * dt; + s.x += s.vx * dt + Math.sin(s.y * 0.02) * 10 * dt; + if (s.y > camY + H + 20 || weatherState.type !== 'snow') snowflakes.splice(i, 1); + } + } + + function drawRain() { + // Дождь + if (raindrops.length > 0) { + ctx.save(); + ctx.strokeStyle = (weatherState.type === 'storm') ? 'rgba(174,194,224,0.7)' : 'rgba(174,194,224,0.5)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + for (const d of raindrops) { + ctx.moveTo(d.x, d.y); + ctx.lineTo(d.x - 3, d.y + d.len); + } + ctx.stroke(); + ctx.restore(); + } + // Снег + if (snowflakes.length > 0) { + ctx.save(); + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + for (const s of snowflakes) { + ctx.beginPath(); + ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + // Гроза — вспышка + if (weatherState.type === 'storm' && Math.random() < 0.003) { + ctx.save(); + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.fillRect(camX, camY, W, H); + ctx.restore(); + } + // Туман — серый оверлей + if (weatherState.type === 'fog') { + ctx.save(); + ctx.fillStyle = 'rgba(200,200,200,0.4)'; + ctx.fillRect(camX, camY, W, H); + ctx.restore(); + } + } + + // Частицы (взрыв) + const parts = []; + function spawnExplosion(x,y, power){ + const n = Math.floor(16 + power*10); + for(let i=0;i 100){ + playSound('splash'); + } + } + + function resolveY(e){ + // Всегда пересчитываем grounded (не держим "липким") + e.grounded = false; + + const x1 = e.x + 2; + const x2 = e.x + e.w - 2; + + // Проверяем, находится ли игрок на лестнице (по центру) + const cx = e.x + e.w/2; + const cy = e.y + e.h/2; + const gx = Math.floor(cx / TILE); + const gy = Math.floor(cy / TILE); + const b = getBlock(gx, gy); + const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable; + + // Если на лестнице - можно двигаться вверх/вниз + if(onLadder){ + e.grounded = true; + + // Если нажимаем прыжок на лестнице - поднимаемся + if(inp.j){ + e.vy = -200; + } + // Если нажимаем вниз - спускаемся + else if(inp.s){ + e.vy = 100; + } + // Иначе - остаёмся на месте (нет гравитации) + else { + e.vy = 0; + } + return; + } + + // Проверяем, можно ли запрыгнуть на лестницу рядом (слева или справа) + const leftGX = Math.floor((e.x - 4) / TILE); + const rightGX = Math.floor((e.x + e.w + 4) / TILE); + const playerGY = Math.floor((e.y + e.h/2) / TILE); + + const leftBlock = getBlock(leftGX, playerGY); + const rightBlock = getBlock(rightGX, playerGY); + const leftLadder = leftBlock && BLOCKS[leftBlock.t] && BLOCKS[leftBlock.t].climbable; + const rightLadder = rightBlock && BLOCKS[rightBlock.t] && BLOCKS[rightBlock.t].climbable; + + // Если рядом есть лестница и игрок прыгает - притягиваем к ней + if((leftLadder || rightLadder) && inp.j && e.vy < 0){ + // Перемещаем игрока к лестнице + if(leftLadder && e.x > leftGX * TILE + TILE/2){ + e.x = leftGX * TILE + TILE/2 - e.w/2; + } else if(rightLadder && e.x < rightGX * TILE + TILE/2){ + e.x = rightGX * TILE + TILE/2 - e.w/2; + } + e.grounded = true; + e.vy = -150; // меньший прыжок при запрыгивании на лестницу + return; + } + + // 1) Если движемся вниз ИЛИ стоим (vy >= 0) — проверяем опору под ногами + // Берём точку на 1px ниже стопы, чтобы не зависеть от ровного попадания в границу тайла. + if(e.vy >= 0){ + const probeY = e.y + e.h + 1; + const gy = Math.floor(probeY / TILE); + const gxA = Math.floor(x1 / TILE); + const gxB = Math.floor(x2 / TILE); + + if(isSolid(gxA, gy) || isSolid(gxB, gy)){ + e.y = gy * TILE - e.h; // прижимаем к полу + e.vy = 0; + e.grounded = true; + + // урон от падения — только игроку и только не в воде + if(e === player && !player.inWater){ + const fallTiles = (e.y - e.fallStartY) / TILE; + if(fallTiles > 6) { + const damage = calculateDamage((fallTiles - 6) * 10); + player.hp -= damage; + } + } + if(e === player) e.fallStartY = e.y; + } + } + + // 2) Проверка на возможность запрыгнуть на блок, если игрок рядом с ним + if(e.vy < 0 && e === player){ + const gy = Math.floor(e.y / TILE); + const gxA = Math.floor(x1 / TILE); + const gxB = Math.floor(x2 / TILE); + + // Проверяем, есть ли блок рядом с игроком + if((isSolid(gxA, gy) || isSolid(gxB, gy)) && !isSolid(gxA, gy - 1) && !isSolid(gxB, gy - 1)){ + e.y = (gy + 1) * TILE; + e.vy = 0; + e.grounded = true; + if(e === player) e.fallStartY = e.y; + console.log("Jumped onto block!"); + } + } + + // 2) Если движемся вверх — проверяем потолок + if(e.vy < 0){ + const gy = Math.floor(e.y / TILE); + const gxA = Math.floor(x1 / TILE); + const gxB = Math.floor(x2 / TILE); + if(isSolid(gxA, gy) || isSolid(gxB, gy)){ + e.y = (gy + 1) * TILE; + e.vy = 0; + } + } + } + + function resolveX(e){ + const y1 = e.y + 2; + const y2 = e.y + e.h - 2; + + // Проверяем, находимся ли мы на лестнице + const cx = e.x + e.w/2; + const cy = e.y + e.h/2; + const gx = Math.floor(cx / TILE); + const gy = Math.floor(cy / TILE); + const b = getBlock(gx, gy); + const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable; + + if(e.vx > 0){ + const gx = Math.floor((e.x + e.w)/TILE); + const gyA = Math.floor(y1/TILE); + const gyB = Math.floor(y2/TILE); + const solidA = isSolid(gx, gyA); + const solidB = isSolid(gx, gyB); + + if(solidA || solidB){ + e.x = gx*TILE - e.w; + e.vx = 0; + } + } else if(e.vx < 0){ + const gx = Math.floor(e.x/TILE); + const gyA = Math.floor(y1/TILE); + const gyB = Math.floor(y2/TILE); + const solidA = isSolid(gx, gyA); + const solidB = isSolid(gx, gyB); + + if(solidA || solidB){ + e.x = (gx+1)*TILE; + e.vx = 0; + } + } + } + + // TNT логика: цепь + усиление + const activeTNT = new Set(); // хранит key + function activateTNT(b, fuse=3.2){ + if(b.dead) return; + if(b.active) return; + b.active=true; + b.fuse=fuse; + activeTNT.add(k(b.gx,b.gy)); + } + + function explodeAt(gx,gy){ + const center = getBlock(gx,gy); + if(!center) return; + + // усиление: считаем сколько TNT рядом (в радиусе 2) и активируем их «почти сразу» + let bonus = 0; + for(let x=gx-2; x<=gx+2; x++){ + for(let y=gy-2; y<=gy+2; y++){ + const b = getBlock(x,y); + if(b && !b.dead && b.t==='tnt' && !(x===gx && y===gy)){ + bonus += 0.8; + activateTNT(b, 0.12); // цепь + } + } + } + + const power = 1 + bonus; // условная мощность + const radius = 3.2 + bonus*0.7; // радиус разрушения в тайлах + const dmgR = 150 + bonus*60; // радиус урона в пикселях + + removeBlock(gx,gy); + activeTNT.delete(k(gx,gy)); + playSound('explode1'); // Звук взрыва + spawnExplosion(gx*TILE + TILE/2, gy*TILE + TILE/2, power); + + for(let x=Math.floor(gx-radius); x<=Math.ceil(gx+radius); x++){ + for(let y=Math.floor(gy-radius); y<=Math.ceil(gy+radius); y++){ + const d = Math.hypot(x-gx, y-gy); + if(d > radius) continue; + const b = getBlock(x,y); + if(!b || b.dead) continue; + if(BLOCKS[b.t].fluid) continue; + if(BLOCKS[b.t].unbreakable) continue; + if(b.t==='tnt') { activateTNT(b, 0.12); continue; } + removeBlock(x,y); + if(inv[b.t] !== undefined && Math.random()<0.20) inv[b.t]++; // немного дропа + } + } + rebuildHotbar(); + + // урон + const hurt = (e)=>{ + const dx = (e.x+e.w/2) - (gx*TILE+TILE/2); + const dy = (e.y+e.h/2) - (gy*TILE+TILE/2); + const dist = Math.hypot(dx,dy); + if(dist < dmgR){ + const dmg = (dmgR - dist) * 0.06 * power; + if(e === player) { + const actualDamage = calculateDamage(dmg); + player.hp -= actualDamage; + } else { + e.hp -= dmg; + } + e.vx += (dx/dist || 0) * 600; + e.vy -= 320; + } + }; + hurt(player); + mobs.forEach(hurt); + } + + // Взаимодействие мышь/тап + const mouse = { x:null, y:null }; + canvas.addEventListener('pointermove', (e)=>{ + const r = canvas.getBoundingClientRect(); + mouse.x = e.clientX - r.left; + mouse.y = e.clientY - r.top; + }); + + canvas.addEventListener('pointerdown', (e)=>{ + if(craftOpen) return; + if(player.hp<=0) return; + + const r = canvas.getBoundingClientRect(); + const sx = e.clientX - r.left; + const sy = e.clientY - r.top; + + const wx = sx + camX; + const wy = sy + camY; + + const gx = Math.floor(wx / TILE); + const gy = Math.floor(wy / TILE); + + // Пробуждение: клик по любой кровати когда спишь + const b = getBlock(gx,gy); + if(player.sleeping && b && b.t==='bed'){ + player.sleeping = false; + return; + } + + if(player.sleeping) return; // Нельзя взаимодействовать во время сна + + // Клик по печи — открываем панель обжига + if(b && b.t === 'furnace' && mode() === 'mine'){ + openFurnaceUI(gx, gy); + return; + } + + // клик по мобу (в режиме mine) + if(mode()==='mine'){ + // Check all mobs (local + server-spawned) using getAllMobs + const allClickMobs = getAllMobs(); + for(let i = allClickMobs.length - 1; i >= 0; i--){ + const m = allClickMobs[i]; + if(m.dead) continue; + if(wx>=m.x && wx<=m.x+m.w && wy>=m.y && wy<=m.y+m.h){ + let dmg = 1; + const swordTypes = ['iron_sword','stone_sword','wood_sword']; + for (const st of swordTypes) { + if (inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; } + } + m.hp -= dmg; + m.vx += (m.x - player.x) * 2; + m.vy -= 200; + playSound('attack'); + // Server-spawned mob: emit hurt to server for relay, handle death locally + if(m.id !== undefined && isMultiplayer){ + socket.emit('mob_hurt', { id: m.id, dmg }); + if(m.hp <= 0){ + socket.emit('mob_died', { id: m.id }); + } + } + if(m.hp<=0){ + if(m.kind === 'chicken') playSound('hurt_chicken'); + spawnDrops(m.x + m.w/2, m.y + m.h/2, m.kind); + grantXP(getMobXP(m.kind)); + // Remove from the correct array + if(m.id !== undefined){ + serverMobs.delete(m.id); + } else { + const localIdx = mobs.indexOf(m); + if(localIdx >= 0) mobs.splice(localIdx, 1); + } + rebuildHotbar(); + } + return; + } + } + } + + // Лук — стреляем стрелой + if(selected === 'bow' && inv.bow > 0 && inv.arrow > 0){ + const aimX = wx - player.x - player.w/2; + const aimY = wy - player.y - player.h/2; + const angle = Math.atan2(aimY, aimX); + projectiles.push({ + x: player.x + player.w/2, + y: player.y + player.h/3, + vx: Math.cos(angle) * 550, + vy: Math.sin(angle) * 550, + dmg: 10, + owner: 'player', + life: 4 + }); + inv.arrow--; + useTool('bow'); + playSound('hit1'); + rebuildHotbar(); + return; + } + + // еда (предмет) + if(ITEMS[selected] && inv[selected]>0){ + const it = ITEMS[selected]; + if(player.hp < 100 || player.hunger < 100){ + playSound('eat1'); + player.hunger = Math.min(100, player.hunger + it.food); + player.hp = Math.min(100, player.hp + 15); + inv[selected]--; + rebuildHotbar(); + } + return; + } + + //Посадка семян на грядку + if(b && b.t === 'farmland' && mode()==='build'){ + const seedMap = { wheat: 'wheat_stage0', carrot: 'carrot_stage0', potato: 'potato_stage0' }; + if(seedMap[selected] && inv[selected] > 0){ + inv[selected]--; + const cropType = seedMap[selected]; + setBlock(gx, gy-1, cropType); + growthTimers[gx+','+(gy-1)] = { stage:0, growTimer: 10+Math.random()*5 }; + sendBlockChange(gx, gy-1, cropType, 'add'); + playSound('cloth1'); + rebuildHotbar(); + return; + } + } + + // Мотыга — превращает grass/dirt в farmland (в любом режиме) + if(selected === 'hoe' && inv.hoe > 0 && b){ + if(b.t === 'grass' || b.t === 'dirt'){ + setBlock(gx, gy, 'farmland'); + sendBlockChange(gx, gy, b.t, 'remove'); + sendBlockChange(gx, gy, 'farmland', 'add'); + useTool('hoe'); + playSound('cloth1'); + rebuildHotbar(); + return; + } + } + + // жарка на костре: выбран meat + клик по campfire + if(b && b.t==='campfire' && selected==='meat' && inv.meat>0){ + playSound('fire'); // Звук при жарке на костре + inv.meat--; inv.cooked++; + rebuildHotbar(); + return; + } + + // Сон на кровати: клик по bed + if(b && b.t==='bed' && isNight()){ + player.sleeping = true; + saveGame(); // Сохраняем при отходе ко сну + return; + } + + if(mode()==='mine'){ + if(!b) return; + if(BLOCKS[b.t].fluid) return; + // Клик на урожайную культуру — сбор + if(BLOCKS[b.t].harvestable){ + const hInfo = BLOCKS[b.t]; + inv[hInfo.harvestItem] = (inv[hInfo.harvestItem]||0) + hInfo.harvestQty; + removeBlock(gx, gy); + sendBlockChange(gx, gy, b.t, 'remove'); + delete growthTimers[gx+','+gy]; + playSound('cloth1'); + rebuildHotbar(); + return; + } + if(BLOCKS[b.t].decor) return; + + if(b.t==='tnt'){ activateTNT(b, 3.2); return; } // не взрывается сразу + + const removed = removeBlock(gx,gy); + if(removed){ + inv[removed.t] = (inv[removed.t]||0) + 1; + + // Тратим прочность кирки (если есть в инвентаре) + const pickTypes = ['iron_pickaxe','stone_pickaxe','wood_pickaxe']; + for (const pt of pickTypes) { + if (inv[pt] > 0) { + const broke = useTool(pt); + if (broke) playSound('cloth1'); // звук поломки + break; + } + } + + // Отправляем изменение блока на сервер + sendBlockChange(gx, gy, removed.t, 'remove'); + + // Звуки при добыче блоков + if(removed.t === 'glass') playSound('glass1'); + else if(removed.t === 'sand') playSound('sand1'); + else if(removed.t === 'snow') playSound('snow1'); + else if(removed.t === 'stone' || removed.t.endsWith('_ore')) playSound('stone1'); + else if(removed.t === 'wood') playSound('wood1'); + else playSound('cloth1'); + + rebuildHotbar(); + } + return; + } + + if(mode()==='build'){ + if(inv[selected] <= 0) return; + if(!BLOCKS[selected]) return; + if(b) return; // занято + + // Проверяем, ставим ли лодку + if(selected === 'boat'){ + // Лодку можно ставить только на воду + const waterBelow = getBlock(gx, gy+1); + if(!waterBelow || waterBelow.t !== 'water'){ + return; + } + + // Создаём лодку + boat.x = gx * TILE; + boat.y = gy * TILE; + boat.vx = 0; + boat.vy = 0; + boat.active = true; + boat.inWater = true; + + // Сажаем игрока в лодку + player.inBoat = true; + player.x = boat.x; + player.y = boat.y; + player.vx = 0; + player.vy = 0; + + playSound('splash'); + inv[selected]--; + rebuildHotbar(); + return; + } + + // запрет ставить в игрока + const bx = gx*TILE, by = gy*TILE; + const overlap = !(bx >= player.x+player.w || bx+TILE <= player.x || by >= player.y+player.h || by+TILE <= player.y); + if(overlap) return; + + setBlock(gx,gy,selected, true); // true = блок установлен игроком + inv[selected]--; + + // Отправляем изменение блока на сервер + sendBlockChange(gx, gy, selected, 'set'); + + // Звук при строительстве + if(selected === 'stone' || selected === 'brick') playSound('stone_build'); + else if(selected === 'wood' || selected === 'planks') playSound('wood_build'); + else if(selected === 'glass') playSound('glass1'); + else if(selected === 'sand') playSound('sand1'); + else if(selected === 'snow') playSound('snow1'); + else if(selected === 'dirt' || selected === 'grass') playSound('cloth1'); + + rebuildHotbar(); + return; + } + }); + + // ==================== БИОМЫ ==================== + const BIOMES = { + plains: { name:'Равнина', surface:'grass', subsurface:'dirt', trees:true, flowers:true, treeChance:0.12 }, + desert: { name:'Пустыня', surface:'sand', subsurface:'sand', trees:false, flowers:false, treeChance:0 }, + tundra: { name:'Тундра', surface:'snow', subsurface:'dirt', trees:true, flowers:false, treeChance:0.06 }, + swamp: { name:'Болото', surface:'moss', subsurface:'dirt', trees:true, flowers:false, treeChance:0.10 }, + mountains:{ name:'Горы', surface:'stone', subsurface:'stone', trees:false, flowers:false, treeChance:0 } + }; + + function getBiome(gx) { + const temp = Math.sin(gx*0.003 + worldSeed*0.01)*0.5 + Math.sin(gx*0.007 + worldSeed*0.02)*0.3 + 0.5; + const humid = Math.sin(gx*0.004 + worldSeed*0.015 + 1000)*0.5 + Math.cos(gx*0.006 + worldSeed*0.02 + 2000)*0.3 + 0.5; + const mtVal = Math.sin(gx*0.001 + worldSeed*0.005)*0.5 + 0.5; + if (temp > 0.7) return 'desert'; + if (temp < 0.3) return 'tundra'; + if (humid > 0.7 && temp >= 0.3 && temp <= 0.7) return 'swamp'; + if (temp >= 0.5 && temp <= 0.7 && mtVal > 0.75) return 'mountains'; + return 'plains'; + } + + const biomeCache = {}; + function getCachedBiome(gx) { + const chunk = Math.floor(gx / 8); // cache per 8-tile chunk for smoother biomes + if (biomeCache[chunk] === undefined) biomeCache[chunk] = getBiome(chunk * 8); + return biomeCache[chunk]; + } + + // ==================== ПОГОДА ==================== + const weatherState = { type: 'clear', intensity: 0, timer: 0, duration: 180, nextChange: 120 + Math.random()*180 }; + const BIOME_WEATHER = { + plains: { clear:0.50, rain:0.30, storm:0.10, snow:0, fog:0.10 }, + desert: { clear:0.80, rain:0.05, storm:0, snow:0, fog:0.15 }, + tundra: { clear:0.20, rain:0, storm:0.10, snow:0.60, fog:0.10 }, + swamp: { clear:0.20, rain:0.30, storm:0.10, snow:0, fog:0.40 }, + mountains:{ clear:0.40, rain:0.30, storm:0.10, snow:0.10, fog:0.10 } + }; + + function updateWeather(dt) { + weatherState.timer += dt; + if (weatherState.timer >= weatherState.nextChange) { + weatherState.timer = 0; + weatherState.nextChange = 60 + Math.random() * 240; + const biome = getCachedBiome(Math.floor(player.x / TILE)); + const probs = BIOME_WEATHER[biome] || BIOME_WEATHER.plains; + const r = Math.random(); + let cum = 0; + if ((cum += probs.clear) > r) { weatherState.type = 'clear'; } + else if ((cum += probs.rain) > r) { weatherState.type = 'rain'; } + else if ((cum += probs.storm) > r) { weatherState.type = 'storm'; } + else if ((cum += probs.snow) > r) { weatherState.type = 'snow'; } + else { weatherState.type = 'fog'; } + weatherState.duration = 60 + Math.random() * 300; + } + // Intensity interpolation + const target = (weatherState.type === 'clear') ? 0 : 1; + weatherState.intensity += (target - weatherState.intensity) * dt * 0.5; + } + + function getWeatherSpeedMultiplier() { + if (weatherState.type === 'rain') return 0.85; + if (weatherState.type === 'snow') return 0.7; + if (weatherState.type === 'storm') return 0.85; + return 1; + } + + function isOutdoorLight(lx, ly) { + // Check if position is outdoors (no block above) + const aboveGy = Math.floor(ly / TILE) - 1; + const aboveGx = Math.floor(lx / TILE); + const above = getBlock(aboveGx, aboveGy); + return !above || !BLOCKS[above.t]?.solid; + } + + // ==================== СТРУКТУРЫ МИРА ==================== + function placeStructure(startGx, startGy, pattern) { + for (let dy = 0; dy < pattern.length; dy++) { + for (let dx = 0; dx < pattern[dy].length; dx++) { + const bt = pattern[dy][dx]; + if (bt && bt !== 'air') { + setBlock(startGx + dx, startGy + dy, bt); + } + } + } + } + + // Пирамида в пустыне + const PYRAMID_PATTERN = [ + ['sand','sand','sand','sand','sand','sand','sand'], + ['sand','stone','stone','stone','stone','stone','sand'], + ['sand','stone','air','air','air','stone','sand'], + ['sand','stone','air','air','air','stone','sand'], + ['sand','stone','air','air','air','stone','sand'], + ['sand','sand','stone','stone','stone','sand','sand'] + ]; + + // Дом в равнине + const HOUSE_PATTERN = [ + ['air','planks','planks','planks','planks','air'], + ['planks','air','air','air','air','planks'], + ['planks','air','torch','air','air','planks'], + ['planks','air','air','air','air','planks'], + ['planks','planks','air','planks','planks','planks'] + ]; + + // Хижина в болоте + const HUT_PATTERN = [ + ['air','wood','wood','wood','air'], + ['wood','air','air','air','wood'], + ['wood','air','torch','air','wood'], + ['moss','moss','air','moss','moss'] + ]; + + // ==================== ГЕНЕРАЦИЯ ==================== + const generated = new Set(); // gx already generated + function surfaceGyAt(gx) { + const biome = getCachedBiome(gx); + // Base noise (same for all biomes) + const n1 = Math.sin(gx*0.025 + worldSeed*0.001)*8; + const n2 = Math.sin(gx*0.012 + worldSeed*0.002)*12; + const n3 = Math.sin(gx*0.006 + worldSeed*0.003)*6; + const n4 = Math.sin(gx*0.045 + worldSeed*0.004)*4; + const n5 = Math.cos(gx*0.018 + worldSeed*0.005)*5; + let h; + switch(biome) { + case 'desert': + h = Math.floor(SEA_GY - 4 + n3*0.3 + n4*0.5); // flatter, slightly higher + break; + case 'tundra': + h = Math.floor(SEA_GY - 6 + n2*0.5 + n3*0.4 + n5*0.3); // gentle rolling + break; + case 'swamp': + h = Math.floor(SEA_GY - 2 + n3*0.2 + n4*0.3); // very flat, near sea level + h = Math.max(h, SEA_GY - 3); // never too deep + break; + case 'mountains': + h = Math.floor(SEA_GY - 15 + n1*1.5 + n2*1.2 + n3); // tall peaks + break; + default: // plains + h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5); + } + return h; + } + + function genColumn(gx) { + if(generated.has(gx)) return; + generated.add(gx); + + const sgy = surfaceGyAt(gx); + const biome = getCachedBiome(gx); + + // === Вода и поверхность === + if(sgy > SEA_GY) { + // ниже уровня моря — заливаем водой + for(let gy = SEA_GY; gy < sgy; gy++) { + const blockType = (biome === 'swamp' && gy >= SEA_GY - 1) ? 'swamp_water' : 'water'; + setBlock(gx, gy, blockType); + } + setBlock(gx, sgy, 'sand'); + } else { + // поверхность + const b = BIOMES[biome]; + setBlock(gx, sgy, b.surface); + // болото: случайные лужи болотной воды + if(biome === 'swamp' && seededRandom(gx*3, sgy) < 0.15) { + setBlock(gx, sgy-1, 'swamp_water'); + } + // тундра: лёд на воде рядом + if(biome === 'tundra' && sgy === SEA_GY && seededRandom(gx, SEA_GY-1) < 0.3) { + setBlock(gx, SEA_GY-1, 'ice'); + } + } + + // === Подповерхностные слои === + for(let gy = sgy+1; gy <= BEDROCK_GY; gy++) { + if(gy === BEDROCK_GY) { setBlock(gx,gy,'bedrock'); continue; } + + let t = BIOMES[biome].subsurface; + + // глубже — камень + if(gy > sgy + 3) t = 'stone'; + + // пустыня: sand глубже + if(biome === 'desert' && gy <= sgy + 6) t = 'sand'; + + // болото: глина ближе к поверхности + if(biome === 'swamp' && gy <= sgy + 2 && seededRandom(gx, gy) < 0.3) t = 'clay'; + + // горы: gravel + if(biome === 'mountains' && gy > sgy + 4 && seededRandom(gx, gy) < 0.12) t = 'gravel'; + + // общие руды + const depth = gy - sgy; + const r = seededRandom(gx, gy); + if(t === 'stone') { + if(r < 0.06) t = 'coal'; + else if(r < 0.10) t = 'copper_ore'; + else if(r < 0.13) t = 'iron_ore'; + else if(depth > 40 && r < 0.145) t = 'gold_ore'; + else if(depth > 70 && r < 0.152) t = 'diamond_ore'; + } + + setBlock(gx, gy, t); + } + + // === Растительность и декор === + const b = BIOMES[biome]; + const top = getBlock(gx, sgy); + + // цветы (только plain) + if(biome === 'plains' && top && top.t === 'grass' && seededRandom(gx, sgy-1) < 0.10) { + setBlock(gx, sgy-1, 'flower'); + } + + // деревья + if(b.trees && seededRandom(gx*7, sgy-2) < b.treeChance) { + if(biome === 'tundra') { + // ёлки (треугольные, 3-5 высоты) + const th = 3 + Math.floor(seededRandom(gx, sgy) * 3); + for(let i = 0; i < th; i++) setBlock(gx, sgy-1-i, 'wood'); + // крона (треугольник) + for(let row = 0; row < th; row++) { + const w = Math.min(row + 1, 2); + for(let dx = -w; dx <= w; dx++) { + const ly = sgy-1-th+row; + if(ly >= 0) setBlock(gx+dx, ly, 'spruce_leaves'); + } + } + } else if(biome === 'swamp') { + // болотное дерево (короче, с мхом) + setBlock(gx, sgy-1, 'wood'); + setBlock(gx, sgy-2, 'moss'); + setBlock(gx-1, sgy-2, 'leaves'); + setBlock(gx+1, sgy-2, 'leaves'); + } else { + // обычное дерево (plains) + setBlock(gx, sgy-1, 'wood'); + setBlock(gx, sgy-2, 'wood'); + setBlock(gx, sgy-3, 'leaves'); + setBlock(gx-1, sgy-3, 'leaves'); + setBlock(gx+1, sgy-3, 'leaves'); + } + } + + // кактусы (пустыня) + if(biome === 'desert' && seededRandom(gx*11, sgy) < 0.04) { + const ch = 1 + Math.floor(seededRandom(gx, sgy+1) * 2); + for(let i = 0; i < ch; i++) setBlock(gx, sgy-1-i, 'cactus'); + } + + // грибы (болото) + if(biome === 'swamp' && seededRandom(gx*13, sgy) < 0.06) { + setBlock(gx, sgy-1, 'mushroom'); + } + + // сухие кусты (пустыня) + if(biome === 'desert' && seededRandom(gx*17, sgy) < 0.05) { + setBlock(gx, sgy-1, 'dead_bush'); + } + + // === Структуры мира === + // Пирамида в пустыне + if(biome === 'desert' && ((gx % 200 + 200) % 200) === 47 && sgy < SEA_GY && sgy > SEA_GY - 5) { + placeStructure(gx, sgy - 5, PYRAMID_PATTERN); + } + // Дом в равнине + if(biome === 'plains' && ((gx % 150 + 150) % 150) === 33 && sgy < SEA_GY) { + placeStructure(gx, sgy - 4, HOUSE_PATTERN); + } + // Хижина в болоте + if(biome === 'swamp' && ((gx % 180 + 180) % 180) === 55 && sgy < SEA_GY) { + placeStructure(gx, sgy - 3, HUT_PATTERN); + } + + // Применяем серверные оверрайды для этой колонны + const colPrefix = gx + ','; + for (const [key, ov] of serverOverrides) { + if (!key.startsWith(colPrefix)) continue; + if (ov.op === 'remove') { + const b = grid.get(key); + if (b) { grid.delete(key); b.dead = true; } + } else if (ov.op === 'set') { + if (!grid.has(key)) { + const gy = parseInt(key.split(',')[1]); + const nb = { gx, gy, t: ov.t, dead: false, active: false, fuse: 0 }; + grid.set(key, nb); + blocks.push(nb); + } + } + } + } + + // Перегенерация видимых чанков (используется при загрузке сохранения) + function regenerateVisibleChunks(){ + const gx0 = Math.floor(camX/TILE); + for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){ + // Принудительно перегенерируем колонну + generated.delete(gx); + genColumn(gx); + } + } + + function ensureGenAroundCamera(){ + const gx0 = Math.floor(camX/TILE); + for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){ + genColumn(gx); + } + } + + // Лут с дерева/листвы: дерево -> wood; листья -> leaves + // (уже в mine добавляется inv[type] автоматически) + + // Рисование костра: огонь поверх текстуры + function drawFire(wx,wy,now){ + const baseX = wx; + const baseY = wy; + const flick = 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 if(m.kind==='scorpion') { + // Скорпион — бежит к игроку, яд (замедление) + const dir = Math.sign((player.x) - m.x); + m.vx = dir * m.speed; + if(m.inWater && Math.random()<0.06) m.vy = -260; + // Яд при касании — замедление на 3 сек + if(Math.abs((m.x+ m.w/2) - (player.x+player.w/2)) < 28 && + Math.abs((m.y+ m.h/2) - (player.y+player.h/2)) < 40 && + player.invuln <= 0){ + const damage = calculateDamage(8); + player.hp -= damage; + player.invuln = 0.8; + player.slowTimer = 3; // замедление + player.vx += dir*300; + player.vy -= 200; + playSound('hit1'); + } + } else if(m.kind==='polar_bear') { + // Белый медведь — нейтрален, атакует если ударили (hostile пока нет, атакует через proximity) + m.aiT -= dt; + if(m.aiT <= 0){ + m.aiT = 2.0 + Math.random()*3; + m.dir = Math.random()<0.5 ? -1 : 1; + if(Math.random()<0.3) m.dir = 0; + } + m.vx = m.dir * m.speed; + if(m.inWater) m.vy = -120; + } else if(m.kind==='slime') { + // Слизь — прыгает к игроку + const dir = Math.sign((player.x+player.w/2) - (m.x+m.w/2)); + m.aiT -= dt; + if(m.aiT <= 0){ + m.aiT = 1.5 + Math.random()*1.5; + m.dir = dir; + // Прыжок + m.vy = -200; + } + m.vx = m.dir * m.speed; + } else if(m.kind==='eagle') { + // Орёл — летает, атакует пикированием + const dx = (player.x+player.w/2) - (m.x+m.w/2); + const dy = (player.y+player.h/2) - (m.y+m.h/2); + const dist = Math.hypot(dx, dy); + if(dist < 400) { + // Пикирует на игрока + m.vx = Math.sign(dx) * m.speed; + m.vy = dy > 0 ? 60 : -60; // летит вниз к игроку + } else { + // Патрулирует + m.aiT -= dt; + if(m.aiT <= 0){ + m.aiT = 2+Math.random()*3; + m.dir = Math.random()<0.5 ? -1 : 1; + } + m.vx = m.dir * m.speed * 0.5; + m.vy = Math.sin(performance.now()/1000) * 30; // мягкое покачивание + } + // Атака при касании + if(dist < 30 && player.invuln <= 0){ + const damage = calculateDamage(10); + player.hp -= damage; + player.invuln = 0.8; + player.vy -= 200; + playSound('hit1'); + } + // Орёл не падает — летающий моб + m.vy *= 0.5; + m.grounded = false; + } else { + // животные (pig, chicken) + m.aiT -= dt; + if(m.aiT <= 0){ + m.aiT = 1.8 + Math.random()*2.5; + m.dir = Math.random()<0.5 ? -1 : 1; + if(Math.random()<0.25) m.dir = 0; + } + m.vx = m.dir * (m.kind==='chicken' ? 55 : 40); + if(m.inWater) m.vy = -120; + } + + // физика моба + const g = m.inWater ? GRAV_WATER : GRAV; + m.vy += g*dt; + + m.y += m.vy*dt; m.grounded=false; resolveY(m); + m.x += m.vx*dt; resolveX(m); + } + + function isNight(){ + // Автоматический цикл: ночь когда worldTime > 0.5 + return worldTime > 0.5; + } + + // Respawn + document.getElementById('respawnBtn').onclick = async () => { + playSound('click'); // Звук клика по кнопке + + console.log('=== RESPAWN CLICKED ==='); + console.log('isMultiplayer:', isMultiplayer); + console.log('otherPlayers.size:', otherPlayers.size); + console.log('player.hp before respawn:', player.hp); + + // В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке + if (isMultiplayer && otherPlayers.size > 0) { + console.log('Мультиплеер режим - возрождение в начальной точке'); + player.hp = 100; + player.hunger = 100; + player.o2 = 100; + player.vx = player.vy = 0; + player.invuln = 0; + player.x = spawnPoint.x; + player.y = spawnPoint.y; + player.fallStartY = player.y; + console.log('Возрождение в начальной точке, HP:', player.hp); + } else { + console.log('Одиночный режим - загружаем последнее сохранение'); + // Одиночный режим - загружаем последнее сохранение + const loadedSave = await loadGame(); + if(loadedSave){ + await applySave(loadedSave); + console.log('Загружено последнее сохранение после смерти, final HP:', player.hp); + } else { + // Если сохранения нет, возрождаемся в начальной точке + player.hp = 100; + player.hunger = 100; + player.o2 = 100; + player.vx = player.vy = 0; + player.invuln = 0; + player.x = spawnPoint.x; + player.y = spawnPoint.y; + player.fallStartY = player.y; + console.log('Возрождение в начальной точке, HP:', player.hp); + } + } + + console.log('player.hp after respawn logic:', player.hp); + console.log('Hiding death screen...'); + deathEl.style.display='none'; + console.log('=== RESPAWN END ==='); + }; + + // Resize + function resize(){ + W = gameEl.clientWidth; + H = gameEl.clientHeight; + canvas.width = W*dpr; + canvas.height = H*dpr; + lightC.width = W*dpr; + lightC.height = H*dpr; + ctx.setTransform(dpr,0,0,dpr,0,0); + } + window.addEventListener('resize', resize); + + // init + resize(); + rebuildHotbar(); + + // Инициализируем и загружаем сохранение + initDB().then(async () => { + // Пытаемся загрузить сохранённую игру + const loadedSave = await loadGame(); + if(loadedSave){ + await applySave(loadedSave); + console.log('Загружено сохранение, HP:', player.hp); + + // Проверяем HP после загрузки - если <= 0, возрождаемся + if (player.hp <= 0) { + console.log('WARNING: HP <= 0 после загрузки, возрождаемся'); + player.hp = 100; + player.hunger = 100; + player.o2 = 100; + player.x = spawnPoint.x; + player.y = spawnPoint.y; + player.vx = player.vy = 0; + player.invuln = 0; + player.fallStartY = player.y; + } + } else { + console.log('Сохранение не найдено, начинаем новую игру'); + + // Инициализируем игрока для новой игры + player.hp = 100; + player.hunger = 100; + player.o2 = 100; + player.vx = player.vy = 0; + player.invuln = 0; + + // старт — на поверхности (используем ту же логику что и в world_state) + const startGX = 6; + for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx); + const surfaceY = surfaceGyAt(startGX); + let safeGY = surfaceY - 1; + const aboveBlock = getBlock(startGX, surfaceY - 1); + if (aboveBlock && aboveBlock.t === 'water') { + for (let gy = SEA_GY - 1; gy >= 0; gy--) { + const b = getBlock(startGX, gy); + if (!b || b.dead || b.t === 'air' || b.t === 'water') continue; + safeGY = gy - 1; + break; + } + } + player.y = safeGY * TILE; + player.x = startGX * TILE; + player.fallStartY = player.y; + + // Обновляем spawnPoint, чтобы возрождение было на поверхности + spawnPoint.x = player.x; + spawnPoint.y = player.y; + + console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', player.y, 'player.hp=', player.hp); + console.log('spawnPoint обновлён: x=', spawnPoint.x, 'y=', spawnPoint.y); + + // Генерируем карту вокруг стартовой позиции при инициализации + for(let gx = startGX - 50; gx <= startGX + 50; gx++){ + genColumn(gx); + } + } + + // Автосейв при скрытии страницы (защита от потери прогресса) + document.addEventListener('visibilitychange', () => { + if(document.hidden){ + saveGame(); + } + }); + + // Автосейв перед закрытием страницы (защита от потери прогресса) + window.addEventListener('beforeunload', () => { + saveGame(); + }); + }).catch(err => { + console.error('Ошибка инициализации:', err); + // При ошибке начинаем новую игру + const startGX = 6; + genColumn(startGX); + player.y = (surfaceGyAt(startGX)-1)*TILE; + player.fallStartY = player.y; + + for(let gx = startGX - 50; gx <= startGX + 50; gx++){ + genColumn(gx); + } + }); + + // main loop + let last = performance.now(); + let prevJump = false; + // При возврате на вкладку — сбрасываем last чтобы не было скачка dt + document.addEventListener('visibilitychange', () => { + if (!document.hidden) last = performance.now(); + }); + function loop(now){ + const rawDt = Math.min(0.05, (now-last)/1000); + last = now; + // Sub-stepping: разбиваем физику на шаги ≤16ms чтобы не проваливаться сквозь блоки + const PHYSICS_STEP = 0.016; + const steps = Math.max(1, Math.ceil(rawDt / PHYSICS_STEP)); + const dt = rawDt / steps; + + const jumpPressed = inp.j && !prevJump; + prevJump = inp.j; + + // Ускорение времени во время сна + if(player.sleeping && isNight()){ + worldTime += dt * 8 / DAY_LEN; // В 8 раз быстрее + // Восстанавливаем здоровье во время сна + player.hp = Math.min(100, player.hp + dt * 20); + // Автоматическое пробуждение когда наступает день + if(!isNight()){ + player.sleeping = false; + } + } else { + worldTime += dt / DAY_LEN; + } + if(worldTime >= 1) worldTime -= 1; + + // камера следует за игроком по X/Y + camX = Math.floor((player.x + player.w/2) - W/2); + camY = Math.floor((player.y + player.h/2) - H/2); + + ensureGenAroundCamera(); + + // clouds parallax + for(const c of clouds){ + c.x -= c.s * dt; + if(c.x + c.w < camX - 400) c.x = camX + W + Math.random()*700; + } + + // Погода (биом-зависимая) + updateWeather(dt); + + // player + updateWaterFlag(player); + + // кислород/утопление: тратим O2 только если голова под водой, иначе восстанавливаем [web:223] + if(player.headInWater){ + player.o2 = Math.max(0, player.o2 - 6*dt); // замедлил в 3.7 раза + if(player.o2 === 0){ + const damage = calculateDamage(4*dt); + player.hp -= damage; + } + } else { + player.o2 = Math.min(100, player.o2 + 10*dt); // замедлил восстановление в 4 раза + } + + // голод убывает, но HP не отнимает (как просили) + player.hunger = Math.max(0, player.hunger - dt*0.2); // замедлил в 4 раза + + // Игрок не может двигаться во время сна + if(player.sleeping){ + player.vx = 0; + player.vy = 0; + } else { + const dir = (inp.r?1:0) - (inp.l?1:0); + const speedMult = getWeatherSpeedMultiplier() * (player.slowTimer > 0 ? 0.4 : 1.0); + if(dir) player.vx = dir * MOVE * speedMult; + else player.vx *= 0.82; + } + + // Звук шагов при движении по земле + if(player.grounded && !player.inWater && Math.abs(player.vx) > 50){ + const stepInterval = 0.35; // Интервал между шагами в секундах + if(now/1000 - player.lastStepTime > stepInterval){ + playSound('step'); + player.lastStepTime = now/1000; + } + } + + // прыжок/плавание (новая логика) + if(player.inBoat){ + // Игрок в лодке - лодка следует за игроком + const dir = (inp.r?1:0) - (inp.l?1:0); + if(dir) boat.vx = dir * MOVE; + else boat.vx *= 0.95; + + // Лодка плавает на воде + boat.vy = 0; + + // Игрок следует за лодкой (сидит внутри неё) + player.x = boat.x + 2; // Игрок по центру лодки + player.y = boat.y - 4; // Игрок выше лодки (сидит внутри) + player.vx = boat.vx; + player.vy = boat.vy; + player.grounded = true; + player.inWater = false; // Игрок не в воде когда в лодке + + // Прыжок из лодки (высадка) + if(jumpPressed){ + // Возвращаем лодку в инвентарь + inv.boat = (inv.boat || 0) + 1; + + player.inBoat = false; + boat.active = false; + player.y += TILE; // Прыгаем из лодки + player.vy = -JUMP * 0.5; + playSound('splash'); + } + + } else if(player.inWater){ + // сопротивление в воде + player.vx *= 0.90; + player.vy *= 0.92; + + // Если не нажимаем прыжок - тонем (гравитация в воде) + if(!jumpPressed && !inp.j){ + // Применяем гравитацию в воде - игрок тонет + player.vy += GRAV_WATER * dt; + } else { + // Если нажимаем прыжок - поднимаемся на поверхность + if(jumpPressed){ + player.vy = Math.min(player.vy, -520); // рывок вверх + } else if(inp.j){ + // если держим — мягкое всплытие + player.vy = Math.min(player.vy, -260); + } + } + + } else { + // обычный прыжок (только по нажатию) + if(jumpPressed && player.grounded && !player.sleeping){ + player.vy = -JUMP; + player.grounded = false; + player.fallStartY = player.y; + } + } + + // Гравитация применяется только вне воды и вне лодки + if(!player.inWater && !player.inBoat){ + player.vy += GRAV*dt; + } + + // Обновляем позицию лодки + if(boat.active){ + boat.x += boat.vx * dt; + boat.y += boat.vy * dt; + + // Лодка не выходит за пределы воды + const boatGX = Math.floor(boat.x / TILE); + const boatGY = Math.floor(boat.y / TILE); + const below = getBlock(boatGX, boatGY + 1); + + if(!below || below.t !== 'water'){ + // Если лодка вышла из воды - выкидываем игрока + inv.boat = (inv.boat || 0) + 1; + player.inBoat = false; + boat.active = false; + player.y += TILE; + player.vy = -200; + playSound('splash'); + } + } + + // Проверяем, не доплыл ли игрок из лодки + if(player.inBoat && !boat.active){ + inv.boat = (inv.boat || 0) + 1; + player.inBoat = false; + player.y += TILE; + player.vy = -200; + playSound('splash'); + } + + // Sub-stepped physics: применяем движение мелкими шагами + for (let step = 0; step < steps; step++) { + player.y += player.vy*dt; + resolveY(player); + player.x += player.vx*dt; + resolveX(player); + } + + // Отправляем позицию на сервер (мультиплеер) + sendPlayerPosition(); + + // Обновляем физику воды + updateWaterPhysics(dt); + + // Погода и дождь + updateWeather(dt); + updateRain(dt); + + player.invuln = Math.max(0, player.invuln - dt); + if(player.slowTimer > 0) player.slowTimer = Math.max(0, player.slowTimer - dt); + + // Рост культур + for(const key of Object.keys(growthTimers)){ + const tile = growthTimers[key]; + if(tile.stage < 3){ + tile.growTimer -= dt; + if(tile.growTimer <= 0){ + tile.stage++; + tile.growTimer = 8 + Math.random()*6; + // Обновляем визуальный блок + const [gxStr, gyStr] = key.split(','); + const gx = parseInt(gxStr), gy = parseInt(gyStr); + const curBlock = getBlock(gx, gy); + if(curBlock && curBlock.t !== curBlock.t.replace(/_stage\d/, '_stage'+tile.stage)){ + // Вычисляем следующую стадию + const baseType = curBlock.t.replace(/_stage\d/, ''); + const nextType = baseType + '_stage' + tile.stage; + setBlock(gx, gy, nextType); + sendBlockChange(gx, gy, nextType, 'add'); + // Проверяем созрел ли + if(tile.stage >= 3) delete growthTimers[key]; + } + } + } + } + + // Voice position update + voicePosT += dt; + if(voicePosT > 0.5 && voiceSocket && voiceSocket.connected){ + voicePosT = 0; + voiceSocket.emit('voice_pos', { x: player.x, y: player.y }); + } + + // Furnace tick + tickFurnaces(dt); + + // Обновляем UI печи если открыта + if(currentFurnaceKey && Math.random() < 0.1){ + renderFurnaceUI(); + } + + // Projectile tick (стрелы) + for(let i = projectiles.length-1; i>=0; i--){ + const p = projectiles[i]; + p.x += p.vx * dt; + p.y += p.vy * dt; + p.vy += 400 * dt; // гравитация + p.life -= dt; + + // Столкновение с блоком + const gx = Math.floor(p.x / TILE); + const gy = Math.floor(p.y / TILE); + const blk = getBlock(gx, gy); + if(blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid){ + // Врезался в стену + if(p.owner === 'player' && Math.random() < 0.5) inv.arrow++; // подбираем ~50% + projectiles.splice(i, 1); + continue; + } + + // Столкновение с сущностью + if(p.owner === 'mob'){ + // Попал в игрока + if(p.x > player.x && p.x < player.x+player.w && p.y > player.y && p.y < player.y+player.h){ + if(player.invuln <= 0){ + player.hp -= calculateDamage(p.dmg); + player.invuln = 0.4; + player.vx += p.vx * 0.3; + player.vy -= 150; + playSound('hit1'); + } + projectiles.splice(i, 1); + continue; + } + } else { + // Попал в моба — check all mobs (client-authoritative) + const allArrowMobs = getAllMobs(); + for(let j = allArrowMobs.length - 1; j >= 0; j--){ + const m = allArrowMobs[j]; + if(m.dead) continue; + if(p.x > m.x && p.x < m.x+m.w && p.y > m.y && p.y < m.y+m.h){ + m.hp -= p.dmg; + m.vx += p.vx * 0.2; + m.vy -= 200; + // Server-spawned mob: emit arrow hit to server for relay + if(m.id !== undefined && isMultiplayer){ + socket.emit('mob_arrow_hit', { id: m.id, dmg: p.dmg, vx: p.vx }); + if(m.hp <= 0){ + socket.emit('mob_died', { id: m.id }); + } + } + if(m.hp <= 0){ + spawnDrops(m.x + m.w/2, m.y + m.h/2, m.kind); + grantXP(getMobXP(m.kind)); + // Remove from the correct array + if(m.id !== undefined){ + serverMobs.delete(m.id); + } else { + const localIdx = mobs.indexOf(m); + if(localIdx >= 0) mobs.splice(localIdx, 1); + } + rebuildHotbar(); + } + projectiles.splice(i, 1); + break; + } + } + } + + // Таймаут + if(p.life <= 0) projectiles.splice(i, 1); + } + + // TNT tick + for(const key of Array.from(activeTNT)){ + const b = grid.get(key); + if(!b || b.dead){ activeTNT.delete(key); continue; } + b.fuse -= dt; + if(b.fuse <= 0){ + explodeAt(b.gx,b.gy); + } + } + + // mobs spawn (с обеих сторон камеры) — только в одиночном режиме (MP mobs come from server events) + spawnT += dt; + if(!isMultiplayer && spawnT > 1.8 && getAllMobs().length < 30){ + spawnT = 0; + + // Выбираем сторону спавна (левая или правая) + const spawnLeft = Math.random() < 0.5; + const gx = spawnLeft + ? Math.floor((camX - 200)/TILE) + : Math.floor((camX + W + 200)/TILE); + + genColumn(gx); + const sgy = surfaceGyAt(gx); + const wx = gx*TILE + 4; + const wy = (sgy-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(); + } else if(m.kind==='scorpion') { + // скорпион — оранжево-коричневый + ctx.fillStyle = '#d35400'; + ctx.fillRect(m.x+2, m.y+4, m.w-4, m.h-8); + ctx.fillStyle = '#c0392b'; + ctx.fillRect(m.x+m.w-4, m.y-6, 4, 10); + ctx.fillRect(m.x+m.w-2, m.y-10, 3, 5); + ctx.fillStyle = '#000'; + ctx.fillRect(m.x+4, m.y+6, 3, 3); + ctx.fillRect(m.x+m.w-7, m.y+6, 3, 3); + ctx.fillStyle = '#d35400'; + ctx.fillRect(m.x-4, m.y+8, 6, 4); + ctx.fillRect(m.x+m.w-2, m.y+8, 6, 4); + } else if(m.kind==='polar_bear') { + ctx.fillStyle = '#ecf0f1'; + ctx.fillRect(m.x+2, m.y+4, m.w-4, m.h-4); + ctx.fillRect(m.x+8, m.y-2, m.w-16, 10); + ctx.fillStyle = '#bdc3c7'; + ctx.fillRect(m.x+m.w/2-3, m.y+2, 6, 4); + ctx.fillStyle = '#000'; + ctx.fillRect(m.x+12, m.y+1, 3, 3); + ctx.fillRect(m.x+m.w-14, m.y+1, 3, 3); + ctx.fillStyle = '#ecf0f1'; + ctx.fillRect(m.x+8, m.y-4, 4, 4); + ctx.fillRect(m.x+m.w-12, m.y-4, 4, 4); + } else if(m.kind==='slime') { + const bounce = Math.sin(performance.now()/200)*3; + ctx.fillStyle = 'rgba(46,204,113,0.8)'; + ctx.fillRect(m.x-1, m.y-1+bounce, m.w+2, m.h+2); + ctx.fillStyle = 'rgba(39,174,96,0.9)'; + ctx.fillRect(m.x+1, m.y+1+bounce, m.w-2, m.h-2); + ctx.fillStyle = '#fff'; + ctx.fillRect(m.x+4, m.y+6+bounce, 6, 6); + ctx.fillRect(m.x+m.w-10, m.y+6+bounce, 6, 6); + ctx.fillStyle = '#000'; + ctx.fillRect(m.x+6, m.y+8+bounce, 3, 3); + ctx.fillRect(m.x+m.w-8, m.y+8+bounce, 3, 3); + } else if(m.kind==='eagle') { + ctx.fillStyle = '#8B4513'; + ctx.fillRect(m.x+8, m.y+6, m.w-16, m.h-10); + const wingY = Math.sin(performance.now()/150)*4; + ctx.fillRect(m.x-6, m.y+4+wingY, 16, 6); + ctx.fillRect(m.x+m.w-10, m.y+4-wingY, 16, 6); + ctx.fillStyle = '#fff'; + ctx.fillRect(m.x+m.w/2-3, m.y+2, 6, 6); + ctx.fillStyle = '#f39c12'; + ctx.fillRect(m.x+m.w/2-1, m.y+6, 4, 3); + ctx.fillStyle = '#000'; + ctx.fillRect(m.x+m.w/2-1, m.y+3, 2, 2); + } + } + + // boat (рисуем первой, чтобы игрок был внутри неё) + if(boat.active){ + ctx.drawImage(tex['boat'], boat.x - (TILE-boat.w)/2, boat.y - (TILE-boat.h)/2, TILE, TILE); + } + + // 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); + } + + // Рисуем дропы + drawDrops(ctx); + // Пикап дропов + pickupDrops(); + // Popup уровня + drawLevelUpPopup(ctx); + + // Миникарта (обновляем раз в ~4 кадра для оптимизации) + if(minimapOpen && Math.random() < 0.25){ + renderMinimap(); + } + + requestAnimationFrame(loop); + } + + requestAnimationFrame(loop); +})(); diff --git a/index.html b/index.html index f249f4c..1a2c0ac 100644 --- a/index.html +++ b/index.html @@ -93,6 +93,6 @@ - +