grechka-game/game.js

5151 lines
193 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
// === 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 set from', oldSeed, 'to', worldSeed);
// Full regeneration with correct seed
grid.clear();
blocks.length = 0;
placedBlocks = [];
removedBlocks = [];
generated.clear();
for (let gx = spawnPoint.gx - 25; gx <= spawnPoint.gx + 25; gx++) {
genColumn(gx);
decorations(gx);
}
console.log('World regenerated with 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) => {
for (const u of arr) {
let sm = serverMobs.get(u.id);
if (!sm) {
sm = createMobFromServer({ id: u.id, kind: u.kind || 'zombie', x: u.x, y: u.y,
hp: u.hp || 1, maxHp: u.hp || 1, dir: u.dir || 1 });
serverMobs.set(u.id, sm);
}
sm.x += (u.x - sm.x) * 0.3;
sm.y += (u.y - sm.y) * 0.3;
sm.dir = u.dir != null ? u.dir : sm.dir;
sm.hp = u.hp != null ? u.hp : sm.hp;
sm.fuse = u.fuse != null ? u.fuse : sm.fuse;
sm.grounded = !!u.grounded;
}
});
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);
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 =>
`<div style="margin-bottom:4px;"><span style="color:#aaa;font-size:11px;">${m.time}</span> <strong style="color:${m.sender === 'Система' ? '#f39c12' : '#3498db'};">${m.sender}:</strong> ${m.message}</div>`
).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: null in MP until server sends it; SP uses random seed
let worldSeed = isMultiplayer ? null : 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 tempEl = document.getElementById("temp");
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 biome = getCachedBiome(pGX);
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 = '<div style="color:#fff;font-size:13px;">';
// Доступные рецепты — показываем только те, для которых есть ресурсы
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 += `<div class="recipe">`;
html += `<div class="ricon" style="font-size:24px;display:flex;align-items:center;justify-content:center;">${iconStr}</div>`;
html += `<div class="rinfo">`;
html += `<div class="rname">${nameStr}</div>`;
html += `<div class="rcost">${inName} x${recipe.qty} (есть: ${haveCount}) • ${recipe.time}с</div>`;
html += `</div>`;
html += `<button class="rcraft" onclick="window._smelt(${i})" ${canSmelt ? '' : 'disabled'}>🔥</button>`;
html += `</div>`;
}
// Текущий прогресс
if (active) {
const pct = Math.min(100, Math.floor((active.progress / active.recipe.time) * 100));
html += `<div style="margin-top:10px;padding:8px;background:rgba(255,255,255,0.1);border-radius:8px;">`;
html += `<div style="color:#f39c12;font-weight:900;">🔥 Обжиг: ${pct}%</div>`;
html += `<div style="background:#333;height:8px;border-radius:4px;margin-top:4px;">`;
html += `<div style="background:#f39c12;height:8px;border-radius:4px;width:${pct}%;"></div>`;
html += `</div></div>`;
}
html += '</div>';
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();
}
}
}
}
// ==================== ГОЛОСОВОЙ ЧАТ 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 voiceActive = false;
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.0005, // 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.0005;
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 (false && '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
console.log('[voice] TX Opus chunk, seq:', voiceSeq, 'size:', data.buffer.byteLength);
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 = 1.0; // Start with volume (worklet needs non-zero gain)
remoteSpeakers.set(socketId, sp);
// Register speaker in playback worklet
if (playbackNode) {
playbackNode.port.postMessage({ type: 'addSpeaker', id: socketId, gain: 1.0, pan: 0 });
console.log('[voice] addSpeaker posted to worklet, id:', socketId?.substring(0,8), 'gain:', sp.gainNode.gain.value);
}
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;
if (currentGain > 0.01) console.log('[voice] updateSpatial, id:', sp.id?.substring(0,8), 'gain:', currentGain.toFixed(3), 'pan:', currentPan.toFixed(3));
playbackNode.port.postMessage({
type: 'updateSpatial',
id: sp.id,
gain: currentGain,
pan: currentPan
});
}
}
// ==================== UI ====================
const voiceBtn = document.createElement('div');
voiceBtn.innerHTML = '🎤<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:36px;color:#e74c3c;font-weight:bold;">/</span>';
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 });
}
// 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;max-width:200px;line-height:1.4;';
document.querySelector('.ui').appendChild(speakingIndicator);
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;
voiceBtn.innerHTML = '🎤<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:36px;color:#e74c3c;font-weight:bold;">/</span>';
voiceBtn.style.background = '#555';
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) {} }
}
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,
autoGainControl: true,
echoCancellationType: 'browser',
noiseSuppressionType: 'browser',
sampleRate: { ideal: VC.sampleRate },
channelCount: { ideal: 1 }
}
});
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
// Connect to voice server FIRST so capture frames have a socket
voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
// Handle capture frames
voiceSeq = 0;
voiceTimestamp = 0;
wasSpeaking = false;
voiceActive = true; // Enable capture BEFORE onmessage handler
let _frameCount = 0;
captureNode.port.onmessage = (e) => {
const { type, samples, speaking, rms } = e.data;
if (type !== 'frame') return;
_frameCount++;
if (_frameCount <= 5 || _frameCount % 100 === 0) {
console.log('[voice] capture frame #', _frameCount, 'rms:', rms?.toFixed(4), 'speaking:', speaking, 'voiceActive:', voiceActive, 'socket:', !!voiceSocket, 'connected:', voiceSocket?.connected, 'codec:', voiceCodec);
}
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, speaking, wasSpeaking);
}
} else {
sendPCMFrame(samples, speaking, wasSpeaking);
}
wasSpeaking = speaking;
silenceFrames = 0;
};
function sendPCMFrame(samples, isSpeaking, wasSp) {
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', {
codec: 'pcm',
data: int16.buffer,
seq: voiceSeq++,
ts: performance.now(),
speaking: isSpeaking || wasSp
});
}
// Initialize encoder
await initVoiceEncoder();
codecIndicator.textContent = voiceCodec === 'opus' ? '🔊 Opus' : '🔊 PCM';
// Voice socket already created above
voiceSocket.on('connect', () => {
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] Connect error:', err.message);
});
voiceSocket.on('voice_in', (payload) => {
console.log('[voice] RX voice_in, codec:', payload.codec, 'dataSize:', payload.data?.byteLength || payload.data?.length || 'N/A', 'from:', payload.meta?.from?.substring(0,8));
const { data, meta } = payload;
if (!audioCtx || audioCtx.state === 'closed') { console.warn('[voice] audioCtx missing/closed'); return; }
let sp = remoteSpeakers.get(meta.from);
if (!sp) {
// New speaker — create per-speaker audio nodes
sp = createRemoteSpeaker(meta.from, meta.name, meta.codec || 'pcm');
}
// 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 = '🔊 ' + 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 already set to true above
voiceBtn.textContent = '🎤';
voiceBtn.style.background = '#2ecc71';
console.log('[voice] Voice chat ACTIVE, codec:', voiceCodec);
} catch(e) {
console.error('[voice] Error:', e);
voiceBtn.style.background = '#e74c3c';
}
};
// ==================== PCM decode helper ====================
function decodeAndPushPCM(speakerId, buffer) {
console.log('[voice] decodeAndPushPCM, id:', speakerId?.substring(0,8), 'bufSize:', buffer?.byteLength || buffer?.length || 'N/A');
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;
// Клик на часы для включения ночи
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, xpValue: getMobXP(kind)
});
}
}
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;
if (d.xpValue) grantXP(Math.ceil(d.xpValue * 0.3));
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
},
temperature: player.temperature,
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){
player.temperature = saveData.player.temperature !== undefined ? saveData.player.temperature : 15;
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<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'
});
}
}
// Сущности: животные + зомби
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; }
}
class Pig extends Entity{
constructor(x,y){ super(x,y,34,24); this.kind='pig'; this.hp=2; }
}
class Chicken extends Entity{
constructor(x,y){ super(x,y,26,22); this.kind='chicken'; this.hp=1; }
}
class Zombie extends Entity{
constructor(x,y){ super(x,y,34,50); this.kind='zombie'; this.hp=4; this.speed=80+Math.random()*40; }
}
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; }
}
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; }
}
const mobs = [];
const projectiles = []; // стрелы в полёте
let spawnT=0;
// Физика (стабильная, без «телепортов»)
const GRAV = 2200;
const GRAV_WATER = 550;
const MOVE = 320;
const JUMP = 760;
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 === player && !wasInWater && e.inWater && e.vy > 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);
// 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;
if(worldSeed == null) return; // wait for server seed in multiplayer
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 = 1.5 + (Math.sin(now/200)+1)*1;
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.temperature === undefined) player.temperature = 15;
const BIOME_TEMP = { tundra: -20, plains: 15, swamp: 20, desert: 45, mountains: 5 };
const UNDERGROUND_TEMP = 15;
const COLD_THRESHOLD = -5;
const HEAT_THRESHOLD = 35;
const HEAT_RADIUS = 5;
const pGX = Math.floor(player.x / TILE);
const biome = getCachedBiome(pGX);
const pGY = Math.floor(player.y / TILE);
const sGY = surfaceGyAt(pGX);
const isUndergroundTemp = (pGY - sGY) > 2; // player deeper than 2 blocks below surface
let targetTemp = BIOME_TEMP[biome] || 15;
if (isUndergroundTemp) targetTemp = UNDERGROUND_TEMP;
if (isNight() && !isUndergroundTemp) targetTemp -= 10;
if ((weatherState.type === 'rain' || weatherState.type === 'storm') && !isUndergroundTemp) targetTemp -= 5;
player.temperature += (targetTemp - player.temperature) * dt * 0.3;
let nearHeat = false, nearCool = false;
for (let dx = -HEAT_RADIUS; dx <= HEAT_RADIUS; dx++) {
for (let dy = -HEAT_RADIUS; dy <= HEAT_RADIUS; dy++) {
const b = getBlock(pGX + dx, pGY + dy);
if (!b || b.dead) continue;
if (b.t === 'campfire' || b.t === 'torch') nearHeat = true;
if (b.t === 'water' && biome === 'desert') nearCool = true;
}
}
if (nearHeat) {
player.temperature += (30 - player.temperature) * dt * 2;
if (player.hp < 100) player.hp = Math.min(100, player.hp + 8 * dt);
}
if (nearCool) {
player.temperature += (15 - player.temperature) * dt * 1.5;
if (player.hp < 100 && player.temperature > HEAT_THRESHOLD) {
player.hp = Math.min(100, player.hp + 8 * dt);
}
}
if (isUndergroundTemp && !nearHeat) {
if (player.hp < 100 && player.temperature >= COLD_THRESHOLD && player.temperature <= HEAT_THRESHOLD) {
player.hp = Math.min(100, player.hp + 2 * dt);
}
}
const aboveBlk = getBlock(pGX, pGY - 1);
const inShade = aboveBlk && BLOCKS[aboveBlk.t] && BLOCKS[aboveBlk.t].solid && aboveBlk.t !== 'glass';
if (inShade && !isUndergroundTemp && !nearCool) {
player.temperature -= 5 * dt;
if (player.temperature > HEAT_THRESHOLD - 2 && player.hp < 100) {
player.hp = Math.min(100, player.hp + 1 * dt);
}
}
if (player.temperature < COLD_THRESHOLD && !isUndergroundTemp) {
const severity = Math.abs(player.temperature - COLD_THRESHOLD) / 15;
player.hp -= 1 * severity * dt;
if (severity > 0.5) player.vx *= (1 - 0.3 * Math.min(1, severity) * dt);
}
if (player.temperature > HEAT_THRESHOLD && !isUndergroundTemp) {
const severity = (player.temperature - HEAT_THRESHOLD) / 15;
player.hp -= 1 * severity * dt;
if (severity > 0.5) player.hunger -= 0.5 * severity * dt;
}
// Игрок не может двигаться во время сна
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);
// 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.97 + Math.sin(now/300 + sx*0.01)*0.015 + Math.sin(now/470 + sy*0.02)*0.015;
const r = radius * flick;
// 24 луча — мягкий круглый свет
const steps = 24;
// Собираем дистанции до стен по лучам
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.3; step<r; step+=TILE*0.35){
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.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.9 + Math.sin(now/350 + b.gx*3.7)*0.05 + Math.sin(now/530 + b.gy*2.3)*0.05;
const wx = b.gx*TILE + TILE/2 - camX;
const wy = b.gy*TILE + TILE/2 - camY;
const r = def.lightRadius * 0.85;
const grad = ctx.createRadialGradient(wx,wy, 0, wx,wy, r);
grad.addColorStop(0, `rgba(255,180,80,0.28)`);
grad.addColorStop(0.5, `rgba(255,140,40,0.13)`);
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 ? 'Ночь' : 'День';
document.getElementById('xplevel').textContent = player.level;
const lvXpNext = xpForLevel(player.level + 1);
const lvXpCur = xpForLevel(player.level);
const xpInLevel = player.xp - lvXpCur;
const xpNeeded = lvXpNext - lvXpCur;
document.getElementById('xpbar').textContent = xpInLevel + '/' + xpNeeded;
tempEl.textContent = Math.round(player.temperature) + "°C";
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);
}
// Рисуем дропы
// Температурный оверлей (заморозка/тепловой удар)
if (typeof player.temperature !== 'undefined' && (player.temperature < -5 || player.temperature > 35)) {
const severity = player.temperature < -5
? Math.abs(player.temperature + 5) / 20
: (player.temperature - 35) / 20;
const alpha = Math.min(0.35, severity * 0.15);
if (player.temperature < -5) {
ctx.fillStyle = 'rgba(100,150,255,' + alpha + ')';
} else {
ctx.fillStyle = 'rgba(255,100,50,' + alpha + ')';
}
ctx.fillRect(0, 0, W, H);
}
drawDrops(ctx);
// Пикап дропов
pickupDrops();
// Popup уровня
drawLevelUpPopup(ctx);
// Миникарта (обновляем раз в ~4 кадра для оптимизации)
if(minimapOpen && Math.random() < 0.25){
renderMinimap();
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();