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 @@
👥 0
- 🏃
+ ⛏️
💾
🔨
🔄
💬
📦
+ 🗺️
+
+
+
+
+
+
+
+
-
+