(() => {
// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
// Возможность переопределить сервер через 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}
let mySocketId = null;
// Throttle для отправки позиции (10-20 раз в секунду)
let lastMoveSendTime = 0;
const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду
let lastSentX = 0, lastSentY = 0;
function initSocket() {
try {
socket = io(SERVER_URL, {
path: '/socket.io/',
transports: ['websocket', 'polling']
});
socket.on('connect', () => {
console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${worldId}`);
mySocketId = socket.id;
isMultiplayer = true;
// Присоединяемся к миру
socket.emit('join_world', { world_id: worldId, player_name: playerName });
// Показываем в UI
worldIdEl.textContent = worldId;
multiplayerStatus.style.display = 'block';
});
socket.on('connect_error', (error) => {
console.error('Socket connection error:', error);
isMultiplayer = false;
});
socket.on('disconnect', () => {
console.log('Disconnected from server');
isMultiplayer = false;
otherPlayers.clear();
multiplayerStatus.style.display = 'none';
});
// Обработка world_state
socket.on('world_state', (data) => {
console.log('Received world_state:', data);
// Устанавливаем seed и перегенерируем мир если он изменился
if (data.seed !== undefined && data.seed !== worldSeed) {
const oldSeed = worldSeed;
worldSeed = data.seed;
console.log('World seed changed from', oldSeed, 'to', worldSeed);
// Очищаем и перегенерируем мир с новым seed
generated.clear();
grid.clear();
blocks.length = 0;
placedBlocks = [];
removedBlocks = [];
console.log('World regenerated with new seed:', worldSeed);
}
// Применяем блоки
if (data.blocks && Array.isArray(data.blocks)) {
for (const block of data.blocks) {
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 от сервера - используем его и генерируем эту позицию
if (data.spawnPoint) {
spawnPoint.x = data.spawnPoint.x;
spawnPoint.y = data.spawnPoint.y;
// Генерируем колонну в точке спавна
const spawnGX = Math.floor(spawnPoint.x / TILE);
genColumn(spawnGX);
console.log('Server spawnPoint received and column generated:', spawnPoint);
} else {
// Если spawnPoint не пришёл от сервера - генерируем безопасную позицию
const startGX = 6;
genColumn(startGX);
const surfaceY = surfaceGyAt(startGX);
spawnPoint.x = startGX * TILE;
spawnPoint.y = (surfaceY - 1) * TILE;
console.log('Generated safe spawn point:', spawnPoint, 'surfaceY:', surfaceY);
}
// Устанавливаем игрока в точку спавна
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;
}
});
// Игрок присоединился
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();
});
// Блок изменён
socket.on('block_changed', (data) => {
if (data.op === 'set') {
setBlock(data.gx, data.gy, data.t, false);
} else if (data.op === 'remove') {
removeBlock(data.gx, data.gy);
}
});
// Сообщение в чат
socket.on('chat_message', (data) => {
const senderName = data.socket_id === mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`;
addChatMessage(senderName, data.message);
});
// Обновление времени
socket.on('time_update', (data) => {
if (data.time !== undefined) {
worldTime = data.time;
isNightTime = worldTime > 0.5;
}
});
} catch (e) {
console.error('Error initializing socket:', e);
isMultiplayer = false;
}
}
// Генерация случайного цвета для игрока на основе socket_id
function getRandomPlayerColor(socketId) {
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4'];
let hash = 0;
for (let i = 0; i < socketId.length; i++) {
hash = ((hash << 5) - hash) + socketId.charCodeAt(i);
hash = hash & hash;
}
return colors[Math.abs(hash) % colors.length];
}
// Отправка позиции игрока (с throttle)
function sendPlayerPosition() {
if (!isMultiplayer || !socket || !socket.connected) return;
const now = performance.now() / 1000;
if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return;
// Отправляем только если позиция изменилась
const dx = Math.abs(player.x - lastSentX);
const dy = Math.abs(player.y - lastSentY);
if (dx < 1 && dy < 1) return;
lastMoveSendTime = now;
lastSentX = player.x;
lastSentY = player.y;
socket.emit('player_move', { x: player.x, y: player.y, player_name: playerName });
}
// Отправка изменения блока
function sendBlockChange(gx, gy, t, op) {
if (!isMultiplayer || !socket || !socket.connected) return;
socket.emit('block_change', { gx, gy, t, op });
}
// ==================== ЧАТ ====================
const chatMessages = [];
const MAX_CHAT_MESSAGES = 20;
function addChatMessage(sender, message) {
const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
chatMessages.push({ sender, message, time });
if (chatMessages.length > MAX_CHAT_MESSAGES) {
chatMessages.shift();
}
renderChatMessages();
}
function renderChatMessages() {
const chatMessagesEl = document.getElementById('chatMessages');
if (!chatMessagesEl) return;
chatMessagesEl.innerHTML = chatMessages.map(m =>
`
${m.time} ${m.sender}: ${m.message}
`
).join('');
// Прокручиваем вниз
chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
}
function sendChatMessage(message) {
if (!message || message.trim() === '') return;
if (isMultiplayer && socket && socket.connected) {
socket.emit('chat_message', { message: message.trim() });
} else {
addChatMessage('Вы', message.trim());
}
}
// ==================== ПОДЕЛИТЬСЯ МИРОМ ====================
function shareWorld() {
const shareUrl = new URL(window.location.href);
shareUrl.searchParams.set('world', worldId);
const shareUrlString = shareUrl.toString();
// Копируем в буфер обмена
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(shareUrlString).then(() => {
alert('Ссылка скопирована!');
}).catch(() => {
alert('Ссылка на мир:\n' + shareUrlString);
});
} else {
alert('Ссылка на мир:\n' + shareUrlString);
}
}
// ==================== ИНИЦИАЛИЗАЦИЯ UI ====================
let chatOpen = false;
document.getElementById('chatToggle').onclick = () => {
playSound('click');
chatOpen = !chatOpen;
document.getElementById('chatPanel').style.display = chatOpen ? 'block' : 'none';
if (chatOpen) {
document.getElementById('chatInput').focus();
}
};
document.getElementById('chatClose').onclick = () => {
playSound('click');
chatOpen = false;
document.getElementById('chatPanel').style.display = 'none';
};
document.getElementById('chatSend').onclick = () => {
const input = document.getElementById('chatInput');
sendChatMessage(input.value);
input.value = '';
};
document.getElementById('chatInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
sendChatMessage(e.target.value);
e.target.value = '';
}
});
// ==================== ИНИЦИАЛИЗАЦИЯ СОКЕТА ====================
// Инициализируем socket
initSocket();
// ==================== ЗВУКОВОЙ ДВИЖОК ====================
const sounds = {};
function loadSound(id, src) {
const audio = new Audio();
audio.src = src;
audio.volume = 0.3;
sounds[id] = audio;
}
// Загрузка звуков
loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3');
loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3');
loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3');
loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3');
loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3');
loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3');
loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3');
loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3');
loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3');
loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3');
loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3');
loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3');
loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3');
loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3');
loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3');
loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3');
loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3');
function playSound(id) {
if(sounds[id]) {
sounds[id].currentTime = 0;
sounds[id].play().catch(e => console.error('Sound error:', e));
}
}
// Играем звук при прыжке
const gameEl = document.getElementById('game');
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
// offscreen light map (не вставляем в DOM)
const lightC = document.createElement('canvas');
const lightCtx = lightC.getContext('2d');
const dpr = Math.max(1, window.devicePixelRatio || 1);
let W=0, H=0;
const TILE = 40;
// Мир
const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов
const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже
const GEN_MARGIN_X = 26; // запас генерации по 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:190 },
torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:140 },
bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true },
flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true },
bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true },
boat: { n:'Лодка', c:'#8B4513', solid:false }
};
const ITEMS = {
meat: { n:'Сырое мясо', icon:'🥩', food:15 },
cooked: { n:'Жареное мясо', icon:'🍖', food:45 }
};
// Seed мира для детерминированной генерации
// Инициализируем случайным seed, но он будет перезаписан сервером в мультиплеере
let worldSeed = Math.floor(Math.random() * 1000000);
// Отслеживание изменений мира (для оптимизированного сохранения)
let placedBlocks = []; // [{gx, gy, t}] - блоки, установленные игроком
let removedBlocks = []; // [{gx, gy}] - блоки, удалённые игроком
// Инструменты
const TOOLS = {
wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', durability: 60, miningPower: 1, craft: { wood: 3, planks: 2 } },
stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 } },
iron_pickaxe: { n:'Железная кирка', icon:'⛏️', durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 } },
wood_sword: { n:'Деревянный меч', icon:'⚔️', durability: 40, damage: 5, craft: { wood: 2, planks: 1 } },
stone_sword: { n:'Каменный меч', icon:'⚔️', durability: 100, damage: 8, craft: { wood: 1, stone: 2 } },
iron_sword: { n:'Железный меч', icon:'⚔️', durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 } }
};
// Текстуры блоков (простые)
const tex = {};
function makeTex(type) {
const t = BLOCKS[type];
const c = document.createElement('canvas');
c.width = 32; c.height = 32;
const g = c.getContext('2d');
if (type === 'tnt') {
g.fillStyle='#c0392b'; g.fillRect(0,0,32,32);
g.fillStyle='#fff'; g.fillRect(0,12,32,8);
g.fillStyle='#000'; g.font='bold 10px sans-serif'; g.fillText('TNT',6,20);
return c;
}
if (type === 'campfire') {
g.fillStyle='#5d4037'; g.fillRect(4,26,24,6);
g.fillStyle='#3e2723'; g.fillRect(7,23,18,4);
return c;
}
if (type === 'torch') {
g.fillStyle='#6d4c41'; g.fillRect(14,10,4,18);
g.fillStyle='#f39c12'; g.fillRect(12,6,8,8);
return c;
}
if (type === 'glass') {
g.fillStyle='rgba(200,240,255,0.25)'; g.fillRect(0,0,32,32);
g.strokeStyle='rgba(255,255,255,0.65)'; g.strokeRect(2,2,28,28);
g.beginPath(); g.moveTo(5,27); g.lineTo(27,5); g.stroke();
return c;
}
if (type === 'water') {
g.fillStyle = t.c; g.fillRect(0,0,32,32);
g.fillStyle = 'rgba(255,255,255,0.08)';
g.fillRect(0,6,32,2);
return c;
}
if (type === 'bed') {
// Основание кровати
g.fillStyle = '#e91e63';
g.fillRect(0, 0, 32, 32);
// Подушка
g.fillStyle = '#f8bbd0';
g.fillRect(2, 2, 14, 14);
// Одеяло
g.fillStyle = '#c2185b';
g.fillRect(16, 4, 14, 24);
// Детали одеяла
g.fillStyle = '#e91e63';
g.fillRect(18, 6, 10, 20);
return c;
}
if (type === 'flower') {
g.fillStyle='#2ecc71'; g.fillRect(14,14,4,18);
g.fillStyle=t.c; g.beginPath(); g.arc(16,12,6,0,6.28); g.fill();
return c;
}
if (type === 'boat') {
// Корпус лодки
g.fillStyle = '#8B4513';
g.fillRect(2, 12, 28, 8);
// Борта
g.fillStyle = '#A0522D';
g.fillRect(0, 10, 32, 12);
// Внутренность
g.fillStyle = '#DEB887';
g.fillRect(4, 14, 24, 4);
// Дно
g.fillStyle = '#654321';
g.fillRect(2, 20, 28, 4);
return c;
}
if (type === 'ladder') {
// Боковые стойки лестницы
g.fillStyle = '#8B4513';
g.fillRect(4, 0, 4, 32);
g.fillRect(24, 0, 4, 32);
// Ступени
g.fillStyle = '#A0522D';
g.fillRect(4, 4, 24, 3);
g.fillRect(4, 12, 24, 3);
g.fillRect(4, 20, 24, 3);
g.fillRect(4, 28, 24, 3);
return c;
}
g.fillStyle = t.c || '#000';
g.fillRect(0,0,32,32);
g.fillStyle = 'rgba(0,0,0,0.10)';
for (let i=0;i<6;i++) g.fillRect((Math.random()*28)|0, (Math.random()*28)|0, 4,4);
if (type.endsWith('_ore') || type==='coal') {
g.fillStyle = 'rgba(0,0,0,0.35)';
for (let i=0;i<4;i++) g.fillRect((Math.random()*24)|0, (Math.random()*24)|0, 6,6);
}
return c;
}
Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k));
// Мир-хранилище
const grid = new Map(); // key "gx,gy" => {gx,gy,t, ...}
const blocks = []; // для рендера/перебора видимых
function k(gx,gy){ return gx+','+gy; }
function getBlock(gx,gy){ return grid.get(k(gx,gy)); }
function hasBlock(gx,gy){ return grid.has(k(gx,gy)); }
function isSolid(gx,gy){
const b = getBlock(gx,gy);
if(!b || b.dead) return false;
const def = BLOCKS[b.t];
return !!def.solid && !def.fluid && !def.decor;
}
function setBlock(gx,gy,t, isPlayerPlaced = false){
const key = k(gx,gy);
if(grid.has(key)) return false;
const b = { gx, gy, t, dead:false, active:false, fuse:0 };
grid.set(key, b);
blocks.push(b);
// Отслеживаем блоки, установленные игроком
if(isPlayerPlaced){
placedBlocks.push({gx, gy, t});
}
return true;
}
function removeBlock(gx,gy){
const key = k(gx,gy);
const b = grid.get(key);
if(!b) return null;
if(BLOCKS[b.t].unbreakable) return null;
grid.delete(key);
b.dead = true;
// Отслеживаем удалённые блоки
const wasPlayerPlaced = placedBlocks.some(pb => pb.gx === gx && pb.gy === gy);
if(wasPlayerPlaced){
// Удаляем из placedBlocks
placedBlocks = placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy));
} else {
// Это природный блок - добавляем в removedBlocks
removedBlocks.push({gx, gy});
}
return b;
}
// Физика жидкости
const waterUpdateQueue = new Set();
let waterUpdateTimer = 0;
const WATER_UPDATE_INTERVAL = 0.05; // Обновляем воду каждые 0.05 секунды
function updateWaterPhysics(dt){
waterUpdateTimer += dt;
if(waterUpdateTimer < WATER_UPDATE_INTERVAL) return;
waterUpdateTimer = 0;
// Ограничиваем количество водных блоков для обработки (оптимизация)
const MAX_WATER_BLOCKS_PER_UPDATE = 50;
let processedCount = 0;
// Собираем только видимые водные блоки в очередь (оптимизация)
waterUpdateQueue.clear();
const minGX = Math.floor(camX/TILE) - 10;
const maxGX = Math.floor((camX+W)/TILE) + 10;
const minGY = Math.floor(camY/TILE) - 10;
const maxGY = Math.floor((camY+H)/TILE) + 10;
for(const b of blocks){
if(processedCount >= MAX_WATER_BLOCKS_PER_UPDATE) break;
if(!b.dead && b.t === 'water' &&
b.gx >= minGX && b.gx <= maxGX &&
b.gy >= minGY && b.gy <= maxGY){
waterUpdateQueue.add(k(b.gx, b.gy));
processedCount++;
}
}
// Обновляем воду с ограничением глубины распространения
const processed = new Set();
const toAdd = [];
const MAX_WATER_DEPTH = 20; // Максимальная глубина распространения воды
for(const key of waterUpdateQueue){
if(processed.has(key)) continue;
const b = grid.get(key);
if(!b || b.dead) continue;
processed.add(key);
const gx = b.gx;
const gy = b.gy;
// Проверяем глубину - не распространяем воду слишком глубоко
if(gy > SEA_GY + MAX_WATER_DEPTH) continue;
// Проверяем, можно ли воде упасть вниз
const belowKey = k(gx, gy + 1);
const below = grid.get(belowKey);
// Внизу пусто - вода создаёт новый блок внизу (но не удаляется сверху)
if(!below || below.dead){
// Ограничиваем создание новых водных блоков
if(toAdd.length < 20){ // Максимум 20 новых блоков за обновление
toAdd.push({gx, gy: gy + 1, t: 'water'});
processed.add(belowKey);
}
continue;
}
// Если внизу не вода и не твёрдый блок - вода может течь вниз
if(!isSolid(gx, gy + 1) && below && below.t !== 'water'){
if(toAdd.length < 20){
toAdd.push({gx, gy: gy + 1, t: 'water'});
processed.add(belowKey);
}
continue;
}
// Если внизу твёрдый блок или вода - вода растекается горизонтально
// Проверяем левую сторону
const leftKey = k(gx - 1, gy);
const left = grid.get(leftKey);
if(!left || left.dead){
if(toAdd.length < 20){
toAdd.push({gx: gx - 1, gy, t: 'water'});
processed.add(leftKey);
}
continue;
}
// Проверяем правую сторону
const rightKey = k(gx + 1, gy);
const right = grid.get(rightKey);
if(!right || right.dead){
if(toAdd.length < 20){
toAdd.push({gx: gx + 1, gy, t: 'water'});
processed.add(rightKey);
}
continue;
}
}
// Применяем изменения (только добавляем новые блоки)
for(const newData of toAdd){
const key = k(newData.gx, newData.gy);
if(!grid.has(key)){
const b = {
gx: newData.gx,
gy: newData.gy,
t: newData.t,
dead: false,
active: false,
fuse: 0
};
grid.set(key, b);
blocks.push(b);
}
}
// Очищаем мёртвые блоки из массива
for(let i = blocks.length - 1; i >= 0; i--){
if(blocks[i].dead){
blocks.splice(i, 1);
}
}
}
// Инвентарь
const inv = {
dirt:6, stone:0, sand:0, gravel:0, clay:0,
wood:0, planks:0, ladder:0, leaves:0, coal:0,
copper_ore:0, iron_ore:0, gold_ore:0, diamond_ore:0,
brick:0, glass:0,
tnt:1, campfire:0, torch:0,
meat:0, cooked:0,
wood_pickaxe:0, stone_pickaxe:0, iron_pickaxe:0,
wood_sword:0, stone_sword:0, iron_sword:0,
iron_armor:0,
bed:0, boat:0
};
let selected = 'dirt';
const RECIPES = [
{ out:'planks', qty:4, cost:{ wood:1 } },
{ out:'ladder', qty:3, cost:{ planks:7 } },
{ out:'torch', qty:2, cost:{ coal:1, planks:1 } },
{ out:'glass', qty:1, cost:{ sand:3 } },
{ out:'brick', qty:1, cost:{ stone:2, clay:1 } },
{ out:'campfire', qty:1, cost:{ wood:1, coal:1 } },
{ out:'tnt', qty:1, cost:{ sand:2, coal:1 } },
{ out:'bed', qty:1, cost:{ wood: 3, planks: 3 } },
{ out:'boat', qty:1, cost:{ wood: 5 } },
{ out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 } },
{ out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 } },
{ out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 } },
{ out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 } },
{ out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 } },
{ out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 } },
{ out:'iron_armor', qty:1, cost:{ iron_ore: 5 } }
];
// UI
const hpEl = document.getElementById('hp');
const foodEl = document.getElementById('food');
const sxEl = document.getElementById('sx');
const syEl = document.getElementById('sy');
const todEl = document.getElementById('tod');
const worldIdEl = document.getElementById('worldId');
const playerCountEl = document.getElementById('playerCount');
const hotbarEl = document.getElementById('hotbar');
const craftPanel = document.getElementById('craftPanel');
const recipesEl = document.getElementById('recipes');
const deathEl = document.getElementById('death');
const inventoryPanel = document.getElementById('inventoryPanel');
const inventoryGrid = document.getElementById('inventoryGrid');
// Клик на часы для включения ночи
todEl.style.cursor = 'pointer';
todEl.onclick = () => {
playSound('click');
worldTime = 0.6; // Устанавливаем ночь
isNightTime = true;
};
function rebuildHotbar(){
hotbarEl.innerHTML='';
// Показываем последние 5 выбранных предметов (если они есть в инвентаре)
const items = recentItems.filter(id => inv[id] > 0).slice(0, 5);
for(const id of items){
const s = document.createElement('div');
s.className = 'slot'+(id===selected?' sel':'');
if(BLOCKS[id]) {
s.style.backgroundImage = `url(${tex[id].toDataURL()})`;
s.style.backgroundSize = 'cover';
} else if(ITEMS[id]) {
s.textContent = ITEMS[id].icon;
} else if(TOOLS[id]) {
s.textContent = TOOLS[id].icon;
} else if(id === 'iron_armor') {
s.textContent = '🛡️';
s.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
}
const c = document.createElement('div');
c.className='count';
c.textContent = inv[id];
s.appendChild(c);
s.onclick = () => {
playSound('click'); // Звук клика по инвентарю
selected=id;
// Обновляем список последних предметов
recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть
recentItems.unshift(id); // Добавляем в начало
recentItems = recentItems.slice(0, 5); // Оставляем только 5
rebuildHotbar();
};
// Показываем индикатор надетой брони
if(id === 'iron_armor' && player.equippedArmor === 'iron_armor') {
const equipped = document.createElement('div');
equipped.className = 'equipped-indicator';
equipped.textContent = '✓';
s.appendChild(equipped);
}
hotbarEl.appendChild(s);
}
}
function renderInventory() {
inventoryGrid.innerHTML = '';
// Создаём сетку инвентаря 7x3
const items = Object.keys(inv).filter(id => inv[id] > 0);
// Добавляем пустые слоты для полной сетки
for(let i = 0; i < 21; i++) {
const slot = document.createElement('div');
slot.className = 'inv-slot' + (i < items.length && items[i] === selected ? ' sel' : '');
if(i < items.length) {
const id = items[i];
if(BLOCKS[id]) {
slot.style.backgroundImage = `url(${tex[id].toDataURL()})`;
slot.style.backgroundSize = 'cover';
} else if(ITEMS[id]) {
slot.textContent = ITEMS[id].icon;
} else if(TOOLS[id]) {
slot.textContent = TOOLS[id].icon;
} else if(id === 'iron_armor') {
slot.textContent = '🛡️';
slot.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
}
const count = document.createElement('div');
count.className = 'inv-count';
count.textContent = inv[id];
slot.appendChild(count);
slot.onclick = () => {
playSound('click'); // Звук клика по инвентарю
selected = id;
// Обновляем список последних предметов
recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть
recentItems.unshift(id); // Добавляем в начало
recentItems = recentItems.slice(0, 5); // Оставляем только 5
rebuildHotbar();
renderInventory();
};
// Двойной клик для надевания брони
slot.ondblclick = () => {
if(id === 'iron_armor' && inv.iron_armor > 0) {
// Если уже надета броня - снимаем её
if(player.equippedArmor === 'iron_armor') {
player.equippedArmor = null;
player.armor = 0;
console.log('[ARMOR] Iron armor unequipped');
} else {
// Надеваем броню
player.equippedArmor = 'iron_armor';
player.armor = BLOCKS['iron_armor'].armor;
console.log('[ARMOR] Iron armor equipped - armor:', player.armor);
}
playSound('click');
renderInventory();
}
};
}
inventoryGrid.appendChild(slot);
}
}
function canCraft(r){
console.log('[CRAFT] Checking recipe for:', r.out, '- cost:', r.cost);
for(const res in r.cost){
const have = inv[res] || 0;
const need = r.cost[res];
console.log('[CRAFT] Resource:', res, '- have:', have, '- need:', need, '- can craft:', have >= need);
if(have < need) return false;
}
return true;
}
function renderCraft(){
recipesEl.innerHTML='';
for(const r of RECIPES){
const row = document.createElement('div');
row.className='recipe';
const icon = document.createElement('div');
icon.className='ricon';
icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`;
const info = document.createElement('div');
info.className='rinfo';
const nm = document.createElement('div');
nm.className='rname';
nm.textContent = `${BLOCKS[r.out].n} x${r.qty}`;
const cs = document.createElement('div');
cs.className='rcost';
cs.textContent = Object.keys(r.cost).map(x => `${BLOCKS[x].n}: ${(inv[x]||0)}/${r.cost[x]}`).join(' ');
info.appendChild(nm); info.appendChild(cs);
const btn = document.createElement('button');
btn.className='rcraft';
btn.textContent='Создать';
btn.disabled = !canCraft(r);
btn.onclick = () => {
if(!canCraft(r)) return;
playSound('click'); // Звук клика по кнопке крафта
for(const res in r.cost) inv[res]-=r.cost[res];
inv[r.out] = (inv[r.out]||0) + r.qty;
rebuildHotbar();
renderCraft();
};
row.appendChild(icon); row.appendChild(info); row.appendChild(btn);
recipesEl.appendChild(row);
}
}
let craftOpen=false;
let inventoryOpen = false;
document.getElementById('craftBtn').onclick = () => {
playSound('click'); // Звук клика по кнопке
craftOpen = !craftOpen;
craftPanel.style.display = craftOpen ? 'block' : 'none';
if(craftOpen) {
renderCraft();
// Закрываем инвентарь если открыт крафт
inventoryOpen = false;
inventoryPanel.style.display = 'none';
}
};
document.getElementById('craftClose').onclick = () => {
playSound('click'); // Звук клика по кнопке
craftOpen = false;
craftPanel.style.display = 'none';
};
// Кнопка открытия инвентаря
document.getElementById('invToggle').onclick = () => {
playSound('click'); // Звук клика по кнопке
inventoryOpen = true;
inventoryPanel.style.display = 'block';
renderInventory();
// Закрываем крафт если открыт инвентарь
craftOpen = false;
craftPanel.style.display = 'none';
};
document.getElementById('inventoryClose').onclick = () => {
playSound('click'); // Звук клика по кнопке
inventoryOpen = false;
inventoryPanel.style.display = 'none';
};
// Кнопка сохранения игры (только для одиночного режима)
const saveBtn = document.getElementById('saveBtn');
saveBtn.onclick = () => {
playSound('click');
saveGame();
alert('Игра сохранена!');
};
// Кнопка сброса игры (удаление сохранения и создание нового мира)
const resetBtn = document.getElementById('resetBtn');
resetBtn.onclick = () => {
if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) {
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:'move',icon:'🏃'},{id:'mine',icon:'⛏️'},{id:'build',icon:'🧱'}];
let modeIdx=0;
const modeBtn = document.getElementById('modeBtn');
function mode(){ return MODES[modeIdx].id; }
modeBtn.onclick = () => {
playSound('click'); // Звук клика по кнопке режима
modeIdx=(modeIdx+1)%MODES.length; modeBtn.textContent=MODES[modeIdx].icon;
};
// День/ночь (автоматический цикл)
let isNightTime = false;
// Управление
const inp = { l:false, r:false, j:false, s:false };
function bindHold(el, key){
const down=(e)=>{ e.preventDefault(); inp[key]=true; };
const up=(e)=>{ e.preventDefault(); inp[key]=false; };
el.addEventListener('pointerdown', down);
el.addEventListener('pointerup', up);
el.addEventListener('pointerleave', up);
}
const leftBtn = document.getElementById('left');
const rightBtn = document.getElementById('right');
const jumpBtn = document.getElementById('jump');
const downBtn = document.getElementById('down');
if(leftBtn) bindHold(leftBtn,'l');
if(rightBtn) bindHold(rightBtn,'r');
if(jumpBtn) bindHold(jumpBtn,'j');
if(downBtn) bindHold(downBtn,'s');
window.addEventListener('keydown', (e)=>{
if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=true;
if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=true;
if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=true;
if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=true;
});
window.addEventListener('keyup', (e)=>{
if(e.code==='KeyA'||e.code==='ArrowLeft') inp.l=false;
if(e.code==='KeyD'||e.code==='ArrowRight') inp.r=false;
if(e.code==='Space'||e.code==='KeyW'||e.code==='ArrowUp') inp.j=false;
if(e.code==='KeyS'||e.code==='ArrowDown') inp.s=false;
});
// Лодка
const boat = {
x: 0, y: 0,
w: 34, h: 34,
vx: 0, vy: 0,
active: false,
inWater: false
};
// Функция для расчёта урона с учётом брони
function calculateDamage(baseDamage) {
// Броня снижает урон пропорционально
// armor: 0 = без брони (100% урона)
// armor: 0.5 = железная броня (50% урона)
const reduction = player.armor;
const actualDamage = baseDamage * (1 - reduction);
console.log('[DAMAGE] Base:', baseDamage, '- Armor:', reduction, '- Actual:', actualDamage.toFixed(1));
return actualDamage;
}
// Игрок
const player = {
x: 6*TILE, y: 0*TILE,
w: 34, h: 34,
vx: 0, vy: 0,
grounded: false,
inWater: false,
headInWater: false,
hp: 100,
hunger: 100,
o2: 100,
invuln: 0,
fallStartY: 0,
lastStepTime: 0,
sleeping: false,
inBoat: false,
armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня)
equippedArmor: null // Тип надетой брони
};
// Сохраняем начальную позицию для возрождения
const spawnPoint = { x: 6*TILE, y: 0*TILE };
// Система сохранения игры (localStorage + in-memory fallback)
const SAVE_KEY = 'minegrechka_save';
let db = null; // Оставляем для совместимости, но не используем
let inMemorySave = null; // Запасное сохранение в памяти
// Инициализация (localStorage + in-memory fallback)
function initDB(){
return new Promise((resolve) => {
console.log('Используем localStorage для сохранений (sandbox режим)');
resolve(null);
});
}
// Детерминированный генератор псевдослучайных чисел на основе seed
function seededRandom(gx, gy){
const n = Math.sin(gx * 12.9898 + gy * 78.233 + worldSeed * 0.1) * 43758.5453;
return n - Math.floor(n);
}
function saveGame(){
const saveData = {
version: 2,
worldSeed: worldSeed,
player: {
x: player.x,
y: player.y,
hp: player.hp,
hunger: player.hunger,
o2: player.o2
},
inventory: inv,
time: worldTime,
isNight: isNightTime,
// Сохраняем только изменения
placedBlocks: placedBlocks.slice(),
removedBlocks: removedBlocks.slice()
};
const saveSize = JSON.stringify(saveData).length;
console.log('Сохранение: player HP:', player.hp, 'hunger:', player.hunger, 'o2:', player.o2);
// Пробуем сохранить в localStorage (основной метод для персистентности)
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`);
} catch(e){
console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e);
// Если localStorage недоступен, используем in-memory fallback
inMemorySave = saveData;
console.log(`Игра сохранена в памяти (размер: ${saveSize} байт)`);
}
}
function loadGame(){
return new Promise((resolve, reject) => {
// Пробуем localStorage
try {
const localSave = localStorage.getItem(SAVE_KEY);
if(localSave){
const parsed = JSON.parse(localSave);
console.log('Загружено из localStorage, player HP:', parsed.player?.hp);
resolve(parsed);
return;
}
} catch(e){
console.warn('Ошибка доступа к localStorage:', e);
}
// Если localStorage недоступен, используем in-memory сохранение
if(inMemorySave){
console.log('Загружено из in-memory сохранения, player HP:', inMemorySave.player?.hp);
resolve(inMemorySave);
return;
}
console.log('Сохранение не найдено');
resolve(null);
});
}
// Миграция с версии 1 на версию 2
function migrateV1toV2(saveData){
console.log('Миграция сохранения с версии 1 на версию 2...');
// Сохраняем seed из текущей игры (так как v1 его не хранил)
saveData.worldSeed = worldSeed;
// Инициализируем массивы изменений
saveData.placedBlocks = [];
saveData.removedBlocks = [];
// Для v1 нам нужно сравнить сгенерированные блоки с тем, что должно быть по seed
// Это сложно сделать без полной перегенерации, поэтому для v1 сохраняем только seed
// и при загрузке просто перегенерируем мир
// Удаляем старые данные
delete saveData.generatedBlocks;
saveData.version = 2;
console.log('Миграция завершена');
}
async function applySave(saveData){
if(!saveData) return;
console.log('=== applySave START ===');
console.log('player HP before applySave:', player.hp);
console.log('saveData.player.hp:', saveData.player?.hp);
// Миграция версий
if(saveData.version === 1){
migrateV1toV2(saveData);
}
// Восстанавливаем seed
if(saveData.worldSeed !== undefined){
worldSeed = saveData.worldSeed;
}
// Восстанавливаем игрока
if(saveData.player){
player.x = saveData.player.x;
player.y = saveData.player.y;
player.hunger = saveData.player.hunger;
player.o2 = saveData.player.o2;
// Обновляем spawnPoint на позицию из сохранения
spawnPoint.x = player.x;
spawnPoint.y = player.y;
// Проверяем HP из сохранения - если <= 0, устанавливаем 100
const savedHP = saveData.player.hp;
console.log('Saved HP from file:', savedHP);
if(savedHP <= 0){
console.log('WARNING: Saved HP is <= 0, setting to 100!');
player.hp = 100;
} else {
player.hp = savedHP;
}
console.log('player HP after restore:', player.hp);
console.log('spawnPoint обновлён из сохранения: x=', spawnPoint.x, 'y=', spawnPoint.y);
} else {
console.log('No player data in save, setting default HP: 100');
player.hp = 100;
}
console.log('=== applySave END ===');
// Восстанавливаем инвентарь
if(saveData.inventory){
for(const key in saveData.inventory){
inv[key] = saveData.inventory[key];
}
}
// Восстанавливаем время
if(saveData.time !== undefined){
worldTime = saveData.time;
}
// Восстанавливаем день/ночь
if(saveData.isNight !== undefined){
isNightTime = saveData.isNight;
}
// Перегенерируем мир по seed
regenerateVisibleChunks();
// Применяем изменения (только для v2)
if(saveData.version === 2){
// Применяем блоки, установленные игроком
for(const block of saveData.placedBlocks){
setBlock(block.gx, block.gy, block.t, true);
}
// Применяем удалённые блоки
for(const block of saveData.removedBlocks){
removeBlock(block.gx, block.gy);
}
// Восстанавливаем массивы изменений
placedBlocks = saveData.placedBlocks || [];
removedBlocks = saveData.removedBlocks || [];
}
rebuildHotbar();
console.log('Игра загружена');
}
// Камера (двухосевая)
let camX=0, camY=0;
// День/ночь
let worldTime=0;
const DAY_LEN=360; // замедлил смену дня/ночи в 3 раза
// Облака
const clouds = Array.from({length:10}, ()=>({
x: Math.random()*2000,
y: -200 - Math.random()*260, // выше экрана, потому что мир по Y теперь двигается
w: 80+Math.random()*120,
s: 12+Math.random()*20
}));
// Частицы (взрыв)
const parts = [];
function spawnExplosion(x,y, power){
const n = Math.floor(16 + power*10);
for(let i=0;i 100){
playSound('splash');
}
}
function resolveY(e){
// Всегда пересчитываем grounded (не держим "липким")
e.grounded = false;
const x1 = e.x + 2;
const x2 = e.x + e.w - 2;
// Проверяем, находится ли игрок на лестнице (по центру)
const cx = e.x + e.w/2;
const cy = e.y + e.h/2;
const gx = Math.floor(cx / TILE);
const gy = Math.floor(cy / TILE);
const b = getBlock(gx, gy);
const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
// Если на лестнице - можно двигаться вверх/вниз
if(onLadder){
e.grounded = true;
// Если нажимаем прыжок на лестнице - поднимаемся
if(inp.j){
e.vy = -200;
}
// Если нажимаем вниз - спускаемся
else if(inp.s){
e.vy = 100;
}
// Иначе - остаёмся на месте (нет гравитации)
else {
e.vy = 0;
}
return;
}
// Проверяем, можно ли запрыгнуть на лестницу рядом (слева или справа)
const leftGX = Math.floor((e.x - 4) / TILE);
const rightGX = Math.floor((e.x + e.w + 4) / TILE);
const playerGY = Math.floor((e.y + e.h/2) / TILE);
const leftBlock = getBlock(leftGX, playerGY);
const rightBlock = getBlock(rightGX, playerGY);
const leftLadder = leftBlock && BLOCKS[leftBlock.t] && BLOCKS[leftBlock.t].climbable;
const rightLadder = rightBlock && BLOCKS[rightBlock.t] && BLOCKS[rightBlock.t].climbable;
// Если рядом есть лестница и игрок прыгает - притягиваем к ней
if((leftLadder || rightLadder) && inp.j && e.vy < 0){
// Перемещаем игрока к лестнице
if(leftLadder && e.x > leftGX * TILE + TILE/2){
e.x = leftGX * TILE + TILE/2 - e.w/2;
} else if(rightLadder && e.x < rightGX * TILE + TILE/2){
e.x = rightGX * TILE + TILE/2 - e.w/2;
}
e.grounded = true;
e.vy = -150; // меньший прыжок при запрыгивании на лестницу
return;
}
// 1) Если движемся вниз ИЛИ стоим (vy >= 0) — проверяем опору под ногами
// Берём точку на 1px ниже стопы, чтобы не зависеть от ровного попадания в границу тайла.
if(e.vy >= 0){
const probeY = e.y + e.h + 1;
const gy = Math.floor(probeY / TILE);
const gxA = Math.floor(x1 / TILE);
const gxB = Math.floor(x2 / TILE);
if(isSolid(gxA, gy) || isSolid(gxB, gy)){
e.y = gy * TILE - e.h; // прижимаем к полу
e.vy = 0;
e.grounded = true;
// урон от падения — только игроку и только не в воде
if(e === player && !player.inWater){
const fallTiles = (e.y - e.fallStartY) / TILE;
if(fallTiles > 6) {
const damage = calculateDamage((fallTiles - 6) * 10);
player.hp -= damage;
}
}
if(e === player) e.fallStartY = e.y;
}
}
// 2) Проверка на возможность запрыгнуть на блок, если игрок рядом с ним
if(e.vy < 0 && e === player){
const gy = Math.floor(e.y / TILE);
const gxA = Math.floor(x1 / TILE);
const gxB = Math.floor(x2 / TILE);
// Проверяем, есть ли блок рядом с игроком
if((isSolid(gxA, gy) || isSolid(gxB, gy)) && !isSolid(gxA, gy - 1) && !isSolid(gxB, gy - 1)){
e.y = (gy + 1) * TILE;
e.vy = 0;
e.grounded = true;
if(e === player) e.fallStartY = e.y;
console.log("Jumped onto block!");
}
}
// 2) Если движемся вверх — проверяем потолок
if(e.vy < 0){
const gy = Math.floor(e.y / TILE);
const gxA = Math.floor(x1 / TILE);
const gxB = Math.floor(x2 / TILE);
if(isSolid(gxA, gy) || isSolid(gxB, gy)){
e.y = (gy + 1) * TILE;
e.vy = 0;
}
}
}
function resolveX(e){
const y1 = e.y + 2;
const y2 = e.y + e.h - 2;
// Проверяем, находимся ли мы на лестнице
const cx = e.x + e.w/2;
const cy = e.y + e.h/2;
const gx = Math.floor(cx / TILE);
const gy = Math.floor(cy / TILE);
const b = getBlock(gx, gy);
const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
if(e.vx > 0){
const gx = Math.floor((e.x + e.w)/TILE);
const gyA = Math.floor(y1/TILE);
const gyB = Math.floor(y2/TILE);
const solidA = isSolid(gx, gyA);
const solidB = isSolid(gx, gyB);
if(solidA || solidB){
e.x = gx*TILE - e.w;
e.vx = 0;
}
} else if(e.vx < 0){
const gx = Math.floor(e.x/TILE);
const gyA = Math.floor(y1/TILE);
const gyB = Math.floor(y2/TILE);
const solidA = isSolid(gx, gyA);
const solidB = isSolid(gx, gyB);
if(solidA || solidB){
e.x = (gx+1)*TILE;
e.vx = 0;
}
}
}
// TNT логика: цепь + усиление
const activeTNT = new Set(); // хранит key
function activateTNT(b, fuse=3.2){
if(b.dead) return;
if(b.active) return;
b.active=true;
b.fuse=fuse;
activeTNT.add(k(b.gx,b.gy));
}
function explodeAt(gx,gy){
const center = getBlock(gx,gy);
if(!center) return;
// усиление: считаем сколько TNT рядом (в радиусе 2) и активируем их «почти сразу»
let bonus = 0;
for(let x=gx-2; x<=gx+2; x++){
for(let y=gy-2; y<=gy+2; y++){
const b = getBlock(x,y);
if(b && !b.dead && b.t==='tnt' && !(x===gx && y===gy)){
bonus += 0.8;
activateTNT(b, 0.12); // цепь
}
}
}
const power = 1 + bonus; // условная мощность
const radius = 3.2 + bonus*0.7; // радиус разрушения в тайлах
const dmgR = 150 + bonus*60; // радиус урона в пикселях
removeBlock(gx,gy);
activeTNT.delete(k(gx,gy));
playSound('explode1'); // Звук взрыва
spawnExplosion(gx*TILE + TILE/2, gy*TILE + TILE/2, power);
for(let x=Math.floor(gx-radius); x<=Math.ceil(gx+radius); x++){
for(let y=Math.floor(gy-radius); y<=Math.ceil(gy+radius); y++){
const d = Math.hypot(x-gx, y-gy);
if(d > radius) continue;
const b = getBlock(x,y);
if(!b || b.dead) continue;
if(BLOCKS[b.t].fluid) continue;
if(BLOCKS[b.t].unbreakable) continue;
if(b.t==='tnt') { activateTNT(b, 0.12); continue; }
removeBlock(x,y);
if(inv[b.t] !== undefined && Math.random()<0.20) inv[b.t]++; // немного дропа
}
}
rebuildHotbar();
// урон
const hurt = (e)=>{
const dx = (e.x+e.w/2) - (gx*TILE+TILE/2);
const dy = (e.y+e.h/2) - (gy*TILE+TILE/2);
const dist = Math.hypot(dx,dy);
if(dist < dmgR){
const dmg = (dmgR - dist) * 0.06 * power;
if(e === player) {
const actualDamage = calculateDamage(dmg);
player.hp -= actualDamage;
} else {
e.hp -= dmg;
}
e.vx += (dx/dist || 0) * 600;
e.vy -= 320;
}
};
hurt(player);
mobs.forEach(hurt);
}
// Взаимодействие мышь/тап
const mouse = { x:null, y:null };
canvas.addEventListener('pointermove', (e)=>{
const r = canvas.getBoundingClientRect();
mouse.x = e.clientX - r.left;
mouse.y = e.clientY - r.top;
});
canvas.addEventListener('pointerdown', (e)=>{
if(craftOpen) return;
if(player.hp<=0) return;
const r = canvas.getBoundingClientRect();
const sx = e.clientX - r.left;
const sy = e.clientY - r.top;
const wx = sx + camX;
const wy = sy + camY;
const gx = Math.floor(wx / TILE);
const gy = Math.floor(wy / TILE);
// Пробуждение: клик по любой кровати когда спишь
const b = getBlock(gx,gy);
if(player.sleeping && b && b.t==='bed'){
player.sleeping = false;
return;
}
if(player.sleeping) return; // Нельзя взаимодействовать во время сна
// клик по мобу (в режиме mine)
if(mode()==='mine'){
for(let i=mobs.length-1;i>=0;i--){
const m = mobs[i];
if(wx>=m.x && wx<=m.x+m.w && wy>=m.y && wy<=m.y+m.h){
m.hp -= 1;
m.vx += (m.x - player.x) * 2;
m.vy -= 200;
playSound('attack'); // Звук атаки игрока
if(m.hp<=0){
// дроп еды
if(m.kind === 'chicken') playSound('hurt_chicken'); // Звук при убийстве курицы
inv.meat += (m.kind==='chicken' ? 1 : 2);
mobs.splice(i,1);
rebuildHotbar();
}
return;
}
}
}
// еда (предмет)
if(ITEMS[selected] && inv[selected]>0){
const it = ITEMS[selected];
if(player.hp < 100 || player.hunger < 100){
playSound('eat1'); // Звук употребления еды
player.hunger = Math.min(100, player.hunger + it.food);
player.hp = Math.min(100, player.hp + 15);
inv[selected]--;
rebuildHotbar();
}
return;
}
// жарка на костре: выбран meat + клик по campfire
if(b && b.t==='campfire' && selected==='meat' && inv.meat>0){
playSound('fire'); // Звук при жарке на костре
inv.meat--; inv.cooked++;
rebuildHotbar();
return;
}
// Сон на кровати: клик по bed
if(b && b.t==='bed' && isNight()){
player.sleeping = true;
saveGame(); // Сохраняем при отходе ко сну
return;
}
if(mode()==='mine'){
if(!b) return;
if(BLOCKS[b.t].fluid || BLOCKS[b.t].decor) return;
if(b.t==='tnt'){ activateTNT(b, 3.2); return; } // не взрывается сразу
const removed = removeBlock(gx,gy);
if(removed){
inv[removed.t] = (inv[removed.t]||0) + 1;
// Отправляем изменение блока на сервер
sendBlockChange(gx, gy, removed.t, 'remove');
// Звуки при добыче блоков
if(removed.t === 'glass') playSound('glass1');
else if(removed.t === 'sand') playSound('sand1');
else if(removed.t === 'snow') playSound('snow1');
else if(removed.t === 'stone' || removed.t.endsWith('_ore')) playSound('stone1');
else if(removed.t === 'wood') playSound('wood1');
else playSound('cloth1');
rebuildHotbar();
}
return;
}
if(mode()==='build'){
if(inv[selected] <= 0) return;
if(!BLOCKS[selected]) return;
if(b) return; // занято
// Проверяем, ставим ли лодку
if(selected === 'boat'){
// Лодку можно ставить только на воду
const waterBelow = getBlock(gx, gy+1);
if(!waterBelow || waterBelow.t !== 'water'){
return;
}
// Создаём лодку
boat.x = gx * TILE;
boat.y = gy * TILE;
boat.vx = 0;
boat.vy = 0;
boat.active = true;
boat.inWater = true;
// Сажаем игрока в лодку
player.inBoat = true;
player.x = boat.x;
player.y = boat.y;
player.vx = 0;
player.vy = 0;
playSound('splash');
inv[selected]--;
rebuildHotbar();
return;
}
// запрет ставить в игрока
const bx = gx*TILE, by = gy*TILE;
const overlap = !(bx >= player.x+player.w || bx+TILE <= player.x || by >= player.y+player.h || by+TILE <= player.y);
if(overlap) return;
setBlock(gx,gy,selected, true); // true = блок установлен игроком
inv[selected]--;
// Отправляем изменение блока на сервер
sendBlockChange(gx, gy, selected, 'set');
// Звук при строительстве
if(selected === 'stone' || selected === 'brick') playSound('stone_build');
else if(selected === 'wood' || selected === 'planks') playSound('wood_build');
else if(selected === 'glass') playSound('glass1');
else if(selected === 'sand') playSound('sand1');
else if(selected === 'snow') playSound('snow1');
else if(selected === 'dirt' || selected === 'grass') playSound('cloth1');
rebuildHotbar();
return;
}
});
// Генерация (по X, на всю глубину до bedrock)
const generated = new Set(); // gx already generated
function surfaceGyAt(gx){
// базовая поверхность выше уровня воды с вариациями + "горы"
// Используем seed для детерминированной генерации
// Увеличили амплитуду и добавили больше частот для разнообразия
const n1 = Math.sin(gx*0.025 + worldSeed*0.001)*8; // крупные горы
const n2 = Math.sin(gx*0.012 + worldSeed*0.002)*12; // средние горы
const n3 = Math.sin(gx*0.006 + worldSeed*0.003)*6; // мелкие холмы
const n4 = Math.sin(gx*0.045 + worldSeed*0.004)*4; // детали
const n5 = Math.cos(gx*0.018 + worldSeed*0.005)*5; // дополнительные вариации
const h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5); // чем меньше gy - тем выше
return h;
}
function genColumn(gx){
if(generated.has(gx)) return;
generated.add(gx);
const sgy = surfaceGyAt(gx);
// вода (если поверхность ниже уровня моря => sgy > SEA_GY)
if(sgy > SEA_GY){
for(let gy=SEA_GY; gy SEA_GY && gy === sgy+1 && seededRandom(gx, gy) < 0.25) t='clay';
if(gy > sgy+6 && seededRandom(gx, gy) < 0.07) t='gravel';
// руды: чем глубже, тем интереснее
const depth = gy - sgy;
const r = seededRandom(gx, gy);
if(t==='stone'){
if(r < 0.06) t='coal';
else if(r < 0.10) t='copper_ore';
else if(r < 0.13) t='iron_ore';
else if(depth > 40 && r < 0.145) t='gold_ore';
else if(depth > 70 && r < 0.152) t='diamond_ore';
}
setBlock(gx,gy,t);
}
// Деревья и цветы (только на траве, и не в воде)
const top = getBlock(gx, sgy);
if(top && top.t==='grass'){
if(seededRandom(gx, sgy-1) < 0.10){
setBlock(gx, sgy-1,'flower');
}
if(seededRandom(gx, sgy-2) < 0.12){
// простое дерево
setBlock(gx, sgy-1, 'wood');
setBlock(gx, sgy-2, 'wood');
setBlock(gx, sgy-3, 'leaves');
setBlock(gx-1, sgy-3,'leaves');
setBlock(gx+1, sgy-3,'leaves');
}
}
}
// Перегенерация видимых чанков (используется при загрузке сохранения)
function regenerateVisibleChunks(){
const gx0 = Math.floor(camX/TILE);
for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){
// Принудительно перегенерируем колонну
generated.delete(gx);
genColumn(gx);
}
}
function ensureGenAroundCamera(){
const gx0 = Math.floor(camX/TILE);
for(let gx=gx0-GEN_MARGIN_X; gx<=gx0+GEN_MARGIN_X; gx++){
genColumn(gx);
}
}
// Лут с дерева/листвы: дерево -> wood; листья -> leaves
// (уже в mine добавляется inv[type] автоматически)
// Рисование костра: огонь поверх текстуры
function drawFire(wx,wy,now){
const baseX = wx;
const baseY = wy;
const flick = 6 + (Math.sin(now/90)+1)*4;
ctx.fillStyle = 'rgba(255,140,0,0.85)';
ctx.beginPath();
ctx.moveTo(baseX+10, baseY+30);
ctx.lineTo(baseX+20, baseY+30-flick);
ctx.lineTo(baseX+30, baseY+30);
ctx.fill();
ctx.fillStyle = 'rgba(255,230,150,0.75)';
ctx.beginPath();
ctx.moveTo(baseX+14, baseY+30);
ctx.lineTo(baseX+20, baseY+30-(flick*0.7));
ctx.lineTo(baseX+26, baseY+30);
ctx.fill();
}
// Моб AI
function mobAI(m, dt){
updateWaterFlag(m);
if(m.kind==='zombie'){
// активность ночью
const night = isNight();
if(!night){ m.hp=0; 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=0; 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=0; 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 < 200 && m.shootCooldown <= 0){
m.shootCooldown = 1.5;
// Создаём стрелу (упрощённо - просто урон)
const dx = (player.x+player.w/2) - (m.x+m.w/2);
const dy = (player.y+player.h/2) - (m.y+m.h/2);
const angle = Math.atan2(dy, dx);
// Проверяем препятствия (до 20 блоков для более точной проверки)
let blocked = false;
const checkSteps = 20;
const stepSize = dist / checkSteps;
for(let i = 1; i <= checkSteps; i++){
const checkX = m.x + m.w/2 + Math.cos(angle) * stepSize * i;
const checkY = m.y + m.h/2 + Math.sin(angle) * stepSize * i;
const checkGX = Math.floor(checkX / TILE);
const checkGY = Math.floor(checkY / TILE);
const block = getBlock(checkGX, checkGY);
// Любой блок (кроме воздуха) является укрытием
if(block && !block.dead && block.t !== 'air'){
blocked = true;
break;
}
}
// Урон игроку если попали и нет препятствий
if(!blocked && dist < 150 && player.invuln <= 0){
const damage = calculateDamage(8);
player.hp -= damage;
player.invuln = 0.5;
player.vx += Math.cos(angle) * 300;
player.vy -= 200;
playSound('hit1');
}
}
} else {
// животные
m.aiT -= dt;
if(m.aiT <= 0){
m.aiT = 1.8 + Math.random()*2.5;
m.dir = Math.random()<0.5 ? -1 : 1;
if(Math.random()<0.25) m.dir = 0;
}
m.vx = m.dir * (m.kind==='chicken' ? 55 : 40);
if(m.inWater) m.vy = -120;
}
// физика моба
const g = m.inWater ? GRAV_WATER : GRAV;
m.vy += g*dt;
m.y += m.vy*dt; m.grounded=false; resolveY(m);
m.x += m.vx*dt; resolveX(m);
}
function isNight(){
// Автоматический цикл: ночь когда worldTime > 0.5
return worldTime > 0.5;
}
// Respawn
document.getElementById('respawnBtn').onclick = async () => {
playSound('click'); // Звук клика по кнопке
console.log('=== RESPAWN CLICKED ===');
console.log('isMultiplayer:', isMultiplayer);
console.log('otherPlayers.size:', otherPlayers.size);
console.log('player.hp before respawn:', player.hp);
// В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке
if (isMultiplayer && otherPlayers.size > 0) {
console.log('Мультиплеер режим - возрождение в начальной точке');
player.hp = 100;
player.hunger = 100;
player.o2 = 100;
player.vx = player.vy = 0;
player.invuln = 0;
player.x = spawnPoint.x;
player.y = spawnPoint.y;
player.fallStartY = player.y;
console.log('Возрождение в начальной точке, HP:', player.hp);
} else {
console.log('Одиночный режим - загружаем последнее сохранение');
// Одиночный режим - загружаем последнее сохранение
const loadedSave = await loadGame();
if(loadedSave){
await applySave(loadedSave);
console.log('Загружено последнее сохранение после смерти, final HP:', player.hp);
} else {
// Если сохранения нет, возрождаемся в начальной точке
player.hp = 100;
player.hunger = 100;
player.o2 = 100;
player.vx = player.vy = 0;
player.invuln = 0;
player.x = spawnPoint.x;
player.y = spawnPoint.y;
player.fallStartY = player.y;
console.log('Возрождение в начальной точке, HP:', player.hp);
}
}
console.log('player.hp after respawn logic:', player.hp);
console.log('Hiding death screen...');
deathEl.style.display='none';
console.log('=== RESPAWN END ===');
};
// Resize
function resize(){
W = gameEl.clientWidth;
H = gameEl.clientHeight;
canvas.width = W*dpr;
canvas.height = H*dpr;
lightC.width = W*dpr;
lightC.height = H*dpr;
ctx.setTransform(dpr,0,0,dpr,0,0);
}
window.addEventListener('resize', resize);
// init
resize();
rebuildHotbar();
// Инициализируем и загружаем сохранение
initDB().then(async () => {
// Пытаемся загрузить сохранённую игру
const loadedSave = await loadGame();
if(loadedSave){
await applySave(loadedSave);
console.log('Загружено сохранение, HP:', player.hp);
// Проверяем HP после загрузки - если <= 0, возрождаемся
if (player.hp <= 0) {
console.log('WARNING: HP <= 0 после загрузки, возрождаемся');
player.hp = 100;
player.hunger = 100;
player.o2 = 100;
player.x = spawnPoint.x;
player.y = spawnPoint.y;
player.vx = player.vy = 0;
player.invuln = 0;
player.fallStartY = player.y;
}
} else {
console.log('Сохранение не найдено, начинаем новую игру');
// Инициализируем игрока для новой игры
player.hp = 100;
player.hunger = 100;
player.o2 = 100;
player.vx = player.vy = 0;
player.invuln = 0;
// старт — на поверхности (ровно на 1 тайл выше поверхности)
const startGX = 6;
genColumn(startGX);
const surfaceY = surfaceGyAt(startGX);
player.y = (surfaceY - 1) * 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;
function loop(now){
const dt = Math.min(0.05, (now-last)/1000);
last = now;
const jumpPressed = inp.j && !prevJump;
prevJump = inp.j;
// Ускорение времени во время сна
if(player.sleeping && isNight()){
worldTime += dt * 8 / DAY_LEN; // В 8 раз быстрее
// Восстанавливаем здоровье во время сна
player.hp = Math.min(100, player.hp + dt * 20);
// Автоматическое пробуждение когда наступает день
if(!isNight()){
player.sleeping = false;
}
} else {
worldTime += dt / DAY_LEN;
}
if(worldTime >= 1) worldTime -= 1;
// камера следует за игроком по X/Y
camX = Math.floor((player.x + player.w/2) - W/2);
camY = Math.floor((player.y + player.h/2) - H/2);
ensureGenAroundCamera();
// clouds parallax
for(const c of clouds){
c.x -= c.s * dt;
if(c.x + c.w < camX - 400) c.x = camX + W + Math.random()*700;
}
// player
updateWaterFlag(player);
// кислород/утопление: тратим O2 только если голова под водой, иначе восстанавливаем [web:223]
if(player.headInWater){
player.o2 = Math.max(0, player.o2 - 6*dt); // замедлил в 3.7 раза
if(player.o2 === 0){
const damage = calculateDamage(4*dt);
player.hp -= damage;
}
} else {
player.o2 = Math.min(100, player.o2 + 10*dt); // замедлил восстановление в 4 раза
}
// голод убывает, но HP не отнимает (как просили)
player.hunger = Math.max(0, player.hunger - dt*0.2); // замедлил в 4 раза
// Игрок не может двигаться во время сна
if(player.sleeping){
player.vx = 0;
player.vy = 0;
} else {
const dir = (inp.r?1:0) - (inp.l?1:0);
if(dir) player.vx = dir*MOVE;
else player.vx *= 0.82;
}
// Звук шагов при движении по земле
if(player.grounded && !player.inWater && Math.abs(player.vx) > 50){
const stepInterval = 0.35; // Интервал между шагами в секундах
if(now/1000 - player.lastStepTime > stepInterval){
playSound('step');
player.lastStepTime = now/1000;
}
}
// прыжок/плавание (новая логика)
if(player.inBoat){
// Игрок в лодке - лодка следует за игроком
const dir = (inp.r?1:0) - (inp.l?1:0);
if(dir) boat.vx = dir * MOVE;
else boat.vx *= 0.95;
// Лодка плавает на воде
boat.vy = 0;
// Игрок следует за лодкой (сидит внутри неё)
player.x = boat.x + 2; // Игрок по центру лодки
player.y = boat.y - 4; // Игрок выше лодки (сидит внутри)
player.vx = boat.vx;
player.vy = boat.vy;
player.grounded = true;
player.inWater = false; // Игрок не в воде когда в лодке
// Прыжок из лодки (высадка)
if(jumpPressed){
// Возвращаем лодку в инвентарь
inv.boat = (inv.boat || 0) + 1;
player.inBoat = false;
boat.active = false;
player.y += TILE; // Прыгаем из лодки
player.vy = -JUMP * 0.5;
playSound('splash');
}
} else if(player.inWater){
// сопротивление в воде
player.vx *= 0.90;
player.vy *= 0.92;
// Если не нажимаем прыжок - тонем (гравитация в воде)
if(!jumpPressed && !inp.j){
// Применяем гравитацию в воде - игрок тонет
player.vy += GRAV_WATER * dt;
} else {
// Если нажимаем прыжок - поднимаемся на поверхность
if(jumpPressed){
player.vy = Math.min(player.vy, -520); // рывок вверх
} else if(inp.j){
// если держим — мягкое всплытие
player.vy = Math.min(player.vy, -260);
}
}
} else {
// обычный прыжок (только по нажатию)
if(jumpPressed && player.grounded && !player.sleeping){
player.vy = -JUMP;
player.grounded = false;
player.fallStartY = player.y;
}
}
// Гравитация применяется только вне воды и вне лодки
if(!player.inWater && !player.inBoat){
player.vy += GRAV*dt;
}
// Обновляем позицию лодки
if(boat.active){
boat.x += boat.vx * dt;
boat.y += boat.vy * dt;
// Лодка не выходит за пределы воды
const boatGX = Math.floor(boat.x / TILE);
const boatGY = Math.floor(boat.y / TILE);
const below = getBlock(boatGX, boatGY + 1);
if(!below || below.t !== 'water'){
// Если лодка вышла из воды - выкидываем игрока
inv.boat = (inv.boat || 0) + 1;
player.inBoat = false;
boat.active = false;
player.y += TILE;
player.vy = -200;
playSound('splash');
}
}
// Проверяем, не доплыл ли игрок из лодки
if(player.inBoat && !boat.active){
inv.boat = (inv.boat || 0) + 1;
player.inBoat = false;
player.y += TILE;
player.vy = -200;
playSound('splash');
}
player.y += player.vy*dt;
resolveY(player);
player.x += player.vx*dt; resolveX(player);
// Отправляем позицию на сервер (мультиплеер)
sendPlayerPosition();
// Обновляем физику воды
updateWaterPhysics(dt);
player.invuln = Math.max(0, player.invuln - dt);
// 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 (с обеих сторон камеры)
spawnT += dt;
if(spawnT > 1.8 && mobs.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 {
if(isNight()){
// Ночью спавним больше враждебных мобов
const rand = Math.random();
if(rand < 0.35){
mobs.push(new Zombie(wx, wy));
} else if(rand < 0.55){
mobs.push(new Creeper(wx, wy));
} else {
mobs.push(new Skeleton(wx, wy));
}
} else {
// Днём только животные
mobs.push(Math.random()<0.5 ? new Pig(wx, wy) : new Chicken(wx, wy));
}
}
}
// mobs update
for(let i=mobs.length-1;i>=0;i--){
const m = mobs[i];
mobAI(m, dt);
if(m.hp<=0) mobs.splice(i,1);
}
// particles
for(let i=parts.length-1;i>=0;i--){
const p = parts[i];
p.t -= dt;
p.x += p.vx*dt;
p.y += p.vy*dt;
p.vy += 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' : '#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);
}
}
// mobs
for(const m of mobs){
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);
}
}
// 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);
}
// 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
if(night){
// Рисуем полупрозрачный тёмный оверлей
ctx.save();
ctx.fillStyle = 'rgba(0,0,0,0.50)';
ctx.fillRect(0, 0, W, H);
ctx.restore();
// Освещение от факелов и костров
ctx.save();
ctx.globalCompositeOperation = 'lighter';
// Функция для рисования света
function drawLight(x, y, radius, intensity) {
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, `rgba(255, 255, 200, ${intensity})`);
gradient.addColorStop(1, `rgba(255, 255, 200, 0)`);
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
// Освещение от факелов (1/3 яркости)
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.lightRadius){
const wx = b.gx*TILE + TILE/2 - camX;
const wy = b.gy*TILE + TILE/2 - camY;
drawLight(wx, wy, def.lightRadius, 0.33);
}
}
ctx.restore();
}
// UI tick
if(Math.random()<0.25){
hpEl.textContent = Math.max(0, Math.ceil(player.hp));
foodEl.textContent = Math.ceil(player.hunger);
document.getElementById('o2').textContent = Math.ceil(player.o2);
sxEl.textContent = Math.floor(player.x/TILE);
syEl.textContent = Math.floor(player.y/TILE);
todEl.textContent = night ? 'Ночь' : 'День';
worldIdEl.textContent = worldId;
if(isMultiplayer){
document.getElementById('multiplayerStatus').style.display = 'flex';
playerCountEl.textContent = otherPlayers.size + 1; // +1 = мы сами
} else {
document.getElementById('multiplayerStatus').style.display = 'none';
}
}
// Индикатор сна
if(player.sleeping){
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#fff';
ctx.font = 'bold 32px system-ui';
ctx.textAlign = 'center';
ctx.fillText('💤 Спим...', W/2, H/2);
ctx.font = '18px system-ui';
ctx.fillText('Нажмите на кровать чтобы проснуться', W/2, H/2 + 40);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();