diff --git a/Dockerfile b/Dockerfile index fd37297..3b76dc3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,9 @@ FROM nginx:alpine -# Копируем файлы игры в директорию nginx +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 -# Используем конфигурацию nginx по умолчанию EXPOSE 80 - CMD ["nginx", "-g", "daemon off;"] diff --git a/game.js b/game.js index 2349c55..c00fa4f 100644 --- a/game.js +++ b/game.js @@ -142,9 +142,12 @@ 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') { @@ -159,23 +162,30 @@ isNightTime = worldTime > 0.5; } - // Если есть spawnPoint от сервера - используем его и генерируем эту позицию - if (data.spawnPoint) { - spawnPoint.x = data.spawnPoint.x; - spawnPoint.y = data.spawnPoint.y; - // Генерируем колонну в точке спавна - const spawnGX = Math.floor(spawnPoint.x / TILE); - genColumn(spawnGX); - console.log('Server spawnPoint received and column generated:', spawnPoint); - } else { - // Если spawnPoint не пришёл от сервера - генерируем безопасную позицию - const startGX = 6; - genColumn(startGX); - const surfaceY = surfaceGyAt(startGX); - spawnPoint.x = startGX * TILE; - spawnPoint.y = (surfaceY - 1) * TILE; - console.log('Generated safe spawn point:', spawnPoint, 'surfaceY:', surfaceY); - } + // Всегда считаем 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; @@ -257,6 +267,8 @@ // Блок изменён 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') { @@ -461,7 +473,7 @@ // Мир const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже - const GEN_MARGIN_X = 26; // запас генерации по X (в тайлах) + const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки const heroImg = new Image(); heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png'; @@ -497,12 +509,14 @@ 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 } + 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 } + cooked: { n:'Жареное мясо', icon:'🍖', food:45 }, + arrow: { n:'Стрела', icon:'➡️', stack:64 }, }; // Seed мира для детерминированной генерации @@ -512,6 +526,9 @@ // Отслеживание изменений мира (для оптимизированного сохранения) let placedBlocks = []; // [{gx, gy, t}] - блоки, установленные игроком let removedBlocks = []; // [{gx, gy}] - блоки, удалённые игроком + + // Серверные изменения — применяются после genColumn чтобы не перезатирались + const serverOverrides = new Map(); // key "gx,gy" => {op:'set'|'remove', t?:string} // Инструменты const TOOLS = { @@ -520,7 +537,8 @@ 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 } } + iron_sword: { n:'Железный меч', icon:'⚔️', durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 } }, + bow: { n:'Лук', icon:'🏹', durability: 150, craft: { wood: 3, string: 0 } } }; // Текстуры блоков (простые) @@ -796,13 +814,56 @@ 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, + 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, - bed:0, boat: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 } }, @@ -820,9 +881,31 @@ { 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:'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'); @@ -838,6 +921,337 @@ 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); + } + } + + // Мобы — красные (враждебные) / зелёные (животные) + for (const m of mobs) { + 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; + 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: player.x, y: player.y, name: playerName || 'Игрок' }); + }); + + voiceSocket.on('voice_in', (payload) => { + // Воспроизводим входящий голос + const { data, meta, volume } = payload; + if (!audioCtx || audioCtx.state === 'closed') return; + + // Int16 → Float32 + const int16 = new Int16Array(data); + const float32 = new Float32Array(int16.length); + for (let i = 0; i < int16.length; i++) { + float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF) * volume; + } + + const buf = audioCtx.createBuffer(1, float32.length, 24000); + buf.getChannelData(0).set(float32); + const src = audioCtx.createBufferSource(); + src.buffer = buf; + + const gain = audioCtx.createGain(); + gain.gain.value = volume; + src.connect(gain).connect(audioCtx.destination); + src.start(); + + // Индикатор + speakingIndicator.style.display = 'block'; + speakingIndicator.textContent = `🔊 ${meta.name}`; + clearTimeout(speakingTimeout); + speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500); + }); + + voiceActive = true; + voiceBtn.textContent = '🎤'; + voiceBtn.style.background = '#2ecc71'; + } catch(e) { + console.error('Voice error:', e); + voiceBtn.style.background = '#e74c3c'; + } + }; + + // Обновляем позицию для voice server + const origPlayerMove = () => {}; + // Хук в главный цикл — обновляем позицию каждые ~500ms + let voicePosT = 0; + // Клик на часы для включения ночи todEl.style.cursor = 'pointer'; todEl.onclick = () => { @@ -887,6 +1301,29 @@ 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); } } @@ -973,15 +1410,34 @@ row.className='recipe'; const icon = document.createElement('div'); icon.className='ricon'; - icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`; + // Иконка — блок, инструмент или предмет + 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'; - nm.textContent = `${BLOCKS[r.out].n} x${r.qty}`; + 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 => `${BLOCKS[x].n}: ${(inv[x]||0)}/${r.cost[x]}`).join(' '); + 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'; @@ -989,9 +1445,10 @@ btn.disabled = !canCraft(r); btn.onclick = () => { if(!canCraft(r)) return; - playSound('click'); // Звук клика по кнопке крафта + 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(); }; @@ -1095,7 +1552,7 @@ } // Режимы - const MODES = [{id:'move',icon:'🏃'},{id:'mine',icon:'⛏️'},{id:'build',icon:'🧱'}]; + const MODES = [{id:'mine',icon:'⛏️'},{id:'build',icon:'🧱'}]; let modeIdx=0; const modeBtn = document.getElementById('modeBtn'); function mode(){ return MODES[modeIdx].id; } @@ -1386,6 +1843,69 @@ 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 = []; @@ -1421,6 +1941,7 @@ constructor(x,y){ super(x,y,34,50); this.kind='skeleton'; this.hp=4; this.speed=70+Math.random()*30; this.shootCooldown=0; } } const mobs = []; + const projectiles = []; // стрелы в полёте let spawnT=0; // Физика (стабильная, без «телепортов») @@ -1702,20 +2223,41 @@ } if(player.sleeping) return; // Нельзя взаимодействовать во время сна + + // Клик по печи — открываем панель обжига + if(b && b.t === 'furnace' && mode() === 'mine'){ + openFurnaceUI(gx, gy); + return; + } // клик по мобу (в режиме mine) if(mode()==='mine'){ for(let i=mobs.length-1;i>=0;i--){ const m = mobs[i]; if(wx>=m.x && wx<=m.x+m.w && wy>=m.y && wy<=m.y+m.h){ - m.hp -= 1; + // Урон зависит от меча + let dmg = 1; + const swordTypes = ['iron_sword','stone_sword','wood_sword']; + for (const st of swordTypes) { + if (inv[st] > 0) { + dmg = TOOLS[st].damage || 3; + useTool(st); + break; + } + } + m.hp -= dmg; m.vx += (m.x - player.x) * 2; m.vy -= 200; playSound('attack'); // Звук атаки игрока if(m.hp<=0){ // дроп еды - if(m.kind === 'chicken') playSound('hurt_chicken'); // Звук при убийстве курицы + if(m.kind === 'chicken') playSound('hurt_chicken'); inv.meat += (m.kind==='chicken' ? 1 : 2); + // скелет дропает стрелы (иногда лук) + if(m.kind === 'skeleton'){ + inv.arrow += 2 + Math.floor(Math.random()*3); + if(Math.random() < 0.15) inv.bow = (inv.bow||0) + 1; + } mobs.splice(i,1); rebuildHotbar(); } @@ -1724,6 +2266,27 @@ } } + // Лук — стреляем стрелой + 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]; @@ -1762,6 +2325,16 @@ 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'); @@ -1915,6 +2488,23 @@ 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); + } + } + } } // Перегенерация видимых чанков (используется при загрузке сохранения) @@ -1964,7 +2554,7 @@ if(m.kind==='zombie'){ // активность ночью const night = isNight(); - if(!night){ m.hp=0; return; } + 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; @@ -1982,7 +2572,7 @@ } else if(m.kind==='creeper'){ // активность ночью const night = isNight(); - if(!night){ m.hp=0; return; } + 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)); @@ -2006,7 +2596,7 @@ } else if(m.kind==='skeleton'){ // активность ночью const night = isNight(); - if(!night){ m.hp=0; return; } + 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)); @@ -2014,41 +2604,23 @@ m.vx = dir * m.speed; if(m.inWater && Math.random()<0.06) m.vy = -260; - // Стрельба стрелами с проверкой препятствий + // Стрельба стрелами m.shootCooldown -= dt; - if(dist < 200 && m.shootCooldown <= 0){ - m.shootCooldown = 1.5; - // Создаём стрелу (упрощённо - просто урон) + 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); - - // Проверяем препятствия (до 20 блоков для более точной проверки) - let blocked = false; - const checkSteps = 20; - const stepSize = dist / checkSteps; - for(let i = 1; i <= checkSteps; i++){ - const checkX = m.x + m.w/2 + Math.cos(angle) * stepSize * i; - const checkY = m.y + m.h/2 + Math.sin(angle) * stepSize * i; - const checkGX = Math.floor(checkX / TILE); - const checkGY = Math.floor(checkY / TILE); - const block = getBlock(checkGX, checkGY); - // Любой блок (кроме воздуха) является укрытием - if(block && !block.dead && block.t !== 'air'){ - blocked = true; - break; - } - } - - // Урон игроку если попали и нет препятствий - if(!blocked && dist < 150 && player.invuln <= 0){ - const damage = calculateDamage(8); - player.hp -= damage; - player.invuln = 0.5; - player.vx += Math.cos(angle) * 300; - player.vy -= 200; - playSound('hit1'); - } + 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 { // животные @@ -2169,11 +2741,21 @@ player.vx = player.vy = 0; player.invuln = 0; - // старт — на поверхности (ровно на 1 тайл выше поверхности) + // старт — на поверхности (используем ту же логику что и в world_state) const startGX = 6; - genColumn(startGX); + for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx); const surfaceY = surfaceGyAt(startGX); - player.y = (surfaceY - 1) * TILE; + 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; @@ -2217,9 +2799,17 @@ // main loop let last = performance.now(); let prevJump = false; + // При возврате на вкладку — сбрасываем last чтобы не было скачка dt + document.addEventListener('visibilitychange', () => { + if (!document.hidden) last = performance.now(); + }); function loop(now){ - const dt = Math.min(0.05, (now-last)/1000); + 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; @@ -2379,9 +2969,13 @@ playSound('splash'); } - player.y += player.vy*dt; - resolveY(player); - player.x += player.vx*dt; resolveX(player); + // 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(); @@ -2389,8 +2983,87 @@ // Обновляем физику воды 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 { + // Попал в моба + 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(activeTNT)){ const b = grid.get(key); @@ -2416,24 +3089,30 @@ const sgy = surfaceGyAt(gx); const wx = gx*TILE + 4; const wy = (sgy-2)*TILE; - + // не спавнить в воде const top = getBlock(gx, sgy); if(top && top.t==='water') { // skip } else { - if(isNight()){ - // Ночью спавним больше враждебных мобов - const rand = Math.random(); - if(rand < 0.35){ - mobs.push(new Zombie(wx, wy)); - } else if(rand < 0.55){ - mobs.push(new Creeper(wx, wy)); - } else { - mobs.push(new Skeleton(wx, wy)); + 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)); + } } - } else { - // Днём только животные + } + // Животные спавнятся и днём и ночью (с лимитом) + 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)); } } @@ -2468,7 +3147,7 @@ const night = isNight(); // sky - ctx.fillStyle = night ? '#070816' : '#87CEEB'; + ctx.fillStyle = night ? '#070816' : (isRaining ? '#6B7B8D' : '#87CEEB'); ctx.fillRect(0,0,W,H); // clouds (parallax x/y) @@ -2515,6 +3194,10 @@ 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 @@ -2575,6 +3258,22 @@ // Ноги 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(); } } @@ -2605,7 +3304,29 @@ 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; @@ -2697,8 +3418,9 @@ ctx.restore(); } - - // UI tick + + // Дождь (после ночного оверлея) + drawRain(); if(Math.random()<0.25){ hpEl.textContent = Math.max(0, Math.ceil(player.hp)); foodEl.textContent = Math.ceil(player.hunger); @@ -2726,6 +3448,11 @@ ctx.font = '18px system-ui'; ctx.fillText('Нажмите на кровать чтобы проснуться', W/2, H/2 + 40); } + + // Миникарта (обновляем раз в ~4 кадра для оптимизации) + if(minimapOpen && Math.random() < 0.25){ + renderMinimap(); + } requestAnimationFrame(loop); } diff --git a/index.html b/index.html index 7c21cf0..64dfd90 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ GrechkaCraft: Multiplayer - + @@ -23,16 +23,31 @@ -
🏃
+
⛏️
💾
🔨
🔄
💬
📦
+
🗺️
+ + + + + +
⬅️
⬆️
@@ -77,6 +92,6 @@
- + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..df2c109 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location ~* \.(js|css)$ { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/style.css b/style.css index a6e3d6a..09f40eb 100644 --- a/style.css +++ b/style.css @@ -38,6 +38,7 @@ canvas { display:block; width:100%; height:100%; image-rendering:pixelated; } #modeBtn { top:10px; background:#f39c12; } #saveBtn { top:10px; right:70px !important; background:#27ae60; } #resetBtn { top:10px; right:130px !important; background:#e74c3c; } +#mapToggle { top:10px; right:190px !important; background:#1abc9c; } #craftBtn { top:74px; right:10px !important; background:#9b59b6; } #invToggle { top:74px; right:70px !important; background:#3498db; } #chatToggle { display: none !important; }