grechka-game/backup/20260103_082922/game.js

2615 lines
93 KiB
JavaScript
Raw Permalink 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.

(() => {
// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
// Возможность переопределить сервер через 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);
// Обновляем список игроков
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 =>
`<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 = 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 },
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,
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 } }
];
// 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;
}
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();
};
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;
}
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();
};
}
inventoryGrid.appendChild(slot);
}
}
function canCraft(r){
for(const res in r.cost){
if((inv[res]||0) < r.cost[res]) 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 };
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);
}
bindHold(document.getElementById('left'),'l');
bindHold(document.getElementById('right'),'r');
bindHold(document.getElementById('jump'),'j');
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;
});
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;
});
// Лодка
const boat = {
x: 0, y: 0,
w: 34, h: 34,
vx: 0, vy: 0,
active: false,
inWater: false
};
// Игрок
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
};
// Сохраняем начальную позицию для возрождения
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<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 = [];
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 === player){
e.grounded = true;
e.vy *= 0.8; // замедляем падение на лестнице
// Если нажимаем прыжок на лестнице - поднимаемся
if(inp.j && e.vy > -200){
e.vy = -200;
}
// Если не нажимаем прыжок - медленно спускаемся
else if(!inp.j && e.vy > 0){
e.vy = Math.min(e.vy, 100);
}
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) player.hp -= (fallTiles - 6) * 10;
}
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;
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);
if(isSolid(gx, gyA) || isSolid(gx, gyB)){
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);
if(isSolid(gx, gyA) || isSolid(gx, gyB)){
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) player.hp -= dmg;
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<sgy; gy++){
setBlock(gx,gy,'water');
}
// пляж
setBlock(gx, sgy, 'sand');
} else {
// верхний блок: снег на высоких точках
if(sgy < SEA_GY - 10) setBlock(gx, sgy, 'stone');
else setBlock(gx, sgy, 'grass');
}
// подповерхностные слои
for(let gy=sgy+1; gy<=BEDROCK_GY; gy++){
if(gy === BEDROCK_GY){
setBlock(gx,gy,'bedrock');
continue;
}
let t = 'stone';
// ближе к поверхности
if(gy <= sgy+3) t = 'dirt';
// биомы/материалы
if(sgy > SEA_GY && gy === sgy+1 && seededRandom(gx, gy) < 0.25) t='clay';
if(gy > sgy+6 && seededRandom(gx, gy) < 0.07) t='gravel';
// руды: чем глубже, тем интереснее
const depth = gy - sgy;
const r = seededRandom(gx, gy);
if(t==='stone'){
if(r < 0.06) t='coal';
else if(r < 0.10) t='copper_ore';
else if(r < 0.13) t='iron_ore';
else if(depth > 40 && r < 0.145) t='gold_ore';
else if(depth > 70 && r < 0.152) t='diamond_ore';
}
setBlock(gx,gy,t);
}
// Деревья и цветы (только на траве, и не в воде)
const top = getBlock(gx, sgy);
if(top && top.t==='grass'){
if(seededRandom(gx, sgy-1) < 0.10){
setBlock(gx, sgy-1,'flower');
}
if(seededRandom(gx, sgy-2) < 0.12){
// простое дерево
setBlock(gx, sgy-1, 'wood');
setBlock(gx, sgy-2, 'wood');
setBlock(gx, sgy-3, 'leaves');
setBlock(gx-1, sgy-3,'leaves');
setBlock(gx+1, sgy-3,'leaves');
}
}
}
// Перегенерация видимых чанков (используется при загрузке сохранения)
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){
player.hp -= 15;
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);
// Проверяем препятствия (до 5 блоков)
let blocked = false;
const checkSteps = 5;
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);
if(isSolid(checkGX, checkGY)){
blocked = true;
break;
}
}
// Урон игроку если попали и нет препятствий
if(!blocked && dist < 150 && player.invuln <= 0){
player.hp -= 8;
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){
player.hp -= 4*dt; // уменьшил урон от утопления
}
} 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);
})();